fix(library): stop the wall's window loop that blanked the screen

Selecting thumbs and flipping SINGLE <-> ALL THUMBS crashed the view with
React #185 ("Maximum update depth exceeded") and left the app white.

useWallWindow measures after every render through a layout effect with no
dependency array, and it ended by setting state with a value it had just
found. A set made from a layout effect renders again whether or not the value
moved, so an unchanged measurement re-rendered, which remeasured, which set
again: the loop. It is now guarded on the last window actually written (a
ref), and nothing is set that the wall already stands at.

Reproduced with the throwaway storm probe against a 3000-file roll (the tree
vanished at round 2, ~50 identical renders). After the fix: 12 rounds of hard
SINGLE/ALL THUMBS toggling on the production bundle, no error, no white
screen.
This commit is contained in:
2026-10-09 17:10:44 +07:00
parent 3433c9da8a
commit fa2e9e133e
+18 -4
View File
@@ -2937,6 +2937,13 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// The first window is a screenful: the wall has not been measured yet. // The first window is a screenful: the wall has not been measured yet.
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 })); const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 }));
// The window last written, because the reader of a `setWin` in a layout effect
// is not the state: a set made from a layout effect renders again whether or
// not the value moved, and this one is remeasured after every render — so a set
// it did not need is the screen's own render loop (React #185, "Maximum update
// depth exceeded"). The ref is the guard: nothing is set that the wall already
// stands at.
const winRef = useRef(win);
const measureRef = useRef<() => void>(() => {}); const measureRef = useRef<() => void>(() => {});
// The window over a scroll container is taken rather than kept, so it is taken // The window over a scroll container is taken rather than kept, so it is taken
// after every render: the first pass draws a screenful, the next reads the row's // after every render: the first pass draws a screenful, the next reads the row's
@@ -2947,6 +2954,15 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
if (!on) return; if (!on) return;
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
// The window is only ever set through here: a `setWin` the wall already
// stands at is a render bought for nothing, and this is measured after every
// render — that is the loop (React #185).
const show = (next: { from: number; to: number; above: number; below: number }) => {
const prev = winRef.current;
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
winRef.current = next;
setWin(next);
};
const measure = () => { const measure = () => {
const cards = el.querySelectorAll<HTMLElement>(':scope > .adm-card'); const cards = el.querySelectorAll<HTMLElement>(':scope > .adm-card');
const first = cards[0]; const first = cards[0];
@@ -2956,7 +2972,7 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
// next pass read the step off what it drew. // next pass read the step off what it drew.
if (cards.length <= cols) { if (cards.length <= cols) {
const to = Math.min(count, 24); const to = Math.min(count, 24);
setWin((prev) => (prev.from === 0 && prev.to === to && !prev.above && !prev.below ? prev : { from: 0, to, above: 0, below: 0 })); show({ from: 0, to, above: 0, below: 0 });
return; return;
} }
// The step from one row to the next, taken as the drawn run's own average. // The step from one row to the next, taken as the drawn run's own average.
@@ -2978,9 +2994,7 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
// last row it stands in for. // last row it stands in for.
const above = row0 ? row0 * pitch - WALL_GAP : 0; const above = row0 ? row0 * pitch - WALL_GAP : 0;
const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0; const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0;
setWin((prev) => show({ from, to, above, below });
prev.from === from && prev.to === to && prev.above === above && prev.below === below ? prev : { from, to, above, below }
);
}; };
measureRef.current = measure; measureRef.current = measure;
const onScroll = () => measure(); const onScroll = () => measure();