From e2210f1668e815037b8be23c2cf44edbc7e76476 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 15 Oct 2026 16:36:49 +0200 Subject: [PATCH] fix(virtual-list): keep the cross size when layout effects re-run without a data change --- .../virtuallist-cross-reset-on-reveal.md | 5 + .../react-lightning-components/package.json | 4 +- .../components/VirtualList/VirtualList.tsx | 30 +-- .../VirtualList/useContentCross.spec.tsx | 200 ++++++++++++++++++ .../components/VirtualList/useContentCross.ts | 52 +++++ pnpm-lock.yaml | 6 + 6 files changed, 269 insertions(+), 28 deletions(-) create mode 100644 .changeset/virtuallist-cross-reset-on-reveal.md create mode 100644 packages/react-lightning-components/src/components/VirtualList/useContentCross.spec.tsx create mode 100644 packages/react-lightning-components/src/components/VirtualList/useContentCross.ts diff --git a/.changeset/virtuallist-cross-reset-on-reveal.md b/.changeset/virtuallist-cross-reset-on-reveal.md new file mode 100644 index 00000000..fbe9ed16 --- /dev/null +++ b/.changeset/virtuallist-cross-reset-on-reveal.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +virtual list keeps its cross size when layout effects re-run without a data change (e.g. a suspense-frozen screen coming back). diff --git a/packages/react-lightning-components/package.json b/packages/react-lightning-components/package.json index a279e039..b9122e58 100644 --- a/packages/react-lightning-components/package.json +++ b/packages/react-lightning-components/package.json @@ -47,7 +47,9 @@ }, "devDependencies": { "@repo/configs": "workspace:*", - "@types/react": "catalog:" + "@types/react": "catalog:", + "@types/react-reconciler": "catalog:", + "react-reconciler": "0.33.0" }, "peerDependencies": { "@plextv/react-lightning": "workspace:^", diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index bc1ec5c2..a8d69409 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -41,6 +41,7 @@ import { resolveContentReport } from './resolveContentReport'; import { resolveRevealBoundary } from './resolveRevealBoundary'; import { resolveSectionSize } from './resolveSectionSize'; import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; +import { useContentCross } from './useContentCross'; import { useScrollHandler } from './useScrollHandler'; import { useViewability } from './useViewability'; @@ -193,13 +194,8 @@ function VirtualListInner( const measuredHeaderSizeRef = useRef(0); const [measuredFooterSize, setMeasuredFooterSize] = useState(0); const measuredFooterSizeRef = useRef(0); - // Monotonic per-dataset: once a cell reports cross=N, stays at N or - // larger until data/extraData identity changes. - const [maxContentCross, setMaxContentCross] = useState(0); - const maxContentCrossRef = useRef(0); - // Bumped whenever the cross measurement is reset, to make mounted cells - // re-push their current cross so it climbs back (see the reset effect). - const [crossGeneration, setCrossGeneration] = useState(0); + const { maxContentCross, crossGeneration, handleContentCrossLayout } = + useContentCross(data, extraData); // A row measures bottom-up and async: it reports a placeholder size, then // grows to its real height. `revealGate` tracks how long each cell's size has // held steady so the list can keep a cell hidden until it settles, then paint @@ -395,13 +391,6 @@ function VirtualListInner( } }; - const handleContentCrossLayout = (size: number) => { - if (size > maxContentCrossRef.current) { - maxContentCrossRef.current = size; - setMaxContentCross(size); - } - }; - const handleHeaderLayout = (event: { w: number; h: number }) => { const main = horizontal ? event.w : event.h; @@ -689,19 +678,6 @@ function VirtualListInner( // tab switches re-create the array even when userKeys are identical, and // re-measuring from estimate would shift rows visibly. Callers can call // `layoutManager.clearMeasurements()` imperatively when they need it. - // A data-identity change can be a recycle to a shorter row (cross must - // shrink) or an in-place refresh keeping the same items (cross must NOT - // collapse). Zero it, then bump the generation so every mounted cell - // re-pushes its current cross: recycled cells report the new height, - // refreshed cells report the same one, and grow-only settles on the real - // max either way. Without the re-push a same-key refresh (mergeData) left - // cross stuck at 0 and clipped taller-than-default content. - useLayoutEffect(() => { - maxContentCrossRef.current = 0; - setMaxContentCross(0); - setCrossGeneration((g) => g + 1); - // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentional reset on data identity change - }, [data, extraData]); const handleViewportResize = (event: { w: number; h: number }) => { setMeasuredSize((prev) => diff --git a/packages/react-lightning-components/src/components/VirtualList/useContentCross.spec.tsx b/packages/react-lightning-components/src/components/VirtualList/useContentCross.spec.tsx new file mode 100644 index 00000000..151b966e --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/useContentCross.spec.tsx @@ -0,0 +1,200 @@ +import { Activity, createContext } from 'react'; +import createReconciler, { type ReactContext } from 'react-reconciler'; +import { DiscreteEventPriority, NoEventPriority } from 'react-reconciler/constants'; +import { describe, expect, it } from 'vitest'; + +import { type ContentCross, useContentCross } from './useContentCross'; + +const noop = () => {}; + +// Bare host config: the probe renders nothing, so only the scheduling and +// effect machinery matters. Every update is sync so tests can flush in place. +function createTestRenderer() { + const transitionContext = createContext(null); + let priority: number = NoEventPriority; + + // oxlint-disable-next-line typescript/no-explicit-any -- test-only host config, nothing is a real instance + const reconciler = createReconciler< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + >({ + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + isPrimaryRenderer: false, + warnsIfNotActing: false, + supportsMicrotasks: true, + scheduleMicrotask: queueMicrotask, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + noTimeout: -1, + getRootHostContext: () => null, + getChildHostContext: (ctx: unknown) => ctx, + getPublicInstance: (instance: unknown) => instance, + createInstance: () => ({}), + createTextInstance: () => ({}), + appendInitialChild: noop, + appendChild: noop, + appendChildToContainer: noop, + insertBefore: noop, + insertInContainerBefore: noop, + removeChild: noop, + removeChildFromContainer: noop, + clearContainer: noop, + commitUpdate: noop, + commitTextUpdate: noop, + hideInstance: noop, + unhideInstance: noop, + hideTextInstance: noop, + unhideTextInstance: noop, + detachDeletedInstance: noop, + finalizeInitialChildren: () => false, + prepareForCommit: () => null, + resetAfterCommit: noop, + shouldSetTextContent: () => false, + preparePortalMount: noop, + getInstanceFromNode: () => null, + beforeActiveInstanceBlur: noop, + afterActiveInstanceBlur: noop, + prepareScopeUpdate: noop, + getInstanceFromScope: () => null, + resetFormInstance: noop, + requestPostPaintCallback: noop, + shouldAttemptEagerTransition: () => false, + trackSchedulerEvent: noop, + resolveEventType: () => null, + resolveEventTimeStamp: () => -1.1, + maySuspendCommit: () => false, + startSuspendingCommit: noop, + suspendInstance: noop, + preloadInstance: () => true, + waitForCommitToBeReady: () => null, + setCurrentUpdatePriority: (next: number) => { + priority = next; + }, + getCurrentUpdatePriority: () => priority, + resolveUpdatePriority: () => (priority !== NoEventPriority ? priority : DiscreteEventPriority), + NotPendingTransition: null, + // React's own context object carries the internal fields the typings ask for. + HostTransitionContext: transitionContext as unknown as ReactContext, + }); + + const container = reconciler.createContainer( + {}, + 0, + null, + false, + null, + '', + noop, + noop, + noop, + noop, + ); + + return { + render: (element: Parameters[0]) => { + reconciler.updateContainerSync(element, container, null, null); + reconciler.flushSyncWork(); + }, + flush: () => reconciler.flushSyncWork(), + }; +} + +function setup() { + const { render, flush } = createTestRenderer(); + let current: ContentCross | undefined; + + function Probe({ data, extraData }: { data: unknown; extraData?: unknown }) { + current = useContentCross(data, extraData); + + return null; + } + + const renderList = (data: unknown, mode: 'visible' | 'hidden' = 'visible', extraData?: unknown) => + render( + + + , + ); + + const report = (size: number) => { + // oxlint-disable-next-line typescript/no-non-null-assertion -- set by the first render + current!.handleContentCrossLayout(size); + flush(); + }; + + // oxlint-disable-next-line typescript/no-non-null-assertion -- set by the first render + return { renderList, report, latest: () => current! }; +} + +describe('useContentCross', () => { + it('keeps the cross size when layout effects re-run without a data change', () => { + const { renderList, report, latest } = setup(); + const data = [1, 2, 3]; + + renderList(data); + report(928); + + expect(latest().maxContentCross).toBe(928); + const generation = latest().crossGeneration; + + // Hiding and revealing tears down and re-runs layout effects, the way a + // Suspense-based freeze does when a covered screen comes back. + renderList(data, 'hidden'); + renderList(data, 'visible'); + + expect(latest().maxContentCross).toBe(928); + expect(latest().crossGeneration).toBe(generation); + }); + + it('resets the cross size when data identity changes', () => { + const { renderList, report, latest } = setup(); + + renderList([1, 2, 3]); + report(928); + const generation = latest().crossGeneration; + + renderList([1, 2, 3]); + + expect(latest().maxContentCross).toBe(0); + expect(latest().crossGeneration).toBe(generation + 1); + }); + + it('resets the cross size when extraData changes', () => { + const { renderList, report, latest } = setup(); + const data = [1, 2, 3]; + + renderList(data, 'visible', 'a'); + report(928); + const generation = latest().crossGeneration; + + renderList(data, 'visible', 'b'); + + expect(latest().maxContentCross).toBe(0); + expect(latest().crossGeneration).toBe(generation + 1); + }); + + it('only grows within a dataset', () => { + const { renderList, report, latest } = setup(); + const data = [1, 2, 3]; + + renderList(data); + report(928); + report(672); + + expect(latest().maxContentCross).toBe(928); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/useContentCross.ts b/packages/react-lightning-components/src/components/VirtualList/useContentCross.ts new file mode 100644 index 00000000..64b832f2 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/useContentCross.ts @@ -0,0 +1,52 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +export type ContentCross = { + maxContentCross: number; + crossGeneration: number; + handleContentCrossLayout: (size: number) => void; +}; + +/** + * Grow-only max of the cross size cells report, reset when `data` or + * `extraData` identity changes. `crossGeneration` bumps on every reset so + * mounted cells re-push their current cross and it climbs back. + */ +export function useContentCross(data: unknown, extraData: unknown): ContentCross { + const [maxContentCross, setMaxContentCross] = useState(0); + const maxContentCrossRef = useRef(0); + const [crossGeneration, setCrossGeneration] = useState(0); + const crossResetForRef = useRef({ data, extraData }); + + // A data-identity change can be a recycle to a shorter row (cross must + // shrink) or an in-place refresh keeping the same items (cross must NOT + // collapse). Zero it, then bump the generation so every mounted cell + // re-pushes its current cross: recycled cells report the new height, + // refreshed cells report the same one, and grow-only settles on the real + // max either way. Without the re-push a same-key refresh (mergeData) left + // cross stuck at 0 and clipped taller-than-default content. + // Layout effects also re-run when a Suspense boundary reveals the tree + // again (a frozen screen coming back), so compare identities instead of + // trusting the effect firing: a reset there drops cross to the default for + // a pass and rows that leave the draw window keep the short size. + useLayoutEffect(() => { + const last = crossResetForRef.current; + + if (last.data === data && last.extraData === extraData) { + return; + } + + crossResetForRef.current = { data, extraData }; + maxContentCrossRef.current = 0; + setMaxContentCross(0); + setCrossGeneration((g) => g + 1); + }, [data, extraData]); + + const handleContentCrossLayout = (size: number) => { + if (size > maxContentCrossRef.current) { + maxContentCrossRef.current = size; + setMaxContentCross(size); + } + }; + + return { maxContentCross, crossGeneration, handleContentCrossLayout }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b130cef8..158343a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,6 +506,12 @@ importers: '@types/react': specifier: 'catalog:' version: 19.2.14 + '@types/react-reconciler': + specifier: 'catalog:' + version: 0.33.0(@types/react@19.2.14) + react-reconciler: + specifier: 0.33.0 + version: 0.33.0(react@19.2.5) packages/react-native-lightning: dependencies: