Skip to content

Commit c7a0d95

Browse files
authored
Add LayoutCommitObserver (#1758)
1 parent 4b42163 commit c7a0d95

File tree

5 files changed

+194
-2
lines changed

5 files changed

+194
-2
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
id: layout-commit-observer
3+
title: Layout Commit Observer
4+
---
5+
6+
# Layout Commit Observer
7+
8+
The `LayoutCommitObserver` is a utility component that helps you track when all FlashList components in your component tree have completed their initial layout. This is particularly useful for coordinating complex UI behaviors that depend on list rendering completion. Doing your own `setState` in this callback will block paint till your state change is ready to be committed.
9+
10+
## Overview
11+
12+
When working with multiple FlashList components or when you need to perform actions after a FlashList has finished its initial render, the LayoutCommitObserver provides a clean way to observe and react to these layout events.
13+
14+
## When to Use
15+
16+
- Measure size of views after all internal lists have rendered
17+
- Don't have access to FlashList for example, your component just accepts `children` prop.
18+
19+
## When not to use
20+
21+
- If you don't need to block paint then using the `onLoad` callback is a better approach.
22+
- If you only have one FlashList and have access to it. `onCommitLayoutEffect` is a prop on FlashList too.
23+
24+
## Basic Usage
25+
26+
Wrap your component tree containing FlashLists with LayoutCommitObserver:
27+
28+
```tsx
29+
import { LayoutCommitObserver } from "@shopify/flash-list";
30+
31+
function MyScreen() {
32+
const handleLayoutComplete = () => {
33+
console.log("All FlashLists have completed their initial layout!");
34+
// Perform any post-layout actions here
35+
};
36+
37+
return (
38+
<LayoutCommitObserver onCommitLayoutEffect={handleLayoutComplete}>
39+
<View>
40+
<FlashList
41+
data={data1}
42+
renderItem={renderItem1}
43+
estimatedItemSize={50}
44+
/>
45+
<FlashList
46+
data={data2}
47+
renderItem={renderItem2}
48+
estimatedItemSize={100}
49+
/>
50+
</View>
51+
</LayoutCommitObserver>
52+
);
53+
}
54+
```

fixture/react-native/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,7 +1775,7 @@ PODS:
17751775
- SDWebImageAVIFCoder (~> 0.11.0)
17761776
- SDWebImageWebPCoder (~> 0.14)
17771777
- Yoga
1778-
- RNFlashList (2.0.0-rc.1):
1778+
- RNFlashList (2.0.0-rc.10):
17791779
- DoubleConversion
17801780
- glog
17811781
- hermes-engine
@@ -2331,7 +2331,7 @@ SPEC CHECKSUMS:
23312331
ReactCodegen: 025b1e9baf2a32b7466b00c20dd5b1b8c6388d07
23322332
ReactCommon: ff9f7add7560c9f15cf6e9d0320f9ad45b696062
23332333
RNFastImage: 2990d3d7033b95119354cc27f383010c7d1e2165
2334-
RNFlashList: 31c767ec71f73679dd3804149ff1111259b0c872
2334+
RNFlashList: 1c295214ba885a4b5053daa19263cacf4602986e
23352335
RNGestureHandler: 8ff2b1434b0ff8bab28c8242a656fb842990bbc8
23362336
RNReanimated: f52ccd5ceea2bae48d7421eec89b3f0c10d7b642
23372337
RNScreens: b09235ac2de51cc1c3dbae34ad7b15a604745983
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from "react";
2+
import { render } from "@quilted/react-testing";
3+
4+
import { RecyclerView } from "../recyclerview/RecyclerView";
5+
import { useFlashListContext } from "../recyclerview/RecyclerViewContextProvider";
6+
import { LayoutCommitObserver } from "../recyclerview/LayoutCommitObserver";
7+
8+
describe("LayoutCommitObserver", () => {
9+
it("should not alter ref captured by child", () => {
10+
const ChildComponent = () => {
11+
const context = useFlashListContext();
12+
expect(context?.getRef()?.props.testID).toBe("child");
13+
expect(context?.getParentRef()?.props.testID).toBe("parent");
14+
expect(context?.getScrollViewRef()?.props.testID).toBe("child");
15+
expect(context?.getParentScrollViewRef()?.props.testID).toBe("parent");
16+
17+
return null;
18+
};
19+
20+
let commitLayoutEffectCount = 0;
21+
22+
const content = (
23+
<RecyclerView
24+
testID="parent"
25+
data={[1]}
26+
renderItem={() => (
27+
<LayoutCommitObserver
28+
onCommitLayoutEffect={() => {
29+
commitLayoutEffectCount++;
30+
}}
31+
>
32+
<RecyclerView
33+
testID="child"
34+
data={[1]}
35+
renderItem={() => (
36+
<LayoutCommitObserver
37+
onCommitLayoutEffect={() => {
38+
commitLayoutEffectCount++;
39+
}}
40+
>
41+
<LayoutCommitObserver
42+
onCommitLayoutEffect={() => {
43+
commitLayoutEffectCount++;
44+
}}
45+
>
46+
<ChildComponent />
47+
</LayoutCommitObserver>
48+
</LayoutCommitObserver>
49+
)}
50+
/>
51+
</LayoutCommitObserver>
52+
)}
53+
/>
54+
);
55+
56+
render(content);
57+
58+
expect(commitLayoutEffectCount).toBe(3);
59+
});
60+
});

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export { default as CellContainer } from "./native/cell-container/CellContainer"
5454
export { RecyclerView } from "./recyclerview/RecyclerView";
5555
export { RecyclerViewProps } from "./recyclerview/RecyclerViewProps";
5656
export { useFlashListContext } from "./recyclerview/RecyclerViewContextProvider";
57+
export {
58+
LayoutCommitObserver,
59+
LayoutCommitObserverProps,
60+
} from "./recyclerview/LayoutCommitObserver";
5761

5862
// @ts-ignore - This is ignored by TypeScript but will be present in the compiled JS
5963
// In the compiled JS, this will override the previous FlashList export with a conditional one
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React, { useLayoutEffect, useMemo, useRef } from "react";
2+
3+
import {
4+
RecyclerViewContext,
5+
RecyclerViewContextProvider,
6+
useRecyclerViewContext,
7+
} from "./RecyclerViewContextProvider";
8+
import { useLayoutState } from "./hooks/useLayoutState";
9+
10+
export interface LayoutCommitObserverProps {
11+
children: React.ReactNode;
12+
onCommitLayoutEffect?: () => void;
13+
}
14+
15+
/**
16+
* LayoutCommitObserver can be used to observe when FlashList commits a layout.
17+
* It is useful when your component has one or more FlashLists somewhere down the tree.
18+
* LayoutCommitObserver will trigger `onCommitLayoutEffect` when all of the FlashLists in the tree have finished their first commit.
19+
*/
20+
export const LayoutCommitObserver = React.memo(
21+
(props: LayoutCommitObserverProps) => {
22+
const { children, onCommitLayoutEffect } = props;
23+
const parentRecyclerViewContext = useRecyclerViewContext();
24+
const [_, setRenderId] = useLayoutState(0);
25+
const pendingChildIds = useRef<Set<string>>(new Set()).current;
26+
27+
useLayoutEffect(() => {
28+
if (pendingChildIds.size > 0) {
29+
return;
30+
}
31+
onCommitLayoutEffect?.();
32+
});
33+
34+
// Create context for child components
35+
const recyclerViewContext: RecyclerViewContext<unknown> = useMemo(() => {
36+
return {
37+
layout: () => {
38+
setRenderId((prev) => prev + 1);
39+
},
40+
getRef: () => {
41+
return parentRecyclerViewContext?.getRef() ?? null;
42+
},
43+
getParentRef: () => {
44+
return parentRecyclerViewContext?.getParentRef() ?? null;
45+
},
46+
getParentScrollViewRef: () => {
47+
return parentRecyclerViewContext?.getParentScrollViewRef() ?? null;
48+
},
49+
getScrollViewRef: () => {
50+
return parentRecyclerViewContext?.getScrollViewRef() ?? null;
51+
},
52+
markChildLayoutAsPending: (id: string) => {
53+
parentRecyclerViewContext?.markChildLayoutAsPending(id);
54+
pendingChildIds.add(id);
55+
},
56+
unmarkChildLayoutAsPending: (id: string) => {
57+
parentRecyclerViewContext?.unmarkChildLayoutAsPending(id);
58+
if (pendingChildIds.has(id)) {
59+
pendingChildIds.delete(id);
60+
recyclerViewContext.layout();
61+
}
62+
},
63+
};
64+
}, [parentRecyclerViewContext, pendingChildIds, setRenderId]);
65+
66+
return (
67+
<RecyclerViewContextProvider value={recyclerViewContext}>
68+
{children}
69+
</RecyclerViewContextProvider>
70+
);
71+
}
72+
);
73+
74+
LayoutCommitObserver.displayName = "LayoutCommitObserver";

0 commit comments

Comments
 (0)