Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions __tests__/components/ListComponent.renderScrollComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as React from "react";
import { Text, View } from "react-native";

import { describe, expect, it } from "bun:test";
import { ListComponent } from "../../src/components/ListComponent";
import { StateProvider, useStateContext } from "../../src/state/state";
import { createMockState } from "../__mocks__/createMockState";
import TestRenderer, { act } from "../helpers/testRenderer";
import "../setup";

function collectTextFromTree(node: any, values: string[] = []) {
if (node == null) {
return values;
}

if (typeof node === "string") {
values.push(node);
return values;
}

if (Array.isArray(node)) {
for (const child of node) {
collectTextFromTree(child, values);
}
return values;
}

if (node.children) {
collectTextFromTree(node.children, values);
}

return values;
}

function Header({ events }: { events: string[] }) {
React.useEffect(() => {
events.push("mount:header");
return () => {
events.push("unmount:header");
};
}, [events]);

return <Text>Header</Text>;
}

function ListComponentHarness({ events, label }: { events: string[]; label: string }) {
const ctx = useStateContext();
const state = React.useMemo(() => createMockState(), []);
ctx.state = state;

return (
<ListComponent
canRender={false}
drawDistance={0}
estimatedItemSize={100}
getRenderedItem={() => null}
horizontal={false}
initialContentOffset={undefined}
ListHeaderComponent={<Header events={events} />}
onLayout={() => {}}
onScroll={() => {}}
recycleItems={false}
refScrollView={{ current: null }}
renderScrollComponent={(scrollProps) => {
const { children, ...rest } = scrollProps as any;
return (
<View {...rest}>
<Text>{label}</Text>
{children}
</View>
);
}}
scrollAdjustHandler={state.scrollAdjustHandler}
scrollEventThrottle={0}
snapToIndices={undefined}
stickyHeaderIndices={undefined}
style={{}}
updateItemSize={() => {}}
/>
);
}

describe("ListComponent renderScrollComponent", () => {
it("keeps the scroll subtree mounted when the render callback identity changes", () => {
const events: string[] = [];
let renderer!: TestRenderer.ReactTestRenderer;

act(() => {
renderer = TestRenderer.create(
<StateProvider>
<ListComponentHarness events={events} label="first" />
</StateProvider>,
);
});

expect(collectTextFromTree(renderer.toJSON())).toContain("first");
expect(events).toEqual(["mount:header"]);

act(() => {
renderer.update(
<StateProvider>
<ListComponentHarness events={events} label="second" />
</StateProvider>,
);
});

expect(collectTextFromTree(renderer.toJSON())).toContain("second");
expect(events).toEqual(["mount:header"]);

act(() => {
renderer.unmount();
});
});
});
56 changes: 56 additions & 0 deletions __tests__/hooks/useLatestRef.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from "react";

import { describe, expect, it } from "bun:test";
import { useLatestRef } from "../../src/hooks/useLatestRef";
import TestRenderer, { act } from "../helpers/testRenderer";
import "../setup";

function Probe({ onRef, value }: { onRef: (ref: React.RefObject<string>) => void; value: string }) {
const ref = useLatestRef(value);

React.useEffect(() => {
onRef(ref);
});

return null;
}

describe("useLatestRef", () => {
it("keeps a stable ref updated with the latest value", () => {
const refs: React.RefObject<string>[] = [];
let renderer!: TestRenderer.ReactTestRenderer;

act(() => {
renderer = TestRenderer.create(
<Probe
onRef={(ref) => {
refs.push(ref);
}}
value="first"
/>,
);
});

expect(refs).toHaveLength(1);
expect(refs[0].current).toBe("first");

act(() => {
renderer.update(
<Probe
onRef={(ref) => {
refs.push(ref);
}}
value="second"
/>,
);
});

expect(refs).toHaveLength(2);
expect(refs[1]).toBe(refs[0]);
expect(refs[0].current).toBe("second");

act(() => {
renderer.unmount();
});
});
});
74 changes: 74 additions & 0 deletions __tests__/integrations/reanimated.itemLayoutAnimation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ let legendListPropsRenders: any[] = [];
let reanimatedViewRenders: any[] = [];
let reanimatedScrollViewRenders: any[] = [];

function collectTextFromTree(node: any, values: string[] = []) {
if (node == null) {
return values;
}

if (typeof node === "string") {
values.push(node);
return values;
}

if (Array.isArray(node)) {
for (const child of node) {
collectTextFromTree(child, values);
}
return values;
}

if (node.children) {
collectTextFromTree(node.children, values);
}

return values;
}

const LegendListMock = React.forwardRef(function LegendListStub(props: any, _ref: React.Ref<any>) {
legendListPropsRenders.push(props);
return null;
Expand Down Expand Up @@ -199,6 +223,56 @@ describe("AnimatedLegendList itemLayoutAnimation integration", () => {
expect(props.positionComponentInternal).toBeUndefined();
});

it("keeps the custom scroll subtree mounted when the render callback identity changes", async () => {
const { AnimatedLegendList } = await import("../../src/integrations/reanimated?scroll-component-stability");
const events: string[] = [];
const ScrollHarness = ({ children, label }: { children?: React.ReactNode; label: string }) => {
React.useEffect(() => {
events.push(`mount:${label}`);
return () => {
events.push(`unmount:${label}`);
};
}, []);

return React.createElement("custom-scroll-view", null, label, children);
};
const renderList = (label: string) => (
<AnimatedLegendList
data={[{ id: "a" }]}
estimatedItemSize={10}
renderItem={() => null}
renderScrollComponent={(props) => <ScrollHarness {...props} label={label} />}
/>
);
let listRenderer!: TestRenderer.ReactTestRenderer;
let bridgeRenderer!: TestRenderer.ReactTestRenderer;

act(() => {
listRenderer = TestRenderer.create(renderList("first"));
});
act(() => {
bridgeRenderer = TestRenderer.create(legendListPropsRenders.at(-1).renderScrollComponent({ ref: null }));
});

expect(collectTextFromTree(bridgeRenderer.toJSON())).toContain("first");
expect(events).toEqual(["mount:first"]);

act(() => {
listRenderer.update(renderList("second"));
});
act(() => {
bridgeRenderer.update(legendListPropsRenders.at(-1).renderScrollComponent({ ref: null }));
});

expect(collectTextFromTree(bridgeRenderer.toJSON())).toContain("second");
expect(events).toEqual(["mount:first"]);

act(() => {
bridgeRenderer.unmount();
listRenderer.unmount();
});
});

it("keeps positionComponentInternal stable when transition reference is stable", async () => {
const { AnimatedLegendList } = await import("../../src/integrations/reanimated?item-layout-stable");
const transition = { type: "linear" } as any;
Expand Down
57 changes: 28 additions & 29 deletions example/screens/fixtures/ai-chat-keyboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,7 @@ type Message = {

const createId = () => String(Date.now());

const INITIAL_AI_TEXT = `Tip: Type 'a' for a short reply, 'b' for medium, 'c' for long, or 'd' for extra long. Any other text picks a random length.

React Native virtualization is a performance optimization technique that's crucial for handling large lists efficiently. Here's how it works:

1. **Rendering Only Visible Items**: Instead of rendering all items in a list at once, virtualization only renders the items that are currently visible on screen, plus a small buffer of items just outside the visible area.

2. **Dynamic Item Creation/Destruction**: As you scroll, items that move out of view are removed from the DOM/native view hierarchy, and new items that come into view are created. This keeps memory usage constant regardless of list size.

3. **View Recycling**: Advanced virtualization systems reuse view components rather than creating new ones, which reduces garbage collection and improves performance.

4. **Estimated vs Actual Sizing**: The system uses estimated item sizes to calculate scroll positions and total content size, then adjusts as actual sizes are measured.

5. **Legend List Implementation**: Legend List enhances this by providing better handling of dynamic item sizes, bidirectional scrolling, and maintains scroll position more accurately than FlatList.

The key benefits are:
- Constant memory usage regardless of data size
- Smooth scrolling performance
- Better handling of dynamic content
- Reduced time to interactive

This makes it possible to scroll through thousands of items without performance degradation, which is essential for modern mobile apps dealing with large datasets like social media feeds, chat histories, or product catalogs.

Tip: Type 'a' for a short reply, 'b' for medium, 'c' for long, or 'd' for extra long. Any other text picks a random length.`;
const INITIAL_AI_TEXT = `Tip: Type 'a' for a short reply, 'b' for medium, 'c' for long, or 'd' for extra long. Any other text picks a random length.`;

const INITIAL_MESSAGES: Message[] = [
{
Expand Down Expand Up @@ -101,7 +79,7 @@ const LIFT_BEHAVIORS = ["always", "whenAtEnd", "persistent", "never"] as const;
type LiftBehavior = (typeof LIFT_BEHAVIORS)[number];

const REPLIES = [
(msg: string) => `Got it! "${msg}" - let me know if you need more help.`,
(msg: string) => `Got it!`,
(msg: string) =>
`I understand you said: "${msg}". That's a great point! Here are a few thoughts:\n\n1. First consideration\n2. Second aspect\n\nAnything else? First point about your question - this is important to consider when thinking about the broader context of your inquiry.\n\n2. Second important consideration - there are multiple angles to approach this from, and each has its own merits.`,
(msg: string) =>
Expand All @@ -127,6 +105,7 @@ const AILegendListChat = () => {
const [isStreaming, setIsStreaming] = useState(false);
const [liftBehavior, setLiftBehavior] = useState<LiftBehavior>("whenAtEnd");
const [anchorAtStartIndex, setAnchorAtStartIndex] = useState<number | undefined>(undefined);
const [anchorEndSpaceEnabled, setAnchorEndSpaceEnabled] = useState(Platform.OS !== "android");
const listRef = useRef<LegendListRef>(null);
const inputRef = useRef<TextInput>(null);
const composerRef = useRef<View>(null);
Expand All @@ -135,6 +114,18 @@ const AILegendListChat = () => {

const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(listRef, composerRef, 120);

useEffect(() => {
const state = listRef.current?.getState();
if (!state) {
return;
}
return state.listen("totalSize", (totalSize) => {
if (totalSize > state.scrollLength + contentInsetEndAdjustment.value) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the current scroll length before enabling anchored space

On Android/new-architecture mounts where the list ref is available before the final layout size is known, this captures the one-time getState() snapshot and keeps using its initial scrollLength for every later totalSize update. Since scrollLength is copied into the returned state object rather than kept live, the first content measurement can satisfy the check with a stale 0/old viewport and enable anchoredEndSpace while the content is still shorter than the actual viewport plus composer inset, reintroducing the KeyboardChatScrollView short-content path this guard is meant to avoid. Fetch the latest state inside the listener, or otherwise wait for the measured scroll length before flipping the flag.

Useful? React with 👍 / 👎.

setAnchorEndSpaceEnabled(true);
}
});
}, []);

const schedule = useCallback((fn: () => void, ms: number) => {
const id = setTimeout(fn, ms);

Expand All @@ -150,7 +141,9 @@ const AILegendListChat = () => {
}, []);

const doSendMessage = (text: string, rawInput: string) => {
setAnchorAtStartIndex(messages.length);
if (anchorEndSpaceEnabled) {
setAnchorAtStartIndex(messages.length);
}

setMessages((prevMessages) => [
...prevMessages,
Expand Down Expand Up @@ -217,7 +210,7 @@ const AILegendListChat = () => {
const currentText = words.slice(0, currentWordIndex).join(" ");

setMessages((prevMessages) =>
prevMessages.map((msg) => (msg.id === aiMessageId ? { ...msg, text: currentText } : msg)),
prevMessages.map((msg) => (msg.id === aiMessageId ? { ...msg, text: currentText } : msg))
);
} else {
clearInterval(intervalId);
Expand Down Expand Up @@ -249,16 +242,19 @@ const AILegendListChat = () => {
<KeyboardGestureArea interpolator="ios" offset={60} style={styles.container}>
<KeyboardChatLegendList
anchoredEndSpace={
anchorAtStartIndex !== undefined ? { anchorIndex: anchorAtStartIndex } : undefined
anchorEndSpaceEnabled && anchorAtStartIndex !== undefined
? { anchorIndex: anchorAtStartIndex }
: undefined
}
contentContainerStyle={styles.contentContainer}
contentInsetEndAdjustment={contentInsetEndAdjustment}
contentInsetEndAdjustment={anchorEndSpaceEnabled ? contentInsetEndAdjustment : undefined}
data={messages}
initialScrollAtEnd
keyboardLiftBehavior={liftBehavior}
keyboardOffset={insets.bottom}
keyExtractor={(_item, index) => `item-${index}`}
maintainVisibleContentPosition
maintainScrollAtEnd={!anchorEndSpaceEnabled}
recycleItems
ref={listRef}
renderItem={({ item }) => (
Expand Down Expand Up @@ -288,7 +284,10 @@ const AILegendListChat = () => {
style={styles.list}
/>
</KeyboardGestureArea>
<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }} style={styles.composerWrapper}>
<KeyboardStickyView
offset={{ closed: 0, opened: insets.bottom }}
style={anchorEndSpaceEnabled ? styles.composerWrapper : undefined}
>
<View
onLayout={onComposerLayout}
ref={composerRef}
Expand Down
Loading