@@ -664,24 +664,58 @@ const virtualizerOptions = (): any => ({
664664 }
665665});
666666
667- // Defer remeasureWindow() one frame (the recycled <tr> nodes must exist in the DOM first —
668- // onChange fires BEFORE React/Solid commit the new window), falling back to a microtask/timeout
669- // where rAF is unavailable (SSR / test envs). DEDUPED via remeasurePending so a scroll burst
670- // queues at most one sweep per frame (piled-up rAF sweeps broke the Solid scroll-then-focus
671- // seam). The sweep clears the flag first, then measures.
672- // Defer remeasureWindow() one frame (the recycled <tr> nodes must exist in the DOM first —
673- // onChange fires BEFORE React/Solid commit the new window), falling back to a microtask/timeout
674- // where rAF is unavailable (SSR / test envs). DEDUPED via remeasurePending so a scroll burst
675- // queues at most one sweep per frame (piled-up rAF sweeps broke the Solid scroll-then-focus
676- // seam). The sweep clears the flag first, then measures.
667+ // Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
668+ // BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
669+ // test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
670+ // (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
671+ // polls for its target cell, so it no longer depends on remeasure timing).
672+ //
673+ // TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
674+ // - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
675+ // tick that the windowVer bump triggers, so the recycled nodes already exist by the next
676+ // microtask — measuring there observes them while they are still connected, BEFORE the next
677+ // fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
678+ // fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
679+ // frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
680+ // total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
681+ // - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
682+ // committed yet), so the rAF pass is what observes React's recycled rows.
683+ // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
684+ // already-observed node, so running both is cheap and loop-free.
685+ // Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
686+ // BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
687+ // test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
688+ // (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
689+ // polls for its target cell, so it no longer depends on remeasure timing).
690+ //
691+ // TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
692+ // - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
693+ // tick that the windowVer bump triggers, so the recycled nodes already exist by the next
694+ // microtask — measuring there observes them while they are still connected, BEFORE the next
695+ // fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
696+ // fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
697+ // frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
698+ // total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
699+ // - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
700+ // committed yet), so the rAF pass is what observes React's recycled rows.
701+ // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
702+ // already-observed node, so running both is cheap and loop-free.
677703const scheduleRemeasure = () => {
678704 if (remeasurePending ) return ;
679705 remeasurePending = true ;
680- const run = () => {
706+ let ranMicro = false ;
707+ const microPass = () => {
708+ remeasureWindow ();
709+ };
710+ const rafPass = () => {
681711 remeasurePending = false ;
682712 remeasureWindow ();
683713 };
684- if (typeof requestAnimationFrame === ' function' ) requestAnimationFrame (run );else if (typeof queueMicrotask !== ' undefined' ) queueMicrotask (run );else setTimeout (run , 0 );
714+ if (typeof queueMicrotask !== ' undefined' ) {
715+ ranMicro = true ;
716+ queueMicrotask (microPass );
717+ }
718+ if (typeof requestAnimationFrame === ' function' ) requestAnimationFrame (rafPass );else if (ranMicro ) remeasurePending = false ;else setTimeout (rafPass , 0 );
685719};
686720
687721// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
@@ -1430,11 +1464,26 @@ const focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null) =>
14301464 virtualizer .scrollToIndex (r , {
14311465 align: ' center'
14321466 });
1433- const focusNow = () => {
1467+ // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
1468+ // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
1469+ // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
1470+ // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
1471+ // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
1472+ // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
1473+ // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
1474+ // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
1475+ let focusAttempts = 0 ;
1476+ const focusWhenReady = () => {
14341477 const el = resolveCellEl (String (r ), c );
1435- if (el ) el .focus ();
1478+ if (el ) {
1479+ el .focus ();
1480+ return ;
1481+ }
1482+ focusAttempts = focusAttempts + 1 ;
1483+ if (focusAttempts >= 30 ) return ;
1484+ if (typeof requestAnimationFrame === ' function' ) requestAnimationFrame (focusWhenReady );else setTimeout (focusWhenReady , 16 );
14361485 };
1437- if (typeof requestAnimationFrame === ' function' ) requestAnimationFrame (() => requestAnimationFrame ( focusNow )) ;else setTimeout (focusNow , 0 );
1486+ if (typeof requestAnimationFrame === ' function' ) requestAnimationFrame (focusWhenReady ) ;else setTimeout (focusWhenReady , 0 );
14381487 return ;
14391488 }
14401489 const rowKey = header ? ' __header' : String (r );
0 commit comments