Three eva-authored e2e assertions encoded the pre-virtualization layout and
break once the timeline windows rows out of the DOM and positions them with
absolute/translateY:
- relay-reconnect: the reconnect-backfill test expected both the newest and the
260-rows-old message mounted at once. A virtualized timeline windows the
oldest rows out while the user sits at the bottom, so assert the newest at the
bottom, then scroll to the top and poll until the oldest mounts -- the backfill
depth is now proven by reachability, not simultaneous mounting.
- channels (x2): expectIntroBalancedAroundDayDivider compared the intro->divider
gap against the divider->message gap for equality. The intro is a flex sibling
above the timeline while the divider and first row are virtualized items, so
the two gaps are measured across different layout regimes and no longer match
within a pixel. Assert the intended reading order instead: intro, divider,
then the first message, cleanly separated with no overlap.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Re-platforms the index-anchor scroll model onto eva's single-owner anchor
(useAnchoredScroll) so the virtualized timeline keeps one scroll writer
under windowing. Removes eva's older-history IntersectionObserver and makes
useLoadOlderOnScroll the sole top-sentinel/fetchOlder owner; eva's anchored
scrollBy cedes to the index path on a prepend via a front-id/tail-id
discriminator (new prevFirstMessageIdRef). Adds a minimal restoreScrollPosition
writer that re-derives the anchor after a programmatic scroll instead of
re-introducing a competing scroll-owner. Windowed-out jump targets converge
through the virtualizer (indexByMessageId + getOffsetForIndex) rather than a
querySelector that goes null off-screen. Load-older roles are gated on
fetchOlder/virtualizer presence so MessageThreadPanel degrades to the passive
anchor. Reserves a fixed-height spinner slot so the fetch indicator does not
shift the bottom row.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
biome check enforces line-wrapping that biome lint does not. The
load-older test 07 had two over-width statements that passed local lint
but failed the Desktop Core biome check gate. Format-only, no behavior
change.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Loading older messages under virtualization let three writers fight over
scrollTop on overlapping frames, so the anchored row jittered or collapsed
to the top (~33% of prepends) and the library's reconcile spun the full 5s
MAX_RECONCILE_MS valve. Establish a single owner of scroll position across
the whole fetch+restore window:
- useLoadOlderOnScroll restores by scrollTop ONLY (drop scrollToIndex), via
one getOffsetForIndex(anchorIndex + prepended, "start")[0] + intra-row gap
write. getOffsetForIndex is a pure measurement-cache read, so no library
scrollState is set and the reconcile loop has nothing to fight.
- The viewport ResizeObserver in useTimelineScrollManager no longer runs a
competing restore during a fetch: it skips while isFetchingOlder is true
(the spinner's clientHeight 720->590 mount-shift fires before the lock is
set) and otherwise defers to lockedScrollTopRef when the load-older restore
holds it. MessageTimeline threads isFetchingOlder into the manager.
The defect was invisible to unit tests (jsdom getBoundingClientRect -> 0) and
to static traces; the new load-older E2E drives a real prepend on six fresh
page loads and asserts the anchor holds every run, the scroller genuinely
grew, and the reconcile terminates. emitMockHistory now honors the relay
filter's until/limit so the mock relay paginates like a real one, which the
E2E needs to exercise a genuine older page.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The ResizeObserver in useAnchoredScroll only re-pinned the scroller when
the user was at-bottom — when scrolled up reading older history and a row
above the reading row reflowed (link-card decode, async embed expand, late
font load, markdown that expands), the anchor row shifted on them and
nothing restored it. The PR description claimed image and embed loads all
flow through the anchor, but on a careful read only NIP-92 imeta images
are actually covered (their dim is reserved before decode, so no resize
fires). Every other in-viewport content growth fell into the gap.
Extract the anchor-restoration primitive — find the row (with the
nearest-newer fallback already used for prepends), measure its current
top, scrollBy the delta — into a shared restoreAnchorToMessage helper,
and call it from both the layout effect and the ResizeObserver. One
primitive serves the React-driven path (post-commit, on messages /
spinner change) and the non-React-driven path (image decode, embed
expand, font load), preserving the single-owner invariant. A messagesRef
is mirrored from the layout effect so the observer reads the same list
the DOM was last rendered from without resubscribing on every commit.
E2E coverage: scroll-history e2e adds an in-viewport reflow case that
seeds a scrollable channel, scrolls to a mid position, captures the
top-crossing row's offset, programmatically grows a row above the anchor
via style.minHeight, and asserts the anchor's offset is unchanged within
2px after the observer fires. Confirmed the assertion catches the
pre-fix behavior (80px drift) by reverting the implementation and
re-running.
tsc, biome (764 files), 998/998 unit, scroll-history e2e 7/7 green.
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
An instrument, not a gate. Seeds a busy channel against the mock bridge and
measures the main-thread cost the headless correctness suite cannot feel.
Two measurements:
- Fast-wheel scroll of the bounded ~200-row window: compositor-cheap
(~0.2ms layout + 1.3ms recalc over a 241-frame, 12k-px burst).
- Prepend re-render cost while scrolled up: ~9-13ms main-thread tick,
attributable to the O(rendered-rows) parent walk (videoReviewContext
rebuild + day-group boundaries + element construction), NOT layout
and NOT leaf-row reconciliation (MessageRow memo bails correctly).
Anchor holds at 0px drift on this path.
Scope limit: measures Chromium reconciliation, not the WKWebView
compositor feel — that remains the real-Tauri macOS pass.
Run: pnpm build && npx playwright test --config=playwright.perf.config.ts
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Replaces the two competing scroll writers (useTimelineScrollManager +
useLoadOlderOnScroll) with one anchor-based primitive, useAnchoredScroll,
owned by both the main timeline and the thread panel.
The prior design had two hooks both mutating scrollTop on prepend, which
is the root of the timeline-jumping bug: a fetch-older restore and a
scroll-anchor adjustment would fight over the same frame. The new hook
keeps a single anchor (the row the reader's eye is on, picked by a
top-crossing walk) and restores it relative to its prior top offset after
every render — prepends, appends, image loads, and embed expansions all
flow through that one path.
Also fixes three concrete bugs surfaced by the ported E2E suite:
- Deep-link targets in older history: the post-mount target effect bailed
once when the row wasn't loaded yet and never retried. It now keys on
`messages` and re-runs until the spliced-in row commits, then centers.
- Root-message deep-link reopen: the initial-mount target path scrolled
and marked the target handled but never fired `onTargetReached`, so the
`messageId` URL param stuck and re-clicking the same link was a no-op.
The initial path now fires the callback too, matching the post-mount one.
- Inline image height reservation: images never read their NIP-92 `dim`
tag, so a tall image grew from ~0 on load and shoved the timeline. We
now stamp intrinsic width/height so the row reserves space before decode.
Verification: tsc clean, biome (764 files) clean, 989/989 unit tests,
scroll-history 6/6, full e2e smoke 253 passed (3 failures all reproduce
on a clean main checkout — pre-existing, unrelated to this change).
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Adds a build-time-configured transport reconnect hook so internal builds can recover the underlying transport (e.g. WARP VPN) before the relay reconnect, while OSS builds stay a pure no-op.
The generic "Reconnect to relay" button is transport-agnostic; internal users behind a VPN need the transport re-established first. A build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` (set only in `squareup/buzz-releases`) carries a JSON config validated into a typed Rust struct at compile time, so the OSS binary ships with zero VPN knowledge.
- New Tauri command `relay_reconnect_hook` runs structured fixed-argv steps plus a readiness probe, wrapped in `tokio::task::spawn_blocking`; non-fatal end-to-end so any failure falls through to `preconnect()`.
- `ReconnectHookConfig` lives in one dep-free source file `include!`'d by both `build.rs` and the runtime command, so the compile-time validation and runtime parse cannot drift.
- `useReconnectRelay.ts` invokes the hook before relay preconnect, guarded so a hook rejection cannot abort the reconnect.
- When `BUZZ_BUILD_RELAY_RECONNECT_CMD` is unset (OSS), the command compiles to an early-return no-op.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>