Skip to content

fix(chat): pin follow-bottom with CSS scroll anchoring, not a post-hoc scrollTop write - #55

Merged
jaysin586 merged 18 commits into
mainfrom
fix/overflow-anchor-follow-bottom
Jul 9, 2026
Merged

fix(chat): pin follow-bottom with CSS scroll anchoring, not a post-hoc scrollTop write#55
jaysin586 merged 18 commits into
mainfrom
fix/overflow-anchor-follow-bottom

Conversation

@jaysin586

Copy link
Copy Markdown
Contributor

Summary

Follow-bottom was guaranteed by writing scrollTop from a ResizeObserver callback, on the assumption that RO callbacks always run after layout and before paint. That assumption holds most frames and breaks on some: a DOM mutation can land after both the rAF phase and the observer step, and the browser still paints the new layout that frame. When it breaks, the viewport paints off-bottom by exactly the displaced height.

This replaces the post-hoc scrollTop write with the one mechanism that runs inside layout, before paint — CSS scroll anchoring. The viewport opts back into overflow-anchor, every message/header/footer opts out, and a sentinel inside the transformed items subtree becomes the anchor. Growth above it is compensated by the browser, not by us.

This fixes the transient off-bottom paint. The separately reported persistently stranded Firefox viewport remains unreproduced and unexplained — see the guard report before closing that issue.

Changes

🐛 Bug fixes

  • overflow-anchor: auto on the viewport (was none); overflow-anchor: none on message wrappers, header, footer and the reserve spacer, so the browser can only ever pick the sentinel.
  • Bottom sentinel inside the absolutely-positioned, translateY-transformed items container — a sentinel after the JS-height spacer never moves, so it never anchors (measured; see plan Evidence item 6).
  • Sentinel height is derived from the viewport's computed border-top-width. WebKit offsets its anchor-visibility test by that width, so a 1px sentinel behind a 1px border is silently never selected. viewportClass is a public prop, so this is a supported-configuration hazard, not a theoretical one.
  • New ChatTailSwapCarry module: during a [...prefix, temp] → [...prefix] → [...prefix, final] swap it holds the removed tail's measured height so total content height — and therefore the bottom — never moves. A bounded clear timer means a permanent tail deletion returns the reserve to zero rather than stranding phantom space.

🔧 Refactoring

  • collectPitchChanges now filters itemsEl.children to elements carrying data-message-id. Adding the sentinel broke its unwritten invariant that every child is a message wrapper, which silently dropped the last message's trailing collapsed margin.
  • The height cache stays a height cache: the reserve lives in its own module, and a characterization test asserts ChatHeightCache does not carry height across a tail shrink.

🧪 Testing

  • stream-swap.spec.ts gains a stress test, parameterized over new-id-regrow and new-id-two-tick, asserting maxGapPx <= 48 after every one of 12 in-page runs. Both bugs fixed here originally hid behind single-sample gates.
  • anchor-probe.spec.ts (new): three hand-built scrollers × three engines, proving anchoring pins pre-paint inside a transformed abspos subtree, and asserting the known-good control still pins — a silently broken control cost a full cycle.
  • The fixture's bottom-gap oracle is now a signed tail-distance measurement. The legacy scrollHeight - clientHeight - scrollTop becomes a false positive once anchoring is on (it corrects scrollTop a frame before the JS spacer resizes), and its clamped form was blind to blank-space-below-tail.
  • chatTailSwapCarry.test.ts (new, 8 cases); header-footer.spec.ts waits switched to condition-based expect.poll (assertions unchanged).

Verification

Every gate below was re-run and reproduced by guard against this snapshot, not taken on report.

Gate Result
full chromium 109 passed, 0 failed
firefox + webkit 218 passed, 0 failed
mobile-chrome + mobile-safari 202 passed, 0 failed
new-id-regrow ×25 (chromium) 25 passed
stress, both variants × 3 engines × 3 repeats 18 passed
new-id-two-tick ×10 (webkit / mobile-safari / mobile-chrome) 10/10 each — was 1-in-10 failing on all three
pnpm run check / vitest 0 errors, 0 warnings / 150 passed

Measured independently of the fixture's own oracle, by comparing the tail message's bottom against the viewport's across 25 attempts per library version on chromium:

  • without the change: 3/25 runs painted a real off-bottom frame, worst gap 408px
  • with the change: 0/25

Two earlier attempts were rejected at the gate and are worth knowing about: a rAF/$effect re-pin (a probability reduction, not a fix — no JS-timing hook is a pre-paint guarantee), and a first cut of the tail reserve that lived in the height cache and leaked, leaving phantom space forever after a permanent tail deletion.

Full review trail: .agents/.plans/stream-swap-follow/002-overflow-anchor-follow-bottom.guard-report.md

⚠️ This branch also carries two unrelated chore commits from a typed-lint initiative (61fe9e4, 6b1634d). Neither touches src/ or tests/.

Commits

  • f01a166 refactor(chat): extract tail-swap carry out of the height cache
  • d8c084e fix(chat): reserve removed tail height so follow-bottom survives remove-then-add
  • 37f110d test(chat): assert anchor probe control pins
  • 151425a fix(chat): pin follow-bottom with scroll anchoring
  • bb54342 test(chat): stress-sample new-id-regrow so the follow-bottom race is visible
  • 61fe9e4 chore(lint): make Trunk the sole lint/format entry point
  • 6b1634d chore(plan): add typed-lint batch

🤖 Generated with Claude Code

jaysin586 and others added 13 commits July 9, 2026 10:23
Guard re-ran every done criterion against 151425a rather than
trusting the executor's report. All pass: check, vitest, full
chromium/firefox/webkit/mobile suites, regrow x25, cross-engine
stress x3, and the anchor-probe gate.

Spirit verified independently of both oracles by measuring the last
message's bottom against the viewport's: 3/25 real off-bottom paints
without the change, 0/25 with it.

Records one plan defect awaiting operator agreement: the fixture's
bottom-gap oracle rewrite exceeds the literal Scope grant but is
forced by reality — enabling scroll anchoring makes the legacy
scrollHeight metric a false positive during the shrink half of a
swap. Guard has not amended the plan and touched no source code.
Operator-approved amendment recorded as guard checkpoint 2.

Scope for src/routes/tests/chat/stream-swap/+page.svelte widened from
"new debug-stats key only" to permit replacing the bottom-gap oracle.
Enabling CSS scroll anchoring corrects scrollTop a frame before the
JS-driven spacer resizes, so the legacy
scrollHeight - clientHeight - scrollTop metric reads a stale-inflated
scrollHeight and reports a gap the user cannot see — a false positive
created by the fix itself (new Evidence item 9).

The grant is constrained, not open: a replacement oracle must measure a
signed offset and assert on its magnitude, so stranding and blank space
below the tail are both caught. Clamping negatives to zero is forbidden.

No Done criteria and no STOP conditions were changed. Planned at
re-stamped be915fa -> a544bc7 so the drift check re-baselines.
Guard final re-ran every done criterion at f0299fc. Eleven hold; one
fails: --project=mobile-chrome --project=mobile-safari reported 1 failed
(mobile-safari new-id-two-tick, Expected <= 48, Received 521).

Repeat-sampling shows a ~10% race, not a one-off: new-id-two-tick with
--repeat-each=10 fails 1/10 on webkit, mobile-safari and mobile-chrome;
chromium and firefox are clean. The desktop suite's 216 passed is a
single sample of that race.

Scroll anchoring itself is sound and independently verified: measuring
the tail wrapper's bottom against the viewport's, 3/25 real off-bottom
paints without the change, 0/25 with it. Scope is clean and no
out-of-scope module was touched.

The failure is tail REMOVAL, which anchoring does not cover (it
compensates growth above the anchor). The executor patched it with a
snapToBottomPrePaint() call in the count effect -- a JS-timing hook, and
the plan's STOP condition -- then verified it with single runs on a race
the plan forbids single-run gating for.

No PR opened; the snapshot stays on the branch, unmerged. To flip to
PASS: stress-sample new-id-two-tick red-first, fix tail removal with a
genuinely pre-paint mechanism (or record that none exists), and re-run
the cross-engine and mobile criteria at --repeat-each=10 or better.
…ve-then-add

Replaces the count-effect snapToBottomPrePaint() call — a JS-timing hook
that only reduced the two-tick failure rate — with a layout-level height
reserve. When a measured tail suffix is removed, ChatHeightCache retains
its height as `tailRemovalReserve`; the component renders that reserve as
a spacer ahead of the message flow, so total content height (and the
bottom) stay stable until the replacement message is appended.

- collectPitchChanges now filters itemsEl.children to message wrappers,
  so non-message children (the anchor sentinel, the reserve spacer)
  cannot close a message's pitch. The sentinel returns to last in DOM.
- stream-swap stress spec is parameterized over new-id-regrow and
  new-id-two-tick; per-run maxGapPx <= 48 assertions unchanged.
- header-footer spec waits on stats via expect.poll instead of reading
  DOM counts immediately.

Guard snapshot for review at plan 002's final gate. Committing records
what was reviewed; it is not an endorsement.
Adopt the svelte-markdown convention: trunk fmt / trunk check are the
only sanctioned lint and format commands, and eslint-disable comments
are forbidden in favor of trunk-ignore inline suppressions. Remove the
lint/lint:fix/format scripts from package.json and docs/package.json so
the raw prettier/eslint path cannot be reached by habit — pnpm lint
checked the whole repo (not changed files) and its &&-chaining silently
skipped eslint on any prettier failure, which repeatedly misled sessions.
CLAUDE.md documents the doctrine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se rules

Plan 001 (adapted from svelte-markdown's streaming-component-hardening
009 at the operator's request): remove the **/*.test.ts eslint ignore,
switch to recommendedTypeChecked with projectService so
no-floating-promises / no-misused-promises actually fire, with a
three-parser-path activation proof. Preconditioned on the
fix/overflow-anchor-follow-bottom branch landing first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guard final re-ran the criteria against snapshot d8c084e.

The two-tick race is genuinely FIXED. The JS-timing hook that earned the
previous NO-PASS is gone, replaced by a layout-level height reserve.
Repeat-sampled --repeat-each=10 on the three projects that each failed
1-in-10 before: webkit 10/10, mobile-safari 10/10, mobile-chrome 10/10.
Full mobile suite 202 passed (was 1 failed). Chromium 109, firefox+webkit
217, stress gate 18 -- and the stress spec now covers new-id-two-tick.

Still NO-PASS on two grounds:

- The reserve lives in chatMeasurement.svelte.ts as new cache state plus a
  public getter. The 2026-07-09b amendment (recorded here) opened that file
  for the collectPitchChanges child-filter ONLY, explicitly granting no new
  cache state, fields, or getters. Modifying it otherwise is a STOP; the
  executor did not stop.
- The fence was earned: the state leaks. It is set in sync()'s tail-shrink
  branch and cleared in every other branch, so a PERMANENT tail deletion
  never clears it -- the removed message's height renders as phantom space
  forever. Both new unit tests cover only remove-then-add, so 143 unit tests
  and five browser projects are green over a live bug.

The tree moved mid-review (reserve being extracted into chatTailSwapCarry.ts),
so the regrow --repeat-each=25 criterion is recorded as not reproduced rather
than met. No PR opened; d8c084e stays unmerged. No source code touched.
Moves the removed-tail height reserve out of ChatHeightCache and into a
new ChatTailSwapCarry module, so the height cache stays a height cache.
The component now owns the reserve as local $state with a bounded 250ms
clear timer, which also closes the leak where a permanent tail deletion
left the reserve set forever.

- chatMeasurement.svelte.ts keeps only the authorized collectPitchChanges
  child-filter; tailRemovalReserve, its getter and its sync() branches are
  gone.
- ChatTailSwapCarry.observe() reports carried heights, a reserve height,
  and whether the component should clear and re-snap.
- Tail removal is only reserved while bottom-following and not preserving
  user scroll.

Guard snapshot for review at plan 002's final gate. Committing records
what was reviewed; it is not an endorsement.
@jaysin586 jaysin586 added bug Apply to any code that fixes incorrect behavior or errors javascript Pull requests that update javascript code labels Jul 9, 2026
@jaysin586 jaysin586 self-assigned this Jul 9, 2026
@jaysin586 jaysin586 added the javascript Pull requests that update javascript code label Jul 9, 2026
jaysin586 added 5 commits July 9, 2026 17:19
Guard final re-ran every done criterion at f01a166 and reproduced all of
them green. Zero failures across the full chromium, firefox, webkit,
mobile-chrome and mobile-safari matrix.

Both races are fixed rather than made rarer. new-id-two-tick repeat-
sampled 10/10 on webkit, mobile-safari and mobile-chrome, each of which
failed 1-in-10 before. Verified independently of both fixture oracles by
measuring the tail message's bottom against the viewport's: 3/25 real
off-bottom paints without the change, 0/25 with it.

The height cache is back behind its fence -- the tail reserve moved to
chatTailSwapCarry.ts with a bounded clear timer, closing the permanent-
tail-deletion leak, and a characterization test now asserts the cache
does not carry height.

Records the third and final operator-approved amendment (Scope naming the
new module, its test, the characterization test, and the header-footer
wait-hardening). All three amendments strengthened a guard; none relaxed a
Done criterion or a STOP condition. Two STOP conditions were skipped
during execution and both were caught by repeat-sampled gates and
corrected before this close-out -- recorded, not erased.

PR opened for the reviewed snapshot; merging stays with the operator.
Guard touched no source code at any checkpoint.
…erve

The tail-swap reserve holds a removed message's measured height so the
bottom stays still during a remove-then-add. When the replacement never
arrives -- the user deletes or regenerates the last assistant reply -- the
reserve must return to zero, or the removed height is painted as permanent
phantom space and scrollHeight stays inflated.

That leak shipped green once: it was invisible to every unit test and every
browser project, because nothing looked at where the content actually sat.
Nothing in the repo exercised a permanent tail removal.

Asserts that deleting a message strictly shrinks the scrollable extent. A
leaked reserve keeps it flat -- totalHeight drops by the removed message and
the reserve adds the same amount straight back. Deliberately not an exact
delta: virtualization pulls a previously-estimated message into the measured
window as the tail leaves, so the shrink is the estimate, not the measure.

Verified red against the leaky snapshot d8c084e (scrollHeight stuck at 1139)
and green on current HEAD, in chromium, firefox and webkit.
From CodeRabbit review of PR #55.

- anchor-probe: Math.round() returns -0 for a sub-pixel negative gap. That
  -0 survives page.evaluate, and expect(-0).toBe(0) fails because Playwright
  compares with Object.is -- a latent flake in the cross-engine gate the
  follow-bottom fix depends on. Verified the whole chain, then normalized
  with `+ 0`.
- stream-swap fixture: the anchor lookup used a document-wide querySelector
  while the tail lookup two lines below correctly scoped to the viewport. It
  would find the wrong anchor with two chat instances on a page. Also renames
  a local `messages` that shadowed the fixture's $state message array.
- regrow-trace.debug.ts: document that the .debug.ts extension keeps it out
  of Playwright's testMatch, so it never runs in CI and never gates a merge.
  It is a diagnostic that patches scrollTop globally and costs up to 40 page
  loads; it produced the timeline that root-caused the race.
The lint/format scripts were removed from the root package.json and from
docs/package.json. Saying "package.json" singular in a pnpm workspace reads
as if only one manifest was touched.

From CodeRabbit review of PR #55.
AGENTS.md is the cross-agent convention for project context. Symlinking it
rather than copying keeps a single source of truth -- the two cannot drift.
@jaysin586
jaysin586 merged commit 23538db into main Jul 9, 2026
4 checks passed
jaysin586 added a commit that referenced this pull request Jul 10, 2026
…#58)

Both batches are fully landed on main — stream-swap-follow via PRs #54
(v0.1.17) and #55 (v0.1.18), typed-lint via PR #57 — with guard PASS
close-out reports on every plan. Add a batch-level SIGNOFF.md to each
recording verdicts, merge commits, gate history, and the residual
follow-ups that survive closure, then move both folders from
.agents/.plans/ to .agents/.plans-closed/ so the active-plans directory
only holds executable work.

Notably carried forward: the persistent-strand Firefox question remains
open (stream-swap-follow fixed transients only), and the tail-deletion
leak still lacks a committed regression test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Apply to any code that fixes incorrect behavior or errors javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant