Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/virtuallist-cross-reset-on-reveal.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion packages/react-lightning-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -193,13 +194,8 @@ function VirtualListInner<T>(
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
Expand Down Expand Up @@ -395,13 +391,6 @@ function VirtualListInner<T>(
}
};

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;

Expand Down Expand Up @@ -689,19 +678,6 @@ function VirtualListInner<T>(
// 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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<null>,
});

const container = reconciler.createContainer(
{},
0,
null,
false,
null,
'',
noop,
noop,
noop,
noop,
);

return {
render: (element: Parameters<typeof reconciler.updateContainerSync>[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(
<Activity mode={mode}>
<Probe data={data} extraData={extraData} />
</Activity>,
);

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);
});
});
Original file line number Diff line number Diff line change
@@ -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 };
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading