diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 17c87654d..d8b6d4d18 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -379,10 +379,21 @@ export function sumAboveAnchorShift( * instruments agreed on a real above-anchor reflow this call; `residual` is the * `|aboveShift|` the walk saw — the second observer of the same realization * sees this at ~0 because the first observer's walk already refreshed the cache. + * + * `signedShift` is `aboveShift` WITHOUT the abs — diagnostic-only, read by the + * slow-scroll classifier. Its sign is the grow/shrink discriminator the escape + * counter cannot recover from magnitude alone: `> 0` = content above grew, the + * anchor was pushed down and the correction WRITE is the felt backward snap + * (absorption's amortizable topology); `< 0` = content above shrank, the reflow + * itself pulls the anchor up and renders the reversal BEFORE any write touches + * it (structurally uncorrectable by us — only smaller per-frame realization + * helps). `residual = |signedShift|` throws that sign away, so the classifier + * reads `signedShift` directly. Not consumed in production. */ type MidHistoryCorrection = { wouldFire: boolean; residual: number; + signedShift: number; }; /** @@ -421,7 +432,7 @@ function applyMidHistoryCorrection( Math.abs(currentScrollTop - baseline.scrollTop) > COMPENSATION_SCROLL_SKIP_PX ) { - return { wouldFire: false, residual: 0 }; + return { wouldFire: false, residual: 0, signedShift: 0 }; } const containerTop = container.getBoundingClientRect().top; const currentTopOffset = @@ -445,15 +456,16 @@ function applyMidHistoryCorrection( (baseline.scrollTop + baseline.topOffset); const residual = Math.abs(aboveShift); const target = computeAnchorCorrection(baseline, current); - if (target === null) return { wouldFire: false, residual }; + if (target === null) + return { wouldFire: false, residual, signedShift: aboveShift }; // Fire only when the two instruments agree — sufficiency cross-check that the // net shift is a real above-anchor reflow, not a straddler miscount. if (Math.abs(aboveShift - observedShift) > 0.5) { - return { wouldFire: false, residual }; + return { wouldFire: false, residual, signedShift: aboveShift }; } // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). container.scrollTop = target; - return { wouldFire: true, residual }; + return { wouldFire: true, residual, signedShift: aboveShift }; } /** @@ -467,7 +479,7 @@ function applyMidHistoryCorrection( * this string whenever the correction mechanism under test changes so a stale * bundle can never masquerade as the current experiment. */ -const ANCHOR_BUILD_STAMP = "w4a-ungated-ro-2"; +const ANCHOR_BUILD_STAMP = "w4a-classifier-1"; /** * Test-only tripwire hook. In production `window.__ANCHOR_PROBE__` is undefined @@ -491,6 +503,7 @@ function reportCorrection( source: "raf" | "ro"; wouldFire: boolean; residual: number; + signedShift: number; }>; __ANCHOR_BUILD_STAMP__?: string; } @@ -503,6 +516,7 @@ function reportCorrection( source, wouldFire: result.wouldFire, residual: result.residual, + signedShift: result.signedShift, }); } } @@ -944,7 +958,11 @@ export function useAnchoredScroll({ if (Math.abs(height - last) > 0.5) changed = true; } if (!changed) { - reportCorrection("ro", { wouldFire: false, residual: 0 }); + reportCorrection("ro", { + wouldFire: false, + residual: 0, + signedShift: 0, + }); return; } // Correct from the anchor's own measured drift. The layout engine already @@ -956,7 +974,11 @@ export function useAnchoredScroll({ baseline.row.getBoundingClientRect().top - containerTop; const drift = currentTopOffset - baseline.topOffset; if (Math.abs(drift) <= 0.5) { - reportCorrection("ro", { wouldFire: false, residual: Math.abs(drift) }); + reportCorrection("ro", { + wouldFire: false, + residual: Math.abs(drift), + signedShift: drift, + }); return; } // Synchronous setter (not `scrollTo`, which WebKit may defer past paint). @@ -964,7 +986,11 @@ export function useAnchoredScroll({ // Re-baseline so a second RO batch this frame measures from where we // pinned, not the pre-correction position. readingAnchorRef.current = snapshotReadingAnchor(container); - reportCorrection("ro", { wouldFire: true, residual: Math.abs(drift) }); + reportCorrection("ro", { + wouldFire: true, + residual: Math.abs(drift), + signedShift: drift, + }); }); // Observe every timeline row (not the content wrapper): a // `content-visibility: auto` row realizing to its true height is a resize diff --git a/desktop/tests/e2e/upscroll-raf-correction.perf.ts b/desktop/tests/e2e/upscroll-raf-correction.perf.ts index 3da0656ec..f01687752 100644 --- a/desktop/tests/e2e/upscroll-raf-correction.perf.ts +++ b/desktop/tests/e2e/upscroll-raf-correction.perf.ts @@ -47,7 +47,7 @@ const REVERSAL_PX = 3; // is `tsc && vite build`, and a tsc failure leaves the PRIOR bundle in `dist/`, // so a fixture can silently exercise stale code. Must equal `ANCHOR_BUILD_STAMP` // in `useAnchoredScroll.ts`; bump BOTH together per experiment. -const EXPECTED_BUILD_STAMP = "w4a-ungated-ro-2"; +const EXPECTED_BUILD_STAMP = "w4a-classifier-1"; type Frame = { t: number; diff --git a/desktop/tests/e2e/upscroll-slow-scroll.perf.ts b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts index 717cabb19..cacec49ea 100644 --- a/desktop/tests/e2e/upscroll-slow-scroll.perf.ts +++ b/desktop/tests/e2e/upscroll-slow-scroll.perf.ts @@ -39,7 +39,7 @@ const SAFE_MARGIN = 100; const REVERSAL_PX = 3; // Must equal `ANCHOR_BUILD_STAMP` in `useAnchoredScroll.ts` — stale-`dist` // guard (see the gate fixture). Bump BOTH together per experiment. -const EXPECTED_BUILD_STAMP = "w4a-ungated-ro-2"; +const EXPECTED_BUILD_STAMP = "w4a-classifier-1"; type Frame = { t: number; @@ -47,6 +47,7 @@ type Frame = { scrollTop: number; mounted: number; rowId: string | null; + probeLen: number; }; test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", async ({ @@ -85,19 +86,35 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a await page.waitForTimeout(200); await timeline.hover(); - // Per-frame sampler (identical to the gate: one clock, same row-tracking) so - // the two legs' distributions are directly comparable. + // Per-frame sampler (identical row-tracking to the gate, plus a join key into + // the hook's correction probe). The fixture sampler and the hook's rAF sampler + // are SEPARATE rAF loops, so "the correction for frame i" is not reliably the + // same-tick probe entry (two rAF callbacks in one frame fire in registration + // order, which we don't control). The order-robust join is by APPEND COUNT: + // each frame records `probeLen` (the correction-probe array length at that + // tick) and the signed shift + fire flag of any attempts that appended since + // the previous frame. A reversal between frame i-1 and i is then attributed to + // the attempts in that interval — no same-tick ordering assumption. await timeline.evaluate((element, margin: number) => { const el = element as HTMLDivElement; const w = window as unknown as { __PROBE__: { frames: Frame[]; stop: boolean }; }; + const g = globalThis as unknown as { + __ANCHOR_PROBE__?: Array<{ + wouldFire: boolean; + residual: number; + signedShift: number; + }>; + }; type Frame = { t: number; top: number | null; scrollTop: number; mounted: number; rowId: string | null; + // Correction-probe array length at this tick — the append-count join key. + probeLen: number; }; w.__PROBE__ = { frames: [], stop: false }; let trackedId: string | null = null; @@ -134,6 +151,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a scrollTop: el.scrollTop, mounted, rowId: trackedId, + probeLen: g.__ANCHOR_PROBE__?.length ?? 0, }); requestAnimationFrame(tick); }; @@ -166,6 +184,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a scrollTop: number; mounted: number; rowId: string | null; + probeLen: number; }; w.__PROBE__.stop = true; return w.__PROBE__.frames; @@ -177,6 +196,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a source: "raf" | "ro"; wouldFire: boolean; residual: number; + signedShift: number; }>; __ANCHOR_BUILD_STAMP__?: string; }; @@ -186,15 +206,34 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a }; }); - // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. Pair each - // with the per-frame rendered scroll delta (dScroll) so we can see whether a - // reversal lands on a near-still frame (the felt case) or rides momentum. + // Score same-row frame pairs. A reversal is rowMove <= -REVERSAL_PX. For each + // reversal, join to the correction attempt(s) that appended to the hook probe + // BETWEEN frame i-1 and i (append-count window: probe indices [a.probeLen, + // b.probeLen)). Classify by the SIGN of aboveShift + whether the write fired — + // the three-way discriminator Sami specced (thread event 5b46582e): + // • wouldFire == false → SKIP: momentum gate (:29) / cross- + // check (:451) suppressed the write. The reversal is UNCORRECTED reflow; + // absorption never got to act. A 27→27 here = wiring/gate, not physics. + // • fired, signedShift > 0 (GROW) → content above grew, anchor shoved + // DOWN, the correction WRITE is the felt backward snap. Absorption's + // amortizable topology — deferring the pullback into forward frames helps. + // • fired, signedShift < 0 (SHRINK) → content above shrank, the reflow + // ITSELF pulls the anchor up and renders the reversal before any write. + // Structurally uncorrectable by us; only smaller per-frame realization + // (Max's pre-realization band / contain-intrinsic-size) shrinks it. + // A reversal with no attempt in its window is UNATTRIBUTED (the correcting + // observer's attempt landed in an adjacent frame under rAF interleave) — we + // count it separately rather than force it into a class. let scored = 0; let reanchors = 0; + type Klass = "skip" | "grow" | "shrink" | "unattributed"; const reversals: Array<{ i: number; rowMove: number; dScroll: number; + signedShift: number | null; + fired: boolean; + klass: Klass; rowId: string | null; }> = []; for (let i = 1; i < frames.length; i += 1) { @@ -212,9 +251,36 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a scored += 1; const rowMove = b.top - a.top; const dScroll = b.scrollTop - a.scrollTop; - if (rowMove <= -REVERSAL_PX) { - reversals.push({ i, rowMove, dScroll, rowId: b.rowId }); + if (rowMove > -REVERSAL_PX) continue; + // Attempts appended in this frame's window; pick the one whose |signedShift| + // is largest (the dominant reflow this frame drives the felt motion). + const window = corrections.slice(a.probeLen, b.probeLen); + let attempt: (typeof corrections)[number] | null = null; + for (const c of window) { + if ( + attempt === null || + Math.abs(c.signedShift) > Math.abs(attempt.signedShift) + ) { + attempt = c; + } } + let klass: Klass; + if (attempt === null) { + klass = "unattributed"; + } else if (!attempt.wouldFire) { + klass = "skip"; + } else { + klass = attempt.signedShift >= 0 ? "grow" : "shrink"; + } + reversals.push({ + i, + rowMove, + dScroll, + signedShift: attempt?.signedShift ?? null, + fired: attempt?.wouldFire ?? false, + klass, + rowId: b.rowId, + }); } const maxReversalPx = @@ -226,6 +292,7 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a const stillFrameReversals = reversals.filter( (r) => Math.abs(r.dScroll) < REVERSAL_PX, ); + const byClass = (k: Klass) => reversals.filter((r) => r.klass === k).length; /* eslint-disable no-console */ console.log("\n=== W4a SLOW-SCROLL CHARACTERIZATION ==="); @@ -237,12 +304,24 @@ test("W4a slow-scroll: reversal distribution in the felt low-velocity regime", a console.log(`reversal frames: ${reversals.length}`); console.log(` of which still-frame: ${stillFrameReversals.length}`); console.log(`max reversal px: ${maxReversalPx.toFixed(1)}`); + console.log("--- reversal class mix (Sami's discriminator) ---"); + console.log(` SKIP (gate/xcheck, uncorrected reflow): ${byClass("skip")}`); + console.log( + ` GROW (fired, write is the snap — absorb): ${byClass("grow")}`, + ); + console.log( + ` SHRINK (fired, reflow renders it — Max): ${byClass("shrink")}`, + ); + console.log( + ` UNATTRIBUTED (no attempt in window): ${byClass("unattributed")}`, + ); for (const r of reversals .slice() .sort((x, y) => x.rowMove - y.rowMove) .slice(0, 12)) { + const s = r.signedShift === null ? "n/a" : r.signedShift.toFixed(1); console.log( - ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} row=${r.rowId}`, + ` frame ${r.i} rowMove=${r.rowMove.toFixed(1)} dScroll=${r.dScroll.toFixed(1)} signedShift=${s} fired=${r.fired} class=${r.klass} row=${r.rowId}`, ); } console.log("========================================\n");