Skip to content

Commit e09bf8a

Browse files
serpentbladeclaude
andcommitted
fix(53-05): land Solid CR-01 convergence + React off-window focus (two residuals)
ea8e493 fixed the CR-01 recycled-row remeasure core but shipped two per-target residuals, both root-caused in the debug session: 1. Solid CR-01 under-convergence — Solid's <For> commits recycled <tr>s synchronously in the windowVer tick, so a once-per-frame rAF sweep (a full frame later) missed most rows under a 40ms-step scroll and getTotalSize converged to ~23k vs the >=24k floor. Fix: two-pass scheduleRemeasure (microtask THEN rAF) — the microtask catches the sync-committed fine-grained rows, the rAF catches React's async commit. 2. React off-window scroll-then-focus — the one-shot double-rAF fired before React's async setState->reconcile->commit painted the far row, so resolveCellEl returned null and focus was lost. Fix: bounded rAF-poll-until-cell-present (cap 30 frames); the five fast targets resolve on attempt 1 (unchanged), React retries across its commit frames. Both changes inside the $props.virtual guard; non-virtual r-else path byte-identical (dist-parity zero drift, 875 pass). Verified 66/66 in the pinned Linux container (data-table-grid + data-table-virtual) and per-target locally; typecheck 13/13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wvxhsB5hcDKD8UadLLSyw
1 parent ea8e493 commit e09bf8a

7 files changed

Lines changed: 302 additions & 67 deletions

File tree

packages/ui/data-table/packages/angular/src/DataTable.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,11 +1091,19 @@ export class DataTable {
10911091
scheduleRemeasure = () => {
10921092
if (this.remeasurePending) return;
10931093
this.remeasurePending = true;
1094-
const run = () => {
1094+
let ranMicro = false;
1095+
const microPass = () => {
1096+
this.remeasureWindow();
1097+
};
1098+
const rafPass = () => {
10951099
this.remeasurePending = false;
10961100
this.remeasureWindow();
10971101
};
1098-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);else if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else setTimeout(run, 0);
1102+
if (typeof queueMicrotask !== 'undefined') {
1103+
ranMicro = true;
1104+
queueMicrotask(microPass);
1105+
}
1106+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
10991107
};
11001108
windowedRows = () => {
11011109
const __rows = this.rows();
@@ -1470,11 +1478,26 @@ export class DataTable {
14701478
this.virtualizer.scrollToIndex(r, {
14711479
align: 'center'
14721480
});
1473-
const focusNow = () => {
1481+
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
1482+
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
1483+
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
1484+
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
1485+
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
1486+
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
1487+
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
1488+
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
1489+
let focusAttempts = 0;
1490+
const focusWhenReady = () => {
14741491
const el = this.resolveCellEl(String(r), c);
1475-
if (el) el.focus();
1492+
if (el) {
1493+
el.focus();
1494+
return;
1495+
}
1496+
focusAttempts = focusAttempts + 1;
1497+
if (focusAttempts >= 30) return;
1498+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
14761499
};
1477-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(focusNow));else setTimeout(focusNow, 0);
1500+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
14781501
return;
14791502
}
14801503
const rowKey = header ? '__header' : String(r);

packages/ui/data-table/packages/lit/src/DataTable.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,11 +1108,19 @@ ${this.virtual ? html`<div class="rdt-scroll" style=${this.maxHeight ? 'max-heig
11081108
scheduleRemeasure = () => {
11091109
if (this.remeasurePending) return;
11101110
this.remeasurePending = true;
1111-
const run = () => {
1111+
let ranMicro = false;
1112+
const microPass = () => {
1113+
this.remeasureWindow();
1114+
};
1115+
const rafPass = () => {
11121116
this.remeasurePending = false;
11131117
this.remeasureWindow();
11141118
};
1115-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);else if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else setTimeout(run, 0);
1119+
if (typeof queueMicrotask !== 'undefined') {
1120+
ranMicro = true;
1121+
queueMicrotask(microPass);
1122+
}
1123+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
11161124
};
11171125

11181126
windowedRows = () => {
@@ -1554,11 +1562,26 @@ ${this.virtual ? html`<div class="rdt-scroll" style=${this.maxHeight ? 'max-heig
15541562
this.virtualizer.scrollToIndex(r, {
15551563
align: 'center'
15561564
});
1557-
const focusNow = () => {
1565+
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
1566+
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
1567+
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
1568+
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
1569+
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
1570+
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
1571+
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
1572+
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
1573+
let focusAttempts = 0;
1574+
const focusWhenReady = () => {
15581575
const el = this.resolveCellEl(String(r), c);
1559-
if (el) el.focus();
1576+
if (el) {
1577+
el.focus();
1578+
return;
1579+
}
1580+
focusAttempts = focusAttempts + 1;
1581+
if (focusAttempts >= 30) return;
1582+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
15601583
};
1561-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(focusNow));else setTimeout(focusNow, 0);
1584+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
15621585
return;
15631586
}
15641587
const rowKey = header ? '__header' : String(r);

packages/ui/data-table/packages/react/src/DataTable.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -492,11 +492,19 @@ const DataTable = forwardRef<DataTableHandle, DataTableProps>(function DataTable
492492
function scheduleRemeasure() {
493493
if (remeasurePending.current) return;
494494
remeasurePending.current = true;
495-
const run = () => {
495+
let ranMicro = false;
496+
const microPass = () => {
497+
remeasureWindow();
498+
};
499+
const rafPass = () => {
496500
remeasurePending.current = false;
497501
remeasureWindow();
498502
};
499-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);else if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else setTimeout(run, 0);
503+
if (typeof queueMicrotask !== 'undefined') {
504+
ranMicro = true;
505+
queueMicrotask(microPass);
506+
}
507+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending.current = false;else setTimeout(rafPass, 0);
500508
}
501509
function windowedRows() {
502510
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
@@ -897,11 +905,26 @@ const DataTable = forwardRef<DataTableHandle, DataTableProps>(function DataTable
897905
virtualizer.current.scrollToIndex(r, {
898906
align: 'center'
899907
});
900-
const focusNow = () => {
908+
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
909+
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
910+
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
911+
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
912+
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
913+
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
914+
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
915+
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
916+
let focusAttempts = 0;
917+
const focusWhenReady = () => {
901918
const el = resolveCellEl(String(r), c);
902-
if (el) el.focus();
919+
if (el) {
920+
el.focus();
921+
return;
922+
}
923+
focusAttempts = focusAttempts + 1;
924+
if (focusAttempts >= 30) return;
925+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
903926
};
904-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(focusNow));else setTimeout(focusNow, 0);
927+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
905928
return;
906929
}
907930
const rowKey = header ? '__header' : String(r);

packages/ui/data-table/packages/solid/src/DataTable.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -914,19 +914,40 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
914914
};
915915
}
916916

917-
// Defer remeasureWindow() one frame (the recycled <tr> nodes must exist in the DOM first —
918-
// onChange fires BEFORE React/Solid commit the new window), falling back to a microtask/timeout
919-
// where rAF is unavailable (SSR / test envs). DEDUPED via remeasurePending so a scroll burst
920-
// queues at most one sweep per frame (piled-up rAF sweeps broke the Solid scroll-then-focus
921-
// seam). The sweep clears the flag first, then measures.
917+
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
918+
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
919+
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
920+
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
921+
// polls for its target cell, so it no longer depends on remeasure timing).
922+
//
923+
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
924+
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
925+
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
926+
// microtask — measuring there observes them while they are still connected, BEFORE the next
927+
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
928+
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
929+
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
930+
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
931+
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
932+
// committed yet), so the rAF pass is what observes React's recycled rows.
933+
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
934+
// already-observed node, so running both is cheap and loop-free.
922935
function scheduleRemeasure() {
923936
if (remeasurePending) return;
924937
remeasurePending = true;
925-
const run = () => {
938+
let ranMicro = false;
939+
const microPass = () => {
940+
remeasureWindow();
941+
};
942+
const rafPass = () => {
926943
remeasurePending = false;
927944
remeasureWindow();
928945
};
929-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);else if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else setTimeout(run, 0);
946+
if (typeof queueMicrotask !== 'undefined') {
947+
ranMicro = true;
948+
queueMicrotask(microPass);
949+
}
950+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
930951
}
931952

932953
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
@@ -1534,11 +1555,26 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
15341555
virtualizer.scrollToIndex(r, {
15351556
align: 'center'
15361557
});
1537-
const focusNow = () => {
1558+
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
1559+
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
1560+
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
1561+
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
1562+
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
1563+
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
1564+
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
1565+
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
1566+
let focusAttempts = 0;
1567+
const focusWhenReady = () => {
15381568
const el = resolveCellEl(String(r), c);
1539-
if (el) el.focus();
1569+
if (el) {
1570+
el.focus();
1571+
return;
1572+
}
1573+
focusAttempts = focusAttempts + 1;
1574+
if (focusAttempts >= 30) return;
1575+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
15401576
};
1541-
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(focusNow));else setTimeout(focusNow, 0);
1577+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
15421578
return;
15431579
}
15441580
const rowKey = header ? '__header' : String(r);

packages/ui/data-table/packages/svelte/src/DataTable.svelte

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
677703
const 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

Comments
 (0)