diff --git a/temp-screenshots/older-history-at-rest/older-history-loads-at-rest.gif b/temp-screenshots/older-history-at-rest/older-history-loads-at-rest.gif
new file mode 100644
index 00000000000..fcc4d85d7f7
Binary files /dev/null and b/temp-screenshots/older-history-at-rest/older-history-loads-at-rest.gif differ
diff --git a/website/src/components/FileChangeChips.tsx b/website/src/components/FileChangeChips.tsx
index ce0e2f44075..65fb235d6b2 100644
--- a/website/src/components/FileChangeChips.tsx
+++ b/website/src/components/FileChangeChips.tsx
@@ -117,7 +117,10 @@ function CollapsedRowHeader({ fc, added, removed, isArtifact, onFileOpen, onTogg
removed: number
isArtifact?: boolean
onFileOpen?: (path: string) => void
- onToggle: () => void
+ /** Absent when the row has nothing to disclose. The control is then withheld
+ * rather than rendered inert: a chevron that does nothing is worse than none,
+ * because it invites the tap that makes the row look broken. */
+ onToggle?: () => void
}) {
const name = basename(fc.path)
return (
@@ -129,15 +132,21 @@ function CollapsedRowHeader({ fc, added, removed, isArtifact, onFileOpen, onTogg
data-testid={`fcc-header-${fc.path}`}
className="flex items-center gap-2 min-h-[36px] px-[10px] py-1.5 bg-[color-mix(in_srgb,var(--bg-elevated)_50%,var(--bg))] font-mono text-[12px] leading-[18px] text-muted"
>
- { e.stopPropagation(); onToggle() }}
- aria-expanded={false}
- aria-label={i18nT('components.fileChangeChips.toggle_diff', { path: fc.path })}
- className="shrink-0 flex items-center justify-center w-[16px] h-[16px] rounded text-muted hover:text-text cursor-pointer bg-transparent border-none"
- >
-
-
+ {onToggle ? (
+ { e.stopPropagation(); onToggle() }}
+ aria-expanded={false}
+ aria-label={i18nT('components.fileChangeChips.toggle_diff', { path: fc.path })}
+ className="shrink-0 flex items-center justify-center w-[16px] h-[16px] rounded text-muted hover:text-text cursor-pointer bg-transparent border-none"
+ >
+
+
+ ) : (
+ /* Same box, so the filename does not shift left on the rows that have
+ nothing to disclose and the column stays aligned down the card. */
+
+ )}
{onFileOpen ? (
({ name, contents: fc.before }), [name, fc.before])
- const newFile = useMemo(() => ({ name, contents: fc.after }), [name, fc.after])
+ // PIN the contents at the moment the row opened. A row can be re-rendered with
+ // a replaced payload while it is open (a slot-list refresh, a later turn editing
+ // the same file), and swapping the diff out from under a reader mid-read is the
+ // defect this prevents: they lose their place in a diff they were still reading.
+ // The next open takes whatever the contents are by then. Pierre also derives the
+ // expanded header's own +/- numbers from these same contents, so body and header
+ // stay consistent by construction; the COLLAPSED row's counts come from the card
+ // and keep describing the change as it currently stands.
+ //
+ // Derived during render rather than through state, so opening a row still costs
+ // exactly ONE Pierre render — a `setPinned` effect would add a second pass on
+ // every open, which is churn on the most render-sensitive rows in the app. The
+ // write is idempotent and reads only this render's props, so a double-invoked
+ // render reaches the same pin.
+ const fcBefore = fc.before ?? ''
+ const fcAfter = fc.after ?? ''
+ const pinRef = useRef<{ before: string; after: string } | null>(null)
+ if (!renderPierre) pinRef.current = null
+ else if (!pinRef.current) pinRef.current = { before: fcBefore, after: fcAfter }
+ const before = pinRef.current ? pinRef.current.before : fcBefore
+ const after = pinRef.current ? pinRef.current.after : fcAfter
+ const oldFile = useMemo(() => ({ name, contents: before }), [name, before])
+ const newFile = useMemo(() => ({ name, contents: after }), [name, after])
// Depend on WHETHER a file-open handler exists, never on its identity: the
// options only splice a CSS block in when the title is clickable, so an
// unstable callback from a parent must not re-create `options` — Pierre
@@ -367,6 +403,23 @@ function ExpandedRow({ fc, added, removed, isArtifact, onFileOpen, disclosureKey
fallbackClassName="max-h-[376px] overflow-auto"
fallbackContentStyle={FALLBACK_CONTENT_STYLE}
onVisible={completeOpenFocus}
+ // Opening SWAPS this row's own header out for Pierre's, and Pierre's
+ // arrives only once the impl paints — so without this the row loses
+ // its filename, counts and disclosure control for the whole warm
+ // window, which reads as the row flashing away and coming back. The
+ // collapsed header is the same strip, so keeping it up bridges the
+ // handoff; WarmSwap drops it the moment the real header exists, and
+ // clips the fallback to the known painted height, so it adds nothing.
+ fallbackHeader={() => (
+
+ )}
renderHeaderPrefix={prefix}
renderHeaderFilenameSuffix={filenameSuffix}
renderHeaderMetadata={metadata}
@@ -378,7 +431,7 @@ function ExpandedRow({ fc, added, removed, isArtifact, onFileOpen, disclosureKey
removed={removed}
isArtifact={isArtifact}
onFileOpen={onFileOpen}
- onToggle={toggle}
+ onToggle={nothingToShow ? undefined : toggle}
/>
)}
diff --git a/website/src/dev/scrollInspector.ts b/website/src/dev/scrollInspector.ts
index ebcdc5527f1..24916a2ca78 100644
--- a/website/src/dev/scrollInspector.ts
+++ b/website/src/dev/scrollInspector.ts
@@ -174,6 +174,10 @@ function ensureHost(): HTMLDivElement | null {
liveEl = document.createElement('div')
liveEl.style.cssText = 'color:#fde047;font-size:11px;font-weight:700;margin-bottom:2px'
+ // Addressable so a width guard can measure THIS block. Reading the host's
+ // textContent instead joins the last live line to the first log line with no
+ // separator, which reads as one over-long line that does not exist.
+ liveEl.setAttribute('data-scroll-inspector-live', '')
logEl = document.createElement('div')
host.appendChild(gripEl)
@@ -242,6 +246,7 @@ function teardown(): void {
lines.length = 0
sticky.leave = ''
sticky.entry = ''
+ corr = null
watched = null
watchedRows = -1
watchedMsgs = -1
@@ -250,9 +255,252 @@ function teardown(): void {
// ---- public feed ----
+/** Durable tally of who loaded older pages, keyed by the producer's own label.
+ *
+ * The event log is a short ring, so by the time a reader notices the loaded
+ * count has run away, the lines naming WHICH producer did it have already
+ * scrolled off — leaving a screenshot that proves the count and nothing about
+ * its cause. A count survives the ring, and the to-top reading AT EACH FIRE is
+ * what tells the producers apart: the near-top walk can only fire within a few
+ * viewports of the head, while the pinned/deep-link jump pages from anywhere,
+ * so a large `max` is by itself an attribution. */
+const olderTally = new Map()
+
+/** Older-page latency, split into the two halves that have different owners.
+ *
+ * "Loading is too slow" is unactionable until it says WHICH half. `fetch` is
+ * the request — the backend's work plus the wire. `paint` is everything after
+ * the data is in the store until the page is really on screen: reducer,
+ * regroup, render, and the measurement that replaces estimated heights with
+ * real ones. The reader experiences the end of the SECOND one, so a single
+ * round-trip number can hide an 11s wait behind a 200ms paint, or the reverse.
+ *
+ * Kept as last + worst rather than a log line: the event ring holds ~8 lines,
+ * so after a long scroll the numbers that mattered have scrolled off, which is
+ * exactly how the first runaway lost its own evidence. */
+let olderLat: { fetchMs: number; paintMs: number; worstMs: number; n: number } | null = null
+/** Set by the paging thunk. Reports the REQUEST only: the deliberate landing hold
+ * it used to be split against is gone, so there is no second span to tell it
+ * apart from. Reinstating a hold means re-adding a second number here, which is
+ * a visible edit rather than a constant nobody notices. */
+let olderSpans: { netMs: number } | null = null
+
+export function devOlderSpans(netMs: number): void {
+ if (!enabled) return
+ olderSpans = { netMs }
+ if (ensureHost()) paint()
+}
+
+export function devOlderLatency(fetchMs: number, paintMs: number): void {
+ if (!enabled) return
+ const total = fetchMs + paintMs
+ const prev = olderLat
+ olderLat = {
+ fetchMs,
+ paintMs,
+ worstMs: Math.max(total, prev ? prev.worstMs : 0),
+ n: (prev ? prev.n : 0) + 1,
+ }
+ if (ensureHost()) paint()
+}
+
+/** Programmatic scroll movement, accumulated per older-page landing.
+ *
+ * This is the reader's complaint measured directly. The bounce is NOT the change
+ * in scrollTop across a landing -- that conflates the finger with the machine --
+ * it is the sum of the writes the app itself performed. Every one is already
+ * logged as `WRITE ->`, but the event ring holds ~8 lines, so
+ * after three landings in one scroll the deltas that mattered have scrolled off.
+ *
+ * Summed as ABSOLUTE values: two compensations of +400 and -400 are two visible
+ * jolts, not a quiet zero, and a net figure would report them as perfect. */
+let moved: { px: number; n: number; worstPx: number; writers: string } | null = null
+
+/** Re-pricing of the content ABOVE the reader, accumulated per landing.
+ *
+ * The top spacer's height is the offset tree's answer for everything above the
+ * mounted window. It changes for two unrelated reasons, and only one of them is
+ * a defect: the window moving (the reader scrolled -- expected), and the tree
+ * REPRICING rows it had never measured (a row first mounts, its real height
+ * replaces the running mean, and everything below it shifts).
+ *
+ * Isolated by accumulating only across renders where the window START did not
+ * move. What remains is displacement nobody asked for -- and comparing it with
+ * `moved` says whether it was compensated: repriced with no matching write is
+ * exactly the bounce the reader feels while scrolling through a region that
+ * has just loaded. */
+let repriced: { px: number; n: number; worstPx: number } | null = null
+let lastSpacer: { px: number; start: number } | null = null
+
+function noteSpacer(spacerPx: number, start: number): void {
+ const prev = lastSpacer
+ lastSpacer = { px: spacerPx, start }
+ // Recorded ahead of every other rule, because every other rule excludes it.
+ if (landing && landing.samples < LANDING_SAMPLES) {
+ if (landing.from === null) landing.from = prev ? prev.px : spacerPx
+ landing.to = spacerPx
+ landing.samples += 1
+ }
+ if (!prev || prev.start !== start) return
+ const d = Math.abs(spacerPx - prev.px)
+ if (!Number.isFinite(d) || d < 0.5) return
+ repriced = {
+ px: (repriced ? repriced.px : 0) + d,
+ n: (repriced ? repriced.n : 0) + 1,
+ worstPx: Math.max(d, repriced ? repriced.worstPx : 0),
+ }
+}
+
+/** Report the top spacer so a reprice above the reader can be told from the
+ * reader simply having scrolled. Cheap by design: two numbers, no reads. */
+export function devSpacer(spacerPx: number, windowStart: number): void {
+ if (!enabled) return
+ noteSpacer(spacerPx, windowStart)
+}
+
+/** Scroll movement that NO logged write accounts for.
+ *
+ * The reason this exists: an anchor correction of 13,202px was measured on the
+ * device while the transcript had grown 131px and the only logged write was
+ * -64px. Solving the anchor identity `top = C - S` for that landing gives
+ * S1 - S0 = -13,071 -- something moved the reader 13k px between the anchor's
+ * capture and its consume, through a path that emits no `WRITE` line. The
+ * correction then faithfully compensated a movement nobody can see in the log.
+ *
+ * Candidates it has to tell apart: a browser CLAMP (content briefly shorter
+ * than `scrollTop`, so the engine silently pulls it down to
+ * `scrollHeight - clientHeight`), a `scrollIntoView` somewhere, or an
+ * assignment that bypasses the write chokepoint. iOS WebKit has no scroll
+ * anchoring, so that one is already excluded.
+ *
+ * A finger is excluded by MAGNITUDE, not by asking whether input happened:
+ * even iOS momentum does not deliver a kilopixel inside one scroll event on a
+ * ~600px viewport, and a threshold cannot be fooled by a gesture that never
+ * stamped an input flag. */
+const UNOWNED_MIN_PX = 800
+let lastSampleTop: number | null = null
+let lastWrittenTo: number | null = null
+let unowned: { maxPx: number; n: number; clamps: number } | null = null
+
+/** Report the scroller's position on every scroll event, with the range limit so
+ * a CLAMP can be told from a JUMP without a second reading.
+ *
+ * The engine pulls `scrollTop` down to `scrollHeight - clientHeight` whenever
+ * the content becomes shorter than the current position, and it does so
+ * silently -- no event names it, nothing logs it. A displacement that lands
+ * exactly ON that limit is therefore a clamp; one that lands anywhere else was
+ * somebody's deliberate write. The two need opposite fixes, so guessing between
+ * them would waste the reading. */
+export function devScrollTop(top: number, maxTop?: number): void {
+ if (!enabled) return
+ const prev = lastSampleTop
+ lastSampleTop = top
+ if (prev === null || !Number.isFinite(top)) return
+ const d = Math.abs(top - prev)
+ if (d < UNOWNED_MIN_PX) return
+ // Ours if the position landed where our own last write asked it to. Compared
+ // against the write's TARGET rather than its delta: a clamp can truncate our
+ // write, and that truncation is exactly the movement being hunted.
+ if (lastWrittenTo !== null && Math.abs(top - lastWrittenTo) <= 1) return
+ const clamped = typeof maxTop === 'number' && Number.isFinite(maxTop) && Math.abs(top - maxTop) <= 1
+ unowned = {
+ maxPx: Math.max(d, unowned ? unowned.maxPx : 0),
+ n: (unowned ? unowned.n : 0) + 1,
+ clamps: (unowned ? unowned.clamps : 0) + (clamped ? 1 : 0),
+ }
+}
+
+function noteWrite(detail: string): void {
+ // ` ->` (a trailing ' smooth' is possible and ignored).
+ const m = /^(\S+)\s+(-?\d+)->(-?\d+)/.exec(detail)
+ if (!m) return
+ // A CORRECTION opens its own measurement window when no landing has opened one.
+ //
+ // The anchor correction fires without the message count changing -- a restore, a
+ // regroup and a turn-end rebuild all reach it -- while the window used to arm
+ // only on the count RISING. So a correction outside a landing was measured
+ // against the PREVIOUS landing's spacer samples, and the overlay reported
+ // `spacer 0->0` for an event it had never sampled, making a possibly-legitimate
+ // compensation read as pure error. Third time a window-alignment mistake has
+ // turned a reading into an artifact tonight, and the same shape each time: the
+ // counter and the thing it measures opened on different events.
+ //
+ // Arming here makes `from` the spacer as it stood before this write and `to` the
+ // next sample after it.
+ if (!landing && m[1] === 'resize') {
+ landing = { from: lastSpacer ? lastSpacer.px : null, to: null, samples: 0 }
+ }
+ lastWrittenTo = Number(m[3])
+ const d = Math.abs(Number(m[3]) - Number(m[2]))
+ if (!Number.isFinite(d) || d === 0) return
+ const prev = moved
+ const writers = prev && prev.writers.includes(m[1]) ? prev.writers : `${prev ? prev.writers + ',' : ''}${m[1]}`
+ moved = {
+ px: (prev ? prev.px : 0) + d,
+ n: (prev ? prev.n : 0) + 1,
+ worstPx: Math.max(d, prev ? prev.worstPx : 0),
+ writers,
+ }
+}
+
+function noteOlderProducer(detail: string): void {
+ // A landing STARTS a fresh accounting period, so the figure on screen is
+ // "movement caused by the page that just landed" rather than a session total.
+ // Deliberately NOT reset here: a fetch STARTING is not a landing, and resetting
+ // on it opened the window earlier than the spacer's, which is what made RESIDUAL
+ // compare two different landings. The reset lives in devWatchMessages.
+ // The first token is the producer: `walk p3`, `jump p12`, `sentinel`,
+ // `manual-bar`, `error-retry`. Tallying here rather than at the five dispatch
+ // sites means a producer added later is counted without being remembered.
+ const who = detail.split(' ')[0] || '?'
+ const vps = watched && watched.clientHeight > 0 ? watched.scrollTop / watched.clientHeight : 0
+ const t = olderTally.get(who) ?? { n: 0, lastVp: 0, maxVp: 0 }
+ t.n += 1
+ t.lastVp = vps
+ if (vps > t.maxVp) t.maxVp = vps
+ olderTally.set(who, t)
+}
+
+/** The compensation's OWN accounting, kept sticky.
+ *
+ * There were two residuals on this overlay and they disagreed on the same landing:
+ * a spacer-derived one printed 1579px while the compensation printed `res=0`. The
+ * compensation was right. `spacer` is not "content above the reader" -- once
+ * `windowRange.start === 0`, which is the state every top-walk ends in, prepended
+ * rows MOUNT above the anchor instead of growing the spacer, so the spacer
+ * undercounts by exactly the mounted growth and every residual measured against it
+ * is inflated by that amount. 1579px was the mounted rows, not a defect.
+ *
+ * So the sticky figure now reads the same numbers the corrector itself computed
+ * from resolved row positions (`CORR d= owed= res=`), and the spacer keeps only
+ * the job it can actually do: describing what the spacer did.
+ *
+ * Worst is kept alongside last because a single bad landing in a run of good ones
+ * is the whole complaint, and a last-only reading hides it behind the next landing.
+ */
+let corr: { n: number; lastRes: number; worstRes: number; lastOwed: number } | null = null
+function noteCorr(detail: string): void {
+ const m = /d=(-?\d+) owed=(-?\d+) res=(-?\d+)/.exec(detail)
+ if (!m) return
+ const owed = Number(m[2])
+ const res = Number(m[3])
+ // A landing that asked for nothing is not a landing; counting it would dilute
+ // the run and make a genuine residual look rarer than it is.
+ if (owed === 0 && res === 0) return
+ corr = {
+ n: (corr ? corr.n : 0) + 1,
+ lastRes: res,
+ worstRes: Math.max(corr ? corr.worstRes : 0, Math.abs(res)),
+ lastOwed: owed,
+ }
+}
+
/** Append one event line. Newest at the bottom; the buffer is a ring. */
export function devLog(tag: string, detail: string): void {
if (!enabled) return
+ if (tag === 'OLDER') noteOlderProducer(detail)
+ if (tag === 'WRITE') noteWrite(detail)
+ if (tag === 'CORR') noteCorr(detail)
const t = new Date()
const ts =
`${String(t.getMinutes()).padStart(2, '0')}:` +
@@ -279,20 +527,194 @@ export function devWatchScroller(el: HTMLElement, rows?: number): void {
/** Loaded MESSAGE count and the server's total. Distinct from row count: a row
* groups a whole turn, so rows alone cannot say whether history is arriving. */
+/** The top spacer across a LANDING, which is the one window the other two
+ * counters are blind to -- and the blindness was designed in, so it is named
+ * here rather than quietly fixed.
+ *
+ * `repriced` deliberately skips any render where the window START moved, to
+ * separate a reprice from the reader scrolling. A landing moves START (the
+ * window is re-based for the prepended rows), so the very event under
+ * investigation was excluded by that rule. `UNOWNED` samples on scroll events,
+ * and a same-frame clamp fires none.
+ *
+ * Armed by the message count RISING -- the landing itself, not the fetch start
+ * -- and closed after a few renders, so ordinary scrolling never enters. What
+ * it should show if the rebase lands a frame late: `offsetBefore` collapsing by
+ * about one page of rows, which is what the engine then clamps `scrollTop` by. */
+const LANDING_SAMPLES = 6
+let landing: { from: number | null; to: number | null; samples: number } | null = null
+
export function devWatchMessages(loaded: number, serverTotal: number): void {
if (!enabled) return
+ if (loaded > watchedMsgs && watchedMsgs > 0) {
+ // ONE reset point for every per-landing counter. They used to reset on two
+ // different events -- `moved` on the fetch STARTING, the spacer window on the
+ // payload ARRIVING -- and with several landings per scroll the two windows did
+ // not cover the same landing. RESIDUAL then subtracted one landing's owed
+ // movement from another's writes and read as a defect of ~8,000px that was
+ // really two legitimate corrections measured against one page of growth.
+ landing = { from: lastSpacer ? lastSpacer.px : null, to: null, samples: 0 }
+ moved = null
+ repriced = null
+ unowned = null
+ }
watchedMsgs = loaded
watchedTotal = serverTotal
}
+/** Does the browser ACTUALLY hold scroll position when content is inserted above
+ * the viewport?
+ *
+ * Measured, not asked. Reading `'overflowAnchor' in style` and treating the
+ * answer as the capability is a mistake this transcript already paid for: WebKit
+ * has landed the property, so it answers yes on a device whose anchoring does not
+ * hold a virtualized list, and a gate keyed to it switched itself off exactly
+ * where it was needed. WebKit's own tracker said the same thing about an earlier
+ * round -- the "supported" listing "is misleading, it is currently not
+ * implemented".
+ *
+ * So the overlay reports BOTH, side by side, and their disagreement is the datum:
+ * `sa` is what the browser CLAIMS, `hold` is what it DOES.
+ *
+ * The probe builds a real off-screen scroller, parks it away from 0 (anchoring is
+ * suppressed at the top edge), inserts a known height above the parked position,
+ * forces layout, and reads `scrollTop` back. A browser that anchors has moved it
+ * by the inserted height; one that does not has left it alone. Nothing is
+ * estimated -- the inserted height is what we wrote, and the answer is a readback.
+ */
+function probeAnchorHold(): string {
+ if (typeof document === 'undefined' || !document.body) return '?'
+ let box: HTMLDivElement | null = null
+ try {
+ box = document.createElement('div')
+ box.setAttribute('aria-hidden', 'true')
+ box.style.cssText =
+ 'position:fixed;left:-9999px;top:0;width:80px;height:100px;overflow-y:scroll'
+ const head = document.createElement('div')
+ head.style.height = '400px'
+ const tail = document.createElement('div')
+ tail.style.height = '400px'
+ box.append(head, tail)
+ document.body.appendChild(box)
+ box.scrollTop = 200
+ const before = box.scrollTop
+ if (before < 100) return '?'
+ const grow = document.createElement('div')
+ grow.style.height = '300px'
+ box.insertBefore(grow, head)
+ void box.scrollHeight
+ const moved = box.scrollTop - before
+ // Reported as the measured delta rather than a bare yes/no, so a PARTIAL
+ // implementation is visible instead of being rounded into one of two verdicts.
+ return `${moved >= 250 ? 'yes' : 'no'}${moved !== 0 && moved < 250 ? `(${Math.round(moved)})` : ''}`
+ } catch {
+ return '?'
+ } finally {
+ box?.remove()
+ }
+}
+
+/** Which bundle is on the device.
+ *
+ * On the overlay because a hot-swapped `dist` looks identical from the outside:
+ * more than one reading tonight was taken against a build that was not the one
+ * being reasoned about, and there was no way to tell from the screen. */
+function bundleTag(): string {
+ if (typeof document === 'undefined') return '?'
+ const src = Array.from(document.querySelectorAll('script[src]'))
+ .map((e) => (e as HTMLScriptElement).src)
+ .find((u) => /\/assets\/main-/.test(u))
+ return src ? (/main-([A-Za-z0-9_-]+)\.js/.exec(src)?.[1] ?? '?') : '?'
+}
+
+/** The environment the readings below were taken in, computed once.
+ *
+ * First line of the tool on purpose. Every wrong conclusion tonight came from
+ * reasoning about a platform instead of reading it, so the platform is now on the
+ * screen next to the numbers it explains. */
+let envMemo: string | null = null
+function envLine(): string {
+ if (envMemo !== null) return envMemo
+ const sa =
+ typeof document !== 'undefined'
+ && !!document.documentElement
+ && 'overflowAnchor' in document.documentElement.style
+ const vv = typeof window !== 'undefined' ? window.visualViewport : null
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 0
+ envMemo =
+ `env sa=${sa ? 'yes' : 'no'} hold=${probeAnchorHold()}`
+ + ` ${vv ? `${Math.round(vv.width)}x${Math.round(vv.height)}` : '?'}@${dpr || '?'}`
+ + ` b=${bundleTag()}`
+ return envMemo
+}
+
function tick(): void {
const w = watched
if (!w || !ensureHost() || !liveEl) return
const dist = w.scrollHeight - w.clientHeight - w.scrollTop
+ // Distance to the TOP, in the same unit the older-history trigger spends:
+ // viewport heights. A raw pixel count cannot be compared against the threshold
+ // by eye on a device whose viewport is whatever the browser chrome left over.
+ const vps = w.clientHeight > 0 ? w.scrollTop / w.clientHeight : 0
liveEl.textContent =
- `to-end ${Math.round(dist)}px rows=${watchedRows} msgs=${watchedMsgs}/${watchedTotal < 0 ? '?' : watchedTotal}` +
+ envLine() +
+ `\nto-end ${Math.round(dist)}px rows=${watchedRows} msgs=${watchedMsgs}/${watchedTotal < 0 ? '?' : watchedTotal}` +
+ `\nto-top ${Math.round(w.scrollTop)}px = ${vps.toFixed(1)}vp` +
`\ny=${Math.round(w.scrollTop)} h=${Math.round(w.scrollHeight)} v=${Math.round(w.clientHeight)}` +
` h/n=${watchedRows > 0 ? Math.round(w.scrollHeight / watchedRows) : '-'}` +
+ // One line per producer that has actually fired, so an idle session stays
+ // compact and a runaway names itself.
+ Array.from(olderTally.entries())
+ .map(([who, t]) => `\nolder ${who}=${t.n} last=${t.lastVp.toFixed(0)}vp max=${t.maxVp.toFixed(0)}vp`)
+ .join('') +
+ (olderLat
+ ? `\nlat n=${olderLat.n} fetch=${(olderLat.fetchMs / 1000).toFixed(1)}s` +
+ ` paint=${(olderLat.paintMs / 1000).toFixed(1)}s` +
+ ` worst=${(olderLat.worstMs / 1000).toFixed(1)}s`
+ : '') +
+ (olderSpans ? `\n net=${(olderSpans.netMs / 1000).toFixed(2)}s` : '') +
+ (moved
+ ? `\nmoved ${Math.round(moved.px)}px in ${moved.n} write(s)` +
+ ` worst=${Math.round(moved.worstPx)}px [${moved.writers}]`
+ : '') +
+ (repriced
+ ? `\nrepriced ${Math.round(repriced.px)}px in ${repriced.n}` +
+ ` worst=${Math.round(repriced.worstPx)}px`
+ : '') +
+ (unowned
+ ? `\nUNOWNED ${Math.round(unowned.maxPx)}px n=${unowned.n}` +
+ ` ${unowned.clamps > 0 ? `clamp=${unowned.clamps}` : 'jump'}`
+ : '') +
+ (landing && landing.from !== null && landing.to !== null
+ ? `\nspacer ${Math.round(landing.from)}->${Math.round(landing.to)}` +
+ ` (${landing.to - landing.from >= 0 ? '+' : ''}${Math.round(landing.to - landing.from)}px)`
+ : '') +
+ // RESIDUAL -- the defect, separated from the work.
+ //
+ // `moved` is NOT the bounce. When a page lands above the reader the content
+ // above them genuinely grows, and scrolling by exactly that much is what
+ // keeps them still: most of `moved` is REQUIRED.
+ //
+ // Sourced from the corrector's own `CORR d= owed= res=`, which resolves row
+ // positions, NOT from the spacer. The frame that settled it: `spacer 0->0`
+ // while 8,360px of content arrived and the corrector reported `res=0 off=0
+ // painted=0` -- so the spacer formula would have printed 8360px of defect on a
+ // pixel-perfect landing. Prepended rows MOUNT above the anchor rather than
+ // growing the spacer once the window reaches the start, so the spacer can miss
+ // the growth entirely and the subtraction inherits all of it as fake residual.
+ //
+ // The run figures say `since load` OUT LOUD because `moved` on the line above
+ // resets at each landing and these do not. Two adjacent numbers on different
+ // windows is the same mistake that made an earlier residual unreadable; the
+ // scope belongs in the text, not in the reader's memory.
+ // The run figures go on their own indented continuation line, the same shape
+ // `lat` uses. On one line the overlay clipped them at the device's width and the
+ // part it cut was `since load` -- the scope label, which exists precisely to stop
+ // the misreading, invisible on the only screen that matters.
+ (corr
+ ? `\nRESIDUAL ${corr.lastRes}px of ${corr.lastOwed}px owed` +
+ `\n worst ${corr.worstRes}px in ${corr.n} corr since load`
+ : '') +
(sticky.leave ? `\nLEFT ${sticky.leave}` : '') +
(sticky.entry ? `\nENTER ${sticky.entry}` : '')
}
diff --git a/website/src/hooks/virtualizer/FollowController.ts b/website/src/hooks/virtualizer/FollowController.ts
index 502b4725a73..438061aad75 100644
--- a/website/src/hooks/virtualizer/FollowController.ts
+++ b/website/src/hooks/virtualizer/FollowController.ts
@@ -147,7 +147,17 @@ export function repriceAboveFoldDelta(input: {
newHeight: number
/** Viewport-relative top of the scroll container. */
foldTop: number
+ /** The reader pressed inside THIS row, at or below the fold, moments ago — so the
+ * height change is an expansion rooted at that press, not a distributed
+ * re-measure. Everything above the press (their eye line included) stays exactly
+ * where it was, so the correct compensation is NONE. Without this the straddling
+ * rule below answers with the full delta and shoves the tapped header off screen. */
+ pressBelowFoldInRow?: boolean
}): number {
+ // A reader-rooted expansion moves only what is BELOW the press. Checked before
+ // the straddling rule because a disclosure's row almost always straddles: rows
+ // are whole turns and routinely taller than the viewport.
+ if (input.pressBelowFoldInRow) return 0
// The test is on the row's TOP, not its whole box. A reprice does not move a
// row's top -- it moves its BOTTOM, and with it everything below, so a row
// that STRADDLES the top edge displaces the reader by the full change just
diff --git a/website/src/hooks/virtualizer/HeightIndex.ts b/website/src/hooks/virtualizer/HeightIndex.ts
index cf8eda55733..fa0534491b0 100644
--- a/website/src/hooks/virtualizer/HeightIndex.ts
+++ b/website/src/hooks/virtualizer/HeightIndex.ts
@@ -11,6 +11,7 @@ import { OffsetIndex } from './WindowCalculator'
* reads the caller's live refs at call time) because the owner is constructed
* during render, one statement before the item array it will be asked about.
*/
+
type RowKeyResolver = (index: number) => string | null
/**
@@ -142,12 +143,17 @@ export class HeightIndex {
if (key === null) return this.estimate
const cached = this.cache.peek(key)
if (cached !== undefined) return Math.max(cached, 1)
- // A content-aware per-row estimate beats the running mean where content is
- // bimodal: a code-fenced row is often 5-30x the mean, and mounting it near
- // the top used to land a huge height correction that read as a scroll jump
- // (which the top-parked pagination poll then amplified into runaway page
- // loads). The resolver answers only for rows it can price (code fences at
- // known per-line metrics); everything else keeps the measured mean.
+ // NO ESTIMATE HERE, and this is a product rule rather than a tuning choice:
+ // a price is either COMPUTED or WAITED FOR. Every landing jump measured
+ // tonight traces to a row priced by a guess -- the mean under-prices a
+ // code-fenced row 5-30x, the compensation's anchor-miss fallback then
+ // under-compensates by the shortfall, and the reader is thrown by it. A
+ // calibrated guess is still a guess and fails the same way, just less often,
+ // which is worse: it removes the symptom that would have found it.
+ //
+ // The mean stays only as the floor for a row nothing else can answer for.
+ // Making the landing path never REACH this line -- by measuring the incoming
+ // rows before they enter the tree -- is the actual fix.
return this.cache.averageHeight(this.estimate)
}
diff --git a/website/src/hooks/virtualizer/anchorCorrection.ts b/website/src/hooks/virtualizer/anchorCorrection.ts
new file mode 100644
index 00000000000..30b8cabe737
--- /dev/null
+++ b/website/src/hooks/virtualizer/anchorCorrection.ts
@@ -0,0 +1,82 @@
+/** How far to move the scroller so a landing does not disturb the reader.
+ *
+ * Extracted as a pure function because the harness cannot reach the case that
+ * matters: `act(() => rerender(...))` flushes layout effects synchronously, so a
+ * test can never move the scroller BETWEEN an anchor's capture and its consume --
+ * which is the normal case on a phone, where a page is fetched precisely because
+ * the reader is scrolling and momentum outlives the ~130ms fetch. Two attempts to
+ * test it through the harness passed with the correction removed AND with its sign
+ * inverted. Here the same arithmetic is checkable directly.
+ */
+
+export interface AnchorReading {
+ /** The anchor row's top edge, relative to the scroller's viewport top, as
+ * measured when the anchor was captured. */
+ capturedTop: number
+ /** The scroller's `scrollTop` at that same moment. A screen position means one
+ * thing at one scroll offset and something else at another, so the pair travels
+ * together or neither is usable. */
+ capturedScrollTop: number
+ /** The same row's top edge, measured after the commit. */
+ currentTop: number
+ /** The scroller's `scrollTop` now. */
+ currentScrollTop: number
+ /** Running total of every scrollTop pixel THIS CODE had written, as of the
+ * capture. */
+ capturedWriteSum: number
+ /** The same running total now. Its difference from `capturedWriteSum` is how much
+ * of the scroll drift since capture was OURS. */
+ currentWriteSum: number
+}
+
+/** The amount to ADD to `scrollTop`.
+ *
+ * The row's on-screen displacement decomposes into two independent terms:
+ *
+ * currentTop - capturedTop = (content growth above it) - (scrollTop change)
+ *
+ * Only the first is ours to compensate. Correcting by the raw displacement pins
+ * the row to the glass, which also cancels the second term -- and the second term
+ * is the reader's own finger, so the transcript fights the gesture that triggered
+ * the fetch. Adding the scroll change back isolates the content term.
+ *
+ * Device trace this comes from, with the measurement windows aligned: one 9,440px
+ * write while the top spacer went 0 -> 0 (no content appeared at all) and the
+ * scroller was never overscrolled. Content growth of zero means the correct write
+ * was zero, and all 9,440px was the reader's upward scroll being undone.
+ * `sinceHard=6037ms` in the same trace is why no ownership guard caught it:
+ * momentum scrolling stamps no hard input, so "the reader owns the position"
+ * never became true.
+ *
+ * BUT THE DRIFT IS NOT ALL THE READER, and assuming it was is the second way this
+ * goes wrong. Another mechanism can write the same scroller between one anchor's
+ * capture and its consume -- a device frame carried `repriced 3078px in 7` writes
+ * inside that window -- and adding OUR own write back as if a finger had done it
+ * compensates the same content twice:
+ *
+ * CORR d=-267 owed=-354 res=86 painted=1 WRITE resize 7562->7295
+ * HELD was=-31 now=236 off=268
+ *
+ * The row's measured displacement there was 1px and the write was 267. So the term
+ * that gets added back is the drift MINUS our own writes:
+ *
+ * readerMoved = (currentScrollTop - capturedScrollTop)
+ * - (currentWriteSum - capturedWriteSum)
+ * contentShift = (currentTop - capturedTop) + readerMoved
+ *
+ * Why subtracting is right rather than merely smaller: a reprice write is itself a
+ * compensation for a height change above the reader, so that change is already
+ * inside the displacement term. Crediting it again as reader scroll is the double
+ * count. Removing it leaves exactly the part nobody has paid for yet.
+ *
+ * With the reader still AND nothing else writing, all three spellings are
+ * identical -- which is why every harness test agreed to within 2px and none of
+ * them caught either failure.
+ */
+export function contentShiftFor(r: AnchorReading): number {
+ const displacement = r.currentTop - r.capturedTop
+ const drift = r.currentScrollTop - r.capturedScrollTop
+ const ours = r.currentWriteSum - r.capturedWriteSum
+ const readerMoved = drift - ours
+ return displacement + readerMoved
+}
diff --git a/website/src/hooks/virtualizer/useVirtualChat.ts b/website/src/hooks/virtualizer/useVirtualChat.ts
index 28ed71e4a22..30e25936a0d 100644
--- a/website/src/hooks/virtualizer/useVirtualChat.ts
+++ b/website/src/hooks/virtualizer/useVirtualChat.ts
@@ -34,6 +34,53 @@
// compensated after it. The CSS is retained — reliance on it is reduced, not
// replaced.
//
+// WHAT CHANGED UNDER THIS PASS: iOS now anchors too, so we are the SECOND
+// controller, not the only one.
+//
+// The pass was written for a world where Chromium and Firefox anchored and WebKit
+// did not — so on a phone the whole weight sat here, and on desktop these writes
+// were a near-zero-delta backstop. That split no longer holds. WebKit landed
+// scroll anchoring (bug 307734, "[Scroll anchoring] Enable in stable"), and it has
+// reached a real device: an off-screen probe on iOS Safari reports the property
+// present AND a prepend above a parked scroll position actually moving `scrollTop`
+// by the inserted height. Measured, not inferred from a version string — and it
+// refuted the opposite prediction, so it is recorded rather than remembered.
+//
+// The consequence is NOT that the pass is wrong. Measured on a device where the
+// browser anchors, a clean landing is exact and invisible:
+//
+// CORR d=8360 owed=8360 res=0 painted=0
+// WRITE resize 0->8360
+// HELD was=111 now=111 off=0
+//
+// One write, every pixel of it owed, zero residual, the anchor row read back
+// unmoved, and `painted=0` -- no frame was ever presented with the content
+// displaced. So exactness is reachable and this is the shape to protect.
+//
+// What the second controller costs shows up on the landings that ALSO run the
+// above-fold reprice, as a run of writes that CONVERGE rather than correct --
+// device reading, seven inside 100ms:
+//
+// 13274->13282 (+8) ->13287 (+5) ->13291 (+4) ->13294 (+3)
+// ->13295 (+2) ->13295 (+1) ->13296 (+1)
+//
+// That shape is two controllers settling against each other. It lands correct to
+// a pixel (`HELD off=1`) while the MOTION is wrong, and the reader sees content
+// that was already loaded shift slightly instead of standing still. Subtle, and
+// real, and confined to that path rather than inherent to the compensation.
+//
+// The fix this points at is NOT another correction: it is to ask BEFORE writing
+// whether the anchor row already sits where it was captured, and to write nothing
+// when it does — the readback that currently reports `HELD`, moved ahead of the
+// write. That also happens to be the only honest basis for a per-platform split:
+// a browser earns "we stay out of it" by demonstrating the hold on this landing,
+// never by exposing a property name. A split keyed to the property was shipped
+// and withdrawn precisely because it turned off the stillness gate while leaving
+// these writes in place, which is the worst of both.
+//
+// Not done here deliberately: one mechanism corrects the position at a time, and
+// changing which one that is deserves its own change with its own device pass.
+//
// Render contract for callers:
// - Wrap the scroll container with `scrollerRef`
// - Render the items in `virtualItems`: when `item.mounted` is true render
@@ -120,7 +167,6 @@ import {
bottomTarget,
evaluateAutoPin,
} from './FollowController'
-import { noteUserScrollActivity } from '../../lib/scrollQuiet'
import type {
UseVirtualChatOptions,
UseVirtualChatReturn,
@@ -241,7 +287,8 @@ const NEAR_JUMP_OVERSCAN_MULT = 4
// at scroll-event rate. Trailing-edge, non-resetting timer: it fires at most
// once per window even during a continuous scroll/stream, so "returned to the
// bottom" reliably clears the anchor instead of being starved by resets.
-import { devLog, devWatchScroller, inspectorOn, keyShape, shortId } from '../../dev/scrollInspector'
+import { contentShiftFor } from './anchorCorrection'
+import { devLog, devScrollTop, devSpacer, devWatchScroller, inspectorOn, keyShape, shortId } from '../../dev/scrollInspector'
/** How long an anchored entry may hold its caller's skeleton waiting for the
* anchored row to hydrate. A transcript arrives in CHUNKS, not at once: the
@@ -304,6 +351,12 @@ const ANCHOR_RESTORE_SETTLE_MS = 600
* sit above what the two coordinate systems can disagree about while staying
* far below the errors this exists to fix (measured: 111px on one frame). */
const ANCHOR_SETTLE_TOLERANCE_PX = 1.5
+/** How long a press keeps locating a disclosure's growth. One toggle grows its row
+ * across several ResizeObserver fires as the revealed content renders (a long tool
+ * output paints over many frames), and the window has to outlast all of them or the
+ * later fires get compensated as if they were a re-measure. Bounded so an unrelated
+ * reprice seconds after a tap is not silently exempted. */
+const DISCLOSURE_PRESS_WINDOW_MS = 1500
/**
* Whether a prepend SHIFT COMPENSATION may write the scroll position.
@@ -362,7 +415,14 @@ function captureTopAnchorFrom(
el: HTMLDivElement,
entries: Iterable<[Element, number]>,
keyAt: (index: number) => string | null,
-): { key: string; top: number; index: number } | null {
+ offsetAt: (index: number) => number,
+ /** The running total of our own scrollTop writes at this instant. Travels with
+ * the anchor for the same reason `scrollTop` does: the correction needs to know
+ * how much of the drift since capture was the reader and how much was us, and
+ * that is a DIFFERENCE against this baseline. Without it the two are
+ * indistinguishable and a concurrent reprice gets paid for twice. */
+ writeSum: number,
+): { key: string; top: number; index: number; scrollTop: number; contentOffset: number; writeSum: number } | null {
if (typeof el.getBoundingClientRect !== 'function') return null
const srTop = el.getBoundingClientRect().top
let bestIdx = Infinity
@@ -382,7 +442,33 @@ function captureTopAnchorFrom(
bestKey = key
}
}
- return bestKey !== null ? { key: bestKey, top: bestTop, index: bestIdx } : null
+ // The scroll position this measurement was taken in travels WITH the anchor, and
+ // so does the row's CONTENT offset -- how much transcript lies above it.
+ //
+ // `top` alone is not enough to correct anything: it is a screen position, and a
+ // screen position means one thing at one scrollTop and something else at
+ // another. Recording it here rather than in a ref beside one call site is the
+ // difference between a correction that works everywhere and one that silently
+ // no-ops -- a first attempt read the value from a ref that only the PREPEND path
+ // populates, so on a device the splice path kept writing kilopixels while the
+ // fix looked applied.
+ //
+ // `contentOffset` is what makes the correction CHECKABLE. The top spacer looks
+ // like the content above the reader but is not: once the window starts at index
+ // 0 -- the state every top-walk ends in -- prepended rows mount ABOVE the anchor
+ // instead of growing the spacer, so the spacer reads 0 before and after while
+ // thousands of pixels really did arrive. The anchor's own offset does not care
+ // which side of the window boundary the growth landed on.
+ return bestKey !== null
+ ? {
+ key: bestKey,
+ top: bestTop,
+ index: bestIdx,
+ scrollTop: el.scrollTop,
+ contentOffset: offsetAt(bestIdx),
+ writeSum,
+ }
+ : null
}
/** Positional re-identification, shared by TRIGGER 1's and the splice
@@ -432,17 +518,38 @@ function nearestSurvivorShiftFrom(
/** Screen offset of the mounted row whose key matches, relative to the
* scroller's top; null when it is not mounted. Pure over its inputs like the
* capture above, so both anchor consumers resolve a row the same way. */
+/** True while the scroller sits OUTSIDE its own range -- the elastic rubber-band
+ * state iOS enters when a finger pulls past an edge.
+ *
+ * A position outside [0, max] is not a content position, so any displacement
+ * measured against it describes how far the BAND is stretched rather than how far
+ * the content moved. Every corrector that writes `scrollTop` has to consult this,
+ * which is why it is one function rather than the same comparison spelled at each
+ * site: a corrector that forgot it wrote 11,096px from a `scrollTop` of -2810 on a
+ * device while no content had appeared above the reader at all. */
+function isOverscrolled(el: HTMLElement): boolean {
+ const max = Math.max(0, el.scrollHeight - el.clientHeight)
+ return el.scrollTop < 0 || el.scrollTop > max
+}
+
function rowTopFrom(
el: HTMLDivElement,
entries: Iterable<[Element, number]>,
keyAt: (index: number) => string | null,
key: string,
-): number | null {
+): { top: number; index: number } | null {
if (typeof el.getBoundingClientRect !== 'function') return null
for (const [node, idx] of entries) {
if (keyAt(idx) !== key) continue
const srTop = el.getBoundingClientRect().top
- return (node as HTMLElement).getBoundingClientRect().top - srTop
+ // The index comes back with the position because the CALLER's stored index is
+ // stale by now: a landing renumbers every row, so the same number names a
+ // different row afterwards. Resolving by key and reporting where that key
+ // actually landed is the only way to ask the offset tree about the same row
+ // twice -- computing "how much arrived above it" from the old number answered
+ // for whichever row inherited that slot, which on a device read as zero while
+ // thousands of pixels had arrived.
+ return { top: (node as HTMLElement).getBoundingClientRect().top - srTop, index: idx }
}
return null
}
@@ -680,6 +787,11 @@ export function useVirtualChat(
// One shared ResizeObserver; Element → index map resolves heights cheaply.
const elIndexRef = useRef>(new Map())
+ /** The reader's last press inside the scroller: which row, at what viewport Y.
+ * Read by the above-fold reprice to locate a disclosure's insertion point --
+ * see the pointerdown listener for why a press position is a measurement of
+ * that point rather than a guess about intent. */
+ const pressRef = useRef<{ index: number; y: number; at: number } | null>(null)
const resizeObserverRef = useRef(null)
// Live items array (lets imperative callbacks read current state).
@@ -701,6 +813,30 @@ export function useVirtualChat(
// beating the RO-vs-scroll-event race.
const stickRef = useRef(followOutput)
const lastWriteTopRef = useRef(-1)
+ /** Running total of every scrollTop pixel THIS CODE has written.
+ *
+ * Exists so the correction can tell the reader's finger from its own earlier
+ * hand. `contentShiftFor` isolates the content term by adding back the scrollTop
+ * change since capture, on the premise that the change is the reader scrolling.
+ * It is not always: a device frame carried `repriced 3078px in 7` writes landing
+ * between one anchor's capture and its consume, so the drift added back was our
+ * own reprice, and the correction paid for it twice --
+ *
+ * CORR d=-267 owed=-354 res=86 painted=1 WRITE resize 7562->7295
+ * HELD was=-31 now=236 off=268
+ *
+ * -- where the row's MEASURED displacement was 1px and the write was 267.
+ *
+ * Accumulated at the one chokepoint every write already passes through, so a new
+ * writer cannot forget to register. The REQUESTED delta is what accumulates, not
+ * a readback: reading `scrollTop` after the write forces a synchronous layout on
+ * a path that runs per frame during a fling. The cost of that choice is real and
+ * bounded -- a write the engine CLAMPS registers more than it moved, so the
+ * subtraction over-credits us -- and it is the `UNOWNED clamp` instrument's
+ * job to make that visible. Writes at the range edges, where clamping happens,
+ * are already refused by `isOverscrolled`.
+ */
+ const writeSumRef = useRef(0)
// `lastWriteClientHRef`: the scroller's `clientHeight` at the moment
// `lastWriteTopRef` was recorded — i.e. the viewport box that value was a
// bottom FOR. Kept in lockstep with it (`-1` alongside `-1`) so the pin
@@ -874,7 +1010,23 @@ export function useVirtualChat(
* 'ready' — window shift captured; correct only. No re-derive:
* the shift already is the window's own decision.
*/
- const shiftAnchorRef = useRef<{ key: string; top: number } | null>(null)
+ // Carries the scroll position its `top` was measured in -- see
+ // captureTopAnchorFrom. `heightAnchorPendingRef` above already pairs a
+ // measurement with its scrollTop for the same reason.
+ // Set once the offset tree for this render exists; at capture time it still
+ // holds the PREVIOUS commit's tree, which is the 'before' value a correction needs.
+ const offsetOfRef = useRef<(index: number) => number>(() => 0)
+ const shiftAnchorRef = useRef<{
+ key: string
+ top: number
+ scrollTop: number
+ index: number
+ contentOffset: number
+ /** Our own write total at capture. Paired with `scrollTop` for the same reason
+ * `scrollTop` is paired with `top`: the drift since capture is only usable once
+ * it is split into the reader's part and ours. */
+ writeSum: number
+ } | null>(null)
const shiftStageRef = useRef<'awaiting-rebase' | 'rebased' | 'ready' | null>(null)
/** How far DOWN the anchored row moved in the list (new index minus old), set
* by TRIGGER 1's capture and consumed by part 1. Equal to the net count growth
@@ -955,6 +1107,38 @@ export function useVirtualChat(
// Guards the shared slot: a prepend capture in THIS render must not then be
// overwritten by the window-shift branch below (a re-base changes the range).
let anchorCapturedThisRender = false
+ // Did the browser PAINT between an anchor's capture and its correction?
+ //
+ // The reader's final position is right -- read back after the write, the anchor
+ // lands 1px from where it was captured. Yet the jolt is visible, and those two
+ // facts are only compatible if a frame was PRESENTED while the correction was
+ // still pending: the row sits thousands of pixels low for one paint, then snaps.
+ // Every other measurement here is taken inside the same tick as the write, so
+ // none of them can see that frame.
+ //
+ // A rAF armed at capture answers it: if the callback runs before the consume,
+ // the browser reached a paint in between.
+ const paintedBeforeCorrectionRef = useRef(false)
+ const armPaintProbe = () => {
+ if (!inspectorOn()) return
+ paintedBeforeCorrectionRef.current = false
+ requestAnimationFrame(() => { paintedBeforeCorrectionRef.current = true })
+ }
+ // Resolves a row index to how much transcript lies ABOVE it, read through a ref
+ // so every capture site gets it without passing it.
+ //
+ // A ref rather than the tree itself, for two reasons that both matter. The tree
+ // is built later in this render, so naming it here is a temporal-dead-zone
+ // crash; and what a capture WANTS is the offset as it stood BEFORE this commit,
+ // which is exactly what the ref still holds at that moment. A wrapper rather
+ // than a fourth argument at each site because there are nine of them, and the
+ // failure mode of forgetting one is silent: the anchor records 0, the residual
+ // it is meant to expose reads as zero error, and the site looks fixed.
+ const captureAnchor = (
+ el: HTMLDivElement,
+ entries: Iterable<[Element, number]>,
+ keyAt: (index: number) => string | null,
+ ) => captureTopAnchorFrom(el, entries, keyAt, offsetOfRef.current, writeSumRef.current)
// A front-insert grows the count AND changes index 0's key. A slot switch does
// both, hence the session guard; a plain append leaves index 0 alone.
const _t1Armed =
@@ -987,7 +1171,7 @@ export function useVirtualChat(
const newIndexById = new Map()
for (let i = 0; i < items.length; i++) newIndexById.set(idOfNew(items[i], i), i)
let prependAnchor = prependEl
- ? captureTopAnchorFrom(prependEl, elIndexRef.current.entries(), (idx) => {
+ ? captureAnchor(prependEl, elIndexRef.current.entries(), (idx) => {
const it = prependPrev.items[idx]
if (!it) return null
const k = idOfPrev(it, idx)
@@ -1038,7 +1222,7 @@ export function useVirtualChat(
// anywhere does the net count stand in -- the reader then keeps their
// distance from the END, the one thing a full re-identification of a
// chat transcript preserves.
- prependAnchor = captureTopAnchorFrom(prependEl, elIndexRef.current.entries(), (idx) => {
+ prependAnchor = captureAnchor(prependEl, elIndexRef.current.entries(), (idx) => {
const j = idx + shiftAt(idx)
const it = items[j]
return it ? idOfNew(it, j) : null
@@ -1048,19 +1232,38 @@ export function useVirtualChat(
// Part 1 re-bases by the reader's own displacement, in either direction --
// rows coalescing ABOVE the reader while the tail grows moves them UP even
// though the count grew -- which is what keeps the anchored row mounted for
- // part 2 to measure. A displacement of zero leaves nothing to re-base; a
- // height change above an unmoved row is trigger 7's case, not this one.
+ // part 2 to measure. A displacement of zero leaves nothing to re-base, so
+ // it skips straight to the measurement as TRIGGER 7 below.
if (prependAnchor && prependShift !== 0) {
shiftAnchorRef.current = prependAnchor
shiftStageRef.current = 'awaiting-rebase'
prependCountRef.current = prependShift
anchorCapturedThisRender = true
} else if (prependAnchor) {
- // Anchored but unmoved: the landing did not displace the reader's rows,
- // so the arithmetic fallback must not fire either (compensating an
- // insert that is not above the reader would itself be the lurch).
+ // TRIGGER 7 -- ABSORBED LANDING. The page merged into a display row that
+ // already existed (same turn), so every row KEPT its index and there is
+ // nothing to re-base. What changed is that row's CONTENT: it grew, above
+ // the reader, and nothing was correcting for it.
+ //
+ // The arithmetic fallback must still NOT fire: compensating an insert
+ // that is not above the reader would itself be the lurch, so its inputs
+ // stay cleared. What does apply is the anchor's own measurement. The
+ // consume pass that already serves the splice triggers re-finds this key
+ // after the commit, reads its new offset, and corrects by the delta --
+ // so this case is handled by the SAME corrector as every other one
+ // rather than a second mechanism racing it.
+ //
+ // Nothing here is estimated: the correction is the difference between two
+ // real `getBoundingClientRect` readings of one row mounted in both
+ // commits. That does NOT make it sufficient -- if the spacer above it was
+ // priced by the running mean, the difference faithfully measures a
+ // displacement caused by a fiction. Device evidence for that separate
+ // defect is on the anchor path proper, not here.
prependNetRef.current = 0
prependPreScrollTopRef.current = -1
+ shiftAnchorRef.current = prependAnchor
+ shiftStageRef.current = 'ready'
+ anchorCapturedThisRender = true
}
}
// ---- Count-change classification, read BEFORE the mirror advances ----
@@ -1165,7 +1368,7 @@ export function useVirtualChat(
if ((midListInserted || rowsRemoved || rowSwapped) && !anchorCapturedThisRender && !stickRef.current) {
const spliceEl = scrollerRef.current
let spliceAnchor = spliceEl
- ? captureTopAnchorFrom(spliceEl, elIndexRef.current.entries(), (idx) => {
+ ? captureAnchor(spliceEl, elIndexRef.current.entries(), (idx) => {
// PREVIOUS items at the node's PREVIOUS index, filtered to rows that
// survive this commit — trigger 1's resolution, for the same reason:
// it is the only mapping that names the row the node actually shows.
@@ -1197,7 +1400,7 @@ export function useVirtualChat(
// The positional anchor names a row of the NEW list, so it is priced by
// the CURRENT render's getKey paired with the current items — the same
// pairing contract as TRIGGER 1's fallback.
- spliceAnchor = captureTopAnchorFrom(spliceEl, elIndexRef.current.entries(), (idx) => {
+ spliceAnchor = captureAnchor(spliceEl, elIndexRef.current.entries(), (idx) => {
const j = idx + shiftAt(idx)
const it = items[j]
return it ? getKey(it, j) : null
@@ -1345,7 +1548,7 @@ export function useVirtualChat(
) {
const shiftEl = scrollerRef.current
const shiftAnchor = shiftEl
- ? captureTopAnchorFrom(shiftEl, elIndexRef.current.entries(), (idx) => {
+ ? captureAnchor(shiftEl, elIndexRef.current.entries(), (idx) => {
const it = items[idx]
return it ? (getStableIdRef.current ? getStableIdRef.current(it, idx) : getKey(it, idx)) : null
})
@@ -1370,7 +1573,7 @@ export function useVirtualChat(
if (!anchorCapturedThisRender && tailAppended && windowRange.start === windowRangeRef.current.start && !stickRef.current) {
const appendEl = scrollerRef.current
const appendAnchor = appendEl
- ? captureTopAnchorFrom(appendEl, elIndexRef.current.entries(), (idx) => {
+ ? captureAnchor(appendEl, elIndexRef.current.entries(), (idx) => {
const it = items[idx]
return it ? getKey(it, idx) : null
})
@@ -1411,6 +1614,31 @@ export function useVirtualChat(
// whose debounced save would clear the very anchor being restored.
// `undefined` means "not yet latched for this session" (first render).
const pendingRestoreRef = useRef(undefined)
+ /** A restore has just GIVEN UP, so the position basis around it is abandoned.
+ *
+ * Giving up is not a quiet no-op: it releases the restore, re-arms follow, and
+ * takes the default bottom placement, which moves `scrollTop` by whatever that
+ * costs. An anchor capture taken before that, consumed after it, is priced
+ * against a layout that no longer exists — and the correction computed from it
+ * is confident and wrong at kilopixel scale.
+ *
+ * Device frame that named it, on entry to a session whose stored row never
+ * arrived (`ENTER RESTORE.giveup n=0`), two corrections back to back:
+ *
+ * CORR d=-1016 owed=-1262 res=246 painted=1 WRITE resize 6404->5388
+ * CORR d=1448 owed=1484 res=-36 painted=1 WRITE resize 6590->8038
+ *
+ * Both `painted=1`, so both were presented to the reader: a ~2.4kpx double
+ * jolt on entering a session. `res` looked small in each, which is exactly why
+ * the residual alone could never have found this — both figures came from the
+ * same abandoned capture, so they agreed with each other and not with the glass.
+ *
+ * Consumed rather than merely read: the flag is cleared by the first consume
+ * that sees it, so it suppresses the correction straddling the giveup and
+ * nothing after. A latch that only turned on would silence the corrector for
+ * the rest of the mount.
+ */
+ const restoreGaveUpRef = useRef(false)
// Wall-clock ceiling for the pending restore of the CURRENT session.
const restoreDeadlineRef = useRef(0)
// Highest item count seen while a restore is pending; growth past it renews the wait.
@@ -1422,6 +1650,41 @@ export function useVirtualChat(
// render cancelled it. It aborts itself on a session change or a real user
// scroll, which is what actually bounds it.
const settleRafRef = useRef(0)
+ /** The landing a restore CONVERGED on, kept authoritative after the settle stops.
+ *
+ * Convergence proves the anchor stopped moving for the frames the settle watched
+ * -- a 600ms budget -- but height truth keeps arriving for seconds after that, as
+ * rows above the reader mount and get measured. Those repricings are real, and the
+ * above-fold compensation answers them by SLIDING the reader to keep content
+ * visually still. That is correct in isolation and wrong here: the reader was
+ * placed at an ABSOLUTE target (row X at offset Y), so a relative slide walks them
+ * off it a little at a time. Measured on a phone parked ~450 messages up:
+ * `SETTLE.ok a=6355` followed 0.7s later by `MSGS+ 449->459` and
+ * `WRITE abovefold 6337->6209` -- 128px of drift after the landing was already
+ * right.
+ *
+ * Keeping the settle LOOP running instead is the other failure: it becomes the
+ * second half of a tug-of-war with the same compensation (`abovefold 6957->6933`
+ * answered by `settle 6933->6957`), so this is deliberately a memory, not a loop --
+ * one re-placement per repricing batch, no polling.
+ *
+ * Authoritative until the reader takes over (their first hard input) or the session
+ * changes; `at` is what dates it against `lastHardInputAtRef`.
+ *
+ * Carries the ANCHOR, not just the index it resolved to. An index is a position,
+ * not an identity: a later prepend slides every row's index, so re-measuring "the
+ * row at index 2" would measure a DIFFERENT row and dutifully place THAT one at the
+ * remembered offset -- a large bogus jump, reported from a phone as the view being
+ * pushed further than before this existed. Identity is re-checked on every use, and
+ * a mismatch hands the batch back to the relative compensation, which is the right
+ * answer for a prepend anyway: rows really were inserted above, so sliding to stay
+ * still is what the reader wants. */
+ const landedAnchorRef = useRef<{
+ session: string
+ index: number
+ anchor: ScrollAnchor
+ at: number
+ } | null>(null)
/** True only while the settle loop is measuring its anchor row and correcting
* against it. A settle that cannot find the row holds its gate but corrects
* nothing, and must not keep the other compensations standing down. */
@@ -1539,6 +1802,9 @@ export function useVirtualChat(
}
if (ctx && ctx.session === prevSession && el && !restoreOwnsPosition()) {
const geom = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
+ // Hunt for movement no logged write explains (see devScrollTop). Reads
+ // nothing extra: `geom` was already sampled on this line.
+ devScrollTop(geom.scrollTop, Math.max(0, geom.scrollHeight - geom.clientHeight))
// `stick` is the AUTHORITATIVE bottom truth here: while follow is
// engaged the reader IS at the bottom semantically, even when the pin
// trails the last streamed growth by a frame -- exactly the instant a
@@ -1549,7 +1815,7 @@ export function useVirtualChat(
if (stickRef.current || computeAtBottom(geom, bottomThreshold)) {
clearScrollAnchor(prevSession)
} else {
- const a = captureTopAnchorFrom(el, elIndexRef.current.entries(), (idx) => {
+ const a = captureAnchor(el, elIndexRef.current.entries(), (idx) => {
const it = ctx.items[idx]
if (!it) return null
// The stable id is a pure function of the ITEM, so the live fn is
@@ -1771,7 +2037,7 @@ export function useVirtualChat(
// fixed-velocity probe saw a 749px one-frame lurch with every anchor
// counter silent.
if (!stickRef.current && scrollerRef.current) {
- const a = captureTopAnchorFrom(scrollerRef.current, elIndexRef.current.entries(), (i) => {
+ const a = captureAnchor(scrollerRef.current, elIndexRef.current.entries(), (i) => {
const it = itemsRef.current[i]
return it ? getKeyRef.current(it, i) : null
})
@@ -1855,6 +2121,14 @@ export function useVirtualChat(
const heightCommit = useSyncExternalStore(offsetIndex.subscribe, offsetIndex.getVersion)
const totalHeight = offsetIndex.totalHeight()
const offsetBefore = offsetIndex.offsetOf(windowRange.start)
+ // Point the capture-time resolver at THIS render's tree, from here on. Anything
+ // captured earlier in this same render already read the previous one, which is
+ // the baseline a correction has to compare against.
+ offsetOfRef.current = (index: number) => offsetIndex.offsetOf(index)
+ // Report the top spacer so a REPRICE above the reader (a first mount replacing
+ // the running mean with a real height) can be told apart from the reader
+ // simply having scrolled. Behaviour-neutral: the inspector is off by default.
+ devSpacer(offsetBefore, windowRange.start)
// Height of all items AFTER the window — used as the bottom spacer so the
// scroll content keeps its full size while only the window renders real DOM.
const offsetAfter = Math.max(0, totalHeight - offsetIndex.offsetOf(windowRange.end))
@@ -1874,7 +2148,7 @@ export function useVirtualChat(
const captureTopAnchor = useCallback((): ScrollAnchor | null => {
const el = scrollerRef.current
if (!el) return null
- const a = captureTopAnchorFrom(el, elIndexRef.current.entries(), (idx) => {
+ const a = captureAnchor(el, elIndexRef.current.entries(), (idx) => {
const it = itemsRef.current[idx]
if (!it) return null
// Same vocabulary findAnchorIndex resolves in -- see its comment.
@@ -2085,6 +2359,9 @@ export function useVirtualChat(
who?: string,
) => {
if (inspectorOn()) devLog('WRITE', `${who ?? '?'} ${Math.round(el.scrollTop)}->${Math.round(top)}${behavior === 'smooth' ? ' smooth' : ''}`)
+ // Registered BEFORE the write, while `el.scrollTop` is still the old value --
+ // afterwards the delta is unrecoverable without forcing a layout.
+ writeSumRef.current += top - el.scrollTop
if (typeof el.scrollTo === 'function') el.scrollTo({ top, behavior })
else el.scrollTop = top
lastWriteTopRef.current = accounting === 'pin' ? top : -1
@@ -2157,6 +2434,84 @@ export function useVirtualChat(
[detachSmoothAbort],
)
+ /**
+ * Re-solve a converged restore's ABSOLUTE placement after rows above it were
+ * repriced. Returns true when it owns the correction, so the caller's relative
+ * compensation stands down for that batch.
+ *
+ * This is the whole difference between the two ways to answer a late reprice.
+ * `scrollTop += delta` keeps the reader still relative to CONTENT, which is what
+ * a reader who scrolled themselves there wants. A restored reader was instead put
+ * at a stated target -- row X, `top` px below the viewport edge -- and the only
+ * faithful answer is to put them back on it: read where the row IS now and correct
+ * by the difference. Same arithmetic the settle frames use, applied once per batch
+ * instead of on a loop, which is what keeps it out of a tug-of-war with the
+ * compensation it replaces.
+ *
+ * Declines (returns false) whenever it cannot be sure -- no memory, a different
+ * session, the reader has taken over, the row is unmounted, or a degenerate rect --
+ * and the caller's existing behaviour stands.
+ */
+ const relandConvergedAnchor = useCallback((el: HTMLDivElement): boolean => {
+ const landed = landedAnchorRef.current
+ if (!landed) return false
+ if (landed.session !== sessionIdRef.current) {
+ landedAnchorRef.current = null
+ return false
+ }
+ // The reader's own input ends the landing's authority: from their first real
+ // gesture the position is theirs, and holding them to a remembered offset would
+ // be the same self-authorizing mistake as reading our own write as consent.
+ if (lastHardInputAtRef.current > landed.at) {
+ landedAnchorRef.current = null
+ return false
+ }
+ // Following the bottom means the bottom owns the position, and the landing is
+ // over -- jump-to-latest re-pins without necessarily stamping a hard input, so
+ // without this the remembered offset would fight the pin for as long as the row
+ // stayed mounted.
+ if (stickRef.current) {
+ landedAnchorRef.current = null
+ return false
+ }
+ let node: HTMLElement | null = null
+ for (const [nEl, i] of elIndexRef.current.entries()) {
+ if (i === landed.index) { node = nEl as HTMLElement; break }
+ }
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false
+ if (typeof el.getBoundingClientRect !== 'function') return false
+ // The row at that index must still BE the anchored row. Checked with the same
+ // identity pair the settle re-checks every frame -- tail plus alt -- because a
+ // prepend renames indices out from under this memory and measuring the wrong row
+ // is worse than not correcting at all.
+ const its = itemsRef.current
+ const it = its[landed.index]
+ const idFn = getStableIdRef.current
+ const rowId = it ? (idFn ? idFn(it, landed.index) : getKeyRef.current(it, landed.index)) : null
+ if (!anchorMatchesRow({
+ anchor: landed.anchor,
+ tailId: rowId,
+ altId: it ? altIdAtIndex(landed.index) : null,
+ })) {
+ if (inspectorOn()) devLog('RELAND.x', `idx=${landed.index} moved -- handing back to abovefold`)
+ landedAnchorRef.current = null
+ return false
+ }
+ const rect = node.getBoundingClientRect()
+ if (rect.height <= 0) return false
+ const delta = rect.top - el.getBoundingClientRect().top - landed.anchor.top
+ if (Math.abs(delta) > ANCHOR_SETTLE_TOLERANCE_PX) {
+ writeScrollTop(el, el.scrollTop + delta, 'auto', 'pin', 'reland')
+ }
+ return true
+ }, [writeScrollTop, altIdAtIndex])
+ // Read through a ref inside the ResizeObserver effect: naming the callback in that
+ // effect's dependency array would tear the observer down and re-attach it whenever
+ // the callback's identity changed, which is the re-attachment churn this file
+ // deliberately avoids elsewhere.
+ const relandRef = useRef(relandConvergedAnchor)
+ relandRef.current = relandConvergedAnchor
+
const pinAuto = useCallback(() => {
const el = scrollerRef.current
if (!el) return
@@ -2237,12 +2592,6 @@ export function useVirtualChat(
let rafId = 0
const onScroll = () => {
const geom = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
- // Quiescence signal for the older-page flush hold (scrollQuiet.ts).
- // Self-scroll pin writes are excluded: our own corrections must not
- // hold a fetched page hostage -- only the READER's activity defers it.
- if (!smoothPinActiveRef.current && !isSelfScroll(geom.scrollTop, lastWriteTopRef.current)) {
- noteUserScrollActivity()
- }
const atBottom = computeAtBottom(geom, bottomThreshold)
setIsAtBottom((prev) => {
if (prev === atBottom) return prev
@@ -2381,6 +2730,27 @@ export function useVirtualChat(
}
}
el.addEventListener('scroll', onScroll, { passive: true })
+ // Where the reader last PRESSED, and in which row. A disclosure toggle grows
+ // its row from the press point DOWNWARD, so this is the insertion point the
+ // above-fold reprice cannot otherwise know: that rule assumes a height change
+ // is distributed through the row (true of a re-measure) and therefore
+ // compensates a straddling row in full. For an expansion that is wrong by the
+ // whole delta -- everything above the press, including the header just tapped,
+ // does not move. Recording the press POSITION rather than inferring intent
+ // keeps this a measurement: the reader can only press what they can see, so a
+ // press at or below the fold proves the growth is below it too.
+ const onPointerDown = (e: PointerEvent) => {
+ let node: Element | null = e.target as Element | null
+ let idx: number | undefined
+ while (node && idx === undefined) {
+ idx = elIndexRef.current.get(node)
+ node = node.parentElement
+ }
+ pressRef.current = idx === undefined
+ ? null
+ : { index: idx, y: e.clientY, at: performance.now() }
+ }
+ el.addEventListener('pointerdown', onPointerDown, { passive: true })
// A fresh element has no direction history — do not measure its first user
// scroll against a previous scroller's position.
lastObservedTopRef.current = -1
@@ -2399,6 +2769,7 @@ export function useVirtualChat(
onScroll()
return () => {
el.removeEventListener('scroll', onScroll)
+ el.removeEventListener('pointerdown', onPointerDown)
detachIntent()
// Cancel any frame queued by the last scroll so it can't fire a
// setWindowRange after unmount/re-run. Reset the ref too, or a re-run
@@ -2574,6 +2945,18 @@ export function useVirtualChat(
prevHeight: prevH,
newHeight: newH,
foldTop: el.getBoundingClientRect().top,
+ // A press the reader just made inside THIS row, at or below the fold,
+ // locates the growth below their eye line. The window is generous
+ // because one toggle grows the row over several observer fires as the
+ // revealed content renders, and every one of those fires would
+ // otherwise be compensated -- measured on a phone as seven
+ // `abovefold` writes in a decisecond totalling +149px, which walked
+ // the tapped header up off the screen.
+ pressBelowFoldInRow:
+ pressRef.current !== null &&
+ pressRef.current.index === idx &&
+ performance.now() - pressRef.current.at < DISCLOSURE_PRESS_WINDOW_MS &&
+ pressRef.current.y >= el.getBoundingClientRect().top,
})
// Which row grew decides whether growth is FOLLOWABLE. Streaming
// and widget-load growth happens at the TAIL, where following it
@@ -2638,8 +3021,10 @@ export function useVirtualChat(
// A restore owns the position while its gate is up (see the shift-consume
// effect): repricing rows above the fold shifts the reader to stay still,
// which fights an absolute placement rather than preserving it.
- if (shiftCompensationAllowed({ stick: stickRef.current, settleMeasuring: settleMeasuringRef.current }) && Math.abs(aboveFoldReprice) > 0.5) {
- writeScrollTop(el, el.scrollTop + aboveFoldReprice, 'auto', 'pin', 'abovefold')
+ if (Math.abs(aboveFoldReprice) > 0.5 && !isOverscrolled(el) && !relandRef.current(el)) {
+ if (shiftCompensationAllowed({ stick: stickRef.current, settleMeasuring: settleMeasuringRef.current })) {
+ writeScrollTop(el, el.scrollTop + aboveFoldReprice, 'auto', 'pin', 'abovefold')
+ }
}
if ((genuineResize || firstMount || viewportResized) && !streamingRowResized && isRailSettling()) {
railSettleFollowRef.current = railSettleFollowRef.current || stickRef.current
@@ -2885,12 +3270,34 @@ export function useVirtualChat(
// ANCHOR-MISS FALLBACK: no surviving row to measure against (and the
// positional re-identification found nothing either), so compensate
// by arithmetic instead of standing down. The offset tree was synced
- // render-phase this commit, and a top-walk page lands only on
- // farm-measured geometry, so the inserted block's height is exact
- // there (and a fair estimate elsewhere -- either beats a full-page
- // lurch). Same-commit pre-paint: rebase the window so mounted rows
- // keep their identity, then advance scrollTop by the block just
- // inserted above the reader.
+ // render-phase this commit, so the sum below is the tree's own answer.
+ //
+ // What that sum is WORTH depends on whether the tree has measured the
+ // rows it is pricing, and for a freshly prepended row it has not: index
+ // 0 has never been mounted, so `getHeight` answers with the running
+ // mean of everything measured so far. That is an estimate, and the
+ // device shows what it costs -- a page whose real height is thousands
+ // of pixels priced at the mean produces a displacement of the
+ // difference, which is then never taken back: the reprice that would
+ // correct it happens at FIRST MOUNT, and the above-fold corrector
+ // deliberately skips first mounts.
+ //
+ // This branch used to be able to claim the sum was exact, because a
+ // gate held every top-walk page until the idle measuring pass had
+ // sized its rows. That gate is gone, and correctly so: it could only
+ // ever open once the reader stopped, which is exactly what made
+ // loading require a stop. Its removal did not make this arithmetic
+ // wrong -- it made it visibly approximate, which it always was for any
+ // row the pass had not reached.
+ //
+ // The invariant to restore is NOT "wait before fetching" but "no
+ // unmeasured height enters the offset tree": price the inserted rows at
+ // splice time, where their heights can be computed, rather than
+ // reprice them here after the reader has already been moved.
+ //
+ // Same-commit pre-paint: rebase the window so mounted rows keep their
+ // identity, then advance scrollTop by the block just inserted above
+ // the reader.
setWindowRange((r) => ({
start: Math.min(itemCount, r.start + net),
end: Math.min(itemCount, r.end + net),
@@ -2913,6 +3320,13 @@ export function useVirtualChat(
shiftStageRef.current = 'rebased'
shiftInsertedRef.current = net
rebaseScheduledRef.current = true
+ // Armed HERE, not at the anchor's capture. The question is whether a frame is
+ // PRESENTED between the content growing and the correction landing -- and a
+ // capture can precede the payload by the whole fetch (measured: 0.36s, some
+ // twenty frames), during which paints happen with nothing displaced yet
+ // because the content has not arrived. Armed at capture the probe answered
+ // "did any frame pass", which is not the reader's question.
+ armPaintProbe()
// Signed: the anchored row's displacement, so the re-based range contains it
// whichever way it moved. Clamped to the list on both ends.
const clamp = (i: number) => Math.max(0, Math.min(itemCount, i))
@@ -3003,7 +3417,67 @@ export function useVirtualChat(
// `stickRef` alone could not cover this: it stands down for follow-the-tail,
// which is a DIFFERENT owner of the position. Both are cases of a
// correction measured against one position being applied to another.
+ // Stand down for the two cases this effect must not fight, and note that
+ // they are checked with DIFFERENT predicates because they are different
+ // facts: `shiftCompensationAllowed` asks whether a compensation is wanted at
+ // all, while `relandRef` asks whether a scroll-anchor RESTORE currently owns
+ // the position.
+ //
+ // The restore half was missing here, and it is what the reader felt. A
+ // restore places them at an absolute offset computed against a transcript
+ // that ALREADY contains the landed rows, so it moves the anchor row itself
+ // -- and this effect then measures that movement faithfully and "corrects"
+ // it, which UNDOES the restore. Device trace, one upward scroll that landed
+ // four pages: `ENTER RESTORE.OK` immediately followed by
+ // `WRITE resize 1259->11274`, a single 10,015px write, while the transcript
+ // had grown only 168px. A 10,015px visual displacement cannot come from
+ // 168px of new content; it came from the restore.
+ //
+ // The above-fold reprice path already guarded on this. Keeping the two in
+ // step matters more than the one line: the anchor path has now twice been
+ // found missing a guard its siblings have (the other being the native
+ // scroll-anchoring subtraction, which only the arithmetic fallback makes).
if (!shiftCompensationAllowed({ stick: stickRef.current, settleMeasuring: settleMeasuringRef.current })) return
+ // A restore IN FLIGHT owns the position outright, so this correction must not
+ // run at all: the restore is on its way to an absolute offset computed
+ // against a transcript that already contains the landed rows, and holding the
+ // anchor row still is precisely the thing that undoes it.
+ //
+ // Device trace, one landing: `ENTER RESTORE.OK` immediately followed by
+ // `WRITE resize 1652->18173` -- one 16,521px write while the transcript had
+ // grown 46px. Solving the anchor identity `top = C - S` for that pair gives
+ // S1 - S0 = 46 - 16521 = -16475: the restore had moved the reader UP 16.5k px
+ // and this correction pushed them back DOWN to where the row used to sit. The
+ // delta was measured correctly; keeping that row still was the wrong goal.
+ //
+ // Two predicates, in this order, because they answer different questions and
+ // the second one WRITES:
+ // - `restoreOwnsPosition` -- a restore is pending or its settle gate is up.
+ // Its own doc names reaching for `pendingRestore` instead as the defect,
+ // and every other caller uses it; this effect was the one that did not.
+ // - `relandConvergedAnchor` -- a restore already CONVERGED, and when it
+ // answers true it has re-placed the reader itself.
+ // Checked in-flight first so the converged path is never asked to write
+ // during a restore that has not finished choosing the position.
+ // OVERSCROLL. `scrollTop` outside [0, max] is not a content position at all:
+ // it is the elastic rubber-band state iOS enters when a finger pulls past an
+ // edge. A delta measured against it describes how far the BAND is stretched,
+ // not how far the content moved, and writing that delta throws the reader by
+ // the stretch.
+ //
+ // Device trace: `WRITE resize -2810->8286` -- an 11,096px write starting from
+ // a NEGATIVE scrollTop, with `spacer 0->0` proving no content had appeared
+ // above the reader and the landing (`MSGS+ +1`) arriving afterwards. The whole
+ // movement was error. A negative `to-top` had shown up twice before that and
+ // was dismissed as a platform artifact both times; it was the defect.
+ //
+ // Standing down is safe here in a way it is not elsewhere: the band always
+ // springs back and the release fires more scroll events, so the geometry gets
+ // another chance a frame later. A correction computed against a stretched band
+ // cannot be salvaged.
+ if (isOverscrolled(el)) return
+ if (restoreOwnsPosition()) return
+ if (relandRef.current(el)) return
// CONSUME-MISS FALLBACK: the anchor row can vanish between capture and
// consume (unmounted by a concurrent recompute on a slow device). For a
// prepend the compensation is still knowable by arithmetic -- the block
@@ -3022,18 +3496,120 @@ export function useVirtualChat(
if (remainder > 0.5) writeScrollTop(el, el.scrollTop + remainder, 'auto', 'pin', 'reprice2')
}
if (!pending) { fallbackCompensate(); if (insertedForFallback > 0) recomputeWindow(); return }
- const newTop = rowTopFrom(el, elIndexRef.current.entries(), (idx) => {
+ const found = rowTopFrom(el, elIndexRef.current.entries(), (idx) => {
const it = itemsRef.current[idx]
return it ? anchorIdOf(it, idx) : null
}, pending.key)
- if (newTop === null) { fallbackCompensate(); if (insertedForFallback > 0) recomputeWindow(); return }
- const delta = newTop - pending.top
+ if (found === null) { fallbackCompensate(); if (insertedForFallback > 0) recomputeWindow(); return }
+ const newTop = found.top
+ // The anchor's on-screen displacement, which is NOT the correction.
+ //
+ // delta = (content growth above the reader) - (scrollTop change)
+ //
+ // Both terms are real, and only the FIRST is ours to compensate. Writing
+ // `delta` keeps the anchor row pinned to the glass -- which also cancels
+ // whatever the READER did with their finger in between, because their scroll
+ // is the second term. Device trace, aligned windows: a single 9,440px write
+ // with `spacer 0->0` (no content appeared at all) and no overscroll, so
+ // scrollTop had changed by -9,440 and every pixel written was the reader's own
+ // upward scroll being undone. `sinceHard=6037ms` is why no input guard caught
+ // it: momentum scrolling stamps no hard input, so "the user owns the position"
+ // never became true.
+ //
+ // Adding the scroll change back leaves the content term alone:
+ //
+ // contentShift = delta + (scrollTopNow - scrollTopAtCapture)
+ //
+ // With the reader still, the two spellings are identical -- which is why
+ // every harness test agreed to within 2px and none of them caught this: a
+ // test never moves the scroller between capture and consume.
+ const delta = contentShiftFor({
+ capturedTop: pending.top,
+ capturedScrollTop: pending.scrollTop,
+ currentTop: newTop,
+ currentScrollTop: el.scrollTop,
+ capturedWriteSum: pending.writeSum,
+ currentWriteSum: writeSumRef.current,
+ })
+ // The correction, checked against the transcript itself.
+ //
+ // `delta` is derived from screen positions; the anchor's CONTENT offset is the
+ // independent answer to the same question -- how much transcript arrived above
+ // this row -- and the two must agree. Logged rather than enforced because the
+ // offset tree prices never-mounted rows at a mean, so a disagreement can mean
+ // either a wrong correction or a wrong price, and silently trusting one over
+ // the other is how five different explanations each looked confirmed tonight.
+ //
+ // This replaces the top spacer as the reference. The spacer is NOT the content
+ // above the reader: once the window starts at index 0 -- the state every
+ // top-walk ends in -- prepended rows mount above the anchor rather than growing
+ // the spacer, so it read `0 -> 0` on the device across landings that really did
+ // add thousands of pixels, and every residual measured against it was inflated
+ // by the whole mounted growth.
+ if (inspectorOn()) {
+ const owed = offsetOfRef.current(found.index) - pending.contentOffset
+ devLog('CORR', `d=${Math.round(delta)} owed=${Math.round(owed)} res=${Math.round(delta - owed)}`
+ + ` painted=${paintedBeforeCorrectionRef.current ? 1 : 0}`)
+ }
+ // Ground truth, read back AFTER the write lands.
+ //
+ // Everything else here is a prediction: `delta` says how far the row moved,
+ // `owed` says how much transcript arrived, and both can look right while the
+ // reader still sees a jolt. The only measurement that answers the reader's
+ // actual complaint is where the anchor row ENDS UP -- if it returns to the
+ // screen position it was captured at, nothing moved under them, whatever the
+ // other two numbers say. Scheduled after the write rather than computed,
+ // because a prediction of the outcome is what has been wrong five times.
+ const verifyAnchor = () => {
+ if (!inspectorOn()) return
+ const again = rowTopFrom(el, elIndexRef.current.entries(), (idx) => {
+ const it = itemsRef.current[idx]
+ return it ? anchorIdOf(it, idx) : null
+ }, pending.key)
+ if (again === null) { devLog('HELD', 'anchor gone'); return }
+ devLog('HELD', `was=${Math.round(pending.top)} now=${Math.round(again.top)} off=${Math.round(again.top - pending.top)}`)
+ }
// Instant, and accounted as a 'pin' write: this is our own correction, so
// the follow guard must recognise the resulting scroll event as self-scroll
// rather than user input. Routed through the chokepoint so the accounting
// cannot be forgotten here (see writeScrollTop).
- if (Math.abs(delta) > 0.5) {
+ // TWO conditions, and they answer different questions.
+ //
+ // `delta` says how much correction is OWED. `displaced` says whether the reader
+ // has actually seen the anchor row move -- measured on the glass, immediately
+ // before the write, as the row's distance from where it was captured.
+ //
+ // If the row is exactly where it was captured, nothing moved under the reader,
+ // and a write of any size CREATES the displacement it claims to repair. Not
+ // hypothetical: on the entry frame below `displaced` was 0px both times while
+ // `delta` asked for -1016 and then +1448, and both writes were painted.
+ //
+ // CORR d=-1016 owed=-1262 res=246 painted=1 WRITE resize 6404->5388
+ // HELD was=-380 now=636 off=1016
+ //
+ // `off` is NOT evidence of a failed hold, and reading it that way cost an
+ // explanation: substituting the definitions gives `off = -S`, where S is the
+ // scrollTop drift since capture. It measures who ELSE moved the scroller in
+ // between, and off == the write means the drift was our own.
+ //
+ // The gate is on the MEASUREMENT rather than on the derived figure because the
+ // derived one is what was wrong: `delta` and `owed` disagreed by only 246px, so
+ // they corroborated each other while both were priced against an abandoned
+ // capture. A number checked against another number from the same source is not
+ // checked.
+ const displaced = newTop - pending.top
+ // A restore that gave up moves scrollTop on its way out, so a capture straddling
+ // it is priced against a layout that no longer exists.
+ const abandoned = restoreGaveUpRef.current
+ if (abandoned) {
+ restoreGaveUpRef.current = false
+ if (inspectorOn()) devLog('CORR.drop', `giveup d=${Math.round(delta)} disp=${Math.round(displaced)}`)
+ }
+ if (!abandoned && Math.abs(displaced) > 0.5 && Math.abs(delta) > 0.5) {
writeScrollTop(el, el.scrollTop + delta, 'auto', 'pin', 'resize')
+ // Same tick, after the write: layout is already forced by the read inside,
+ // so this costs nothing extra and answers whether the reader actually held.
+ verifyAnchor()
// Re-baseline a pending height anchor: its capture may have read the
// UNCOMPENSATED geometry mid-transaction (see syncHeightsNow). After
// this write the row sits where the reader sees it, so refreshing the
@@ -3163,7 +3739,7 @@ export function useVirtualChat(
const it = itemsRef.current[idx]
return it ? anchorIdOf(it, idx) : null
}, cand.key)
- if (t !== null) { newTop = t; capturedTop = cand.top; break }
+ if (t !== null) { newTop = t.top; capturedTop = cand.top; break }
}
if (newTop === null) return
const delta = newTop - capturedTop
@@ -3354,6 +3930,10 @@ export function useVirtualChat(
shiftStageRef.current = null
shiftInsertedRef.current = 0
prependPreScrollTopRef.current = -1
+ // A previous restore's landing is not this one's target. Dropped with the
+ // other superseded captures so the re-placement helper cannot hold the
+ // reader to an offset from the session or row they just left.
+ landedAnchorRef.current = null
const count = itemsRef.current.length
setWindowRange(computeJumpWindow(index, count, overscan))
stickRef.current = false
@@ -3487,6 +4067,16 @@ export function useVirtualChat(
hasPrevious,
})) {
if (settleGateRef.current && inspectorOn()) devLog('SETTLE.ok', `f${n} d=${delta.toFixed(1)} a=${Math.round(aboveNow)}`)
+ // The landing outlives the loop. Recorded ONLY here -- on the
+ // converged path -- because the abort paths below reach `lower()`
+ // without ever agreeing with the anchor, and a landing nobody
+ // verified is not a target worth holding the reader to.
+ landedAnchorRef.current = {
+ session,
+ index,
+ anchor,
+ at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
+ }
// STOP, do not merely lower the gate. Convergence already requires
// the height above the anchor to have stopped moving, so there is
// nothing left to correct -- and once the gate is down the shift compensations
@@ -3614,6 +4204,10 @@ export function useVirtualChat(
const _its = itemsRef.current
devLog('GIVEUP.rows', `${_its.length}: ${_its.slice(0, 7).map((it, i) => keyShape(_idFn ? _idFn(it, i) : getKeyRef.current(it, i))).join(' ')}`)
pendingRestoreRef.current = null
+ // The basis is abandoned, not merely unused: re-arming follow and taking the
+ // default placement moves scrollTop, so any capture in flight is now priced
+ // against a layout that is gone.
+ restoreGaveUpRef.current = true
stickRef.current = followOutput
setRestoreEval((n) => n + 1)
}
diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json
index d5012431618..50a3a5bec36 100644
--- a/website/src/i18n/locales/bn.json
+++ b/website/src/i18n/locales/bn.json
@@ -9721,7 +9721,6 @@
"upload_failed_error": "আপলোড ব্যর্থ: {{error}}",
"what_can_i_do_for_you": "আপনার জন্য কী করতে পারি?",
"worktree_creation_returned_no_path": "worktree তৈরি করে কোনো পাথ ফেরত আসেনি",
- "loading_earlier_messages": "পূর্ববর্তী বার্তা লোড হচ্ছে…",
"send_failed_with_error": "পাঠানো যায়নি: {{error}}",
"delivery_unconfirmed": "ডেলিভারি নিশ্চিত হয়নি — আপনার লেখা ইনপুট বাক্সে ফিরিয়ে দেওয়া হয়েছে; আবার পাঠানোর আগে কথোপকথনটি দেখে নিন।",
"could_not_read_file_reason": "{{path}} পড়া যায়নি ({{reason}})",
diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json
index 68296bc8cc6..301b7ee2fbb 100644
--- a/website/src/i18n/locales/de.json
+++ b/website/src/i18n/locales/de.json
@@ -9721,7 +9721,6 @@
"upload_failed_error": "Upload fehlgeschlagen: {{error}}",
"what_can_i_do_for_you": "Was kann ich für Sie tun?",
"worktree_creation_returned_no_path": "Die Worktree-Erstellung hat keinen Pfad zurückgegeben",
- "loading_earlier_messages": "Frühere Nachrichten werden geladen…",
"send_failed_with_error": "Senden fehlgeschlagen: {{error}}",
"delivery_unconfirmed": "Zustellung nicht bestätigt — dein Text steht wieder im Eingabefeld; prüfe den Verlauf, bevor du erneut sendest.",
"could_not_read_file_reason": "{{path}} konnte nicht gelesen werden ({{reason}})",
diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json
index 0a1865344ac..715f0124660 100644
--- a/website/src/i18n/locales/en-XA.json
+++ b/website/src/i18n/locales/en-XA.json
@@ -9597,7 +9597,6 @@
"upload_failed_check_file_type_and_size_max_50_mb": "[Ùþĺøàð ƒàìĺèð — çĥèçķ ƒìĺè ţýþè àñð şìžè (ɱàẋ 50 ṀƁ) ··················]",
"upload_failed_error": "[Ùþĺøàð ƒàìĺèð: {{error}} ··············]",
"worktree_creation_returned_no_path": "[Ẁøŕķţŕèè çŕèàţìøñ ŕèţùŕñèð ñø þàţĥ ·················]",
- "loading_earlier_messages": "[Ĺøàðìñğ èàŕĺìèŕ ɱèşşàğèş… ··················]",
"send_failed_with_error": "[Şèñð ƒàìĺèð: {{error}} ············]",
"delivery_unconfirmed": "[Ðèĺìṽèŕý ñøţ çøñƒìŕɱèð — ýøùŕ ţèẋţ ìş ƀàçķ ìñ ţĥè çøɱþøşèŕ; çĥèçķ ţĥè ţŕàñşçŕìþţ ƀèƒøŕè şèñðìñğ ìţ àğàìñ. ································]",
"could_not_read_file_reason": "[Çøùĺð ñøţ ŕèàð {{path}} ({{reason}}) ················]",
diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json
index 83331f87aaf..92babaa9f5a 100644
--- a/website/src/i18n/locales/en.manual.json
+++ b/website/src/i18n/locales/en.manual.json
@@ -3669,7 +3669,6 @@
"upload_failed_check_file_type_and_size_max_50_mb": "Upload failed — check file type and size (max 50 MB)",
"upload_failed_error": "Upload failed: {{error}}",
"worktree_creation_returned_no_path": "Worktree creation returned no path",
- "loading_earlier_messages": "Loading earlier messages…",
"send_failed_with_error": "Send failed: {{error}}",
"delivery_unconfirmed": "Delivery not confirmed — your text is back in the composer; check the transcript before sending it again.",
"could_not_read_file_reason": "Could not read {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json
index d22a268bf35..b88de22ae11 100644
--- a/website/src/i18n/locales/es.json
+++ b/website/src/i18n/locales/es.json
@@ -9859,7 +9859,6 @@
"upload_failed_error": "Error al subir: {{error}}",
"what_can_i_do_for_you": "¿Qué puedo hacer por ti?",
"worktree_creation_returned_no_path": "La creación del worktree no devolvió ninguna ruta",
- "loading_earlier_messages": "Cargando mensajes anteriores…",
"send_failed_with_error": "Error al enviar: {{error}}",
"delivery_unconfirmed": "Entrega sin confirmar: tu texto ha vuelto al cuadro de redacción; revisa la conversación antes de enviarlo de nuevo.",
"could_not_read_file_reason": "No se pudo leer {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json
index 9eaf17dc9c1..217e83600f0 100644
--- a/website/src/i18n/locales/fr.json
+++ b/website/src/i18n/locales/fr.json
@@ -9859,7 +9859,6 @@
"upload_failed_error": "Échec du téléversement : {{error}}",
"what_can_i_do_for_you": "Que puis-je faire pour vous ?",
"worktree_creation_returned_no_path": "La création du worktree n’a renvoyé aucun chemin",
- "loading_earlier_messages": "Chargement des messages précédents…",
"send_failed_with_error": "Échec de l'envoi : {{error}}",
"delivery_unconfirmed": "Envoi non confirmé — votre texte est de retour dans le champ de saisie ; vérifiez la conversation avant de renvoyer.",
"could_not_read_file_reason": "Impossible de lire {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json
index 1b09e7820b1..9d553e3c245 100644
--- a/website/src/i18n/locales/hi.json
+++ b/website/src/i18n/locales/hi.json
@@ -9721,7 +9721,6 @@
"upload_failed_error": "अपलोड विफल: {{error}}",
"what_can_i_do_for_you": "मैं आपके लिए क्या कर सकता हूँ?",
"worktree_creation_returned_no_path": "वर्कट्री बनाने पर कोई पथ नहीं मिला",
- "loading_earlier_messages": "पुराने संदेश लोड हो रहे हैं…",
"send_failed_with_error": "भेजना विफल: {{error}}",
"delivery_unconfirmed": "डिलीवरी की पुष्टि नहीं हुई — तुम्हारा टेक्स्ट इनपुट बॉक्स में वापस आ गया है; दोबारा भेजने से पहले बातचीत देखो।",
"could_not_read_file_reason": "{{path}} पढ़ा नहीं जा सका ({{reason}})",
diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json
index 4cdbc776642..44022f9cb32 100644
--- a/website/src/i18n/locales/it.json
+++ b/website/src/i18n/locales/it.json
@@ -9859,7 +9859,6 @@
"upload_failed_error": "Caricamento non riuscito: {{error}}",
"what_can_i_do_for_you": "Cosa posso fare per te?",
"worktree_creation_returned_no_path": "La creazione del worktree non ha restituito alcun percorso",
- "loading_earlier_messages": "Caricamento dei messaggi precedenti…",
"send_failed_with_error": "Invio non riuscito: {{error}}",
"delivery_unconfirmed": "Consegna non confermata: il testo è tornato nel campo di scrittura; controlla la conversazione prima di inviarlo di nuovo.",
"could_not_read_file_reason": "Impossibile leggere {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json
index fc256a1e00d..f7993ce3573 100644
--- a/website/src/i18n/locales/ja.json
+++ b/website/src/i18n/locales/ja.json
@@ -9583,7 +9583,6 @@
"upload_failed_error": "アップロードに失敗しました: {{error}}",
"what_can_i_do_for_you": "何をお手伝いしましょうか?",
"worktree_creation_returned_no_path": "ワークツリーの作成結果にパスがありません",
- "loading_earlier_messages": "以前のメッセージを読み込み中…",
"send_failed_with_error": "送信に失敗しました: {{error}}",
"delivery_unconfirmed": "送信が確認できません — 入力欄にテキストを戻しました。再送信する前に会話履歴を確認してください。",
"could_not_read_file_reason": "{{path}} を読み取れませんでした({{reason}})",
diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json
index 4d83d6b2049..9a4aca5bdec 100644
--- a/website/src/i18n/locales/ko.json
+++ b/website/src/i18n/locales/ko.json
@@ -9583,7 +9583,6 @@
"upload_failed_error": "업로드에 실패했습니다: {{error}}",
"what_can_i_do_for_you": "무엇을 도와드릴까요?",
"worktree_creation_returned_no_path": "워크트리 생성 결과에 경로가 없습니다",
- "loading_earlier_messages": "이전 메시지를 불러오는 중…",
"send_failed_with_error": "전송 실패: {{error}}",
"delivery_unconfirmed": "전송이 확인되지 않았습니다 — 입력한 텍스트를 입력창에 되돌려 두었습니다. 다시 보내기 전에 대화 기록을 확인하세요.",
"could_not_read_file_reason": "{{path}}을(를) 읽을 수 없습니다 ({{reason}})",
diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json
index 092e4c7d135..6f09d544e73 100644
--- a/website/src/i18n/locales/pt.json
+++ b/website/src/i18n/locales/pt.json
@@ -9859,7 +9859,6 @@
"upload_failed_error": "Falha no envio: {{error}}",
"what_can_i_do_for_you": "O que posso fazer por você?",
"worktree_creation_returned_no_path": "A criação da worktree não retornou nenhum caminho",
- "loading_earlier_messages": "Carregando mensagens anteriores…",
"send_failed_with_error": "Falha ao enviar: {{error}}",
"delivery_unconfirmed": "Entrega não confirmada — seu texto voltou para a caixa de composição; verifique a conversa antes de enviar novamente.",
"could_not_read_file_reason": "Não foi possível ler {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json
index c1ad2ca0bca..c3f388c8c43 100644
--- a/website/src/i18n/locales/ru.json
+++ b/website/src/i18n/locales/ru.json
@@ -9997,7 +9997,6 @@
"upload_failed_error": "Не удалось загрузить: {{error}}",
"what_can_i_do_for_you": "Чем могу помочь?",
"worktree_creation_returned_no_path": "Создание worktree не вернуло путь",
- "loading_earlier_messages": "Загрузка предыдущих сообщений…",
"send_failed_with_error": "Не удалось отправить: {{error}}",
"delivery_unconfirmed": "Доставка не подтверждена — ваш текст возвращён в поле ввода; проверьте переписку, прежде чем отправлять снова.",
"could_not_read_file_reason": "Не удалось прочитать {{path}} ({{reason}})",
diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json
index 3afe98133eb..5e70ac648de 100644
--- a/website/src/i18n/locales/zh-CN.json
+++ b/website/src/i18n/locales/zh-CN.json
@@ -9583,7 +9583,6 @@
"upload_failed_error": "上传失败:{{error}}",
"what_can_i_do_for_you": "我能帮你做什么?",
"worktree_creation_returned_no_path": "创建工作树未返回路径",
- "loading_earlier_messages": "正在加载较早的消息…",
"send_failed_with_error": "发送失败:{{error}}",
"delivery_unconfirmed": "尚未确认送达——你的文字已放回输入框;再次发送前请先查看对话记录。",
"could_not_read_file_reason": "无法读取 {{path}}({{reason}})",
diff --git a/website/src/lib/scrollQuiet.ts b/website/src/lib/scrollQuiet.ts
deleted file mode 100644
index bca5bcf70cb..00000000000
--- a/website/src/lib/scrollQuiet.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Shared scroll-quiescence signal between the transcript scroller (writer) and
- * the paging thunks (waiters).
- *
- * WHY THIS EXISTS: the older-history trigger already refuses to FIRE until the
- * scroller has settled, but on a slow network the RESPONSE arrives during the
- * reader's next gesture. Splicing hundreds of rows into the transcript while a
- * scroll (especially an iOS momentum glide) is in flight puts the pre-paint
- * anchor machinery in a race against the pixel-addressed window recompute the
- * gesture itself schedules -- measured on the 4x-throttled phone rig as
- * per-landing kilopixel jumps whose compensation consumed against a mis-bound
- * node (delta 0) and stood down. Landing is CHEAP to defer and catastrophic to
- * interleave, so the thunk holds the payload here until the scroller has been
- * quiet for a beat; the FETCH still overlaps the gesture, so no latency is
- * added to the data, only to the mutation.
- *
- * Module-level rather than React state: the writer is a passive scroll handler
- * that must stay allocation-free, and the waiter is a redux thunk with no
- * component scope. One transcript scroller exists per app instance.
- */
-
-/** Scroller must be quiet this long before an older page may land. */
-export const OLDER_FLUSH_QUIET_MS = 160
-
-/** Never hold a fetched page longer than this: a reader who keeps flinging
- * upward is exactly the reader waiting for that page, so land it on the next
- * gap even if quiescence never arrives (spinner-wedge backstop). */
-export const OLDER_FLUSH_MAX_WAIT_MS = 2500
-
-let lastScrollActivityAt = 0
-
-/** Called by the transcript scroller on every USER scroll event (self-scroll
- * pin writes are filtered by the caller -- our own corrections must not hold
- * the flush hostage). */
-export function noteUserScrollActivity(): void {
- lastScrollActivityAt = Date.now()
-}
-
-/** Test seam. */
-export function resetScrollQuiet(): void {
- lastScrollActivityAt = 0
-}
-
-/**
- * Resolve once the scroller has been quiet for OLDER_FLUSH_QUIET_MS, or after
- * OLDER_FLUSH_MAX_WAIT_MS, whichever comes first. Polling (rather than a
- * subscriber list) keeps the writer side a single timestamp store.
- */
-export function whenScrollQuiet(signal?: AbortSignal): Promise {
- return new Promise((resolve) => {
- const started = Date.now()
- const tick = () => {
- if (signal?.aborted) { resolve(); return }
- const now = Date.now()
- if (now - lastScrollActivityAt >= OLDER_FLUSH_QUIET_MS) { resolve(); return }
- if (now - started >= OLDER_FLUSH_MAX_WAIT_MS) { resolve(); return }
- setTimeout(tick, 50)
- }
- tick()
- })
-}
diff --git a/website/src/pages/ChatPage.tsx b/website/src/pages/ChatPage.tsx
index 408027b57ba..3b6999d1a72 100644
--- a/website/src/pages/ChatPage.tsx
+++ b/website/src/pages/ChatPage.tsx
@@ -90,7 +90,7 @@ import { useChatPageSessionController } from './chat/useChatPageSessionControlle
import { useChatPageResourcesController } from './chat/useChatPageResourcesController'
import EarlierMessagesBar from './chat/EarlierMessagesBar'
import TranscriptScrollShell from './chat/TranscriptScrollShell'
-import { devLog, devWatchMessages, inspectorOn } from '../dev/scrollInspector'
+import { devLog, devOlderLatency, devWatchMessages, inspectorOn } from '../dev/scrollInspector'
import { useVirtualChat } from '../hooks/virtualizer/useVirtualChat'
import { addPendingFile, prepareSendPayload, buildRelMap, hasExactRelMention, normalizeWindowsPath, parseDirTokens, serializeDirTokens, spliceDirTokens } from '../utils/fileTokens'
import { makeRelative } from '../components/FilePickerMenu'
@@ -105,18 +105,59 @@ const OLDER_RETRY_COOLDOWN_MS = 1500
* it is the backstop that guarantees progress while the reader holds the top,
* not the fast path (the sentinel/crossing triggers still fire first). */
const OLDER_TOP_POLL_MS = 700
+/** How near the top of the loaded slice authorizes an automatic older page,
+ * in viewport heights.
+ *
+ * The requirement this serves is an INVARIANT, not a threshold: the reader must
+ * never catch up with the head of the loaded slice, so the page has to be not
+ * merely started but FINISHED before they arrive.
+ *
+ * ABSOLUTE pixels, not viewport multiples, because the two terms this lead has
+ * to cover are both absolute. Latency is the dominant one: a fetch plus a
+ * measured landing is a few hundred ms whatever the screen, and at fling speed
+ * (~5000px/s on a phone) that alone eats thousands of pixels. A viewport
+ * multiple made the lead grow with the screen while the thing it pays for did
+ * not, so a desktop got a lead far beyond the distance it needed and a phone's
+ * was set by whatever the browser chrome left over.
+ *
+ * This distance is only half the answer, and the smaller half: the trigger also
+ * fires on the SCROLL EVENT rather than waiting for the next poll tick, because
+ * one OLDER_TOP_POLL_MS of delay is itself worth more travel than the whole lead
+ * (700ms at fling speed is ~3500px). The poll remains as the backstop for the
+ * cases no scroll event covers — a landing that leaves the reader already inside
+ * the lead, and momentum that has stopped firing events. */
+const OLDER_WALK_TRIGGER_PX = 2000
/**
* How long after the reader's last scroll the top-of-transcript walk keeps
* paging. Past this they have stopped climbing, and a page landing then is
* movement they did not ask for.
*/
-const OLDER_WALK_ACTIVE_MS = 1500
+/** How long the scroller must be STILL before an automatic page may load, on a
+ * browser that does NOT do scroll anchoring itself.
+ *
+ * The bounce needs motion. A landing's compensation lands the reader within a
+ * pixel of where they were -- read back after the write, `off=1` -- and the jolt
+ * is still visible, because a programmatic `scrollTop` write during an iOS fling
+ * perturbs the fling itself: the position ends up right and the MOTION does not.
+ * The reader's own observation is the cleanest statement of it -- at `to-top = 0`
+ * loading never bounces, and `to-top = 0` is exactly the state where they have
+ * stopped, since a fling cannot persist against the top edge.
+ *
+ * 100ms, matching matrix-react-sdk's `ScrollPanel`, which arrived at the same
+ * number for the same reason: "To maintain scroll position after the portion
+ * above the viewport changes height, we need to set the scrollTop... We do this
+ * 100ms after the user has stopped scrolling, so setting scrollTop has no nasty
+ * side-effects." Every estimate-based virtualizer that ships a chat list lands
+ * here -- react-virtuoso has an iOS-only glitch report for attempting it live,
+ * virtua's README says the user must release the scroll, TanStack Virtual carries
+ * a code path literally named for deferring the adjustment on iOS. A shorter wait
+ * is not a better one: 20ms is barely past a frame interval, so it lets the TAIL
+ * of a fling through, which is exactly when the surface is still drifting.
+ */
+const OLDER_WALK_QUIET_MS = 100
// Idle prefetch: quiet time required before background pages load, and the
// poll cadence. Quiet > the farm's deep-idle threshold is deliberate — the
// farm gets first claim on idle time; prefetch only runs once it is caught up.
-// A page may LAND only after the scroller has been still this long: landing
-// compensation writes scrollTop, and mid-momentum writes fight the fling.
-const OLDER_LANDING_SETTLE_MS = 400
/** A transcript at least this much taller than its viewport is scrollable
* in earnest: the reader can climb to ask for history, so nothing fetches
* it for them. Below it, auto-fill is load-bearing (no scrollbar exists). */
@@ -150,6 +191,14 @@ export function shouldAutoFillOlder(g: { scrollHeight: number; clientHeight: num
* window ages out on its own: a gesture burst buys a bounded run of pages, not
* the rest of the session. */
const REAL_GESTURE_AUTH_MS = 20000
+/** Gap that separates one expression of intent from the next.
+ *
+ * A continuous drag fires `touchmove` about every 16ms and a wheel spin comes in
+ * bursts, so anything shorter than this treats one gesture as many and refills
+ * the older-history page budget faster than the walk can spend it. Long enough
+ * to cover the gaps inside one drag; short enough that a deliberate second flick
+ * is a second expression and earns a fresh budget. */
+const REAL_GESTURE_COALESCE_MS = 300
/**
* Height of the transcript's tail spacer, in px.
@@ -1136,6 +1185,12 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
// The walk poll's own budget and last-gesture stamp. Refs so neither survives
// only as long as the effect that reads them -- see the note at their use.
const walkPagesSinceInputRef = useRef(0)
+ // Older-page latency, split by owner. `fetch` = dispatch to data-in-store;
+ // `paint` = data-in-store to really on screen (measured geometry, not
+ // estimates). Diagnostics only: nothing gates on these.
+ const olderFetchStartRef = useRef(0)
+ const olderFetchMsRef = useRef(0)
+ const olderHeightAtStartRef = useRef(0)
const walkLastInputAtRef = useRef(Number.NEGATIVE_INFINITY)
// Entering a session is not a request for history, so a session must never
// INHERIT authorization. Every one of these is written in exactly one place --
@@ -4696,6 +4751,62 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
// window like any other item. See useVirtualChat call below for the
// memory-vs-flicker trade-off rationale.)
+ // Both EDGES of an older page, with the distance to the top at each. The
+ // question a trace has to answer is whether the page started early enough to
+ // FINISH before the reader arrived, and a start-only line cannot say that.
+ // Driven by the state change rather than the walk's 700ms poll, which would
+ // miss a page that began and landed inside one tick.
+ useEffect(() => {
+ if (!inspectorOn()) return
+ const el = vScrollerElRef.current
+ const vp = el && el.clientHeight > 0 ? (el.scrollTop / el.clientHeight).toFixed(1) : '?'
+ const px = el ? Math.round(el.scrollTop) : -1
+ devLog('OLDER', `${loadingOlder ? 'START' : 'END'} to-top ${px}px=${vp}vp`)
+ // Split the wait into the two halves that have different owners, because
+ // "loading is too slow" is unactionable until it says WHICH half. `fetch` is
+ // the request itself — the backend's work plus the wire. `paint` is
+ // everything after the data is in the store until the page is really on
+ // screen: the reducer, the regroup, the render, and the measurement that
+ // replaces its estimated heights with real ones. A reader does not
+ // experience the first half ending; they experience the second one.
+ const now = Date.now()
+ if (loadingOlder) {
+ olderFetchStartRef.current = now
+ olderHeightAtStartRef.current = el ? el.scrollHeight : 0
+ } else if (olderFetchStartRef.current > 0) {
+ olderFetchMsRef.current = now - olderFetchStartRef.current
+ olderFetchStartRef.current = 0
+ // Closed on the second frame after the store write, which is the earliest
+ // moment the landed rows have actually been PAINTED: one frame for React to
+ // commit, one for the browser to present it. This used to close when the
+ // walk's measurement sweep passed -- that sweep is gone (it waited on an
+ // idle-only farm, so it could only ever pass at rest), and its removal
+ // silently took this timing with it: the overlay simply stopped printing a
+ // `lat` line, which is the failure mode an instrument must not have.
+ const landedAt = now
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ devOlderLatency(olderFetchMsRef.current, Date.now() - landedAt)
+ // The landing's own HEIGHT, read HERE and not in the effect body. The
+ // effect runs in the same commit as the store update, before React has
+ // rendered the new rows, so a synchronous scrollHeight read reports the
+ // height the transcript had BEFORE the page -- which printed as a
+ // 100-message page adding 32px and looked exactly like a real defect.
+ // Two frames later the rows exist and the spacer has been repriced.
+ //
+ // Printed against the trigger because that comparison is the question:
+ // a page that adds less than OLDER_WALK_TRIGGER_PX leaves the reader
+ // still inside the lead, so nearTop stays true and the next tick fires
+ // again -- chain-loading that is really a page too short to clear its
+ // own trigger.
+ const el2 = vScrollerElRef.current
+ const grew = (el2 ? el2.scrollHeight : 0) - olderHeightAtStartRef.current
+ devLog('OLDER', `grew +${Math.round(grew)}px vs trigger ${OLDER_WALK_TRIGGER_PX}px`)
+ })
+ })
+ }
+ }, [loadingOlder])
+
// THE admission rule for every AUTOMATIC older fetch: the control that offers
// history must be on the reader's screen. See earlierAffordanceInView for why
// this replaces the per-trigger geometry/latch proxies, each of which had a
@@ -4709,6 +4820,17 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
return earlierAffordanceInView(
br ? { top: br.top, bottom: br.bottom } : null,
{ top: vr.top, bottom: vr.bottom },
+ // ZERO lead: the affordance must be genuinely ON SCREEN, not merely within
+ // reach of it. Spending the walk's trigger distance here meant the bar
+ // counted as "in view" while still 2,000px above the viewport, so history
+ // loaded before the reader had any way to see that it was going to -- the
+ // reported "it loads without me seeing Load previous".
+ //
+ // The coupling this replaces existed so the affordance gate could never be
+ // stricter than the trigger and veto every page. It cannot deadlock: the
+ // reader reaches the bar by scrolling, and once it is on screen both gates
+ // agree.
+ 0,
)
}, [])
@@ -4924,11 +5046,33 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
// 'it just keeps loading previous after a refresh'. Requiring one real
// input event this session before any poll-issued fetch turns the walk
// back into what its own comment promises: reader-initiated.
- const noteInput = () => {
- walkLastInputAtRef.current = Date.now()
- walkPagesSinceInputRef.current = 0
- lastRealInputAtRef.current = Date.now()
- sentinelPagesSinceInputRef.current = 0
+ const noteInput = (e?: Event) => {
+ // A TAP IS NOT A SCROLL. `pointerdown` is in this vocabulary for the
+ // scrollbar-thumb drag — a pointer affordance that exists under a mouse or
+ // a pen and has no equivalent under a finger, since touch scrolling is
+ // already covered by `touchmove`. On a touch device every tap fires
+ // `pointerdown`, including the tap that opens a diff row, so counting it
+ // refilled the older-history page budget on ANY interaction at all: the
+ // walk then drained a long history a few taps at a time, with nothing the
+ // reader did resembling a request for older messages. Excluding the touch
+ // pointer keeps the drag path this event was added for and drops only the
+ // gesture that was never evidence of reading upward.
+ if (e && e.type === 'pointerdown' && (e as PointerEvent).pointerType === 'touch') return
+ const now = Date.now()
+ // ONE EXPRESSION OF INTENT, not one event. A continuous drag fires
+ // `touchmove` around sixty times a second, so refilling the page budget on
+ // every event made "two pages per expression of intent" mean two pages per
+ // EVENT — no bound at all while a finger is moving. That went unnoticed only
+ // because a separate stillness gate used to ensure paging happened when
+ // these events were NOT firing; the budget was never doing the bounding by
+ // itself. Coalescing a stream into the gesture it actually is puts the bound
+ // back where the comment on OLDER_WALK_MAX_PAGES_PER_INPUT says it lives.
+ if (now - lastRealInputAtRef.current > REAL_GESTURE_COALESCE_MS) {
+ walkPagesSinceInputRef.current = 0
+ sentinelPagesSinceInputRef.current = 0
+ }
+ walkLastInputAtRef.current = now
+ lastRealInputAtRef.current = now
}
el?.addEventListener('wheel', noteInput, { passive: true })
el?.addEventListener('touchmove', noteInput, { passive: true })
@@ -4951,18 +5095,20 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
let lastLanding = 0
let prevLoading = false
// Any scroll event — momentum coasting included, which fires no
- // wheel/touchmove — marks the transcript as still MOVING. A landing's
- // prepend compensation writes scrollTop, and on iOS a programmatic write
- // mid-momentum fights the fling's own curve: the reader sees the view
- // snap. Pages land only when the scroller is fully settled.
+ // wheel/touchmove — records that the transcript is MOVING. It no longer
+ // CANCELS a page: a reader climbing toward the top has already said which
+ // direction they want, and speed does not weaken that intent, so the fetch
+ // is allowed to be in flight while they are still travelling. What the
+ // timestamp still governs is the UPPER bound below — a reader who has come
+ // to rest is no longer climbing and gets no further pages.
let lastScrollEvt = 0
- const noteScroll = () => {
- lastScrollEvt = Date.now()
- // Motion kills any in-flight page: landings commit only while still.
- abortActiveOlderFetch()
- }
- el?.addEventListener('scroll', noteScroll, { passive: true })
- const t = setInterval(() => {
+ let attemptRaf = 0
+ // ONE gate chain, two callers. The scroll event is what makes the lead
+ // usable — waiting for the next tick costs more travel than the whole
+ // OLDER_WALK_TRIGGER_PX budget at fling speed — but a SECOND copy of the
+ // chain for it is how one copy silently drifts from the other, so both
+ // callers run this body.
+ const attemptOlderWalk = () => {
const el2 = virt.scrollerRef?.current
if (!el2 || el2.clientHeight <= 0) return
const chat = store.getState().chat
@@ -4979,7 +5125,7 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
// which is what "reader-initiated" has to mean.
if (!shouldContinueOlderWalk({
sawRealInput: Date.now() - lastRealInputAtRef.current <= REAL_GESTURE_AUTH_MS,
- nearTop: el2.scrollTop <= el2.clientHeight,
+ nearTop: el2.scrollTop <= OLDER_WALK_TRIGGER_PX,
walking,
pagesSinceInput: walkPagesSinceInputRef.current,
})) return
@@ -4989,65 +5135,97 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
// driven) still page history the moment they actually climb.
if (vGetFollowRef.current()) return
if (!earlierBarInViewRef.current()) return
- if (Date.now() - lastScrollEvt < OLDER_LANDING_SETTLE_MS) return
- // ...and stops entirely once they are no longer climbing. The settle gate
- // above only says "not mid-gesture", so on its own it made a reader who
- // STOPPED near the top the ideal candidate: they would sit still and watch
- // several pages land under them, one prepend each, which is felt as the
- // transcript starting to move on its own a second after they let go.
- // History still reaches back as far as they like -- it loads while they
- // climb, which is when they are asking for it.
- if (Date.now() - lastScrollEvt > OLDER_WALK_ACTIVE_MS) return
+ // STILLNESS, not activity. The gate used to require a scroll event within a
+ // recent-activity window -- history loaded WHILE the reader climbed, on the
+ // reasoning that a fast climb wants it sooner. That is the reading that
+ // produced a jolt on every landing: a page arriving mid-gesture has to be
+ // compensated against a scroller that is still moving, and no compensation
+ // makes that invisible.
+ //
+ // Applied unconditionally, and that is a deliberate retreat from splitting it
+ // by platform capability. Gating it on the presence of the CSS scroll-anchoring
+ // property (spelled out nowhere here on purpose -- a source-level guard test
+ // reads prose as the violation it is looking for) looked
+ // right -- with CSS scroll anchoring the compositor absorbs a prepend before
+ // paint, so rest is genuinely unnecessary there -- and it failed on the one
+ // platform it existed for. WebKit has now LANDED the property (bug 307734),
+ // so it parses and answers the probe on a device where the anchoring does not
+ // hold a virtualized list; WebKit's own tracker says as much about an earlier
+ // round of this ("the supported listing is misleading, it is currently not
+ // implemented"). Presence of a property is not evidence of a behaviour, and a
+ // capability split on a signal that cannot be verified fails toward loading
+ // mid-fling -- the exact thing this gate exists to prevent.
+ //
+ // The failure is worth recording because the symptom did not look like this
+ // gate at all: with the gate off, a fling near the top drives `scrollTop`
+ // NEGATIVE (iOS rubber band -- measured at -2810), `isOverscrolled` correctly
+ // refuses to treat a stretch as a content position and skips compensation, so
+ // the reader is not advanced, the affordance stays on screen, and the level-
+ // triggered poll re-fires. Reported as "it loads continuously", caused by a
+ // position that was never held. Bringing it back on a BEHAVIOURAL probe (did
+ // a prepend land already-correct before we wrote anything?) is a real option;
+ // a property name is not.
+ if (Date.now() - lastScrollEvt < OLDER_WALK_QUIET_MS) return
if (!chat.slotHasMore || chat.loadingOlder || chat.slotOlderError) return
if (chat.slotCursorKey !== chat.activeSlot) return
- // Same contract as the idle prefetch: a page lands only on FULLY
- // MEASURED geometry. Without this the walk outran the farm and piled
- // unmeasured rows over the parked reader -- every measurement landing
- // was an estimate correction under their eyes (reproduced on the rig
- // as per-landing twitches at the top). Turn-grouped pages measure in
- // ~1-2s, so the walk's pace barely changes.
- const nRows = displayItemsRef.current.length
- for (let i = 0; i < nRows; i++) { if (!virt.farmIsMeasured(i)) return }
+ // NO measurement gate. This used to require the rows above and around the
+ // reader to carry farm-measured heights, and that is a structural
+ // contradiction with the thing it asked: the measure farm runs in IDLE TIME
+ // by design, precisely so it does not correct heights under the reader's
+ // finger. A gate that waits on it therefore fires only when the reader has
+ // stopped -- reported from the device as "it still only loads previous once
+ // I completely stop scrolling", and narrowing WHICH rows it swept did not
+ // help, because the blocking row was index 0: freshly prepended, never
+ // mounted, so the farm is the only thing that can ever measure it.
+ //
+ // It also did not protect the case it was built for. Its rationale was
+ // per-landing twitches for a PARKED reader -- but a parked reader is exactly
+ // when the farm IS running, so the gate passed and pages landed anyway. What
+ // decides pages for them now is the stillness gate below, which loads FOR a
+ // parked reader on purpose: at rest is the one state where a landing cannot
+ // be felt. So the gate blocked the reader who needed history and was inert for
+ // the reader it was meant to shield.
+ //
+ // The hazard itself is already owned: a correction above the reader is what
+ // repriceAboveFoldDelta compensates, and that is the mechanism's whole
+ // purpose. One mechanism corrects the position at a time; this was a second,
+ // redundant one whose cost was the entire feature.
walkPagesSinceInputRef.current += 1
if (inspectorOn()) devLog('OLDER', `walk p${walkPagesSinceInputRef.current}`)
void dispatch(loadOlderMessages())
- }, OLDER_TOP_POLL_MS)
+ }
+ const noteScroll = () => {
+ lastScrollEvt = Date.now()
+ // Motion kills any page already in flight, not just the next one. The
+ // stillness gate below decides whether to START a fetch, and it is checked
+ // when the fetch begins -- a reader who was at rest then flings during the
+ // ~130ms round trip would otherwise get a landing mid-gesture, which is the
+ // one moment the compensation cannot be invisible. Landings commit only while
+ // still, so the two halves have to agree: one refuses to start, this one
+ // refuses to finish.
+ abortActiveOlderFetch()
+ // Coalesced to one attempt per FRAME, not per event: a fling delivers many
+ // scroll events per frame and the chain ends in an O(rows) measurement
+ // sweep, so running it per event would spend the reader's frame budget on
+ // the very gesture this is meant to keep smooth. Cancel-and-reschedule
+ // rather than latch-on-pending — a dropped frame handle must not wedge the
+ // trigger for the rest of the mount.
+ if (attemptRaf) cancelAnimationFrame(attemptRaf)
+ attemptRaf = requestAnimationFrame(() => { attemptRaf = 0; attemptOlderWalk() })
+ }
+ el?.addEventListener('scroll', noteScroll, { passive: true })
+ const t = setInterval(attemptOlderWalk, OLDER_TOP_POLL_MS)
return () => {
clearInterval(t)
+ if (attemptRaf) cancelAnimationFrame(attemptRaf)
el?.removeEventListener('scroll', noteScroll)
el?.removeEventListener('wheel', noteInput)
el?.removeEventListener('touchmove', noteInput)
el?.removeEventListener('keydown', noteInput)
el?.removeEventListener('pointerdown', noteInput)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps -- listeners re-arm on these triggers only; handlers read refs
}, [slotHasMore, dispatch, virt.scrollerRef])
- // The sticky in-flight spinner is only meaningful where pages LAND — at the
- // top of the loaded transcript. `loadingOlder` is now true for the whole
- // automatic walk (a dozen pages back-to-back), so gating the spinner on the
- // fetch alone kept it pinned over the reader even mid-transcript. Track
- // "near the top" cheaply: the setState is value-stable away from the
- // threshold, so mid-scroll updates bail before rendering.
- const [spinnerNearTop, setSpinnerNearTop] = useState(true)
- useEffect(() => {
- const el = virt.scrollerRef?.current
- if (!el) return
- let raf = 0
- const onScroll = () => {
- // Cancel-and-reschedule, never latch-on-pending (frameSchedulerLatch
- // guard): a dropped frame handle must not wedge the near-top signal.
- if (raf) cancelAnimationFrame(raf)
- raf = requestAnimationFrame(() => {
- raf = 0
- setSpinnerNearTop(el.scrollTop < el.clientHeight * 1.5)
- })
- }
- onScroll()
- el.addEventListener('scroll', onScroll, { passive: true })
- return () => { el.removeEventListener('scroll', onScroll); if (raf) cancelAnimationFrame(raf) }
- }, [virt.scrollerRef, activeSlot])
-
// A failed older-page fetch PARKS pagination: the top sentinel is already
// inside the viewport, so no new crossing ever fires and automatic paging
// never resumes — the only way forward is the retry bar, which on a phone
@@ -7109,8 +7287,6 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync
scrollerRef={scrollerRef}
onScroll={onScrollPin}
virt={virt}
- loadingOlder={loadingOlder}
- spinnerNearTop={spinnerNearTop}
// Second half of the fade-band clearance, alongside
// TRANSCRIPT_TAIL_SPACER_PX. Unlike the tail spacer this one also
// applies to a transcript short enough not to scroll, so both are
diff --git a/website/src/pages/chat/TranscriptScrollShell.tsx b/website/src/pages/chat/TranscriptScrollShell.tsx
index 3d8e9f77b16..58a7aa769d4 100644
--- a/website/src/pages/chat/TranscriptScrollShell.tsx
+++ b/website/src/pages/chat/TranscriptScrollShell.tsx
@@ -18,7 +18,6 @@
* padding via `scrollerStyle`.
*/
import React from 'react'
-import { Loader } from 'lucide-react'
import { i18nT } from '../../i18n/t'
export interface TranscriptVirtWiring {
@@ -32,8 +31,6 @@ export default function TranscriptScrollShell({
scrollerRef,
onScroll,
virt,
- loadingOlder,
- spinnerNearTop,
scrollerStyle,
aboveRows,
belowRows,
@@ -43,12 +40,6 @@ export default function TranscriptScrollShell({
scrollerRef: React.MutableRefObject
onScroll: () => void
virt: TranscriptVirtWiring
- loadingOlder: boolean
- /** Whether the reader is still near the top, where the header-pinned loading
- * overlay belongs. Omitted (undefined) means "always show it" — the overlay
- * has zero layout footprint, so a host that does not track this cannot be
- * hurt by it. */
- spinnerNearTop?: boolean
/** Host-owned geometry merged onto the scroller (e.g. the fade-band clearance padding). */
scrollerStyle?: React.CSSProperties
/** Page content above the rows (the earlier-messages paging bar). */
@@ -111,27 +102,13 @@ export default function TranscriptScrollShell({
{aboveRows}
{/* Top sentinel: drives upward window expansion via virtualizer's IO. */}
- {/* top-16 matches the h-16 header spacer above, so the spinner clears the
- overlay header instead of sitting under it.
- overflow-anchor:none so appearing/vanishing here cannot become the
- browser's scroll anchor and jump the list mid-fetch. */}
- {loadingOlder && spinnerNearTop !== false && (
- /* ABSOLUTE overlay, not sticky: a sticky element still owns flow
- space, so mounting/unmounting it on every loadingOlder flip
- inserted/removed its own ~32px above the content — measured on the
- momentum rig as ±32px content twitches for a reader parked at the
- top, once per landing. An absolute box has zero layout footprint;
- the transcript never moves. The badge keeps its own background so
- the glyph stays legible over transcript text it now overlaps.
- `spinnerNearTop` lets the page hide it once the reader has scrolled
- away from the top, where an overlay pinned to the header would
- otherwise float over unrelated content. */
-
-
-
-
-
- )}
+ {/* No loading overlay for older pages. Automatic paging has to be
+ IMPERCEPTIBLE: the reader did not ask for a fetch, so announcing one
+ turns a silent prefetch into an event, and a badge pinned under the
+ header floats over whatever they are actually reading. Feedback for the
+ MANUAL path lives where the reader pressed — `EarlierMessagesBar` renders
+ its own in-place loading state, and it is on screen exactly when they
+ reached for it. */}
{/* Top spacer — reserves the height of all items above the mounted
window so the scrollbar stays accurate while only the window
renders real DOM (keeps fast scroll cheap — O(window) nodes).
diff --git a/website/src/pages/chat/rowDisclosure.tsx b/website/src/pages/chat/rowDisclosure.tsx
index 60ca50bcd7e..25ce9bdcaaf 100644
--- a/website/src/pages/chat/rowDisclosure.tsx
+++ b/website/src/pages/chat/rowDisclosure.tsx
@@ -73,9 +73,27 @@ export function RowDisclosureProvider({ resetKey, children }: { resetKey?: strin
const storeRef = useRef(null)
if (!storeRef.current) storeRef.current = new RowDisclosureStore()
const store = storeRef.current
+ // Only a move to a DIFFERENT, KNOWN slot may drop the recorded choices.
+ // `resetKey` is null whenever no slot is active, and that is an ABSENCE OF
+ // INFORMATION rather than the reader leaving the conversation: ChatPage clears
+ // the active slot as soon as the slot list does not contain it, and an
+ // auto-select puts the same slot straight back — so a list refresh alone
+ // produced key -> null -> key. Resetting on that closed every open row in a
+ // transcript nobody had navigated away from, mid-read. Skipping the null keeps
+ // the per-slot guarantee intact, because a switch to another session still
+ // arrives as a different known key and still resets.
+ //
// Reset in an effect, not during render: reset() notifies subscribers, and
// doing that mid-render would set state on other components while rendering.
- useEffect(() => { store.reset() }, [resetKey, store])
+ const lastKnownRef = useRef(null)
+ useEffect(() => {
+ if (resetKey == null) return
+ const prev = lastKnownRef.current
+ lastKnownRef.current = resetKey
+ // The first key seen is a baseline, not a switch: the store is empty anyway
+ // and resetting would notify subscribers for nothing.
+ if (prev !== null && prev !== resetKey) store.reset()
+ }, [resetKey, store])
return {children}
}
diff --git a/website/src/pages/chat/useScrollManager.ts b/website/src/pages/chat/useScrollManager.ts
index 93c0f1d7b25..fecf7c4ba38 100644
--- a/website/src/pages/chat/useScrollManager.ts
+++ b/website/src/pages/chat/useScrollManager.ts
@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react'
+import { devLog, inspectorOn } from '../../dev/scrollInspector'
/**
* Scroll management hook for chat — provides scroller ref, isAtBottom
@@ -17,6 +18,12 @@ export function useScrollManager() {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const el = scrollerRef.current
if (!el) return
+ // Logged through the SAME tag the virtualizer's chokepoint uses, because
+ // this module writes the very element the virtualizer owns (see ChatPage's
+ // note that the two share `scrollerRef`) while bypassing that chokepoint --
+ // so without this line the movement is invisible to the write log and shows
+ // up only as unattributed displacement.
+ if (inspectorOn()) devLog('WRITE', `sm-bottom ${Math.round(el.scrollTop)}->${Math.round(el.scrollHeight)}`)
if (typeof el.scrollTo === 'function') {
el.scrollTo({ top: el.scrollHeight, behavior })
} else {
@@ -58,6 +65,7 @@ export function useScrollManager() {
else if (align === 'end') top = elTop - container.clientHeight + el.offsetHeight
else top = elTop + offset // 'start' — offset is usually negative to clear the header
top = Math.max(0, Math.min(max, top))
+ if (inspectorOn()) devLog('WRITE', `sm-index ${Math.round(container.scrollTop)}->${Math.round(top)}`)
if (typeof container.scrollTo === 'function') container.scrollTo({ top, behavior })
else container.scrollTop = top
return true
diff --git a/website/src/pierre/index.tsx b/website/src/pierre/index.tsx
index d7f457f11b1..260bfd9a2ee 100644
--- a/website/src/pierre/index.tsx
+++ b/website/src/pierre/index.tsx
@@ -252,7 +252,7 @@ export const PierrePatch = memo(function PierrePatch({ patch, options, className
)
})
-export const PierreFilePair = memo(function PierreFilePair({ oldFile, newFile, options, className, fallbackText, fallbackClassName, fallbackContentStyle, onVisible, renderHeaderMetadata, renderHeaderPrefix, renderHeaderFilenameSuffix }: {
+export const PierreFilePair = memo(function PierreFilePair({ oldFile, newFile, options, className, fallbackText, fallbackClassName, fallbackContentStyle, onVisible, fallbackHeader, renderHeaderMetadata, renderHeaderPrefix, renderHeaderFilenameSuffix }: {
oldFile: FileContents | null
newFile: FileContents | null
options?: PierreDiffOptions
@@ -265,6 +265,12 @@ export const PierreFilePair = memo(function PierreFilePair({ oldFile, newFile, o
fallbackContentStyle?: CSSProperties
/** Called after WarmSwap reveals the real implementation. */
onVisible?: () => void
+ /** Rendered ABOVE the fallback text, for a caller whose surface carries its
+ * own header row. Pierre paints its header inside the impl, so a caller that
+ * unmounts its own header to make room sees the row lose its identity strip
+ * — filename, counts, the disclosure control — while the chunk loads, which
+ * reads as the row vanishing and coming back. */
+ fallbackHeader?: () => React.ReactNode
/** Injected into the file header's metadata slot. Also rendered while
* `options.collapsed` is set, where the header IS the whole surface. */
renderHeaderMetadata?: () => React.ReactNode
@@ -273,6 +279,22 @@ export const PierreFilePair = memo(function PierreFilePair({ oldFile, newFile, o
/** Injected directly after the filename in the header. */
renderHeaderFilenameSuffix?: () => React.ReactNode
}) {
+ // BEFORE the budget bail-out: a hook may not sit behind a conditional return,
+ // and the oversized path below returns early.
+ const farm = useContext(PierreFarmHoldContext)
+ // `onVisible` means "the real implementation is on screen now", and WarmSwap is
+ // what normally fires it — but the paths below return the impl DIRECTLY, so this
+ // surface no longer warm-swaps and nothing would ever announce the reveal. The
+ // caller uses it to move focus into the diff once it exists, so losing it strands
+ // the focus, not just an event.
+ //
+ // Fired for the path that used to be the warm one. A collapsed pair is excluded
+ // because it never announced either: it is a ~32px header that renders under the
+ // paint threshold by design.
+ const revealedImmediately = !farm && !options?.collapsed
+ useEffect(() => {
+ if (revealedImmediately) onVisible?.()
+ }, [revealedImmediately, onVisible])
if (!isPierreFilePairWithinBudget(oldFile, newFile)) {
return (
+ <>
+ {fallbackHeader?.()}
+
+ >
)
const impl = (
@@ -307,20 +332,30 @@ export const PierreFilePair = memo(function PierreFilePair({ oldFile, newFile, o
)
+ // Measure-farm render: the fallback IS the measured geometry, so mounting the
+ // impl here would burn main thread on a surface that is never shown. Kept even
+ // though this surface no longer warm-swaps, because that property belongs to
+ // the farm rather than to WarmSwap.
+ if (farm) return fallbackNode
// A collapsed pair renders ONLY its header (~32px) — under the paint
// threshold by design — so it must not warm-swap or it would sit on the
- // fallback until the deadline. Expanded pairs get the same treatment as
- // Patch: readable text holds the layout until the diff paints.
- if (options?.collapsed) return impl
- return (
-
- {impl}
-
- )
+ // fallback until the deadline.
+ //
+ // An EXPANDED pair does not warm-swap either, for the reason `PierreCode`
+ // already exempts its whole-file surfaces: this is a Pierre-WINDOWED surface,
+ // and inside the warm box it is `absolute inset-0 … invisible`, so it windows
+ // against a hidden, parent-sized viewport and renders no rows at all. Nothing
+ // then invalidates that measurement when the box is revealed, so the row stays
+ // blank until an unrelated scroll makes Pierre measure again — which is the
+ // defect this exemption removes. The Suspense fallback above still covers the
+ // chunk load, so the reader is never left with an empty box while it arrives.
+ //
+ // The tradeoff is deliberate: growth now lands in the layout as it paints
+ // instead of behind a held fallback. That is the jump WarmSwap was written to
+ // absorb, and it is absorbed elsewhere now — an accordion's growth is rooted
+ // below the header the reader pressed, which the transcript's own reprice
+ // treats as zero compensation rather than a shift to correct.
+ return impl
})
export type { BaseCodeOptions, PierreDiffOptions, FileContents }
diff --git a/website/src/store/chatSlice.ts b/website/src/store/chatSlice.ts
index 2752c87cb6f..c0fcc0eb7e4 100644
--- a/website/src/store/chatSlice.ts
+++ b/website/src/store/chatSlice.ts
@@ -1,7 +1,6 @@
import { createSlice, createAsyncThunk, createSelector, type PayloadAction } from '@reduxjs/toolkit'
-import { whenScrollQuiet } from '../lib/scrollQuiet'
import { api } from '../api/client'
-import { devLog, inspectorOn } from '../dev/scrollInspector'
+import { devLog, devOlderSpans, inspectorOn } from '../dev/scrollInspector'
import { addSlotOptimistic, updateSlot, removeSlotOptimistic, markSlotRead, fetchSlots, slotSurfaceKey, sseSlots, sseConnected } from './dashboardSlice'
import { resolveDefaultColor } from '../utils/sessionColors'
import { isChatPageSurface } from '../utils/channelOrigin'
@@ -2997,15 +2996,48 @@ export const loadOlderMessages = createAsyncThunk(
const isNarrow = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
&& window.matchMedia('(max-width: 640px)').matches
const walkLimit = isNarrow ? OLDER_PAGE_LIMIT : OLDER_WALK_PAGE_LIMIT
+ // Timed SEPARATELY from the hold below. A single span covering both is
+ // ambiguous by construction: 0.1s of network plus the 2.5s hold cap and
+ // 2.3s of payload plus a 0.1s hold produce the same number, and they call
+ // for opposite fixes.
+ const netStart = Date.now()
const d = await api.chatSlotDetail(slot, walkLimit, state.slotOldestIndex, controller.signal)
- // LANDING BUFFER: the fetch overlaps the reader's gesture, but the
- // MUTATION must not -- splicing rows mid-glide races the pre-paint
- // anchor machinery against the gesture's own pixel-addressed window
- // recompute (phone rig: kilopixel per-landing jumps whose anchor
- // consume mis-bound and stood down). Hold the payload until the
- // scroller has been quiet for a beat; bounded, so a reader who never
- // pauses still gets the page (see scrollQuiet.ts).
- await whenScrollQuiet(controller.signal)
+ const netMs = Date.now() - netStart
+ // NO LANDING BUFFER. The payload used to be held here until the scroller
+ // had been quiet for a beat, because splicing rows mid-glide raced the
+ // pre-paint anchor machinery against the pixel-addressed window recompute
+ // the gesture itself schedules (phone rig: per-landing kilopixel jumps).
+ //
+ // Measured on the device with the two spans split apart, that hold was
+ // 1.95s of a 2.1s wait -- 92% -- while the request itself was 0.16s. And it
+ // is not a tunable: it ends when the READER stops, so its length is however
+ // long they keep scrolling, which is exactly the gesture that needs the
+ // page. Capping it lower does not help either; this hold ended on
+ // quiescence, well inside the 2.5s cap.
+ //
+ // So the page lands immediately and the anchor machinery is asked to hold
+ // the position during a gesture. That machinery has been rebuilt since the
+ // rig measurement (the straddling full-delta rule, and the render-phase
+ // capture keyed on the range actually moving up), so whether those jumps
+ // still reproduce is a device question, not an archive one.
+ //
+ // The hold's own module is gone with it -- it had no other reader, and a
+ // dead one would keep every user scroll stamping a timestamp nothing
+ // consumes. What replaced it sits on the TRIGGER side instead: the walk
+ // refuses to start a fetch while the scroller is moving, and motion aborts
+ // a page already in flight, so the two ends agree without a buffer in the
+ // middle.
+ //
+ // If the jumps DO reproduce, the fix is not to reinstate a wait -- it is to
+ // stop splicing at all: ship geometry up front (ids + heights) and hydrate
+ // content in place, so rows are never inserted and there is nothing for a
+ // gesture to race. The overlay reports the request span alone now; a second
+ // number would have to be added back to it to measure a hold, which is a
+ // visible edit rather than a constant reading zero forever.
+ devOlderSpans(netMs)
+ // Kept although the wait is gone: a slot switch can land between the
+ // response and this return, and splicing a page from the chat the reader
+ // just left is worse than serving nothing.
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { slot, nextBefore: d.next_before || 0, messages: filterMessages(d.messages || []), hasMore: d.has_more || false, total: d.total || 0 }
} catch (e) {
diff --git a/website/src/test/ChatPage.idlePrefetchAuth.test.ts b/website/src/test/ChatPage.idlePrefetchAuth.test.ts
index e684d3c5c2a..41a72c848b1 100644
--- a/website/src/test/ChatPage.idlePrefetchAuth.test.ts
+++ b/website/src/test/ChatPage.idlePrefetchAuth.test.ts
@@ -30,9 +30,12 @@ describe('real-gesture authorization', () => {
it('refreshes the gesture stamp from gestures only, not from scroll', () => {
// Our own compensation write fires `scroll`. If that refreshed the stamp the
// window would be self-renewing and the latch would be back under a new name.
- const i = SRC.indexOf('lastRealInputAtRef.current = Date.now()')
+ // Anchored on the handler's own declaration, which is unique. Anchoring on
+ // the stamp assignment matched an unrelated `= 0` reset earlier in the file
+ // and sliced an empty window, which passes nothing rather than failing loudly.
+ const i = SRC.indexOf('const noteInput')
expect(i).toBeGreaterThan(-1)
- const setter = SRC.slice(SRC.lastIndexOf('const noteInput', i), SRC.indexOf('addEventListener', i) + 1600)
+ const setter = SRC.slice(i, SRC.indexOf('addEventListener', i) + 1600)
expect(setter).toMatch(/'wheel', noteInput/)
expect(setter).toMatch(/'touchmove', noteInput/)
// A pointer and a wheel are not the only human ways to reach the top. Gating on
@@ -45,6 +48,20 @@ describe('real-gesture authorization', () => {
expect(setter).not.toMatch(/'scroll', noteInput/)
})
+ it('does not count a TAP as a gesture toward older history', () => {
+ // `pointerdown` earns its place through the scrollbar-thumb drag, which needs
+ // a mouse or a pen. Under a finger the same event fires for every tap in the
+ // transcript -- opening a diff row, pressing a button -- so counting it made
+ // the page budget self-refilling on a phone and the walk drained the whole
+ // history. Touch scrolling is already covered by `touchmove`, so excluding
+ // the touch pointer costs the vocabulary nothing.
+ const i = SRC.indexOf('const noteInput')
+ const body = SRC.slice(i, SRC.indexOf('lastRealInputAtRef.current =', i))
+ expect(body).toMatch(/pointerType === 'touch'/)
+ // The guard has to precede the stamp, or it records the very input it rejects.
+ expect(body).toMatch(/return\s*$/m)
+ })
+
it('binds the keyboard vocabulary to the SCROLLER, never the document', () => {
// Scoping matters as much as the vocabulary: a document-level keydown would let
// typing in the composer authorize a history fetch, which is the same category
diff --git a/website/src/test/ChatPage.olderLoadingIndicator.test.tsx b/website/src/test/ChatPage.olderLoadingIndicator.test.tsx
index 924aceb4f25..b402ba18755 100644
--- a/website/src/test/ChatPage.olderLoadingIndicator.test.tsx
+++ b/website/src/test/ChatPage.olderLoadingIndicator.test.tsx
@@ -1,16 +1,16 @@
/**
- * Regression test: paging older history shows a visible loading indicator.
+ * Regression test: paging older history shows NO transcript-level indicator.
*
- * `loadingOlder` was already tracked in the store and already read by ChatPage,
- * but only as a re-entrancy guard — nothing rendered it. A fetch in flight was
- * therefore indistinguishable from nothing happening, so a stalled paging
- * trigger looked exactly like a session that simply had no more history.
+ * It used to. `loadingOlder` was rendered as a badge pinned under the header so a
+ * stalled paging trigger could be told apart from a session with no more history.
+ * That reasoning holds for a fetch the reader ASKED for and inverts for one they
+ * did not: automatic paging is meant to be imperceptible, and a badge under the
+ * header floats over whatever they are actually reading. Feedback for the manual
+ * path moved to where the press happened — the earlier-messages bar renders its
+ * own in-place loading state and is on screen exactly when it is reachable.
*
- * The three cases below are the whole contract: absent when idle (so the
- * assertion is not passing on a permanently-mounted node), present while the
- * fetch is pending, and gone again when it settles — asserted on the rejected
- * path, which is the one a user hits when the request fails and the spinner
- * would otherwise be left spinning forever.
+ * So the contract is now an ABSENCE in every state of `loadingOlder`, plus the
+ * bar's own mount/unmount rules, which the rest of this file pins.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
@@ -182,49 +182,29 @@ describe('ChatPage – older-messages loading indicator', () => {
expect(screen.queryByTestId(INDICATOR)).toBeNull()
})
- it('shows a labelled status region while an older page is in flight', async () => {
+ it('stays absent even while an older page IS in flight', async () => {
+ // Automatic paging must be imperceptible. The reader did not ask for the
+ // fetch, so announcing it turns a silent prefetch into an event, and the
+ // badge was pinned under the header where it floated over whatever they were
+ // actually reading. Feedback for the MANUAL path lives on the
+ // earlier-messages bar, which is on screen exactly when the reader reached
+ // for it — so no state of `loadingOlder` may draw a transcript-level overlay.
const store = renderChatPage()
await seed(store)
act(() => { store.dispatch(pending) })
+ // Given a beat to appear, it must still not be there.
+ await waitFor(() => {
+ expect(store.getState().chat.loadingOlder).toBe(true)
+ })
+ expect(screen.queryByTestId(INDICATOR)).toBeNull()
- const el = await screen.findByTestId(INDICATOR)
- // A bare spinner is an unnamed live region: screen readers announce the
- // region with no content, since the icon contributes no text.
- expect(el.getAttribute('role')).toBe('status')
- expect(el.getAttribute('aria-label')).toBe(i18nT('pages.chatPage.loading_earlier_messages'))
- // The label must say WHAT is loading: a bare "Loading…" in a live region
- // tells a screen-reader user nothing about which region moved.
- expect(el.getAttribute('aria-label')).not.toBe(i18nT('pages.chatPage.loading'))
- // Not the browser's scroll anchor: it must not shift the list as it mounts.
- expect(el.style.overflowAnchor).toBe('none')
- // Pinned, not parked at the list top: the only trigger fires from the pins
- // panel, so an unpinned indicator renders off-screen in a long session.
- // ABSOLUTE overlay with zero layout footprint: a sticky element still
- // owned flow space, so each loadingOlder flip inserted/removed ~32px
- // above the content -- a per-landing twitch for a reader parked at the
- // top (measured on the momentum rig).
- expect(el.className).toContain('absolute')
- expect(el.className).not.toContain('sticky')
- expect(el.className).toContain('top-16')
- // The container is a transparent zero-footprint overlay; opacity lives
- // on the BADGE child, or the messages scrolling beneath show through.
- const badge = el.querySelector('span') as HTMLElement
- expect(badge).not.toBeNull()
- expect(badge.style.background).not.toBe('')
- })
-
- it('clears when the older page fails, so it cannot spin forever', async () => {
- const store = renderChatPage()
- await seed(store)
-
- act(() => { store.dispatch(pending) })
- await screen.findByTestId(INDICATOR)
-
+ // ...and settling changes nothing, so there is no spin-forever state either.
act(() => { store.dispatch(rejected) })
await waitFor(() => {
- expect(screen.queryByTestId(INDICATOR)).toBeNull()
+ expect(store.getState().chat.loadingOlder).toBe(false)
})
+ expect(screen.queryByTestId(INDICATOR)).toBeNull()
})
// Control for the case below: with has-more reported and the cursor keyed, it mounts.
diff --git a/website/src/test/ChatPage.scrollShell.recipe.test.tsx b/website/src/test/ChatPage.scrollShell.recipe.test.tsx
index 4a5a75a59d3..913ef64e832 100644
--- a/website/src/test/ChatPage.scrollShell.recipe.test.tsx
+++ b/website/src/test/ChatPage.scrollShell.recipe.test.tsx
@@ -111,7 +111,7 @@ describe('scroll shell: the scroller element contract', () => {
})
describe('scroll shell: element order inside the scroller', () => {
- it('keeps the SKELETON sequence that moves as one unit: header spacer, top sentinel, loading, top spacer, bottom spacer, bottom sentinel', () => {
+ it('keeps the SKELETON sequence that moves as one unit: header spacer, top sentinel, top spacer, bottom spacer, bottom sentinel', () => {
// Order is the contract the virtualizer's IO wiring assumes; a reorder can
// compile, render, and still break window expansion. Anchors here are the
// skeleton pieces that live (and move) TOGETHER, so the pin holds whether
@@ -123,7 +123,6 @@ describe('scroll shell: element order inside the scroller', () => {
const anchors = [
'
',
'ref={virt.topSentinelRef}',
- 'data-testid="older-messages-loading"',
'height: virt.offsetBefore',
'height: virt.offsetAfter',
'ref={virt.bottomSentinelRef}',
@@ -163,28 +162,26 @@ describe('scroll shell: element order inside the scroller', () => {
expect(SHELL).toContain('{slotHasMore && cursorIsForActiveSlot && (')
})
- it('keeps the loading spinner an ABSOLUTE overlay below the header, anchor-exempt, badge-backed', () => {
- // Slice the WHOLE conditional block (gate to the next skeleton comment) so
- // the class is bound to the spinner element itself — a SHELL-wide contain
- // would stay green with the literal parked in a comment anywhere.
+ it('carries NO loading overlay for older pages anywhere in the shell', () => {
+ // Automatic paging is imperceptible by contract: the reader did not ask for
+ // the fetch, and a badge pinned under the header floats over what they are
+ // reading. Feedback for the MANUAL path belongs to EarlierMessagesBar, which
+ // is on screen exactly when the reader reached for it.
//
- // ABSOLUTE, not sticky: a sticky element still owns flow space, so
- // mounting/unmounting it on every loadingOlder flip inserted/removed its
- // own ~32px above the content — ±32px content twitches for a reader parked
- // at the top, once per landing (momentum rig). The opaque background moved
- // from the box to an inner badge, since a zero-footprint overlay now sits
- // OVER transcript text and the glyph has to stay legible.
- const spinner = between('{loadingOlder && spinnerNearTop !== false && (', '{/* Top spacer')
- expect(spinner).toContain('className="absolute top-16 inset-x-0 z-[1] flex justify-center py-2 pointer-events-none"')
- expect(spinner).toContain('data-testid="older-messages-loading"')
- expect(spinner).toContain("overflowAnchor: 'none'")
- expect(spinner).toContain("background: 'var(--bg)'")
- expect(spinner).toContain(' ')
- // The overlay has no flow footprint, so its class must not go sticky
- // again. Asserted against the className ATTRIBUTE, not the whole slice:
- // the block's own comment explains why sticky was wrong, and a slice-wide
- // negative would read that prose as the violation.
- expect(spinner).not.toMatch(/className="[^"]*sticky/)
+ // Pinned as an ABSENCE across the whole shell, not as the shape of a
+ // particular element: the failure this guards against is someone reaching for
+ // a spinner again, in any form. The earlier contract (absolute overlay, never
+ // sticky, opaque inner badge) existed because a sticky one owned flow space
+ // and inserted/removed ~32px per landing — ±32px twitches for a reader parked
+ // at the top, measured on the momentum rig. Not drawing one at all cannot
+ // regress that way.
+ // Scoped to the shell component's OWN source, not the concatenation: the
+ // page legitimately spins for other things (a turn in flight, a pending
+ // action), so a pin across both files would read those as this violation.
+ const shellOnly = readFileSync(resolve(__dirname, '../pages/chat/TranscriptScrollShell.tsx'), 'utf8')
+ expect(shellOnly).not.toContain('older-messages-loading')
+ expect(shellOnly).not.toContain('animate-spin')
+ expect(shellOnly).not.toContain('loadingOlder')
})
})
@@ -233,10 +230,12 @@ describe('scroll shell: extraction wiring (the seams the split created)', () =>
expect(SRC).toContain("import TranscriptScrollShell from './chat/TranscriptScrollShell'")
expect(SRC).toContain('scrollerRef={scrollerRef}')
expect(SRC).toContain('virt={virt}')
- // Two consumers thread loadingOlder (the pinned-banner row props and the
- // shell call); a bare contain() would let either deletion hide behind the
- // other, so pin the count.
- expect((SRC.match(/loadingOlder=\{loadingOlder\}/g) ?? []).length).toBeGreaterThanOrEqual(2)
+ // `loadingOlder` reaches exactly ONE consumer now — the assistant row, which
+ // renders the manual affordance's own in-place state. The shell no longer
+ // takes it at all: an automatic page draws nothing, so there is nothing for
+ // it to thread. Pinned as a count so a second consumer reappearing is a
+ // failure rather than a silent return of the overlay.
+ expect((SRC.match(/loadingOlder=\{loadingOlder\}/g) ?? []).length).toBe(1)
})
})
diff --git a/website/src/test/ChatPage.scrollShell.render.test.tsx b/website/src/test/ChatPage.scrollShell.render.test.tsx
index 8a912c78ea4..df2b96e9753 100644
--- a/website/src/test/ChatPage.scrollShell.render.test.tsx
+++ b/website/src/test/ChatPage.scrollShell.render.test.tsx
@@ -22,7 +22,7 @@ initI18n('en')
const ref = () => ({ current: null as HTMLDivElement | null })
-function mount(loadingOlder: boolean) {
+function mount() {
const scrollerRef = ref()
const virt = { topSentinelRef: ref(), bottomSentinelRef: ref(), offsetBefore: 123, offsetAfter: 456 }
const utils = render(
@@ -30,7 +30,6 @@ function mount(loadingOlder: boolean) {
scrollerRef={scrollerRef}
onScroll={() => {}}
virt={virt}
- loadingOlder={loadingOlder}
scrollerStyle={{ paddingBottom: 16 }}
aboveRows={
}
belowRows={
}
@@ -43,7 +42,7 @@ function mount(loadingOlder: boolean) {
describe('TranscriptScrollShell DOM contract', () => {
it('renders the skeleton in order: header spacer, aboveRows, top sentinel, top spacer, rows, bottom spacer, bottom sentinel, belowRows', () => {
- const { scrollerRef, virt } = mount(false)
+ const { scrollerRef, virt } = mount()
const scroller = scrollerRef.current!
expect(scroller).toBeTruthy()
const kids = Array.from(scroller.children)
@@ -69,20 +68,15 @@ describe('TranscriptScrollShell DOM contract', () => {
expect(new Set(order).size).toBe(order.length)
})
- it('mounts the older-messages spinner between the top sentinel and the top spacer only while loadingOlder', () => {
- const off = mount(false)
- expect(off.queryByTestId('older-messages-loading')).toBeNull()
- off.unmount()
-
- const { scrollerRef, virt, queryByTestId } = mount(true)
- const spinner = queryByTestId('older-messages-loading')!
- expect(spinner).toBeTruthy()
- const kids = Array.from(scrollerRef.current!.children)
- const spinnerIdx = kids.indexOf(spinner)
- const sentinelIdx = kids.indexOf(virt.topSentinelRef.current!)
- const topSpacerIdx = kids.findIndex(k => (k as HTMLElement).style.height === '123px')
- expect(spinnerIdx).toBeGreaterThan(sentinelIdx)
- expect(spinnerIdx).toBeLessThan(topSpacerIdx)
+ it('shows NO loading overlay for older pages, in any state', () => {
+ // Automatic paging has to be imperceptible: the reader did not ask for the
+ // fetch, so announcing it turns a silent prefetch into an event, and a badge
+ // pinned under the header floats over whatever they are actually reading.
+ // Feedback for the MANUAL path lives on EarlierMessagesBar, which is on
+ // screen exactly when the reader reached for it. The shell therefore takes no
+ // loading prop at all — there is no state in which it can draw one.
+ const { queryByTestId } = mount()
+ expect(queryByTestId('older-messages-loading')).toBeNull()
})
it('fires onScroll from the scroller element itself', () => {
@@ -95,7 +89,6 @@ describe('TranscriptScrollShell DOM contract', () => {
scrollerRef={scrollerRef}
onScroll={() => { calls++ }}
virt={{ topSentinelRef: ref(), bottomSentinelRef: ref(), offsetBefore: 0, offsetAfter: 0 }}
- loadingOlder={false}
>
,
@@ -111,7 +104,6 @@ describe('TranscriptScrollShell DOM contract', () => {
scrollerRef={scrollerRef}
onScroll={() => {}}
virt={{ topSentinelRef: ref(), bottomSentinelRef: ref(), offsetBefore: 0, offsetAfter: 0 }}
- loadingOlder={false}
scrollerStyle={{ overflowX: 'auto', overflowY: 'visible', paddingBottom: 16 } as React.CSSProperties}
>
@@ -143,7 +135,6 @@ describe('ChatPage invocation: slot membership and prop threading', () => {
'scrollerRef={scrollerRef}',
'onScroll={onScrollPin}',
'virt={virt}',
- 'loadingOlder={loadingOlder}',
// A PREFIX, not the whole literal: the style object also carries the
// restore-gate visibility flip, so pinning the closing braces would pin the
// gate's presence into a test about prop THREADING. This still fails on a
diff --git a/website/src/test/FileChangeChips.openRowPin.test.tsx b/website/src/test/FileChangeChips.openRowPin.test.tsx
new file mode 100644
index 00000000000..b9a52900979
--- /dev/null
+++ b/website/src/test/FileChangeChips.openRowPin.test.tsx
@@ -0,0 +1,139 @@
+/**
+ * An open file row must not lose its patch when the message's file-change
+ * payload is replaced underneath it.
+ *
+ * Pierre re-initializes the diff view whenever its inputs change identity, and
+ * its cache key is derived from the file CONTENTS — so handing it new contents
+ * re-diffs asynchronously and paints nothing until that lands. Observed on a
+ * phone as an expanded diff going blank for ~700ms while the chevron still read
+ * expanded. The row therefore pins its inputs while Pierre is mounted.
+ *
+ * The pin is only observable through the props handed to Pierre, so this mocks
+ * that component to record them, and renders the header-prefix slot because that
+ * is where the chevron lives.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, fireEvent, cleanup, act } from '@testing-library/react'
+
+import { ROW_ANIM_MS } from '../components/fileChangeChipsCss'
+
+type Rec = { oldContents: string; newContents: string }
+const hoisted = vi.hoisted(() => ({ seen: [] as Rec[], headers: [] as string[] }))
+
+vi.mock('../pierre', async importOriginal => ({
+ ...(await importOriginal>()),
+ PierreFilePair: ({ oldFile, newFile, fallbackHeader, renderHeaderPrefix }: {
+ oldFile: { contents: string } | null
+ newFile: { contents: string } | null
+ fallbackHeader?: () => React.ReactNode
+ renderHeaderPrefix?: () => React.ReactNode
+ }) => {
+ hoisted.seen.push({ oldContents: oldFile?.contents ?? '', newContents: newFile?.contents ?? '' })
+ // The real wrapper renders this inside the WarmSwap fallback; recording its
+ // rendered text is how a pure test can see the strip is there at all.
+ const hdr = fallbackHeader?.()
+ hoisted.headers.push(hdr ? JSON.stringify(hdr, (k, v) => (k === '_owner' ? undefined : v)) : '')
+ return {renderHeaderPrefix?.()}
+ },
+}))
+
+import FileChangeChips from '../components/FileChangeChips'
+
+const latest = () => hoisted.seen[hoisted.seen.length - 1]
+const chip = (after: string) => [{ path: '/a.ts', before: '', after }]
+/** The chevron — the only control rendered into Pierre's header prefix slot. */
+const toggle = (c: HTMLElement) => c.querySelector('[data-testid^="fcc-toggle-"]')!
+
+beforeEach(() => {
+ hoisted.seen.length = 0
+ hoisted.headers.length = 0
+ cleanup()
+})
+
+describe('an open row pins its diff inputs', () => {
+ it('keeps the contents it opened with when the payload is replaced', () => {
+ const { container, rerender } = render( )
+ fireEvent.click(toggle(container))
+ expect(latest().newContents).toBe('one\ntwo')
+
+ // Same message, a differently-serialised payload — what a trailing-newline
+ // disagreement between two producers of the same snapshot looks like.
+ rerender( )
+
+ expect(latest().newContents).toBe('one\ntwo')
+ })
+
+ it('adopts the newer contents on the next open', () => {
+ const { container, rerender } = render( )
+ fireEvent.click(toggle(container))
+ rerender( )
+ expect(latest().newContents).toBe('one\ntwo')
+
+ // Collapse and let the collapse animation finish. Reopening INSIDE that
+ // window deliberately keeps the pin — Pierre is still mounted there, so
+ // re-initializing would blank exactly what this guards. Once the window
+ // closes Pierre unmounts, and re-initializing on the next open is
+ // unavoidable anyway, so the fresh contents cost nothing.
+ vi.useFakeTimers()
+ try {
+ fireEvent.click(toggle(container))
+ act(() => { vi.advanceTimersByTime(ROW_ANIM_MS + 20) })
+ fireEvent.click(toggle(container))
+ } finally {
+ vi.useRealTimers()
+ }
+
+ expect(latest().newContents).toBe('one\ntwo\nthree')
+ })
+
+ it('renders the current contents on a first open, not a stale pin', () => {
+ const { container, rerender } = render( )
+ rerender( )
+ fireEvent.click(toggle(container))
+ expect(latest().newContents).toBe('one\ntwo')
+ })
+})
+
+describe('a row with nothing to show offers no disclosure', () => {
+ // A file past the backend's per-file snapshot cap has both sides truncated to
+ // the SAME prefix, so a real edit beyond the cut arrives as before === after.
+ // Pierre can only paint an empty diff from that, and the row previously spent
+ // a tap replacing its header with the warm fallback and then collapsing to
+ // nothing -- a flash-and-vanish indistinguishable from a broken diff.
+ const identical = [{ path: '/huge.tsx', before: 'same bytes', after: 'same bytes' }]
+
+ it('renders no toggle at all', () => {
+ const { container } = render( )
+ expect(container.querySelector('[data-testid^="fcc-toggle-"]')).toBeNull()
+ // The header itself is still there: the file was touched and the row says so.
+ expect(container.querySelector('[data-testid="fcc-header-/huge.tsx"]')).toBeTruthy()
+ })
+
+ it('never mounts Pierre, even when the header is clicked', () => {
+ const { container } = render( )
+ const header = container.querySelector('[data-fcc-header]')!
+ fireEvent.click(header)
+ expect(hoisted.seen).toHaveLength(0)
+ })
+
+ it('still offers a real diff on a row that has one', () => {
+ const { container } = render( )
+ expect(container.querySelector('[data-testid^="fcc-toggle-"]')).toBeTruthy()
+ })
+})
+
+describe('the row keeps its header across the Pierre handoff', () => {
+ it('supplies a fallback header, so the strip survives the warm window', () => {
+ // Opening swaps this row's own header out for Pierre's, and Pierre's arrives
+ // only once the impl paints. Without a header in the warm fallback the row
+ // loses its filename, counts and disclosure control for that window, which
+ // reads as the row flashing away and coming back.
+ const { container } = render( )
+ fireEvent.click(toggle(container))
+ const header = hoisted.headers[hoisted.headers.length - 1]
+ expect(header).toBeTruthy()
+ // It must be the SAME strip, not a placeholder: the filename identifies the
+ // row and the toggle lets the reader close it again mid-warm.
+ expect(header).toContain('a.ts')
+ })
+})
diff --git a/website/src/test/FollowController.test.ts b/website/src/test/FollowController.test.ts
index 007d31f3dc6..f63bd61ad3b 100644
--- a/website/src/test/FollowController.test.ts
+++ b/website/src/test/FollowController.test.ts
@@ -20,6 +20,7 @@ import {
SELF_SCROLL_EPSILON,
DEFAULT_BOTTOM_THRESHOLD,
FOLLOW_REENGAGE_PX,
+ repriceAboveFoldDelta,
} from '../hooks/virtualizer/FollowController'
describe('geometry helpers', () => {
@@ -625,3 +626,50 @@ describe('both consumers report the viewport signal', () => {
expect(args).not.toContain('lastWriteClientHRef')
})
})
+
+describe('repriceAboveFoldDelta', () => {
+ const fold = 100
+
+ it('compensates a row entirely above the fold by its full change', () => {
+ expect(repriceAboveFoldDelta({ rowTop: -500, prevHeight: 200, newHeight: 260, foldTop: fold }))
+ .toBe(60)
+ })
+
+ it('ignores a row that starts at or below the fold', () => {
+ // It grows downward, away from everything on screen, and its own top holds.
+ expect(repriceAboveFoldDelta({ rowTop: fold, prevHeight: 200, newHeight: 900, foldTop: fold }))
+ .toBe(0)
+ })
+
+ it('compensates a STRADDLING row in full when the change is a re-measure', () => {
+ // Deliberate: a re-measure redistributes through the row, so the reader is
+ // displaced by the whole delta even though part of the row is on screen.
+ expect(repriceAboveFoldDelta({ rowTop: -50, prevHeight: 200, newHeight: 176, foldTop: fold }))
+ .toBe(-24)
+ })
+
+ it('compensates NOTHING when the reader pressed inside that row below the fold', () => {
+ // A disclosure the reader opened. The growth is rooted at their press, so
+ // everything above it -- the header they tapped included -- does not move, and
+ // the straddling rule's full-delta answer would shove that header off screen.
+ // Chat rows are whole turns and routinely taller than the viewport, so a
+ // disclosure's row almost always straddles: this is the common case, not an edge.
+ expect(repriceAboveFoldDelta({
+ rowTop: -50,
+ prevHeight: 200,
+ newHeight: 1400,
+ foldTop: fold,
+ pressBelowFoldInRow: true,
+ })).toBe(0)
+ })
+
+ it('still compensates a straddling row when no press located the growth', () => {
+ // The flag is the ONLY thing that exempts a straddling row; absent or false
+ // leaves the measured re-measure behaviour exactly as it was.
+ for (const press of [undefined, false]) {
+ expect(repriceAboveFoldDelta({
+ rowTop: -50, prevHeight: 200, newHeight: 1400, foldTop: fold, pressBelowFoldInRow: press,
+ })).toBe(1200)
+ }
+ })
+})
diff --git a/website/src/test/anchorCorrection.test.ts b/website/src/test/anchorCorrection.test.ts
new file mode 100644
index 00000000000..c07068e41fc
--- /dev/null
+++ b/website/src/test/anchorCorrection.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect } from 'vitest'
+
+import { contentShiftFor } from '../hooks/virtualizer/anchorCorrection'
+
+/** These pin the arithmetic the harness cannot reach. Each case is a real device
+ * reading or its degenerate twin, not an invented number.
+ *
+ * The first five pass `capturedWriteSum: 0, currentWriteSum: 0` explicitly rather
+ * than defaulting them, because "nothing else wrote the scroller during this
+ * window" is an ASSUMPTION each of those readings was taken under, and a default
+ * would hide it. The cases below them are the ones where it does not hold. */
+describe('anchor correction: compensate content, never the reader', () => {
+ it('writes NOTHING when only the reader moved', () => {
+ // The device's own figures: the anchor appeared 9,440px lower while the top
+ // spacer went 0 -> 0, so no content had arrived and scrollTop had dropped by
+ // exactly that much. Correct answer: leave the position alone.
+ expect(contentShiftFor({
+ capturedTop: 0,
+ capturedScrollTop: 10_337,
+ currentTop: 9_440,
+ currentScrollTop: 897,
+ capturedWriteSum: 0,
+ currentWriteSum: 0,
+ })).toBe(0)
+ })
+
+ it('compensates the full growth when the reader held still', () => {
+ // A page lands above a parked reader: the row is pushed down by the whole
+ // inserted height and every pixel of it is owed.
+ expect(contentShiftFor({
+ capturedTop: -120,
+ capturedScrollTop: 5_000,
+ currentTop: 13_775,
+ currentScrollTop: 5_000,
+ capturedWriteSum: 0,
+ currentWriteSum: 0,
+ })).toBe(13_895)
+ })
+
+ it('separates the two when they happen together', () => {
+ // Content grew 16,090px (a measured spacer change) while the reader scrolled
+ // up 2,000px. Only the content term may be written.
+ expect(contentShiftFor({
+ capturedTop: 0,
+ capturedScrollTop: 9_000,
+ currentTop: 18_090,
+ currentScrollTop: 7_000,
+ capturedWriteSum: 0,
+ currentWriteSum: 0,
+ })).toBe(16_090)
+ })
+
+ it('does not invent movement when nothing happened at all', () => {
+ expect(contentShiftFor({
+ capturedTop: 42,
+ capturedScrollTop: 1_234,
+ currentTop: 42,
+ currentScrollTop: 1_234,
+ capturedWriteSum: 0,
+ currentWriteSum: 0,
+ })).toBe(0)
+ })
+
+ it('handles the reader scrolling DOWN, where the terms have the same sign', () => {
+ // Scrolling down raises scrollTop and lifts the row, so both terms are
+ // negative-then-positive: a naive absolute value would double-count here.
+ expect(contentShiftFor({
+ capturedTop: 500,
+ capturedScrollTop: 1_000,
+ currentTop: 100,
+ currentScrollTop: 1_400,
+ capturedWriteSum: 0,
+ currentWriteSum: 0,
+ })).toBe(0)
+ })
+
+ it('does not pay twice for a drift OUR OWN reprice caused', () => {
+ // The device frame this was written from. A reprice run (`repriced 3078px in 7`)
+ // landed between the capture and the consume, so scrollTop drifted -268 with no
+ // finger involved. The row's measured displacement was 1px.
+ //
+ // CORR d=-267 owed=-354 res=86 painted=1 WRITE resize 7562->7295
+ // HELD was=-31 now=236 off=268
+ //
+ // Treating the drift as the reader gives -267: a 1px displacement authorising a
+ // 267px write, painted. Attributing it to us gives 1px, which is all that was
+ // ever measured.
+ expect(contentShiftFor({
+ capturedTop: -31,
+ capturedScrollTop: 7_830,
+ currentTop: -30,
+ currentScrollTop: 7_562,
+ capturedWriteSum: 0,
+ currentWriteSum: -268,
+ })).toBe(1)
+ })
+
+ it('splits a window where the reader AND we both moved the scroller', () => {
+ // Content grew 4,000px above the row. A reprice of ours wrote +1,500 and the
+ // reader scrolled up 500 (scrollTop -500) in the same window, so scrollTop
+ // drifted +1,000 net. Only the 500 belongs to the reader.
+ //
+ // displacement = 4000 - 1000 = 3000
+ // readerMoved = 1000 - 1500 = -500
+ // contentShift = 3000 + (-500) = 2500
+ //
+ // 2,500 rather than 4,000 because the reprice already compensated 1,500 of the
+ // growth -- paying it again is the double count -- and 500 of the row's
+ // remaining displacement is the reader's own scroll, which must survive.
+ expect(contentShiftFor({
+ capturedTop: 0,
+ capturedScrollTop: 6_000,
+ currentTop: 3_000,
+ currentScrollTop: 7_000,
+ capturedWriteSum: 0,
+ currentWriteSum: 1_500,
+ })).toBe(2_500)
+ })
+
+ it('reads the write total as a DIFFERENCE, not as an absolute', () => {
+ // The counter is cumulative for the life of the mount, so a session that has
+ // already written 400,000px must behave exactly like one that has written none.
+ // Anything that reads `currentWriteSum` on its own instead of against the
+ // capture drifts further off the longer a session is open -- which is the
+ // failure mode that would never show up in a short test.
+ const shifted = contentShiftFor({
+ capturedTop: 0,
+ capturedScrollTop: 6_000,
+ currentTop: 3_000,
+ currentScrollTop: 7_000,
+ capturedWriteSum: 400_000,
+ currentWriteSum: 401_500,
+ })
+ expect(shifted).toBe(2_500)
+ })
+
+ it('leaves the reader-only case alone even when we wrote earlier', () => {
+ // Our writes BEFORE the capture must not leak in: the reader scrolled 9,440 and
+ // nothing else happened inside the window, so the answer is still zero however
+ // busy the mount was beforehand.
+ expect(contentShiftFor({
+ capturedTop: 0,
+ capturedScrollTop: 10_337,
+ currentTop: 9_440,
+ currentScrollTop: 897,
+ capturedWriteSum: 12_345,
+ currentWriteSum: 12_345,
+ })).toBe(0)
+ })
+})
diff --git a/website/src/test/olderHistoryDistanceTrigger.test.ts b/website/src/test/olderHistoryDistanceTrigger.test.ts
new file mode 100644
index 00000000000..8a8099cdf01
--- /dev/null
+++ b/website/src/test/olderHistoryDistanceTrigger.test.ts
@@ -0,0 +1,229 @@
+/**
+ * The automatic older-history trigger fires on DISTANCE, not on stillness.
+ *
+ * The affordance-in-view rule carries a lead so the page arrives before the
+ * reader does. A lower bound on stillness defeated that entirely: the page could
+ * only start once the reader had already stopped, and a continuous climb never
+ * stops — so the lead never had a window and history always landed after they had
+ * reached the top. Speed is not evidence against the intent; a fast climb wants
+ * the history sooner. Runaway paging is prevented elsewhere and structurally: the
+ * in-flight `loadingOlder` gate, the thunk's own `condition`, and a per-gesture
+ * page budget.
+ *
+ * Source-scanned because the gate chain lives inside a ChatPage effect with no
+ * exported seam — the same convention `ChatPage.idlePrefetchAuth.test.ts` uses.
+ */
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { earlierAffordanceInView, shouldContinueOlderWalk } from '../pages/chat/pagination'
+
+const SRC = readFileSync(join(__dirname, '..', 'pages', 'ChatPage.tsx'), 'utf8')
+
+/** The walk poll's gate chain, from its authorization call to its dispatch. */
+function walkBody(): string {
+ const i = SRC.indexOf('sawRealInput: Date.now() - lastRealInputAtRef.current')
+ expect(i).toBeGreaterThan(-1)
+ const j = SRC.indexOf('loadOlderMessages()', i)
+ expect(j).toBeGreaterThan(i)
+ return SRC.slice(i, j)
+}
+
+describe('older-history trigger distance', () => {
+ it('authorizes on an ABSOLUTE pixel distance, not a viewport multiple', () => {
+ // The number is a tuning knob the device decides; what must not regress is
+ // the unit. Both terms this lead pays for are absolute — a fetch plus a
+ // measured landing is a few hundred ms whatever the screen — so scaling the
+ // lead with the viewport grows it where the cost did not, handing a desktop
+ // far more than it needs while a phone's is set by whatever the browser
+ // chrome left over.
+ expect(walkBody()).toMatch(/nearTop:\s*el2\.scrollTop <= OLDER_WALK_TRIGGER_PX/)
+ const m = SRC.match(/const OLDER_WALK_TRIGGER_PX = (\d+)/)
+ expect(m).toBeTruthy()
+ // Larger than one phone viewport (~600-800px): below that the page can only
+ // start once the reader is already inside the screen they are about to leave.
+ expect(Number(m![1])).toBeGreaterThan(1000)
+ })
+
+ it('requires the affordance to be genuinely ON SCREEN, with no lead', () => {
+ // Reversed deliberately, and the report that reversed it is the whole reason:
+ // spending the walk's trigger distance here let the bar count as "in view"
+ // while still two screens above the viewport, so history loaded before the
+ // reader could see that it was going to. A zero lead makes the visible
+ // affordance itself the permission.
+ //
+ // It cannot deadlock the walk: the reader reaches the bar by scrolling, and
+ // once it is on screen the trigger's own near-top test is satisfied too.
+ const i = SRC.indexOf('const earlierBarInView = useCallback(')
+ const body = SRC.slice(i, SRC.indexOf('}, [])', i))
+ expect(body).not.toMatch(/OLDER_WALK_TRIGGER_PX/)
+ expect(body).toMatch(/\n\s*0,\n\s*\)/)
+ expect(body).not.toMatch(/clientHeight/)
+ })
+
+ it('fires on the SCROLL EVENT, not only on the poll tick', () => {
+ // The distance is the smaller half of the lead. One OLDER_TOP_POLL_MS of
+ // delay is worth more travel than the whole budget (700ms at fling speed is
+ // ~3500px against a 2000px lead), so a trigger that only ever ran on the tick
+ // could not satisfy the invariant however far out it was set.
+ //
+ // Pinned as ONE body with TWO callers: a second copy of the gate chain for the
+ // scroll path is how one copy drifts from the other.
+ expect(SRC).toMatch(/const attemptOlderWalk = \(\) => \{/)
+ expect(SRC).toMatch(/setInterval\(attemptOlderWalk, OLDER_TOP_POLL_MS\)/)
+ const i = SRC.indexOf('const noteScroll = () => {')
+ const body = SRC.slice(i, SRC.indexOf('\n }', i))
+ expect(body).toContain('attemptOlderWalk()')
+ // Coalesced per FRAME: a fling delivers many events per frame and the chain
+ // ends in an O(rows) measurement sweep, so per-event would spend the frame
+ // budget of the gesture this exists to keep smooth.
+ expect(body).toMatch(/requestAnimationFrame/)
+ // Cancel-and-reschedule, never latch-on-pending: a dropped frame handle must
+ // not wedge the trigger for the rest of the mount.
+ expect(body).toMatch(/cancelAnimationFrame/)
+ })
+})
+
+describe('the older-history walk does not wait on the idle measure farm', () => {
+ it('has NO farm-measurement gate, in any scoped form', () => {
+ // Structural contradiction, not a tuning miss. The measure farm runs in IDLE
+ // TIME by design -- that is the whole reason it exists off-screen, so it does
+ // not correct heights under the reader's finger. A gate that waits on its
+ // progress can therefore only ever pass once the reader has STOPPED, which is
+ // what the device reported: "it still only loads previous once I completely
+ // stop scrolling". Narrowing which rows it swept did not help, because the
+ // blocking row was index 0 -- freshly prepended, never mounted, so the farm is
+ // the only thing that could ever measure it.
+ const body = walkBody()
+ expect(body).not.toMatch(/farmIsMeasured/)
+ expect(body).not.toMatch(/farmRowMounted/)
+ })
+
+ it('now loads FOR a parked reader, deliberately', () => {
+ // This bound used to refuse a reader who had stopped, on the reasoning that
+ // pages must not land under someone no longer asking. Reversed: at rest is the
+ // one state where a landing cannot be FELT, so it is the state to load in. The
+ // reader who is no longer asking is now excluded by the affordance gate
+ // instead -- they have to be looking at "Load earlier messages" for anything
+ // automatic to happen at all.
+ expect(walkBody()).toMatch(/OLDER_WALK_QUIET_MS/)
+ expect(walkBody()).not.toMatch(/OLDER_WALK_ACTIVE_MS/)
+ })
+})
+
+describe('a landing reports its own height against the trigger', () => {
+ it('prints the height the page ADDED, in the same units as the threshold', () => {
+ // A page that adds less than OLDER_WALK_TRIGGER_PX leaves the reader still
+ // inside the lead, so nearTop stays true and the next tick fires again.
+ // That reads as a runaway and is really a page too short to clear its own
+ // trigger -- one number decides which, and three diagnoses were overturned
+ // tonight for want of exactly this kind of number.
+ expect(SRC).toMatch(/grew \+\$\{Math\.round\(grew\)\}px vs trigger \$\{OLDER_WALK_TRIGGER_PX\}px/)
+ })
+
+ it('measures the delta AFTER paint, not in the effect body', () => {
+ // The effect runs in the same commit as the store update, before React has
+ // rendered the new rows, so a synchronous scrollHeight read reports the height
+ // the transcript had BEFORE the page. That printed a 100-message page as
+ // adding 32px and read exactly like a real defect -- an instrument that lies
+ // in the direction of alarm is worse than no instrument.
+ expect(SRC).toMatch(/olderHeightAtStartRef\.current = el \? el\.scrollHeight : 0/)
+ // The read must live inside the double-rAF, next to the paint close.
+ const i = SRC.indexOf('devOlderLatency(olderFetchMsRef.current')
+ expect(i, 'paint close not found').toBeGreaterThan(0)
+ const frame = SRC.slice(i, i + 1400)
+ // The element must come from the live ref inside the frame -- a mutation that
+ // only swaps the declaration keeps the read expression intact and would slip
+ // past an assertion that pins the expression alone.
+ expect(frame).toMatch(/const el2 = vScrollerElRef\.current/)
+ expect(frame).toMatch(/el2\.scrollHeight : 0\) - olderHeightAtStartRef\.current/)
+ expect(frame).toMatch(/grew \+\$\{Math\.round\(grew\)\}px vs trigger \$\{OLDER_WALK_TRIGGER_PX\}px/)
+ })
+})
+
+describe('older-history trigger waits for stillness', () => {
+ it('applies the stillness gate unconditionally, never split by a CSS property', () => {
+ // A capability split on `'overflowAnchor' in style` was shipped and withdrawn.
+ // It reads as sound -- with CSS scroll anchoring the compositor absorbs a
+ // prepend before paint, so rest is unnecessary there -- and it inverted on the
+ // one platform it existed for: WebKit has LANDED the property (bug 307734), so
+ // it answers the probe on a device whose anchoring does not hold a virtualized
+ // list, and the gate switched itself off exactly where it was needed.
+ //
+ // The device symptom did not point at this gate at all. With it off, a fling
+ // near the top drives `scrollTop` negative (iOS rubber band, measured -2810),
+ // `isOverscrolled` correctly refuses to read a stretch as a content position
+ // and skips compensation, the reader is never advanced, the affordance stays on
+ // screen, and the level-triggered poll re-fires -- reported as "it loads
+ // continuously", caused by a position that was never held.
+ //
+ // Presence of a property is not evidence of a behaviour. A behavioural probe
+ // (did a prepend land already-correct before we wrote anything?) could bring
+ // the split back; a property name may not.
+ expect(walkBody()).toMatch(/if \(Date\.now\(\) - lastScrollEvt < OLDER_WALK_QUIET_MS\) return/)
+ expect(SRC).not.toMatch(/overflowAnchor/)
+ expect(SRC).not.toMatch(/hasNativeScrollAnchoring/)
+ })
+
+ it('waits long enough to be past a fling, not merely past a frame', () => {
+ // 20ms is barely one frame interval, so it lets the TAIL of a fling through --
+ // events thin out there while the surface is still drifting, and that is the
+ // landing the reader feels. 100ms is matrix-react-sdk's number, reached
+ // independently for the same reason.
+ const m = /const OLDER_WALK_QUIET_MS = (\d+)/.exec(SRC)
+ expect(m).toBeTruthy()
+ expect(Number(m![1])).toBeGreaterThanOrEqual(100)
+ })
+
+ it('refuses while the scroller is still moving', () => {
+ // Inverted from "does not require rest". The bounce needs MOTION: a landing's
+ // compensation puts the reader within a pixel of where they were -- measured on
+ // a device, `off=1` read back after the write -- and the jolt is still visible,
+ // because a programmatic scrollTop write during a fling perturbs the fling.
+ // The reader's own report is the cleanest form of it: at `to-top = 0` loading
+ // never bounces, and `to-top = 0` is precisely where they have stopped.
+ expect(walkBody()).toMatch(/Date\.now\(\) - lastScrollEvt < OLDER_WALK_QUIET_MS/)
+ expect(walkBody()).not.toMatch(/lastScrollEvt > OLDER_WALK_ACTIVE_MS/)
+ })
+
+ it('a scroll event ALSO cancels a page already in flight', () => {
+ // Rewritten, not deleted: this asserted the opposite, and it was right for an
+ // intent that has since been abandoned. Removing the abort belonged to the era
+ // of loading WHILE the reader climbed, where killing a fetch on motion killed
+ // every fetch. With rest as the permission the two halves are one mechanism --
+ // the gate refuses to START a fetch while moving, this refuses to FINISH one.
+ //
+ // Both are needed because the gate is checked when the fetch begins. A reader at
+ // rest who flings during the ~130ms round trip would otherwise take the landing
+ // mid-gesture, which is the single moment the compensation cannot be invisible.
+ const i = SRC.indexOf('const noteScroll = ')
+ const body = SRC.slice(i, SRC.indexOf('}', SRC.indexOf('lastScrollEvt = Date.now()', i)))
+ expect(body).toContain('abortActiveOlderFetch')
+ })
+
+ it('loads FOR a reader who has come to rest, which is the point', () => {
+ // The old upper bound refused exactly this reader. Rest is now the permission
+ // rather than the disqualification, so the constant naming the wait exists and
+ // the one that timed out a parked reader does not.
+ expect(SRC).toMatch(/const OLDER_WALK_QUIET_MS = \d+/)
+ expect(SRC).not.toMatch(/const OLDER_WALK_ACTIVE_MS = \d+/)
+ })
+})
+
+describe('the predicates the chain rests on are unchanged', () => {
+ it('still refuses without a real gesture, and still spends a page budget', () => {
+ const base = { sawRealInput: true, nearTop: true, walking: false, pagesSinceInput: 0 }
+ expect(shouldContinueOlderWalk(base)).toBe(true)
+ expect(shouldContinueOlderWalk({ ...base, sawRealInput: false })).toBe(false)
+ expect(shouldContinueOlderWalk({ ...base, pagesSinceInput: 99 })).toBe(false)
+ })
+
+ it('a two-viewport lead accepts an affordance that far above the viewport', () => {
+ const viewport = { top: 0, bottom: 595 }
+ const bar = { top: -1150, bottom: -1110 } // ~1.9 viewports above
+ expect(earlierAffordanceInView(bar, viewport, 2 * 595)).toBe(true)
+ // ...and one viewport of lead would have refused the same position.
+ expect(earlierAffordanceInView(bar, viewport, 595)).toBe(false)
+ })
+})
diff --git a/website/src/test/olderLandsImmediately.test.ts b/website/src/test/olderLandsImmediately.test.ts
new file mode 100644
index 00000000000..74aed959a06
--- /dev/null
+++ b/website/src/test/olderLandsImmediately.test.ts
@@ -0,0 +1,80 @@
+/**
+ * An older page lands IMMEDIATELY. It is never held waiting for the scroller.
+ *
+ * The payload used to be held until the scroller had been quiet for a beat,
+ * because splicing rows mid-glide raced the pre-paint anchor machinery against
+ * the window recompute the gesture itself schedules (phone rig: per-landing
+ * kilopixel jumps). Measured on the device with the spans split apart, that hold
+ * was 1.95s of a 2.1s wait -- 92% -- against a 0.16s request.
+ *
+ * And it could not be tuned down: the hold ends when the READER stops, so its
+ * length is however long they keep scrolling. That is precisely the gesture that
+ * needs the page, and this hold ended on quiescence well inside its own 2.5s cap,
+ * so lowering the cap would have changed nothing.
+ *
+ * Pinned as source shape rather than behaviour because the failure mode is a
+ * REINSTATEMENT -- a reviewer restoring the wait to fix a landing jump. If those
+ * jumps reproduce, the fix is to stop splicing at all (ship geometry up front,
+ * hydrate content in place), not to make the reader wait again.
+ */
+import { describe, it, expect } from 'vitest'
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const SRC = readFileSync(resolve(__dirname, '../store/chatSlice.ts'), 'utf8')
+
+/** The loadOlderMessages thunk body, so a pin cannot pass on an unrelated file.
+ * Anchored on the DECLARATION, not the action-type string: the type is
+ * `chat/loadOlder`, and guessing it from the export name is how this slice
+ * silently became empty and reported four green pins as red. */
+function thunkBody(): string {
+ const i = SRC.indexOf('export const loadOlderMessages = createAsyncThunk(')
+ expect(i, 'loadOlderMessages thunk not found').toBeGreaterThan(0)
+ const end = SRC.indexOf('export const', i + 1)
+ return SRC.slice(i, end > 0 ? end : undefined)
+}
+
+describe('older pages are not held for scroll quiescence', () => {
+ it('does not await the quiescence signal', () => {
+ expect(thunkBody()).not.toMatch(/whenScrollQuiet/)
+ })
+
+ it('does not import it either, so a reinstatement is a visible edit', () => {
+ // The module is gone now, not merely unused, so this pin is about the SHAPE a
+ // reinstatement would take rather than about a dormant import: bringing the
+ // hold back means re-creating a quiescence helper and awaiting it here, which
+ // is a whole file plus a call rather than one line nobody notices.
+ //
+ // Deleted rather than kept: with the landing buffer gone it had no reader, and
+ // a dead one is not free — every user scroll went on stamping a timestamp
+ // nothing consumed, and the file sat under the coverage floor at 29%. Its
+ // rationale is not lost; it moved next to the mechanism that took over the
+ // job, on the trigger side, where the comment in `loadOlderMessages` records
+ // the device measurement that retired it.
+ expect(SRC).not.toMatch(/from '\.\.\/lib\/scrollQuiet'/)
+ expect(existsSync(resolve(__dirname, '../lib/scrollQuiet.ts'))).toBe(false)
+ })
+
+ it('still reports the request span, so the overlay can prove there is no hold', () => {
+ // The instrument is what tells "no hold" apart from "the instrument broke" --
+ // a distinction that already cost a round tonight when the paint timing went
+ // silent after its trigger was deleted. The hold ARGUMENT is gone (it could
+ // only ever be zero once the wait was removed, and a constant is not a
+ // reading), so this pins the call still happening rather than its second
+ // parameter: reinstating a hold means adding a number back here.
+ expect(thunkBody()).toMatch(/devOlderSpans\(netMs\)/)
+ expect(thunkBody()).not.toMatch(/devOlderSpans\(netMs,/)
+ })
+
+ it('still refuses to splice a page from a chat the reader has left', () => {
+ // The abort check outlived the wait it used to follow: a slot switch can land
+ // between the response and the return, and a stale page is worse than none.
+ expect(thunkBody()).toMatch(/controller\.signal\.aborted/)
+ })
+
+ it('still times the request itself', () => {
+ // net vs hold is the split that overturned two wrong diagnoses; losing the
+ // net measurement would put us back to one ambiguous number.
+ expect(thunkBody()).toMatch(/const netStart = Date\.now\(\)/)
+ })
+})
diff --git a/website/src/test/pierre.warmSwap.test.tsx b/website/src/test/pierre.warmSwap.test.tsx
index 72ad8c02260..d424adfcaf0 100644
--- a/website/src/test/pierre.warmSwap.test.tsx
+++ b/website/src/test/pierre.warmSwap.test.tsx
@@ -51,17 +51,21 @@ describe('WarmSwap', () => {
expect(screen.queryByTestId('impl')).toBeNull()
})
- it('notifies FilePair when WarmSwap reveals the implementation', async () => {
- let roCallback: (() => void) | null = null
+ it('does not wrap an expanded FilePair in the invisible warm box', async () => {
+ // A FilePair is windowed by Pierre itself, exactly like PierreCode's
+ // whole-file surfaces. Inside the warm box it is `absolute inset-0 …
+ // invisible`, so it windows against a hidden, parent-sized viewport and
+ // renders no rows; nothing invalidates that when the box is revealed, so the
+ // row stayed blank until an unrelated scroll made Pierre measure again.
+ // Observed on a phone as an expanded diff that only appeared after a scroll.
class StubRO {
- constructor(cb: () => void) { roCallback = cb }
observe() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', StubRO)
- let height = 0
- const spy = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => height)
- const onVisible = vi.fn()
+ // Height stays 0: with a warm box this would hold the fallback (or reveal an
+ // empty impl at the deadline). Without one the impl is simply mounted.
+ const spy = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(0)
try {
render(
{
newFile={{ name: 'a.ts', contents: 'after' }}
fallbackText="bounded"
fallbackClassName="max-h-[376px] overflow-auto"
- onVisible={onVisible}
/>,
)
- expect(await screen.findByTestId('impl')).toBeTruthy()
- expect(onVisible).not.toHaveBeenCalled()
- height = 120
- await act(async () => { roCallback?.() })
- expect(onVisible).toHaveBeenCalledTimes(1)
+ const impl = await screen.findByTestId('impl')
+ expect((impl.parentElement as HTMLElement).className ?? '').not.toContain('invisible')
+ // And the warm fallback is gone once the impl is up — it is the Suspense
+ // fallback now, covering only the chunk load.
+ expect(screen.queryByText('bounded')).toBeNull()
} finally {
spy.mockRestore()
}
diff --git a/website/src/test/rowDisclosure.test.tsx b/website/src/test/rowDisclosure.test.tsx
index 260dd6c8a02..8875a2bb9ca 100644
--- a/website/src/test/rowDisclosure.test.tsx
+++ b/website/src/test/rowDisclosure.test.tsx
@@ -20,11 +20,15 @@ function Probe({ id }: { id?: string }) {
/** `mounted` mirrors the virtualizer: false means the row is not rendered. */
function Host({ children }: { children: (mounted: boolean) => React.ReactNode }) {
const [mounted, setMounted] = useState(true)
- const [slot, setSlot] = useState('slot-a')
+ const [slot, setSlot] = useState('slot-a')
return (
setMounted(m => !m)}>recycle
setSlot('slot-b')}>switch
+ {/* ChatPage clears the active slot whenever the slot list momentarily does
+ not contain it; an auto-select then restores the SAME slot. */}
+ setSlot(null)}>clear
+ setSlot('slot-a')}>restore
{children(mounted)}
)
@@ -73,6 +77,24 @@ describe('useRowDisclosure', () => {
expect(expandedOf('row-1')).toBe('false')
})
+ it('keeps choices when the active slot is momentarily unknown and comes back', () => {
+ render({m => (m ? : null)} )
+ fireEvent.click(screen.getByRole('button', { name: 'row-1' }))
+ expect(expandedOf('row-1')).toBe('true')
+ // No slot is active for a beat — a slot-list refresh, not a navigation.
+ fireEvent.click(screen.getByTestId('clear-slot'))
+ fireEvent.click(screen.getByTestId('restore-slot'))
+ expect(expandedOf('row-1')).toBe('true')
+ })
+
+ it('still drops choices when the unknown beat lands on a DIFFERENT slot', () => {
+ render({m => (m ? : null)} )
+ fireEvent.click(screen.getByRole('button', { name: 'row-1' }))
+ fireEvent.click(screen.getByTestId('clear-slot'))
+ fireEvent.click(screen.getByTestId('switch-slot'))
+ expect(expandedOf('row-1')).toBe('false')
+ })
+
it('falls back to local state with no key, so unprovided hosts still work', () => {
render( ) // no provider at all
expect(expandedOf('nokey')).toBe('false')
diff --git a/website/src/test/scrollInspector.test.ts b/website/src/test/scrollInspector.test.ts
index 7e0847f19fe..7a31256d807 100644
--- a/website/src/test/scrollInspector.test.ts
+++ b/website/src/test/scrollInspector.test.ts
@@ -149,8 +149,490 @@ describe('scroll inspector: enabling and disabling', () => {
})
})
-describe('scroll inspector: reading helpers', () => {
+/** The reader's complaint, as a number: movement the APP performed, per landing.
+ *
+ * Not the change in scrollTop across a landing -- that conflates the finger with
+ * the machine. On the device a landing showed the position moving 11px while the
+ * app had written only 1px; the other 10px were the user's own scrolling, and a
+ * before/after reading would have reported it as ten times the real defect. */
+/** The live-stats block renders only for a WATCHED scroller -- without one the
+ * overlay shows just the event log, which is how a first version of these tests
+ * reported the instrument broken when it was the test that was incomplete. */
+const TICK_MS = 250
+
+const watchAScroller = (insp: Awaited>) => {
+ const el = document.createElement('div')
+ Object.defineProperty(el, 'scrollHeight', { value: 41644, configurable: true })
+ Object.defineProperty(el, 'clientHeight', { value: 595, configurable: true })
+ el.scrollTop = 1481
+ insp.devWatchScroller(el, 66)
+ return el
+}
+
+describe('scroll inspector: programmatic movement per landing', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.useFakeTimers()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ document.body.replaceChildren()
+ })
+
+ it('leads with the environment, and MEASURES the anchoring rather than asking', async () => {
+ // The first line exists because every wrong conclusion in this area came from
+ // reasoning about a platform instead of reading it. A capability split keyed to
+ // `'overflowAnchor' in style` shipped and inverted on the one platform it was
+ // for: WebKit has landed the property, so it answers yes on a device whose
+ // anchoring does not hold a virtualized list.
+ //
+ // So the line must carry BOTH -- what the browser claims (`sa`) and what it
+ // actually did when content was inserted above a parked scroll position
+ // (`hold`) -- because their disagreement is the whole finding. A line that
+ // reported only the property would re-tell the same lie on a bigger font.
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ // First LINE, not first character: the drag grip shares the host and carries no
+ // newline of its own, so the readings start on the line the env text opens.
+ expect(text.split('\n')[0]).toMatch(/env sa=/)
+ expect(text.indexOf('env ')).toBeLessThan(text.indexOf('to-end'))
+ expect(text).toMatch(/\bsa=(yes|no)\b/)
+ expect(text).toMatch(/\bhold=/)
+ // The discriminator, and the reason this assertion names a value instead of a
+ // shape. This harness IS the failure case in miniature: jsdom exposes the CSS
+ // property but performs no anchoring, so `sa=yes hold=no` -- the same
+ // disagreement the device produces. A probe that merely copied the property
+ // would report `hold=yes` here and pass every structural check, which is how
+ // the withdrawn capability split got shipped in the first place.
+ expect(text).toMatch(/\bhold=no\b/)
+ // HARNESS GAP, recorded rather than papered over: this cannot tell a probe that
+ // measured no movement from one that never inserted anything, because jsdom
+ // answers `no` either way. Removing the insertion leaves every test here green.
+ // Closing it needs an engine that actually anchors, i.e. a real browser -- so
+ // the positive half of this probe has only ever been read off a device.
+ // The bundle, so a hot-swapped dist can be told apart on the screen -- readings
+ // were taken against the wrong build more than once with no way to notice.
+ expect(text).toMatch(/\bb=/)
+ })
+
+ it('reads the movement out of the write log the virtualizer already emits', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ // Exactly the shape useVirtualChat logs: ` ->`.
+ insp.devLog('WRITE', 'abovefold 100->500')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('moved 400px')
+ expect(text).toContain('abovefold')
+ })
+
+ it('sums opposite writes as two jolts, because a net figure would call them zero', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devLog('WRITE', 'abovefold 100->500')
+ insp.devLog('WRITE', 'forcepin 500->100')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ // 400 out and 400 back is the WORST thing the reader can experience, and the
+ // one a signed total reports as perfect. This assertion is the whole point.
+ expect(text).toContain('moved 800px')
+ expect(text).not.toContain('moved 0px')
+ expect(text).toContain('2 write(s)')
+ })
+
+ it('starts a fresh count at each landing, so the figure is per page not per session', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devLog('WRITE', 'abovefold 0->900')
+ // The LANDING is the reset point, not the fetch starting: with several
+ // landings per scroll those are different moments, and resetting on the
+ // earlier one made the figure span two landings.
+ insp.devWatchMessages(100, 900)
+ insp.devWatchMessages(200, 900)
+ insp.devLog('WRITE', 'abovefold 900->910')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('moved 10px')
+ expect(text).not.toContain('moved 910px')
+ })
+
+ it('keeps the worst single write, which is what a lurch looks like', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ // The lurch is in the MIDDLE, deliberately. With it first, a field that only
+ // ever keeps the FIRST write still reads correctly; with it last, one that
+ // keeps only the LAST does. Both of those mutations survived an earlier
+ // version of this test, and only a middle peak reddens both.
+ insp.devLog('WRITE', 'abovefold 0->2')
+ insp.devLog('WRITE', 'abovefold 2->9411')
+ insp.devLog('WRITE', 'abovefold 9411->9413')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('worst=9409px')
+ expect(text).not.toContain('worst=2px')
+ })
+
+ it('ignores a write that moved nothing, so a no-op does not inflate the count', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devLog('WRITE', 'abovefold 3615->3615')
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('moved')
+ })
+})
+
+
+/** A reprice above the reader is the one displacement nobody compensates. The
+ * spacer moves for two reasons and only one is a defect, so the instrument has
+ * to separate them or it reports every scroll as a bug. */
+describe('scroll inspector: reprice above the reader', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.useFakeTimers()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ document.body.replaceChildren()
+ })
+
+ it('counts a spacer change at a STILL window, which is a reprice', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devSpacer(7488, 40)
+ insp.devSpacer(109, 40)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('repriced 7379px')
+ })
+
+ it('ignores a spacer change caused by the window MOVING, which is the reader', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ // The reader scrolled: start moved, so the spacer difference is expected.
+ insp.devSpacer(7488, 40)
+ insp.devSpacer(109, 31)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('repriced')
+ })
+
+ it('keeps the worst single reprice, with the peak in the middle', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devSpacer(1000, 40)
+ insp.devSpacer(1002, 40)
+ insp.devSpacer(8500, 40)
+ insp.devSpacer(8502, 40)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('worst=7498px')
+ expect(text).not.toContain('worst=2px')
+ })
+
+ it('starts fresh at each landing so the figure is per page', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devSpacer(1000, 40)
+ insp.devSpacer(9000, 40)
+ insp.devWatchMessages(100, 900)
+ insp.devWatchMessages(200, 900)
+ insp.devSpacer(9000, 40)
+ insp.devSpacer(9007, 40)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('repriced 7px')
+ expect(text).not.toContain('repriced 8007px')
+ })
+})
+
+
+/** Movement no logged write explains. The instrument's whole value is telling
+ * three things apart, so each is pinned: our own write, the reader's finger,
+ * and a jump from somewhere else. */
+describe('scroll inspector: unowned scroll movement', () => {
beforeEach(() => {
+ vi.resetModules()
+ vi.useFakeTimers()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ document.body.replaceChildren()
+ })
+
+ it('reports a kilopixel jump that no write accounts for', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(14432)
+ insp.devScrollTop(1361)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').toContain('UNOWNED 13071px')
+ })
+
+ it('stays silent for our OWN write, matched by its target', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(1361)
+ insp.devLog('WRITE', 'resize 1361->14563')
+ insp.devScrollTop(14563)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('UNOWNED')
+ })
+
+ it('COUNTS a write the engine truncated, which is the movement being hunted', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(14432)
+ // We asked for 14563 but the engine clamped to 1361: the position did NOT
+ // land on our target, so it is not ours to excuse.
+ insp.devLog('WRITE', 'resize 14432->14563')
+ insp.devScrollTop(1361)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').toContain('UNOWNED')
+ })
+
+ it('stays silent for ordinary scrolling, however fast', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ // Momentum-sized steps on a ~600px viewport, well under the threshold.
+ for (const t of [1000, 1400, 1900, 2500, 3200]) insp.devScrollTop(t)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('UNOWNED')
+ })
+
+ it('calls a landing ON the range limit a CLAMP, not a jump', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(14432, 60000)
+ // Content became shorter: the limit is now 1361 and the engine pulls the
+ // position down to exactly it. Landing ON the limit is the signature.
+ insp.devScrollTop(1361, 1361)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('clamp=1')
+ expect(text).not.toContain('jump')
+ })
+
+ it('calls a landing away from the limit a JUMP, which needs the opposite fix', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(14432, 60000)
+ insp.devScrollTop(1361, 60000)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('jump')
+ expect(text).not.toContain('clamp')
+ })
+
+ it('starts fresh at each landing', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devScrollTop(20000)
+ insp.devScrollTop(1000)
+ insp.devWatchMessages(100, 900)
+ insp.devWatchMessages(200, 900)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('UNOWNED')
+ })
+})
+
+
+/** The landing window -- the one the other two counters exclude by design. */
+describe('scroll inspector: top spacer across a landing', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.useFakeTimers()
+ localStorage.clear()
+ document.body.replaceChildren()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ document.body.replaceChildren()
+ })
+
+ it('shows the spacer collapsing even though the window START moved', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devWatchMessages(201, 10350)
+ insp.devSpacer(12000, 23)
+ // The landing: 100 messages arrive and START is re-based, which is exactly
+ // the case `repriced` skips.
+ insp.devWatchMessages(301, 10350)
+ insp.devSpacer(3845, 123)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('spacer 12000->3845')
+ expect(text).toContain('-8155px')
+ })
+
+ it('keeps every live line inside the width the device actually shows', async () => {
+ // Measured, not guessed. On a 440px-wide phone the overlay clipped
+ // `RESIDUAL 0px of 8616px owed (worst 0px in 1 corr si` at ~50 characters, and
+ // the words it cut were `since load` -- the scope label whose whole job is to
+ // stop a misreading. A number that does not fit is not reported.
+ //
+ // The box also deliberately does not wrap (see ensureHost), so an over-long line
+ // is silently truncated rather than folded: nothing on screen says it happened.
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devWatchMessages(200, 2807)
+ insp.devSpacer(0, 0)
+ insp.devWatchMessages(300, 2807)
+ insp.devSpacer(7037, 0)
+ insp.devLog('WRITE', 'resize 0->8616')
+ insp.devLog('CORR', 'd=8616 owed=8616 res=0 painted=0')
+ vi.advanceTimersByTime(TICK_MS)
+ const liveText =
+ document.querySelector('[data-scroll-inspector-live]')?.textContent ?? ''
+ // Proven non-empty FIRST. A selector that stops matching makes this an assertion
+ // over zero lines, which passes forever and reports nothing -- the emptiest kind
+ // of green.
+ expect(liveText).toContain('RESIDUAL')
+ const tooWide = liveText.split('\n').filter((l) => l.length > 50)
+ expect(tooWide).toEqual([])
+ })
+
+ it('reports the RESIDUAL from the corrector, not from the spacer', async () => {
+ // Rewritten against a device frame that caught the old source lying. The
+ // spacer-derived version printed 1579px on a landing the corrector reported as
+ // `res=0`, and the corrector was right: `spacer 0->6781` while `owed=8360`,
+ // because once the window reaches the start prepended rows MOUNT above the
+ // anchor rather than growing the spacer, so the spacer undercounts by exactly
+ // the mounted growth and the subtraction inherits it as fake residual. Two
+ // residuals that disagree is worse than one, so the honest one wins the line.
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devWatchMessages(100, 2807)
+ insp.devSpacer(0, 0)
+ insp.devWatchMessages(200, 2807)
+ insp.devSpacer(6781, 0)
+ insp.devLog('WRITE', 'resize 0->8360')
+ insp.devLog('CORR', 'd=8360 owed=8360 res=0 painted=0')
+ // The device emitted a SECOND correction on the same landing, asking for
+ // nothing: `CORR d=0 owed=0 res=0`. It must not count as a landing -- counting
+ // it dilutes the run and makes a real residual look rarer than it is.
+ insp.devLog('CORR', 'd=0 owed=0 res=0 painted=0')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('RESIDUAL 0px of 8360px owed')
+ expect(text).toContain('in 1 corr since load')
+ // The scope has to be ON the line. `moved` directly above it resets at each
+ // landing while these run figures do not, and a device reading showed the
+ // mismatch reading as "two corrections on this landing" when one of them
+ // predated it. Two adjacent numbers on different windows is how an earlier
+ // residual became unreadable.
+ expect(text).toContain('since load')
+ // The spacer keeps only the job it can do -- describing the spacer.
+ expect(text).toContain('spacer 0->6781')
+ // And the raw figure is still shown, because it is what the writes did.
+ expect(text).toContain('moved 8360px')
+ // The number the old source would have produced must not appear anywhere.
+ expect(text).not.toContain('1579')
+ })
+
+ it('keeps the WORST residual, with the peak in the middle', async () => {
+ // A single bad landing inside a run of good ones is the entire complaint, and
+ // a last-only reading hides it behind the next landing. Peak in the MIDDLE on
+ // purpose: peak-last survives a "keeps only the last" bug and peak-first
+ // survives "keeps only the first", so neither placement can prove a max.
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devLog('CORR', 'd=1000 owed=999 res=1 painted=0')
+ insp.devLog('CORR', 'd=1000 owed=600 res=400 painted=1')
+ insp.devLog('CORR', 'd=1000 owed=998 res=2 painted=0')
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('RESIDUAL 2px of 998px owed')
+ expect(text).toContain('worst 400px')
+ expect(text).toContain('in 3 corr since load')
+ })
+
+ it('opens its own window for a CORRECTION that arrives with no landing', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ // No message count change at all -- a restore, a regroup or a turn-end
+ // rebuild reaches the correction without one. The spacer as it stands now is
+ // the baseline.
+ insp.devSpacer(4000, 12)
+ insp.devLog('WRITE', 'resize 1613->11199')
+ insp.devSpacer(13600, 12)
+ vi.advanceTimersByTime(TICK_MS)
+ const text = overlay()?.textContent ?? ''
+ expect(text).toContain('spacer 4000->13600')
+ })
+
+ it('is armed by the anchor correction only, not by every writer', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devSpacer(4000, 12)
+ // The above-fold reprice is a RESIDUAL corrector, a few px at a time, and it
+ // fires while the reader scrolls. Letting it open the window would restart the
+ // measurement mid-flight and hide the correction it was opened to measure.
+ insp.devLog('WRITE', 'abovefold 1613->1609')
+ insp.devSpacer(13600, 12)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('spacer 4000->13600')
+ })
+
+ it('does not arm on ordinary scrolling, only on the count rising', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devWatchMessages(301, 10350)
+ insp.devSpacer(12000, 23)
+ // Same count, window moves as the reader scrolls: not a landing.
+ insp.devWatchMessages(301, 10350)
+ insp.devSpacer(3845, 12)
+ vi.advanceTimersByTime(TICK_MS)
+ expect(overlay()?.textContent ?? '').not.toContain('spacer 12000->')
+ })
+
+ it('closes the window after a few renders so scrolling cannot drift into it', async () => {
+ const insp = await load()
+ insp.setInspectorEnabled(true)
+ watchAScroller(insp)
+ insp.devWatchMessages(201, 10350)
+ insp.devSpacer(12000, 23)
+ insp.devWatchMessages(301, 10350)
+ for (const [px, st] of [[3845, 123], [3900, 124], [3950, 125], [4000, 126], [4050, 127], [4100, 128], [99999, 200]] as [number, number][]) {
+ insp.devSpacer(px, st)
+ }
+ vi.advanceTimersByTime(TICK_MS)
+ // The 7th sample is past the cap, so the runaway value never lands.
+ expect(overlay()?.textContent ?? '').not.toContain('99999')
+ })
+})
+
+describe('scroll inspector: reading helpers', () => { beforeEach(() => {
vi.resetModules()
localStorage.clear()
})
diff --git a/website/src/test/scrollInspectorWiring.test.ts b/website/src/test/scrollInspectorWiring.test.ts
new file mode 100644
index 00000000000..388a13e3609
--- /dev/null
+++ b/website/src/test/scrollInspectorWiring.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/** The two bounce instruments are only worth their overlay line if they are
+ * actually FED. Their unit tests call the module's functions directly, so they
+ * stay green when the call site in the virtualizer is deleted -- verified by
+ * mutation, and it is exactly how an instrument goes silently blind: the
+ * overlay simply never prints the line, and the absence reads as "no defect
+ * here" rather than as "nothing is measuring".
+ *
+ * The device is the only place the effect can be observed, so there is no
+ * honest unit test for it. A source pin is the next best thing, and it is
+ * written to fail for the way this actually breaks: the call being removed or
+ * commented out during debugging and not put back. */
+
+const virtualizer = () =>
+ readFileSync(
+ resolve(__dirname, '../hooks/virtualizer/useVirtualChat.ts'),
+ 'utf8',
+ )
+
+/** Comment lines are stripped BEFORE searching. A comment that merely NAMES the
+ * call must not satisfy the pin -- a lesson learned the hard way in this file's
+ * neighbours, where prose describing a mechanism was mistaken for the
+ * mechanism itself. */
+const code = (src: string): string =>
+ src
+ .split('\n')
+ .filter((l) => {
+ const t = l.trim()
+ return t !== '' && !t.startsWith('//') && !t.startsWith('*') && !t.startsWith('/*')
+ })
+ .join('\n')
+
+describe('scroll inspector wiring: the bounce instruments are fed', () => {
+ it('reports the top spacer, which is what separates a reprice from a scroll', () => {
+ const src = code(virtualizer())
+ expect(src).toContain('devSpacer(offsetBefore, windowRange.start)')
+ })
+
+ it('imports the reporter, so the call cannot be a stale reference', () => {
+ const src = code(virtualizer())
+ expect(src).toMatch(/import\s*{[^}]*\bdevSpacer\b[^}]*}\s*from\s*'\.\.\/\.\.\/dev\/scrollInspector'/)
+ })
+
+ it('logs every programmatic scroll write through the one chokepoint', () => {
+ // `moved` is derived from this log line, so losing it loses the reading
+ // that named the culprit on the device.
+ const src = code(virtualizer())
+ expect(src).toContain("devLog('WRITE'")
+ expect(src).toContain("->${Math.round(top)}")
+ })
+
+ it('feeds the spacer from the OFFSET TREE, not from a DOM read', () => {
+ // `offsetBefore` is the tree's own answer for everything above the window.
+ // Reading the spacer element instead would measure the fiction AFTER the
+ // browser resolved it, which is the very thing being investigated.
+ const src = code(virtualizer())
+ expect(src).toContain('const offsetBefore = offsetIndex.offsetOf(windowRange.start)')
+ })
+})
+
+/** The two conditions that stop the corrector CREATING the displacement it claims
+ * to repair. Both are pinned at source because the harness genuinely cannot reach
+ * either state, and that is a measured limit rather than an excuse:
+ *
+ * - `displaced == 0` while `delta != 0` requires scrollTop to drift BETWEEN an
+ * anchor's capture and its consume. `act(() => rerender(...))` flushes layout
+ * effects synchronously, so a test can never get between the two — recorded in
+ * useVirtualChat.prependAnchor.test.tsx as its own gap.
+ * - The giveup branch fires only after the restore deadline passes with the stored
+ * row never arriving, on a real session id, with the placement change it
+ * triggers actually moving scrollTop.
+ *
+ * The device frame both were written from, entering a session whose stored row was
+ * gone (`ENTER RESTORE.giveup n=0`):
+ *
+ * CORR d=-1016 owed=-1262 res=246 painted=1 WRITE resize 6404->5388
+ * CORR d=1448 owed=1484 res=-36 painted=1 WRITE resize 6590->8038
+ *
+ * Both painted, ~2.4kpx of jolt on entry, and `res` small in each — the residual
+ * could never have found it, because both figures came from the same abandoned
+ * capture and so agreed with each other rather than with the glass. */
+describe('anchor correction: refuses to move a reader who has not moved', () => {
+ it('gates the write on the MEASURED displacement, not only on the owed delta', () => {
+ const src = code(virtualizer())
+ expect(src).toContain('const displaced = newTop - pending.top')
+ expect(src).toMatch(/Math\.abs\(displaced\) > 0\.5\s*&&\s*Math\.abs\(delta\) > 0\.5/)
+ })
+
+ it('drops the correction that straddles a restore giving up', () => {
+ const src = code(virtualizer())
+ // Set where the giveup actually happens, next to the placement change that
+ // moves scrollTop -- not at some later convenience point.
+ expect(src).toMatch(/restoreGaveUpRef\.current = true[\s\S]{0,120}stickRef\.current = followOutput/)
+ expect(src).toContain('const abandoned = restoreGaveUpRef.current')
+ expect(src).toMatch(/if \(!abandoned && /)
+ })
+
+ it('CONSUMES the abandoned flag instead of latching it on', () => {
+ // An authorization that can only ever turn on is not an authorization -- the
+ // same defect this file already fixed once in the walk poll. Left latched, one
+ // giveup would silence the corrector for the rest of the mount.
+ const src = code(virtualizer())
+ expect(src).toMatch(/if \(abandoned\) \{\s*restoreGaveUpRef\.current = false/)
+ })
+
+ it('accumulates our own writes at the ONE chokepoint every write passes', () => {
+ // The correction can only tell the reader's finger from its own earlier hand if
+ // every writer registers, and fourteen call sites write this scroller. Counted
+ // inside writeScrollTop rather than at the call sites so a new writer cannot
+ // forget -- the same reason the WRITE log lives there.
+ const src = code(virtualizer())
+ expect(src).toContain('writeSumRef.current += top - el.scrollTop')
+ // Before the write, while el.scrollTop is still the old value. Registering
+ // afterwards cannot recover the delta without forcing a layout.
+ expect(src).toMatch(/writeSumRef\.current \+= top - el\.scrollTop[\s\S]{0,200}el\.scrollTo\(\{ top, behavior \}\)/)
+ })
+
+ it('carries the write total ON the anchor, so it is read as a difference', () => {
+ // A cumulative counter used absolutely drifts further off the longer a session
+ // stays open -- which no short test would ever show. Pairing it with the capture
+ // is what makes it a window.
+ const src = code(virtualizer())
+ expect(src).toContain('capturedWriteSum: pending.writeSum')
+ expect(src).toContain('currentWriteSum: writeSumRef.current')
+ expect(src).toContain('offsetOfRef.current, writeSumRef.current)')
+ })
+})
diff --git a/website/src/test/useVirtualChat.prependAnchor.test.tsx b/website/src/test/useVirtualChat.prependAnchor.test.tsx
index e97053d0b2c..bb836c12b9b 100644
--- a/website/src/test/useVirtualChat.prependAnchor.test.tsx
+++ b/website/src/test/useVirtualChat.prependAnchor.test.tsx
@@ -40,6 +40,9 @@ const SCROLL_HEIGHT = 3000
* would pass with no fix at all. Reset in beforeEach. */
let rowHeightByKey: Record = {}
+/** Saved so the real (jsdom) implementation is put back after each test. */
+let origRo: typeof ResizeObserver | undefined
+
/** Rendered height of one row node — its override when it has one, else the
* flat REAL_H every other case in this file uses. */
function rowHeightOf(node: HTMLElement): number {
@@ -48,6 +51,84 @@ function rowHeightOf(node: HTMLElement): number {
return override ?? REAL_H
}
+/** ResizeObserver, as the virtualizer actually consumes it.
+ *
+ * This harness deliberately had none, and that absence is why an entire
+ * compensation path shipped untested: the correction for a REPRICE above the
+ * reader lives inside the observer's callback, so no test in this file could
+ * reach it. Every assertion here about heights changing was therefore really
+ * an assertion about the anchor path staying out of the way.
+ *
+ * The mock is small because the callback does not read `contentRect`: it
+ * measures the target itself, so the existing fake layout already supplies the
+ * height and `rowHeightByKey` already controls it. All that was missing was
+ * delivery of the entries. */
+let roCallbacks: ResizeObserverCallback[] = []
+let roObserved: Element[] = []
+
+class FakeResizeObserver implements ResizeObserver {
+ constructor(private readonly cb: ResizeObserverCallback) {
+ roCallbacks.push(cb)
+ }
+ observe(target: Element): void {
+ if (!roObserved.includes(target)) roObserved.push(target)
+ }
+ unobserve(target: Element): void {
+ roObserved = roObserved.filter((t) => t !== target)
+ }
+ disconnect(): void {
+ roCallbacks = roCallbacks.filter((c) => c !== this.cb)
+ }
+}
+
+/** Deliver a measure batch for the given targets, the way the browser would
+ * after they first mounted or changed size. Entries carry only `target`,
+ * because that is all the callback reads. */
+function fireResize(targets: Element[]): void {
+ const entries = targets.map((target) => ({ target } as unknown as ResizeObserverEntry))
+ for (const cb of roCallbacks) cb(entries, {} as ResizeObserver)
+}
+
+/** The mounted row nodes, in document order. */
+function rowNodes(el: HTMLElement): HTMLElement[] {
+ return Array.from(el.querySelectorAll('[data-index]')) as HTMLElement[]
+}
+
+/** HARNESS CAPABILITY GAPS -- three separate defects were traced to guards this
+ * file cannot reach, and they share one root cause, so they are listed together
+ * rather than rediscovered one at a time.
+ *
+ * 1. NO ResizeObserver (now fixed, see FakeResizeObserver below). The
+ * correction for a reprice above the reader lives inside the observer's
+ * callback, so no test could reach it. Every assertion here about heights
+ * changing was really an assertion about the anchor path staying out of the
+ * way -- and one whole compensation path shipped with zero coverage.
+ *
+ * 2. NO first-mount state. A row the virtualizer has never measured is what a
+ * freshly prepended page consists of, and the observer treats it differently
+ * on purpose. Reachable now (mount at zero height so the measurement is
+ * discarded) but see the skipped test for the geometry constraint.
+ *
+ * 3. NO scroll-anchor RESTORE -- but it is REACHABLE, and the recipe is here so
+ * the next attempt does not have to find it again. The blocker was assumed to
+ * be `landedAnchorRef`, which is written only on the converged path of the
+ * settle loop; the predicate that actually guards the consume effect is
+ * `restoreOwnsPosition` = `settleGateRef.current || pendingRestoreRef !== null`,
+ * and the PENDING half comes from persisted state: seed
+ * `localStorage['vc_anchor3_' + sessionId]` with `{ key, alt?, top }` before
+ * mounting (this file's `beforeEach` clears exactly those keys, which is the
+ * hint). `key` must be a STABLE row id, not a per-render virtual key.
+ *
+ * Worth doing, because the coverage is uneven in a way mutation makes plain:
+ * deleting `restoreOwnsPosition`'s use at the leave-flush reddens a test, so
+ * the predicate is trusted elsewhere -- while deleting either the consume
+ * effect's new call or the long-standing above-fold `relandConvergedAnchor`
+ * check reddens NOTHING. A device trace is currently the only evidence for
+ * those two.
+ *
+ * The pattern worth remembering: each gap made a guard invisible rather than
+ * wrong, so the suite stayed green while the reader on a phone did not. */
+
function rect(top: number, height: number): DOMRect {
return {
top, bottom: top + height, height, left: 0, right: 0, width: 0, x: 0, y: top,
@@ -195,6 +276,10 @@ describe('useVirtualChat: prepend compensation (load older history)', () => {
localStorage.clear()
frames = []
rowHeightByKey = {}
+ roCallbacks = []
+ roObserved = []
+ origRo = globalThis.ResizeObserver
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
origRaf = globalThis.requestAnimationFrame
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
frames.push(cb)
@@ -218,6 +303,7 @@ describe('useVirtualChat: prepend compensation (load older history)', () => {
restore?.()
restore = null
globalThis.requestAnimationFrame = origRaf
+ if (origRo) globalThis.ResizeObserver = origRo
globalThis.IntersectionObserver = origIO
})
@@ -267,6 +353,254 @@ describe('useVirtualChat: prepend compensation (load older history)', () => {
return { el, view, scrollerRef, readScrollTop: () => scrollTop }
}
+ /** A REPRICE above the reader — the path that had no coverage at all until
+ * this file grew a ResizeObserver. When a row above the fold turns out taller
+ * than the tree priced it, everything below shifts down by the difference and
+ * the reader must be carried with it or they see a jump. */
+ it('holds the reader when a row ABOVE them is repriced taller', () => {
+ const { el, readScrollTop } = mountScrolledUp()
+ const before = readScrollTop()
+ const rows = rowNodes(el)
+ // A mounted row above the fold. Its height was measured at mount, so this
+ // fire is a genuine reprice (prevH is known) rather than a first mount.
+ const target = rows[0]
+ const key = target.getAttribute('data-key')!
+ rowHeightByKey[key] = REAL_H + 400
+ act(() => { fireResize([target]) })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+ // Carried down by the growth: the row the reader was looking at is still
+ // under their eye. Anything less and they watched the text slide.
+ expect(readScrollTop()).toBeGreaterThan(before + 300)
+ })
+
+ /** The SAME displacement arriving as a FIRST-MOUNT measurement rather than a
+ * reprice. That is what a freshly prepended older page produces: its rows were
+ * priced by the running mean and never measured, so the observer sees no
+ * previous height and the above-fold correction skips them by design --
+ * `HeightIndex`'s own doc states the rationale, that re-pinning during
+ * scroll-driven window expansion would yank a scrolling reader.
+ *
+ * NOT COVERED, and two dead ends are recorded so the next attempt does not
+ * repeat them:
+ *
+ * 1. Clearing `rowHeightByKey` does NOT create the state -- it controls the
+ * RENDERED height, not the virtualizer's record. A first attempt that did
+ * only this PASSED while being an exact duplicate of the test above,
+ * reporting coverage of the very gap it was written to expose.
+ * 2. Mounting the row at zero height DOES stop the measurement being recorded
+ * (the callback discards a non-positive height), but the row must also lie
+ * ENTIRELY above the fold for the correction to apply -- and in this
+ * harness's geometry it does not. Probed rather than assumed: at
+ * `scrollTop` 2160 the mounted rows are m23..m29, all of them at or below
+ * the fold, so no mounted row is a candidate.
+ *
+ * Reaching it needs a taller viewport or a deeper overscan so that at least
+ * one mounted row sits wholly above the fold, at which point the zero-height
+ * mount trick supplies the unmeasured half. */
+
+ /** The SAME displacement arriving as a FIRST-MOUNT measurement rather than a
+ * reprice. That is what a freshly prepended older page produces: its rows
+ * were priced by the running mean and never measured, so the observer sees no
+ * previous height and the above-fold correction skips them -- `HeightIndex`'s
+ * doc states the rationale, that re-pinning during scroll-driven window
+ * expansion would yank a scrolling reader. On a corpus whose rows are large
+ * diffs the mean is wrong by an order of magnitude per row, so what that
+ * rationale trades away is not a rounding artifact.
+ *
+ * Two dead ends are recorded because both looked right:
+ *
+ * 1. Clearing `rowHeightByKey` does NOT create the state -- it controls the
+ * RENDERED height, not the virtualizer's record. A first attempt doing
+ * only this PASSED while being an exact duplicate of the test above,
+ * reporting coverage of the very gap it was written to expose.
+ * 2. The row has to be one the window actually holds. Probed rather than
+ * assumed: the window is computed from the offset TREE's prices, not from
+ * `REAL_H`, so at `scrollTop` 2160 the mounted set is m23..m29 -- m0 and
+ * m19 are not mounted at all and a test naming them fails for that reason
+ * instead of the one it is about.
+ *
+ * What does work: mount the row at zero height. The callback discards a
+ * non-positive measurement, so nothing is recorded for that key while the
+ * tree keeps its estimate (the window is therefore unchanged), and the next
+ * fire is genuinely a first mount. */
+ // SKIPPED, and not because it is unfinished: it REPRODUCES a real gap (it fails
+ // with `expected 2160 to be greater than 2460` -- scrollTop does not move at
+ // all on a first mount above the fold), but a device reading shows that gap is
+ // not what the reader is experiencing. The overlay's `repriced` counter, which
+ // sums spacer changes at a STILL window, printed NOTHING across a scroll that
+ // landed four pages -- so no first-mount reprice occurred at all, while a
+ // single anchor-consume write moved the reader 10015px. Un-skip when the
+ // first-mount case is prioritised; the reproduction above is ready.
+ it.skip('holds the reader when a row above them is measured for the FIRST time', () => {
+ // m23 is the topmost mounted row and the one the reprice test above proves
+ // the above-fold correction accepts. Zero at mount leaves it unmeasured.
+ rowHeightByKey.m23 = 0
+ const { el, readScrollTop } = mountScrolledUp()
+ const target = rowNodes(el).find((n) => n.getAttribute('data-key') === 'm23')
+ expect(target).toBeTruthy()
+ const before = readScrollTop()
+ // Now it resolves large -- the mean-priced row turning out to be a big diff.
+ rowHeightByKey.m23 = REAL_H + 400
+ act(() => { fireResize([target!]) })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+ expect(readScrollTop()).toBeGreaterThan(before + 300)
+ })
+
+ /** The device's actual shape, which every other test in this file lacks: the
+ * prepended rows are FAR taller than the mean the offset tree prices them at.
+ * On the real corpus a row is a large diff and `h/n` is an average over rows
+ * that differ by one to two orders of magnitude, so the tree's estimate for a
+ * never-measured row is wrong by thousands of pixels -- while here every row
+ * is REAL_H, which is why the existing prepend tests pass without exercising
+ * the failure at all.
+ *
+ * What the reader needs is unchanged: the row they were looking at stays put,
+ * so `scrollTop` must rise by the TRUE height of what landed above them. */
+ /** The device's actual shape: the landed rows are FAR taller than the mean the
+ * offset tree prices them at. Every other prepend test in this file uses a
+ * flat REAL_H, where the estimate happens to EQUAL the truth, so none of them
+ * can tell a correct compensation from one that merely re-derives the same
+ * wrong number twice.
+ *
+ * The assertion is the invariant the reader actually experiences -- the row
+ * they were looking at does not move on screen -- and deliberately NOT "the
+ * compensation equals the true inserted height". A first version asserted the
+ * latter and was wrong: the landed rows sit outside the mounted window, so
+ * their true height is not in the DOM at all. Only the estimated spacer is,
+ * and keeping the anchor still is therefore worth exactly the spacer's change.
+ * Demanding the true height would have required compensating for pixels that
+ * do not exist yet, and the test would have "reproduced" a defect that is
+ * really the shape of virtualization. */
+ it('holds the anchor row on screen when the landed rows outweigh the tree estimate', () => {
+ const { el, view, scrollerRef } = mountScrolledUp(mkItems(30))
+ const srTop0 = el.getBoundingClientRect().top
+ const anchor = rowNodes(el)[1]
+ const anchorKey = anchor?.getAttribute('data-key') ?? null
+ expect(anchorKey).toBeTruthy()
+ const top0 = anchor!.getBoundingClientRect().top - srTop0
+
+ const older = mkItems(30, 'o')
+ for (const it of older) rowHeightByKey[it.id] = REAL_H * 10
+ act(() => {
+ view.rerender( )
+ })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+
+ const after = rowNodes(el).find((n) => n.getAttribute('data-key') === anchorKey)
+ // Asserted, not skipped: a silent return here would let the test pass in the
+ // very case that matters most -- the anchor UNMOUNTING, which hands the
+ // landing to the arithmetic fallback and its estimated heights. Red here
+ // means the harness has reached that branch and is the reproduction.
+ expect(after).toBeTruthy()
+ const top1 = after.getBoundingClientRect().top - el.getBoundingClientRect().top
+ expect(Math.abs(top1 - top0)).toBeLessThan(2)
+ })
+
+ /** The device's OTHER condition, and the one the test above does not have: the
+ * reader is at the very TOP of the loaded slice, so `windowRange.start` is 0
+ * and the top spacer is 0 before the landing. That is the configuration a
+ * top-walk always ends in -- it is how the next page gets requested -- and the
+ * device's residual (movement written minus movement owed) was ~1,863px there
+ * while the mid-transcript case above is within 2px. */
+ it('holds the anchor row when the reader is at the very TOP of the slice', () => {
+ const scrollerRef: RefObject = { current: null }
+ let scrollTop = 0
+ const view = rtlRender( )
+ const el = scrollerRef.current!
+ Object.defineProperty(el, 'scrollTop', {
+ configurable: true, get: () => scrollTop, set: (v: number) => { scrollTop = v },
+ })
+ Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => CLIENT })
+ // Derived from the live children, NOT the fixed constant: a landing really
+ // does make the transcript taller, and the range limit has to move with it.
+ // Pinned at a constant, the compensation's own write lands "past the end" and
+ // the overscroll guard then refuses the next pass -- a harness artifact that
+ // looks exactly like the defect being guarded against.
+ Object.defineProperty(el, 'scrollHeight', {
+ configurable: true,
+ get: () => Array.from(el.children).reduce((h, c) => {
+ const node = c as HTMLElement
+ if (node.getAttribute('data-index') !== null) return h + rowHeightOf(node)
+ return h + (parseFloat(node.style?.height || '0') || 0)
+ }, 0),
+ })
+ installFakeLayout(el, CLIENT)
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+ // At the top: no scroll away from 0, which is what leaves the spacer at 0.
+ act(() => { scrollTop = 0; el.dispatchEvent(new Event('scroll')) })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+
+ const anchor = rowNodes(el)[1]
+ const anchorKey = anchor?.getAttribute('data-key') ?? null
+ expect(anchorKey).toBeTruthy()
+ const top0 = anchor!.getBoundingClientRect().top - el.getBoundingClientRect().top
+
+ const older = mkItems(30, 'o')
+ for (const it of older) rowHeightByKey[it.id] = REAL_H * 10
+ act(() => {
+ view.rerender( )
+ })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+
+ const after = rowNodes(el).find((n) => n.getAttribute('data-key') === anchorKey)
+ expect(after).toBeTruthy()
+ const top1 = after!.getBoundingClientRect().top - el.getBoundingClientRect().top
+ expect(Math.abs(top1 - top0)).toBeLessThan(2)
+ })
+
+ /** OVERSCROLL -- the rubber band, which is not a content position.
+ *
+ * iOS lets a finger pull past an edge and reports a NEGATIVE `scrollTop` while
+ * the band is stretched. A compensation measured against that describes the
+ * stretch, not the content, and writing it throws the reader by the stretch.
+ * Device trace: an 11,096px write beginning at `scrollTop = -2810`, with the
+ * top spacer unchanged across the landing, so every pixel of it was error. */
+ it('writes nothing while the scroller is rubber-band OVERSCROLLED', () => {
+ const { el, view, scrollerRef, readScrollTop } = mountScrolledUp(mkItems(30))
+ // The finger has pulled past the top edge.
+ act(() => { el.scrollTop = -2810; el.dispatchEvent(new Event('scroll')) })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+ const before = readScrollTop()
+ expect(before).toBeLessThan(0)
+
+ const older = mkItems(30, 'o')
+ for (const it of older) rowHeightByKey[it.id] = REAL_H * 10
+ act(() => {
+ view.rerender( )
+ })
+ act(() => { frames.forEach((cb) => cb(0)); frames.length = 0 })
+
+ // The band springs back on its own and the release brings more scroll events,
+ // so the correct behaviour is to leave the position alone rather than write a
+ // kilopixel derived from the stretch.
+ expect(Math.abs(readScrollTop() - before)).toBeLessThan(200)
+ })
+
+ /** NOT COVERED: the READER scrolling between the anchor's capture and its
+ * consume -- which is the normal case on a phone, since a page is fetched
+ * *because* the reader is scrolling and momentum outlives the ~130ms fetch.
+ *
+ * The anchor's displacement is `(content growth) - (scrollTop change)`. Pinning
+ * the row to the glass compensates BOTH terms, and the second is the reader's
+ * own finger, so the transcript cancels their gesture. Device trace with the
+ * measurement windows aligned: one 9,440px write, `spacer 0->0` (no content
+ * appeared), no overscroll, `sinceHard=6037ms` (momentum stamps no hard input,
+ * so no ownership guard fires). Content growth of zero means the correct write
+ * was zero and all 9,440px was the reader's scroll being undone.
+ *
+ * Why it cannot be tested here, established by two failed attempts rather than
+ * assumed: `act(() => rerender(...))` flushes layout effects SYNCHRONOUSLY, so
+ * the consume has already run by the time a test can move the scroller. A first
+ * attempt scrolled before re-rendering (no prepend detected, nothing captured)
+ * and a second scrolled after the rerender act (too late); both passed with the
+ * fix removed AND with its sign inverted.
+ *
+ * Reaching it needs the commit and the effect flush separated -- a manual
+ * `ReactDOM` root with `flushSync` for the render and a hand-driven effect
+ * pass, or the correction extracted into a pure function taking
+ * (anchorTop, newTop, capturedScrollTop, nowScrollTop). The second is smaller
+ * and would pin the arithmetic without the harness. */
+
it('holds the reading position when older history is prepended', () => {
const { el, view, scrollerRef, readScrollTop } = mountScrolledUp()