Skip to content

Commit 213cffb

Browse files
committed
fix(virtual-list): keep the ref handle stable across scroll renders
1 parent 6ecc122 commit 213cffb

5 files changed

Lines changed: 254 additions & 130 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@plextv/react-lightning-components': patch
3+
---
4+
5+
virtual list keeps one ref handle for its lifetime, so cells reading it from a layout effect during a scroll no longer get null.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { type ReactNode, useLayoutEffect } from 'react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
vi.mock('@plextv/react-lightning', () => ({
5+
FocusGroup: ({ children }: { children?: ReactNode }) => children,
6+
useFocusManager: () => ({ setFocusedChild: () => {} }),
7+
}));
8+
9+
vi.mock('@plextv/react-lightning-plugin-flexbox', () => ({
10+
FlexBoundary: ({ children }: { children?: ReactNode }) => children,
11+
FlexRoot: ({ children }: { children?: ReactNode }) => children,
12+
useIsInFlex: () => false,
13+
onFlexLayoutSettled: () => () => {},
14+
}));
15+
16+
import { createTestRenderer } from './createTestRenderer';
17+
import { VirtualList } from './VirtualList';
18+
import type { ItemLayout, VirtualListRef } from './VirtualListTypes';
19+
20+
type Read = {
21+
index: number;
22+
hasHandle: boolean;
23+
range?: { startIndex: number; endIndex: number };
24+
layout?: ItemLayout;
25+
};
26+
27+
describe('VirtualList', () => {
28+
it('keeps the ref attached while cells run layout effects after a scroll', () => {
29+
const { render, flush } = createTestRenderer();
30+
const listRef: { current: VirtualListRef | null } = { current: null };
31+
const reads: Read[] = [];
32+
33+
function Cell({ index }: { index: number }) {
34+
useLayoutEffect(() => {
35+
const handle = listRef.current;
36+
37+
reads.push({
38+
index,
39+
hasHandle: handle != null,
40+
range: handle?.getVisibleRange(),
41+
layout: handle?.getLayout(index),
42+
});
43+
}, [index]);
44+
45+
return null;
46+
}
47+
48+
render(
49+
<VirtualList
50+
ref={listRef}
51+
data={Array.from({ length: 50 }, (_, i) => i)}
52+
drawDistance={0}
53+
overrideItemLayout={(layout) => {
54+
layout.size = 100;
55+
}}
56+
renderItem={({ index }) => <Cell index={index} />}
57+
style={{ w: 500, h: 300 }}
58+
/>,
59+
);
60+
61+
reads.length = 0;
62+
listRef.current?.scrollToOffset({ offset: 2000, animated: false });
63+
flush();
64+
65+
expect(reads.length).toBeGreaterThan(0);
66+
67+
for (const read of reads) {
68+
expect(read.hasHandle).toBe(true);
69+
expect(read.range?.startIndex).toBe(20);
70+
expect(read.layout?.y).toBe(read.index * 100);
71+
}
72+
});
73+
});

‎packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx‎

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useContext,
88
useEffect,
99
useImperativeHandle,
10+
useInsertionEffect,
1011
useLayoutEffect,
1112
useRef,
1213
useState,
@@ -705,26 +706,64 @@ function VirtualListInner<T>(
705706
}
706707
};
707708

708-
useImperativeHandle(ref, () => ({
709-
scrollToIndex: (params) =>
710-
scrollToIndex(
711-
params.index,
712-
params.animated,
713-
params.viewPosition,
714-
params.viewOffset,
715-
),
716-
scrollToOffset: (params) => scrollToOffset(params.offset, params.animated),
717-
scrollToEnd: (params) => scrollToEnd(params?.animated),
718-
getScrollOffset: () => scrollOffsetRef.current,
719-
getVisibleRange: () => visibleRange,
720-
getLayout: (index) => {
721-
const layout = layoutManager.getLayout(index);
709+
const handleStateRef = useRef({
710+
scrollToIndex,
711+
scrollToOffset,
712+
scrollToEnd,
713+
visibleRange,
714+
itemAreaOffset,
715+
paddingCross,
716+
horizontal,
717+
});
722718

723-
return layout
724-
? computeItemRect(layout, itemAreaOffset, paddingCross, horizontal)
725-
: undefined;
726-
},
727-
}));
719+
// Insertion effects run before any layout effect in the commit, so cells
720+
// calling the handle from their own layout effects already see this render.
721+
useInsertionEffect(() => {
722+
handleStateRef.current = {
723+
scrollToIndex,
724+
scrollToOffset,
725+
scrollToEnd,
726+
visibleRange,
727+
itemAreaOffset,
728+
paddingCross,
729+
horizontal,
730+
};
731+
});
732+
733+
// One handle for the list's lifetime: a rebuilt handle detaches the ref
734+
// for the whole commit, so cells' layout effects would read null.
735+
useImperativeHandle(
736+
ref,
737+
() => ({
738+
scrollToIndex: (params) =>
739+
handleStateRef.current.scrollToIndex(
740+
params.index,
741+
params.animated,
742+
params.viewPosition,
743+
params.viewOffset,
744+
),
745+
scrollToOffset: (params) =>
746+
handleStateRef.current.scrollToOffset(params.offset, params.animated),
747+
scrollToEnd: (params) =>
748+
handleStateRef.current.scrollToEnd(params?.animated),
749+
getScrollOffset: () => scrollOffsetRef.current,
750+
getVisibleRange: () => handleStateRef.current.visibleRange,
751+
getLayout: (index) => {
752+
const layout = layoutManager.getLayout(index);
753+
const state = handleStateRef.current;
754+
755+
return layout
756+
? computeItemRect(
757+
layout,
758+
state.itemAreaOffset,
759+
state.paddingCross,
760+
state.horizontal,
761+
)
762+
: undefined;
763+
},
764+
}),
765+
[],
766+
);
728767

729768
const loadTimeRef = useRef(Date.now());
730769
const hasLoadedRef = useRef(false);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { type ReactNode, createContext } from 'react';
2+
import createReconciler, { type ReactContext } from 'react-reconciler';
3+
import { DiscreteEventPriority, NoEventPriority } from 'react-reconciler/constants';
4+
5+
const noop = () => {};
6+
7+
// Bare host config: nothing paints, only scheduling and effects matter. Every
8+
// update is sync so tests can flush in place.
9+
export type TestRenderer = {
10+
render: (element: ReactNode) => void;
11+
flush: () => void;
12+
};
13+
14+
export function createTestRenderer(): TestRenderer {
15+
const transitionContext = createContext(null);
16+
let priority: number = NoEventPriority;
17+
18+
// oxlint-disable-next-line typescript/no-explicit-any -- test-only host config, nothing is a real instance
19+
const reconciler = createReconciler<
20+
any,
21+
any,
22+
any,
23+
any,
24+
any,
25+
any,
26+
any,
27+
any,
28+
any,
29+
any,
30+
any,
31+
any,
32+
any,
33+
any
34+
>({
35+
supportsMutation: true,
36+
supportsPersistence: false,
37+
supportsHydration: false,
38+
isPrimaryRenderer: false,
39+
warnsIfNotActing: false,
40+
supportsMicrotasks: true,
41+
scheduleMicrotask: queueMicrotask,
42+
scheduleTimeout: setTimeout,
43+
cancelTimeout: clearTimeout,
44+
noTimeout: -1,
45+
getRootHostContext: () => ({}),
46+
getChildHostContext: (ctx: unknown) => ctx,
47+
getPublicInstance: (instance: unknown) => instance,
48+
createInstance: () => ({ node: { x: 0, y: 0, w: 0, h: 0 } }),
49+
createTextInstance: () => ({}),
50+
appendInitialChild: noop,
51+
appendChild: noop,
52+
appendChildToContainer: noop,
53+
insertBefore: noop,
54+
insertInContainerBefore: noop,
55+
removeChild: noop,
56+
removeChildFromContainer: noop,
57+
clearContainer: noop,
58+
commitUpdate: noop,
59+
commitTextUpdate: noop,
60+
hideInstance: noop,
61+
unhideInstance: noop,
62+
hideTextInstance: noop,
63+
unhideTextInstance: noop,
64+
detachDeletedInstance: noop,
65+
finalizeInitialChildren: () => false,
66+
prepareForCommit: () => null,
67+
resetAfterCommit: noop,
68+
shouldSetTextContent: () => false,
69+
preparePortalMount: noop,
70+
getInstanceFromNode: () => null,
71+
beforeActiveInstanceBlur: noop,
72+
afterActiveInstanceBlur: noop,
73+
prepareScopeUpdate: noop,
74+
getInstanceFromScope: () => null,
75+
resetFormInstance: noop,
76+
requestPostPaintCallback: noop,
77+
shouldAttemptEagerTransition: () => false,
78+
trackSchedulerEvent: noop,
79+
resolveEventType: () => null,
80+
resolveEventTimeStamp: () => -1.1,
81+
maySuspendCommit: () => false,
82+
startSuspendingCommit: noop,
83+
suspendInstance: noop,
84+
preloadInstance: () => true,
85+
waitForCommitToBeReady: () => null,
86+
setCurrentUpdatePriority: (next: number) => {
87+
priority = next;
88+
},
89+
getCurrentUpdatePriority: () => priority,
90+
resolveUpdatePriority: () => (priority !== NoEventPriority ? priority : DiscreteEventPriority),
91+
NotPendingTransition: null,
92+
// React's own context object carries the internal fields the typings ask for.
93+
HostTransitionContext: transitionContext as unknown as ReactContext<null>,
94+
});
95+
96+
const container = reconciler.createContainer(
97+
{},
98+
0,
99+
null,
100+
false,
101+
null,
102+
'',
103+
noop,
104+
noop,
105+
noop,
106+
noop,
107+
);
108+
109+
return {
110+
render: (element) => {
111+
reconciler.updateContainerSync(element, container, null, null);
112+
reconciler.flushSyncWork();
113+
},
114+
flush: () => reconciler.flushSyncWork(),
115+
};
116+
}

0 commit comments

Comments
 (0)