From 6976f23a2255f02acf4261ec6143bda2623e413b Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:45:11 +0200 Subject: [PATCH 01/34] Make the mobile viewport handler idempotent and cheap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every iOS visual-viewport tick (keyboard animation, URL-bar collapse, momentum settling) the shell hook forced a full-document layout via document.body.clientHeight and rewrote shell top/height plus the inherited --bb-shell-height, invalidating computed style for the whole app tree at animation cadence. - Skip all style writes when a pass recomputes the geometry it already applied, and make clearViewportOverride a no-op while nothing is set. - Cache the containing-block height; re-read it only on triggers that can resize the layout viewport (window resize, orientationchange, focusin) — never on visualViewport ticks, which move only the visual viewport. - Gate visualViewport scroll ticks on keyboard focus or an applied override: keyboard-less URL-bar pans need no compensation, while embedded-browser overrides (applied without a keyboard) keep tracking. The focusout fast-restore, the native-layout early-exit, the pinch-zoom guard, and rAF coalescing are preserved; programmatic focus (composer autofocus) still triggers one freshly measured pass, covered by a new test. Co-Authored-By: Claude Fable 5 (cherry picked from commit e0c2d3c6c40d23802425c19f835a4787976a8724) --- .../useMobileVisualViewportHeight.test.tsx | 142 ++++++++++++++++++ .../layout/useMobileVisualViewportHeight.ts | 79 ++++++++-- 2 files changed, 209 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx index 7ec3b26225..fad08417d8 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx @@ -108,6 +108,18 @@ function withElementClientHeight( } } +// Waits out one scheduled rAF pass so "the pass ran and did nothing" is +// distinguishable from "the pass has not run yet". +async function flushScheduledViewportPass() { + await act(async () => { + await new Promise((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => resolve()); + }); + }); + }); +} + beforeEach(() => { vi.spyOn(window, "scrollTo").mockImplementation(() => {}); }); @@ -310,6 +322,136 @@ describe("useMobileVisualViewportHeight", () => { expect(window.scrollTo).not.toHaveBeenCalled(); }); }); + + it("writes shell geometry only when a pass computes new values", async () => { + const visualViewport = new FakeVisualViewport(); + visualViewport.offsetTop = 0; + await withFakeVisualViewport(visualViewport, async () => { + render(); + const shell = screen.getByTestId("shell"); + const shellHeightRoot = screen.getByTestId("shell-height-root"); + expect(shell.style.height).toBe("500px"); + const setShellHeightProperty = vi.spyOn( + shellHeightRoot.style, + "setProperty", + ); + + // Same geometry again: the pass must return before any style write, or + // every keyboard/URL-bar animation frame invalidates the whole tree. + act(() => { + visualViewport.dispatchEvent(new Event("resize")); + }); + await flushScheduledViewportPass(); + expect(setShellHeightProperty).not.toHaveBeenCalled(); + + act(() => { + visualViewport.height = 480; + visualViewport.dispatchEvent(new Event("resize")); + }); + await waitFor(() => expect(shell.style.height).toBe("480px")); + expect(setShellHeightProperty).toHaveBeenCalledTimes(1); + }); + }); + + it("reads the containing block only when the layout viewport can change", async () => { + const visualViewport = new FakeVisualViewport(); + visualViewport.offsetTop = 0; + let containingBlockReads = 0; + await withElementClientHeight( + document.body, + () => { + containingBlockReads += 1; + return 800; + }, + async () => + withFakeVisualViewport(visualViewport, async () => { + render(); + const shell = screen.getByTestId("shell"); + expect(shell.style.height).toBe("500px"); + const readsAfterMount = containingBlockReads; + + // Visual-viewport ticks pan or resize only the visual viewport; + // they must reuse the cached containing-block height instead of + // forcing a full-document layout per animation frame. + act(() => { + visualViewport.offsetTop = 40; + visualViewport.dispatchEvent(new Event("scroll")); + }); + await waitFor(() => expect(shell.style.top).toBe("40px")); + act(() => { + visualViewport.height = 460; + visualViewport.dispatchEvent(new Event("resize")); + }); + await waitFor(() => expect(shell.style.height).toBe("460px")); + expect(containingBlockReads).toBe(readsAfterMount); + + act(() => { + window.dispatchEvent(new Event("resize")); + }); + await waitFor(() => + expect(containingBlockReads).toBe(readsAfterMount + 1), + ); + }), + ); + }); + + it("runs a geometry pass when an editor is focused programmatically", async () => { + const visualViewport = new FakeVisualViewport(); + visualViewport.offsetTop = 0; + await withElementClientHeight( + document.body, + () => 500, + async () => + withFakeVisualViewport(visualViewport, async () => { + render(); + const shell = screen.getByTestId("shell"); + const editor = screen.getByTestId("editor"); + // Native layout matches the visual viewport: no override applied. + expect(shell.style.height).toBe(""); + + // The keyboard shortens the visual viewport around the same time + // the composer autofocuses, without any window resize; the focus + // pass must pick the change up on its own. + visualViewport.height = 300; + act(() => editor.focus()); + await waitFor(() => expect(shell.style.height).toBe("300px")); + }), + ); + }); + + it("ignores visual viewport pans without a keyboard or an applied override", async () => { + const visualViewport = new FakeVisualViewport(); + visualViewport.offsetTop = 0; + await withElementClientHeight( + document.body, + () => 500, + async () => + withFakeVisualViewport(visualViewport, async () => { + render(); + const shell = screen.getByTestId("shell"); + const editor = screen.getByTestId("editor"); + expect(shell.style.height).toBe(""); + + // A URL-bar pan with no keyboard: nothing to compensate. + act(() => { + visualViewport.offsetTop = 340; + visualViewport.dispatchEvent(new Event("scroll")); + }); + await flushScheduledViewportPass(); + expect(window.scrollTo).not.toHaveBeenCalled(); + expect(shell.style.top).toBe(""); + + // With a keyboard editor focused, the same pan is Safari's + // focus-reveal pan and must still be compensated. + act(() => editor.focus()); + act(() => { + visualViewport.dispatchEvent(new Event("scroll")); + }); + await waitFor(() => expect(shell.style.top).toBe("340px")); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }), + ); + }); }); describe("shouldRestoreIOSViewportOnKeyboardDismissal", () => { diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts index e628e7c05b..7ebb4de039 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts @@ -55,7 +55,22 @@ export function useMobileVisualViewportHeight( if (!shell || !shellHeightRoot || !enabled || !visualViewport) return; let animationFrame: number | null = null; + // The override last written to the shell, or null while none is applied. + // Writing shell `top`/`height` and the inherited `--bb-shell-height` + // invalidates computed style for the whole app tree, and passes run at + // visual-viewport event cadence (keyboard animation, URL-bar collapse), + // so a pass that recomputes unchanged geometry must not write at all. + let appliedOverride: { top: number; height: number } | null = null; + // Reading `document.body.clientHeight` forces a full-document layout. The + // shell's containing block only changes when the layout viewport does, so + // cache the read and mark it stale only on triggers that can resize the + // layout viewport — never on visualViewport ticks, which move or resize + // only the visual viewport. + let shellContainingBlockHeight = 0; + let shellContainingBlockHeightStale = true; const clearViewportOverride = () => { + if (appliedOverride === null) return; + appliedOverride = null; shell.style.removeProperty("top"); shell.style.removeProperty("height"); shellHeightRoot.style.removeProperty("--bb-shell-height"); @@ -68,12 +83,15 @@ export function useMobileVisualViewportHeight( } const visualViewportHeight = Math.round(visualViewport.height); - // `documentElement.clientHeight` is the visible viewport height for the - // root element, even when that root's actual CSS box extends behind an - // Android in-app browser toolbar. The body inherits the root box and - // therefore exposes the containing-block height the app shell really - // receives. - const shellContainingBlockHeight = document.body.clientHeight; + if (shellContainingBlockHeightStale) { + // `documentElement.clientHeight` is the visible viewport height for the + // root element, even when that root's actual CSS box extends behind an + // Android in-app browser toolbar. The body inherits the root box and + // therefore exposes the containing-block height the app shell really + // receives. + shellContainingBlockHeight = document.body.clientHeight; + shellContainingBlockHeightStale = false; + } const hasVisualViewportPan = visualViewport.offsetTop > 1 || window.scrollY > 0; if ( @@ -91,7 +109,16 @@ export function useMobileVisualViewportHeight( // compensation below also handles a visual-viewport-only pan. window.scrollTo(0, 0); } - shell.style.top = `${getVisualViewportPageTop(visualViewport)}px`; + const shellTop = getVisualViewportPageTop(visualViewport); + if ( + appliedOverride !== null && + appliedOverride.top === shellTop && + appliedOverride.height === visualViewportHeight + ) { + return; + } + appliedOverride = { top: shellTop, height: visualViewportHeight }; + shell.style.top = `${shellTop}px`; shell.style.height = `${visualViewportHeight}px`; // Fixed-position descendants cannot inherit the shell element's pixel // height. Publish the same correction through the existing shell-height @@ -108,6 +135,27 @@ export function useMobileVisualViewportHeight( } animationFrame = window.requestAnimationFrame(updateHeight); }; + // For triggers that can resize the layout viewport itself: window resize, + // rotation, and an editor gaining focus (the keyboard that follows may + // resize the layout viewport on Android's resizes-content path). + const scheduleContainingBlockUpdate = () => { + shellContainingBlockHeightStale = true; + scheduleUpdate(); + }; + const handleVisualViewportScroll = () => { + // Keyboard-less visual-viewport pans (URL-bar collapse, momentum + // settling) don't change the containing block and need no override — + // the pan compensation exists for the keyboard focus-reveal pan. Only + // an already-applied override still has to track pans, because embedded + // browsers apply one without any keyboard. + if ( + appliedOverride === null && + !isKeyboardFocusTarget(document.activeElement) + ) { + return; + } + scheduleUpdate(); + }; // Safari with its bottom toolbar visible does not update the visual // viewport until the keyboard animation ends. Restore the normal shell @@ -124,13 +172,16 @@ export function useMobileVisualViewportHeight( }; const handleFocusIn = (event: FocusEvent) => { if (!isKeyboardFocusTarget(event.target)) return; - scheduleUpdate(); + // Programmatic focus (composer autofocus) can be the only trigger for a + // keyboard, so this must always schedule a full, freshly measured pass. + scheduleContainingBlockUpdate(); }; updateHeight(); visualViewport.addEventListener("resize", scheduleUpdate); - visualViewport.addEventListener("scroll", scheduleUpdate); - window.addEventListener("resize", scheduleUpdate); + visualViewport.addEventListener("scroll", handleVisualViewportScroll); + window.addEventListener("resize", scheduleContainingBlockUpdate); + window.addEventListener("orientationchange", scheduleContainingBlockUpdate); if (restoreImmediatelyOnKeyboardDismissal) { document.addEventListener("focusout", handleFocusOut); document.addEventListener("focusin", handleFocusIn); @@ -138,8 +189,12 @@ export function useMobileVisualViewportHeight( return () => { visualViewport.removeEventListener("resize", scheduleUpdate); - visualViewport.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); + visualViewport.removeEventListener("scroll", handleVisualViewportScroll); + window.removeEventListener("resize", scheduleContainingBlockUpdate); + window.removeEventListener( + "orientationchange", + scheduleContainingBlockUpdate, + ); if (restoreImmediatelyOnKeyboardDismissal) { document.removeEventListener("focusout", handleFocusOut); document.removeEventListener("focusin", handleFocusIn); From 08fc6999fb673ca7d70726f44fb761d2ae83d6bd Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:52:11 +0200 Subject: [PATCH 02/34] Take the resize cascade's geometry from the observer entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every timeline size change re-entered layout up to five times: the scroll body's ResizeObserver delivery did a live scrollHeight/clientHeight refresh, the observed frame's bottom restore read again, and each of the three rAF settle-tail frames forced another layout — at streaming cadence on an unwindowed tree. - bottom-anchored-scroll-body: refresh the cached max offset from the ResizeObserver's own box sizes when the delivery carries entries (the scroll port's content box + the content wrapper's border box), falling back to the live read for entry-less deliveries (test stubs). The observed frame's restoreBottomOnce keeps its deliberate live read; the settle-tail frames now reuse the cache, and a tail frame that corrected drift arms exactly one live verification read on the next frame. - height-transition: size the wrapper from the entry's borderBoxSize (the same border-box metric as the offsetHeight used by the mount and snap paths) instead of the content rect; non-observer paths keep offsetHeight. The scroll-preservation contract suite passes unmodified. New settle-tail tests count geometry reads per tail frame and cover the entry-derived cache; a height-transition test pins the border-box sizing. Co-Authored-By: Claude Fable 5 (cherry picked from commit e4dc82bfa75063d8a174b6b714678092dc6649c6) --- ...-anchored-scroll-body.settle-tail.test.tsx | 292 ++++++++++++++++++ .../ui/bottom-anchored-scroll-body.tsx | 164 +++++++--- .../components/ui/height-transition.test.tsx | 41 +++ .../src/components/ui/height-transition.tsx | 24 +- 4 files changed, 472 insertions(+), 49 deletions(-) create mode 100644 apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx new file mode 100644 index 0000000000..85cae0d2b9 --- /dev/null +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx @@ -0,0 +1,292 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { getDefaultStore } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BottomAnchoredScrollBody } from "@/components/ui/bottom-anchored-scroll-body"; +import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; + +// Companion to the scroll-preservation suite, focused on the geometry-read +// budget of the resize path: the observed frame may read live +// scrollHeight/clientHeight, but the rAF settle tail must run on the cached +// max offset (at most one live verification read when a cached restore found +// drift), and deliveries that carry ResizeObserver box sizes must refresh the +// cache from them without forcing layout at all. + +interface ScrollMetrics { + scrollHeight: number; + clientHeight: number; + scrollTop: number; +} + +const SCROLL_AREA_CLASS = "scroll-area"; +const THREAD_ID = "settle-thread"; + +class ResizeObserverMock implements ResizeObserver { + static instances: ResizeObserverMock[] = []; + readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + ResizeObserverMock.instances.push(this); + } + observe() {} + unobserve() {} + disconnect() {} + trigger(entries: ResizeObserverEntry[] = []) { + this.callback(entries, this); + } +} + +function getLatestResizeObserver(): ResizeObserverMock { + const instance = ResizeObserverMock.instances.at(-1); + if (!instance) throw new Error("Expected a ResizeObserver instance."); + return instance; +} + +interface ManualAnimationFrames { + runFrame: () => void; + hasPending: () => boolean; +} + +// Unlike the scroll-preservation suite (which discards rAF callbacks because +// the settle tail is irrelevant there), these tests drive the tail frame by +// frame to observe what each one reads. +function installManualAnimationFrames(): ManualAnimationFrames { + let nextHandle = 1; + const pending = new Map(); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + const handle = nextHandle; + nextHandle += 1; + pending.set(handle, callback); + return handle; + }), + ); + vi.stubGlobal( + "cancelAnimationFrame", + vi.fn((handle: number) => { + pending.delete(handle); + }), + ); + return { + runFrame() { + const callbacks = [...pending.values()]; + pending.clear(); + for (const callback of callbacks) { + callback(window.performance.now()); + } + }, + hasPending() { + return pending.size > 0; + }, + }; +} + +function setScrollMetrics(element: HTMLElement, metrics: ScrollMetrics) { + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: metrics.scrollHeight, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: metrics.clientHeight, + }); + element.scrollTop = metrics.scrollTop; +} + +interface GeometryReadCounters { + readScrollHeight: ReturnType; + readClientHeight: ReturnType; +} + +function installGeometryReadCounters( + element: HTMLElement, + metrics: Pick, +): GeometryReadCounters { + const readScrollHeight = vi.fn(() => metrics.scrollHeight); + const readClientHeight = vi.fn(() => metrics.clientHeight); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + get: readScrollHeight, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + get: readClientHeight, + }); + return { readScrollHeight, readClientHeight }; +} + +function makeResizeEntry( + target: Element, + blockSize: number, +): ResizeObserverEntry { + const boxSize: ResizeObserverSize = { blockSize, inlineSize: 100 }; + return { + target, + contentRect: new DOMRect(0, 0, 100, blockSize), + borderBoxSize: [boxSize], + contentBoxSize: [boxSize], + devicePixelContentBoxSize: [boxSize], + }; +} + +function requireHTMLElement(element: Element | null) { + if (!(element instanceof HTMLElement)) { + throw new Error("Expected HTMLElement."); + } + return element; +} + +function renderScrollBody() { + const view = render( + Footer} + maxWidthClassName="max-w-none" + scrollAreaClassName={SCROLL_AREA_CLASS} + scrollAnchorThreadId={THREAD_ID} + > +
row-a
+
, + ); + const scrollArea = requireHTMLElement( + view.container.querySelector(`.${SCROLL_AREA_CLASS}`), + ); + const scrollContent = requireHTMLElement(scrollArea.firstElementChild); + return { scrollArea, scrollContent }; +} + +let frames: ManualAnimationFrames; + +beforeEach(() => { + ResizeObserverMock.instances = []; + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + frames = installManualAnimationFrames(); +}); + +afterEach(() => { + cleanup(); + getDefaultStore().set(threadTimelineScrollAnchorAtomFamily(THREAD_ID), null); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// Pin the viewport to the bottom of settled content and drain the mount tail, +// then grow the content so the observed frame restores to the new bottom and +// arms a fresh settle tail. +function growContentWhilePinned() { + const rendered = renderScrollBody(); + const { scrollArea } = rendered; + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + frames.runFrame(); + + setScrollMetrics(scrollArea, { + scrollHeight: 500, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + expect(scrollArea.scrollTop).toBe(400); + return rendered; +} + +describe("BottomAnchoredScrollBody settle tail", () => { + it("settles without re-reading geometry when the cached restore finds no drift", () => { + const { scrollArea } = growContentWhilePinned(); + const { readScrollHeight, readClientHeight } = installGeometryReadCounters( + scrollArea, + { scrollHeight: 500, clientHeight: 100 }, + ); + + // No drift after the observed frame: the tail's first cached comparison + // sees the pinned position and stops without a single forced layout. + frames.runFrame(); + expect(scrollArea.scrollTop).toBe(400); + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + expect(frames.hasPending()).toBe(false); + }); + + it("spends at most one live read when the settle tail corrects drift", () => { + const { scrollArea } = growContentWhilePinned(); + // Cascading layout (footer/prompt height settling) moved scrollTop after + // the observed frame without resizing the observed boxes. + scrollArea.scrollTop = 390; + const { readScrollHeight, readClientHeight } = installGeometryReadCounters( + scrollArea, + { scrollHeight: 500, clientHeight: 100 }, + ); + + // First tail frame corrects against the cache alone. + frames.runFrame(); + expect(scrollArea.scrollTop).toBe(400); + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + + // The cached correction arms exactly one live verification read. + frames.runFrame(); + expect(readScrollHeight).toHaveBeenCalledTimes(1); + expect(readClientHeight).toHaveBeenCalledTimes(1); + expect(scrollArea.scrollTop).toBe(400); + + // Verification found the bottom stable, so the tail is done. + frames.runFrame(); + expect(readScrollHeight).toHaveBeenCalledTimes(1); + expect(readClientHeight).toHaveBeenCalledTimes(1); + expect(frames.hasPending()).toBe(false); + }); + + it("derives the cached max offset from observed box sizes without forcing layout", () => { + const { scrollArea, scrollContent } = renderScrollBody(); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + getLatestResizeObserver().trigger(); + frames.runFrame(); + + // Detach mid-timeline (the detach edge spends its allowed verification + // read here, before the counters are installed). + scrollArea.scrollTop = 150; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + + const liveMetrics = { scrollHeight: 900, clientHeight: 100 }; + const { readScrollHeight, readClientHeight } = installGeometryReadCounters( + scrollArea, + liveMetrics, + ); + + // Content grows to 900 while detached. The delivery carries the observer's + // own box sizes, so the cache refresh needs no scrollHeight/clientHeight. + getLatestResizeObserver().trigger([ + makeResizeEntry(scrollArea, 100), + makeResizeEntry(scrollContent, 900), + ]); + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + + // The derived max offset (800) is what scroll classification runs on: + // 797 is within the 4px threshold, so this scroll re-attaches — still + // without a live read. + scrollArea.scrollTop = 797; + fireEvent.scroll(scrollArea); + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + + // Re-attached: the next growth's observed frame follows the bottom (its + // restore legitimately reads fresh geometry). + liveMetrics.scrollHeight = 1_000; + getLatestResizeObserver().trigger([ + makeResizeEntry(scrollArea, 100), + makeResizeEntry(scrollContent, 1_000), + ]); + expect(scrollArea.scrollTop).toBe(900); + }); +}); diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 415dd47a04..35e7b7331b 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -285,6 +285,10 @@ export function BottomAnchoredScrollBody({ const pointerScrollIntentRef = useRef(false); const restoreFrameRef = useRef(null); const restoreFramesRemainingRef = useRef(0); + // Set when a settle-tail frame corrected drift against the cached max + // offset: the next tail frame then spends the tail's single live + // verification read (see queueBottomRestore). + const restoreTailLiveReadRef = useRef(false); const pendingPrependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number; @@ -322,6 +326,14 @@ export function BottomAnchoredScrollBody({ // live geometry — the pre-cache behavior — instead of trusting a frozen // value that would classify every position as at-bottom. const resizeObserverHasDeliveredRef = useRef(false); + // The last box sizes the ResizeObserver reported for the scroll port and + // the content wrapper. Together they are the observer's own measurement of + // `scrollHeight - clientHeight`, letting the resize path refresh the cache + // without a forced layout (see handleScrollAreaResize). + const observedScrollGeometryRef = useRef<{ + scrollAreaClientHeight: number | null; + scrollContentHeight: number | null; + }>({ scrollAreaClientHeight: null, scrollContentHeight: null }); const [isAtBottom, setIsAtBottom] = useState(true); const initialScrollRestoreRowId = useMemo(() => { if (scrollAnchorThreadId === undefined) return null; @@ -360,6 +372,7 @@ export function BottomAnchoredScrollBody({ window.cancelAnimationFrame(restoreFrameRef.current); restoreFrameRef.current = null; restoreFramesRemainingRef.current = 0; + restoreTailLiveReadRef.current = false; }, []); // Snap scrollTop back to the bottom if anchoring has let us drift away. @@ -387,6 +400,22 @@ export function BottomAnchoredScrollBody({ return true; }, [refreshMaxScrollOffset]); + // The settle-tail variant: runs on the cached max offset so tail frames + // don't force layout. Any real size change re-enters through the + // ResizeObserver with fresh geometry; what the tail catches is scrollTop + // drifting (clamps, anchoring adjustments) while the observed sizes — and + // therefore the cache — still hold. + const restoreBottomFromCacheOnce = useCallback(() => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea || !shouldStickToBottomRef.current) return false; + const maxScrollOffset = readMaxScrollOffset(scrollArea); + if (isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop)) { + return false; + } + scrollArea.scrollTop = maxScrollOffset; + return true; + }, [readMaxScrollOffset]); + const queueBottomRestore = useCallback(() => { if (!shouldStickToBottomRef.current) return; // Restore synchronously in the frame the size change was observed. @@ -402,13 +431,27 @@ export function BottomAnchoredScrollBody({ // isn't final in the observed frame. restoreBottomOnce(); restoreFramesRemainingRef.current = BOTTOM_RESTORE_SETTLE_FRAME_COUNT; + restoreTailLiveReadRef.current = false; if (restoreFrameRef.current !== null) return; const runQueuedRestore = () => { restoreFrameRef.current = null; - if (!restoreBottomOnce()) { + // Tail frames reuse the cache: the observed frame just read fresh + // geometry, so re-reading it every settle frame only re-forces layout. + // A cached correction can itself mean layout moved under the cache, so + // it arms exactly one live verification read on the following frame — + // bounding the whole tail to a single forced layout. + const useLiveRead = restoreTailLiveReadRef.current; + restoreTailLiveReadRef.current = false; + const restored = useLiveRead + ? restoreBottomOnce() + : restoreBottomFromCacheOnce(); + if (!restored) { restoreFramesRemainingRef.current = 0; return; } + if (!useLiveRead) { + restoreTailLiveReadRef.current = true; + } restoreFramesRemainingRef.current -= 1; if (restoreFramesRemainingRef.current > 0) { restoreFrameRef.current = @@ -416,7 +459,7 @@ export function BottomAnchoredScrollBody({ } }; restoreFrameRef.current = window.requestAnimationFrame(runQueuedRestore); - }, [restoreBottomOnce]); + }, [restoreBottomOnce, restoreBottomFromCacheOnce]); const scrollToBottom = useCallback(() => { const scrollArea = scrollAreaRef.current; @@ -821,48 +864,81 @@ export function BottomAnchoredScrollBody({ return true; }, [applyScrollRestore, queueBottomRestore]); - const handleScrollAreaResize = useCallback(() => { - const scrollArea = scrollAreaRef.current; - let shrankOntoBottomWhileDetached = false; - if (scrollArea) { - // The steady-state cache refresh: the observer watches both the scroll - // port and the content wrapper, so every legitimate - // scrollHeight/clientHeight change passes through here. The first - // delivery is also what makes the cache authoritative for hot-path - // reads (see resizeObserverHasDeliveredRef). - const previousMaxScrollOffset = maxScrollOffsetRef.current; - const cacheWasAuthoritative = resizeObserverHasDeliveredRef.current; - const maxScrollOffset = refreshMaxScrollOffset(scrollArea); - resizeObserverHasDeliveredRef.current = true; - shrankOntoBottomWhileDetached = - cacheWasAuthoritative && - !shouldStickToBottomRef.current && - maxScrollOffset < previousMaxScrollOffset && - isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop); - } - // While a restore is pending, the ResizeObserver is the settle signal; the - // bottom-restore is suppressed (stick-to-bottom is false) anyway. - if (advancePendingScrollRestore()) return; - if (shrankOntoBottomWhileDetached && scrollArea) { - // The detached mirror of the attach->detach edge in - // syncBottomStateFromScroll: a content shrink (collapsing a long tool - // output near the end) clamped a detached viewport onto the new, - // smaller maximum. The browser delivered that clamp's scroll event - // before this refresh, so the scroll handler classified it against the - // stale, larger cache and left the viewport detached. A live read used - // to re-attach on that very scroll event; do the same here, against - // fresh geometry, so streaming content keeps following the bottom. - attachToBottom(); - writeScrollAnchor(scrollArea); - } - queueBottomRestore(); - }, [ - advancePendingScrollRestore, - attachToBottom, - queueBottomRestore, - refreshMaxScrollOffset, - writeScrollAnchor, - ]); + const handleScrollAreaResize = useCallback( + (entries: ResizeObserverEntry[]) => { + const scrollArea = scrollAreaRef.current; + let shrankOntoBottomWhileDetached = false; + if (scrollArea) { + // The steady-state cache refresh: the observer watches both the scroll + // port and the content wrapper, so every legitimate + // scrollHeight/clientHeight change passes through here. The first + // delivery is also what makes the cache authoritative for hot-path + // reads (see resizeObserverHasDeliveredRef). + const previousMaxScrollOffset = maxScrollOffsetRef.current; + const cacheWasAuthoritative = resizeObserverHasDeliveredRef.current; + // Prefer the observer's own box sizes over a live + // scrollHeight/clientHeight read, which forces layout: the scroll + // port's content box is its client height (no padding, no horizontal + // scrollbar) and the content wrapper's border box is the scroll + // height, so the pair the observer just measured is the fresh + // geometry. Environments whose observer delivers no entries (test + // stubs) keep the live read. + const observedGeometry = observedScrollGeometryRef.current; + for (const entry of entries) { + if (entry.target === scrollArea) { + observedGeometry.scrollAreaClientHeight = + entry.contentBoxSize[0]?.blockSize ?? entry.contentRect.height; + } else if (entry.target === scrollContentRef.current) { + observedGeometry.scrollContentHeight = + entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height; + } + } + let maxScrollOffset: number; + if ( + observedGeometry.scrollAreaClientHeight !== null && + observedGeometry.scrollContentHeight !== null + ) { + maxScrollOffset = Math.max( + 0, + Math.round(observedGeometry.scrollContentHeight) - + Math.round(observedGeometry.scrollAreaClientHeight), + ); + maxScrollOffsetRef.current = maxScrollOffset; + } else { + maxScrollOffset = refreshMaxScrollOffset(scrollArea); + } + resizeObserverHasDeliveredRef.current = true; + shrankOntoBottomWhileDetached = + cacheWasAuthoritative && + !shouldStickToBottomRef.current && + maxScrollOffset < previousMaxScrollOffset && + isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop); + } + // While a restore is pending, the ResizeObserver is the settle signal; the + // bottom-restore is suppressed (stick-to-bottom is false) anyway. + if (advancePendingScrollRestore()) return; + if (shrankOntoBottomWhileDetached && scrollArea) { + // The detached mirror of the attach->detach edge in + // syncBottomStateFromScroll: a content shrink (collapsing a long tool + // output near the end) clamped a detached viewport onto the new, + // smaller maximum. The browser delivered that clamp's scroll event + // before this refresh, so the scroll handler classified it against the + // stale, larger cache and left the viewport detached. A live read used + // to re-attach on that very scroll event; do the same here, against + // fresh geometry, so streaming content keeps following the bottom. + attachToBottom(); + writeScrollAnchor(scrollArea); + } + queueBottomRestore(); + }, + [ + advancePendingScrollRestore, + attachToBottom, + queueBottomRestore, + refreshMaxScrollOffset, + writeScrollAnchor, + ], + ); // Begin restoring the saved scroll position on mount, before the listener // effect's `queueBottomRestore()` runs (a useEffect, which runs after layout diff --git a/apps/app/src/components/ui/height-transition.test.tsx b/apps/app/src/components/ui/height-transition.test.tsx index c90caa4a73..9fdbfd8c51 100644 --- a/apps/app/src/components/ui/height-transition.test.tsx +++ b/apps/app/src/components/ui/height-transition.test.tsx @@ -74,7 +74,48 @@ describe("HeightTransition", () => { }); }); +function makeResizeEntry( + target: Element, + borderBoxBlockSize: number, + contentRectHeight: number, +): ResizeObserverEntry { + return { + target, + contentRect: new DOMRect(0, 0, 200, contentRectHeight), + borderBoxSize: [{ blockSize: borderBoxBlockSize, inlineSize: 200 }], + contentBoxSize: [{ blockSize: contentRectHeight, inlineSize: 200 }], + devicePixelContentBoxSize: [ + { blockSize: borderBoxBlockSize, inlineSize: 200 }, + ], + }; +} + describe("AutoHeightContainer", () => { + it("sizes the wrapper from the observed border box", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + + const view = render( + + Streaming response + , + ); + const inner = view.getByText("Streaming response").parentElement; + const wrapper = inner?.parentElement; + const observer = ResizeObserverStub.instances[0]; + if (!inner || !wrapper || !observer) { + throw new Error("AutoHeightContainer did not render"); + } + + // A padded inner: the border box (offsetHeight's metric, used by the + // mount and snap paths) is taller than the content rect. Sizing the + // wrapper from the content rect would clip it. + act(() => { + observer.callback([makeResizeEntry(inner, 120, 112)], observer); + }); + + expect(wrapper.style.height).toBe("120px"); + }); + it("snap-syncs an authoritative layout revision", () => { vi.stubGlobal("ResizeObserver", ResizeObserverStub); diff --git a/apps/app/src/components/ui/height-transition.tsx b/apps/app/src/components/ui/height-transition.tsx index c01c03d720..81aede0797 100644 --- a/apps/app/src/components/ui/height-transition.tsx +++ b/apps/app/src/components/ui/height-transition.tsx @@ -141,6 +141,14 @@ function cancelIntrinsicHeightRestore( resizeState.restoreTimerId = null; } +// The observer already measured the inner this frame, so reading the entry +// costs nothing, and the border box is the same metric as the offsetHeight +// used by the non-observer paths (initial mount, visibility snap) — the two +// must agree or a padded inner would get clipped by a content-box height. +function getObservedInnerHeight(entry: ResizeObserverEntry): number { + return entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height; +} + interface HeightTransitionProps { visible: boolean; children: ReactNode; @@ -171,7 +179,7 @@ export function HeightTransition({ visible, children }: HeightTransitionProps) { const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; - const { width, height } = entry.contentRect; + const { width } = entry.contentRect; const widthChanged = lastWidth !== null && width !== lastWidth; // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) // is in flight, the inner is itself animating its size every frame. @@ -179,10 +187,11 @@ export function HeightTransition({ visible, children }: HeightTransitionProps) { // scrollHeight, which the bottom-anchor sentinel then chases. const layoutAnimationActive = store.get(layoutAnimationInFlightCountAtom) > 0; - const snap = widthChanged || pendingVisibilitySnap || layoutAnimationActive; + const snap = + widthChanged || pendingVisibilitySnap || layoutAnimationActive; pendingVisibilitySnap = false; lastWidth = width; - const nextHeight = visible ? `${height}px` : "0px"; + const nextHeight = visible ? `${getObservedInnerHeight(entry)}px` : "0px"; applyHeight(wrapper, nextHeight, snap, snapState); }); observer.observe(inner); @@ -334,7 +343,7 @@ export function AutoHeightContainer({ const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; - const { width, height } = entry.contentRect; + const { width } = entry.contentRect; const widthChanged = lastWidth !== null && width !== lastWidth; // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) // is in flight, the inner is itself animating its size every frame. @@ -360,7 +369,12 @@ export function AutoHeightContainer({ deferInitialSettleComplete(); return; } - applyHeight(wrapper, `${height}px`, snap, snapState); + applyHeight( + wrapper, + `${getObservedInnerHeight(entry)}px`, + snap, + snapState, + ); deferInitialSettleComplete(); }); observer.observe(inner); From aa7334002c162f32ffdbde37835b09f17e0a99fe Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 14:00:24 +0200 Subject: [PATCH 03/34] Trim per-scroll-event work on coarse pointers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scroll event wrote data-scrollbar-scrolling (matched only by desktop ::-webkit-scrollbar rules) — a pure style invalidation on touch — and each throttled scroll-anchor sample re-ran querySelectorAll over the scroll subtree at 10 Hz while the browser was busy scrolling. - Skip the transient-scrollbar attribute when (pointer: coarse) matches. - Cache the scroll-anchor row NodeList in a ref; the existing ResizeObserver invalidates it, and an end-connectivity check covers windowed row swaps that keep the content size constant. - Raise the scroll-anchor capture throttle to 250ms on coarse pointers — restore-on-return needs the resting position (always carried by the trailing write), not mid-flick samples. Co-Authored-By: Claude Fable 5 (cherry picked from commit 22f41aba2ca1e760810847584078e150cfdbb0fb) --- ...chored-scroll-body.coarse-pointer.test.tsx | 256 ++++++++++++++++++ .../ui/bottom-anchored-scroll-body.tsx | 61 ++++- 2 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx new file mode 100644 index 0000000000..0863cf478e --- /dev/null +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { getDefaultStore } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BottomAnchoredScrollBody } from "@/components/ui/bottom-anchored-scroll-body"; +import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; + +// Per-scroll-event costs that differ by pointer type: the transient-scrollbar +// attribute (desktop-scrollbar-only CSS) is skipped on coarse pointers, the +// scroll-anchor capture throttle relaxes to the coarse cadence, and captures +// reuse a cached row NodeList that the ResizeObserver invalidates. + +interface ScrollMetrics { + scrollHeight: number; + clientHeight: number; + scrollTop: number; +} + +interface RowRect { + top: number; + bottom: number; +} + +const SCROLL_AREA_CLASS = "scroll-area"; + +class ResizeObserverMock implements ResizeObserver { + static instances: ResizeObserverMock[] = []; + readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + ResizeObserverMock.instances.push(this); + } + observe() {} + unobserve() {} + disconnect() {} + trigger() { + this.callback([], this); + } +} + +function getLatestResizeObserver(): ResizeObserverMock { + const instance = ResizeObserverMock.instances.at(-1); + if (!instance) throw new Error("Expected a ResizeObserver instance."); + return instance; +} + +function stubMediaQueries(matching: ReadonlySet): void { + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: matching.has(query), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); +} + +function setScrollMetrics(element: HTMLElement, metrics: ScrollMetrics) { + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: metrics.scrollHeight, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: metrics.clientHeight, + }); + element.scrollTop = metrics.scrollTop; +} + +function mockScrollAreaRect(scrollArea: HTMLElement) { + vi.spyOn(scrollArea, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 100, 100), + ); +} + +function mockRowRect(row: HTMLElement, rect: RowRect) { + vi.spyOn(row, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, rect.top, 100, rect.bottom - rect.top), + ); +} + +function requireHTMLElement(element: Element | null) { + if (!(element instanceof HTMLElement)) { + throw new Error("Expected HTMLElement."); + } + return element; +} + +function renderTimeline(threadId: string, rowIds: string[]) { + const view = render( + Footer} + maxWidthClassName="max-w-none" + scrollAreaClassName={SCROLL_AREA_CLASS} + scrollAnchorThreadId={threadId} + > + {rowIds.map((rowId) => ( +
+ {rowId} +
+ ))} +
, + ); + const scrollArea = requireHTMLElement( + view.container.querySelector(`.${SCROLL_AREA_CLASS}`), + ); + const rowElements = new Map(); + for (const rowId of rowIds) { + rowElements.set( + rowId, + requireHTMLElement( + view.container.querySelector(`[data-timeline-row-id="${rowId}"]`), + ), + ); + } + return { scrollArea, rowElements }; +} + +function readAnchor(threadId: string) { + return getDefaultStore().get(threadTimelineScrollAnchorAtomFamily(threadId)); +} + +beforeEach(() => { + ResizeObserverMock.instances = []; + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 1), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + const store = getDefaultStore(); + for (const threadId of ["coarse-thread", "cache-thread"]) { + store.set(threadTimelineScrollAnchorAtomFamily(threadId), null); + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("BottomAnchoredScrollBody on coarse pointers", () => { + it("never writes the transient scrollbar attribute", () => { + stubMediaQueries(new Set(["(pointer: coarse)"])); + const { scrollArea } = renderTimeline("coarse-thread", ["row-a"]); + + fireEvent.scroll(scrollArea); + + // The attribute only feeds desktop ::-webkit-scrollbar rules; on touch it + // would be a per-scroll-event style invalidation with no visible effect. + expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); + }); + + it("captures scroll anchors at the relaxed coarse cadence", () => { + stubMediaQueries(new Set(["(pointer: coarse)"])); + // Fake performance.now so the throttle windows below are deterministic. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + const { scrollArea } = renderTimeline("coarse-thread", ["row-a"]); + + // Settle the first capture (immediate or trailing, depending on where the + // faked clock started) so the throttle's lastWriteAt equals now. + fireEvent.scroll(scrollArea); + vi.runOnlyPendingTimers(); + expect(readAnchor("coarse-thread")).not.toBeNull(); + getDefaultStore().set( + threadTimelineScrollAnchorAtomFamily("coarse-thread"), + null, + ); + + // A capture 50ms into the window arms a trailing write for the remainder + // of the coarse throttle: 250 - 50 = 200ms out. + vi.advanceTimersByTime(50); + fireEvent.scroll(scrollArea); + expect(readAnchor("coarse-thread")).toBeNull(); + + // The fine-pointer cadence (100ms window → 50ms remainder) must not fire + // on a coarse pointer... + vi.advanceTimersByTime(199); + expect(readAnchor("coarse-thread")).toBeNull(); + + // ...but the trailing write still records the resting position at 250ms. + vi.advanceTimersByTime(1); + expect(readAnchor("coarse-thread")).toEqual({ + rowId: "", + offsetWithinRow: 0, + atBottom: true, + }); + }); +}); + +describe("BottomAnchoredScrollBody row NodeList cache", () => { + it("reuses the cached rows across captures until a resize invalidates them", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + const { scrollArea, rowElements } = renderTimeline("cache-thread", [ + "row-a", + "row-b", + "row-c", + ]); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-c")!), { + top: 80, + bottom: 180, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 300, + }); + const queryRows = vi.spyOn(scrollArea, "querySelectorAll"); + + // Move the clock past the throttle window so every capture below writes + // immediately instead of arming a trailing timeout. + vi.advanceTimersByTime(1_000); + scrollArea.scrollTop = 150; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + expect(readAnchor("cache-thread")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + expect(queryRows).toHaveBeenCalledTimes(1); + + // A second capture in the same layout reuses the cached NodeList. + vi.advanceTimersByTime(200); + scrollArea.scrollTop = 140; + fireEvent.scroll(scrollArea); + expect(queryRows).toHaveBeenCalledTimes(1); + + // A ResizeObserver delivery means rows may have mounted or unmounted; + // the next capture queries fresh. + getLatestResizeObserver().trigger(); + vi.advanceTimersByTime(200); + scrollArea.scrollTop = 130; + fireEvent.scroll(scrollArea); + expect(queryRows).toHaveBeenCalledTimes(2); + expect(readAnchor("cache-thread")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + }); +}); diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 35e7b7331b..1e010f375b 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -11,6 +11,7 @@ import { import type { ReactNode } from "react"; import { useStore } from "jotai"; import { cn } from "@bb/shared-ui/lib/utils"; +import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { PAGE_SHELL_CONTENT_STYLE } from "./page-shell-content-style.js"; import { supportsScrollAnchoring } from "@/lib/scroll-anchoring-support"; import { @@ -91,6 +92,10 @@ const BOTTOM_RESTORE_SETTLE_FRAME_COUNT = 3; // Throttle continuous scroll-anchor capture so a fast scroll writes the atom at // most this often, plus a trailing write for the final resting position. const SCROLL_ANCHOR_CAPTURE_THROTTLE_MS = 100; +// Coarse pointers only need restore-on-return fidelity — the resting position, +// always carried by the trailing write — not 10 Hz mid-flick samples, each of +// which costs rect reads while the browser is already busy scrolling. +const COARSE_SCROLL_ANCHOR_CAPTURE_THROTTLE_MS = 250; // While a saved anchor's row hasn't hydrated yet, the ResizeObserver re-applies // the restore as content settles. Give up (fall back to bottom) after this many // observed re-applies so a deleted/never-arriving row can't hang at the top. @@ -189,9 +194,9 @@ function getScrollAnchorRows(scrollArea: HTMLElement): NodeListOf { // reproduce a mid-row reading position. function getTopMostVisibleRow( scrollArea: HTMLElement, + rows: NodeListOf, ): TopMostVisibleRow | null { const scrollAreaTop = scrollArea.getBoundingClientRect().top; - const rows = getScrollAnchorRows(scrollArea); let low = 0; let high = rows.length - 1; let visibleRow: HTMLElement | null = null; @@ -277,6 +282,7 @@ export function BottomAnchoredScrollBody({ scrollAnchorThreadId, }: BottomAnchoredScrollBodyProps) { const store = useStore(); + const isPointerCoarse = usePointerCoarse(); const scrollAreaRef = useRef(null); const scrollContentRef = useRef(null); const shouldStickToBottomRef = useRef(true); @@ -334,6 +340,12 @@ export function BottomAnchoredScrollBody({ scrollAreaClientHeight: number | null; scrollContentHeight: number | null; }>({ scrollAreaClientHeight: null, scrollContentHeight: null }); + // The row NodeList behind scroll-anchor capture, so throttled samples don't + // repeat a querySelectorAll over the scroll subtree. Row-set changes surface + // as content size changes, so the ResizeObserver invalidates it; the + // connectivity check in getScrollAnchorRowsCached guards the windowed + // timeline, where a row swap at a window edge can keep the size constant. + const scrollAnchorRowsRef = useRef | null>(null); const [isAtBottom, setIsAtBottom] = useState(true); const initialScrollRestoreRowId = useMemo(() => { if (scrollAnchorThreadId === undefined) return null; @@ -555,6 +567,21 @@ export function BottomAnchoredScrollBody({ ); }, []); + const getScrollAnchorRowsCached = useCallback((scrollArea: HTMLElement) => { + const cached = scrollAnchorRowsRef.current; + if ( + cached && + (cached.length === 0 || + (cached[0]?.isConnected === true && + cached[cached.length - 1]?.isConnected === true)) + ) { + return cached; + } + const rows = getScrollAnchorRows(scrollArea); + scrollAnchorRowsRef.current = rows; + return rows; + }, []); + // Persist the current scroll position (top-most visible row + within-row // offset + atBottom) into the per-thread atom so returning to this thread // restores it. Continuous capture keeps the atom current while mounted; cleanup @@ -604,7 +631,10 @@ export function BottomAnchoredScrollBody({ }); return; } - const topMostRow = getTopMostVisibleRow(scrollArea); + const topMostRow = getTopMostVisibleRow( + scrollArea, + getScrollAnchorRowsCached(scrollArea), + ); // No rows yet: don't clobber a good anchor with an empty one. if (!topMostRow) return; store.set(anchorAtom, { @@ -614,6 +644,7 @@ export function BottomAnchoredScrollBody({ }); }, [ + getScrollAnchorRowsCached, hasRecentUserScrollIntent, readMaxScrollOffset, refreshMaxScrollOffset, @@ -622,12 +653,16 @@ export function BottomAnchoredScrollBody({ ], ); + const scrollAnchorCaptureThrottleMs = isPointerCoarse + ? COARSE_SCROLL_ANCHOR_CAPTURE_THROTTLE_MS + : SCROLL_ANCHOR_CAPTURE_THROTTLE_MS; + const captureScrollAnchorThrottled = useCallback(() => { if (scrollAnchorThreadId === undefined) return; const throttle = scrollAnchorCaptureThrottleRef.current; const now = window.performance.now(); const elapsed = now - throttle.lastWriteAt; - if (elapsed >= SCROLL_ANCHOR_CAPTURE_THROTTLE_MS) { + if (elapsed >= scrollAnchorCaptureThrottleMs) { throttle.lastWriteAt = now; writeScrollAnchor(); return; @@ -639,8 +674,8 @@ export function BottomAnchoredScrollBody({ throttle.trailingTimeout = null; throttle.lastWriteAt = window.performance.now(); writeScrollAnchor(); - }, SCROLL_ANCHOR_CAPTURE_THROTTLE_MS - elapsed); - }, [scrollAnchorThreadId, writeScrollAnchor]); + }, scrollAnchorCaptureThrottleMs - elapsed); + }, [scrollAnchorCaptureThrottleMs, scrollAnchorThreadId, writeScrollAnchor]); // Bring the saved anchor row into view (plus its within-row offset). Returns // the resulting scrollTop when the row was found, or null when it isn't yet @@ -867,6 +902,8 @@ export function BottomAnchoredScrollBody({ const handleScrollAreaResize = useCallback( (entries: ResizeObserverEntry[]) => { const scrollArea = scrollAreaRef.current; + // Any observed size change may have mounted or unmounted timeline rows. + scrollAnchorRowsRef.current = null; let shrankOntoBottomWhileDetached = false; if (scrollArea) { // The steady-state cache refresh: the observer watches both the scroll @@ -1022,6 +1059,12 @@ export function BottomAnchoredScrollBody({ }, SCROLLBAR_IDLE_DELAY_MS); handleScroll(); }; + // The transient-scrollbar attribute only feeds the desktop-only + // ::-webkit-scrollbar rules; on coarse pointers (overlay scrollbars) the + // write would be a pure per-scroll-event style invalidation. + const handleScrollEvent = isPointerCoarse + ? handleScroll + : handleScrollWithTransientScrollbar; let resizeObserver: ResizeObserver | undefined; if (typeof ResizeObserver !== "undefined") { @@ -1030,7 +1073,7 @@ export function BottomAnchoredScrollBody({ resizeObserver.observe(scrollContent); } - scrollArea.addEventListener("scroll", handleScrollWithTransientScrollbar, { + scrollArea.addEventListener("scroll", handleScrollEvent, { passive: true, }); scrollArea.addEventListener("wheel", markWheelScrollIntent, { @@ -1056,10 +1099,7 @@ export function BottomAnchoredScrollBody({ return () => { resizeObserver?.disconnect(); - scrollArea.removeEventListener( - "scroll", - handleScrollWithTransientScrollbar, - ); + scrollArea.removeEventListener("scroll", handleScrollEvent); scrollArea.removeEventListener("wheel", markWheelScrollIntent); scrollArea.removeEventListener("touchstart", markTouchStartScrollIntent); scrollArea.removeEventListener("touchmove", markTouchMoveScrollIntent); @@ -1078,6 +1118,7 @@ export function BottomAnchoredScrollBody({ endPointerScrollIntent, handleScroll, handleScrollAreaResize, + isPointerCoarse, markKeyboardScrollIntent, markTouchMoveScrollIntent, markTouchStartScrollIntent, From 33aebda7ecd7e7f0aa25383218a76df966a390d5 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 14:00:24 +0200 Subject: [PATCH 04/34] Clarify why focus-driven viewport passes remeasure the containing block The previous comment credited Android's resizes-content path, but the focusin listener only exists on iOS WebKit. The real reason: the pass that sizes the shell for the arriving keyboard must start from the real containing block, and focus changes are rare enough to afford the read. Co-Authored-By: Claude Fable 5 (cherry picked from commit d1d20fe6f799048fa874ec96e1fda0e6252765d6) --- .../src/components/layout/useMobileVisualViewportHeight.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts index 7ebb4de039..a987dee6bb 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.ts +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.ts @@ -135,9 +135,10 @@ export function useMobileVisualViewportHeight( } animationFrame = window.requestAnimationFrame(updateHeight); }; - // For triggers that can resize the layout viewport itself: window resize, - // rotation, and an editor gaining focus (the keyboard that follows may - // resize the layout viewport on Android's resizes-content path). + // For triggers where the layout viewport may have changed: window resize, + // rotation, and an editor gaining focus — the pass that sizes the shell + // for the arriving keyboard must start from the real containing block, + // and these triggers are rare enough that the forced layout is fine. const scheduleContainingBlockUpdate = () => { shellContainingBlockHeightStale = true; scheduleUpdate(); From ae946e8104ffc28089920c0084c8f92fc3c90dab Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 09:15:36 +0200 Subject: [PATCH 05/34] Navigate app routes at transition priority with a pending signal Every route tap ran navigateRef.current() bare inside the click's discrete event. Wrap it in useTransition's startTransition inside RouteNavigationProvider so the tap's urgent commit paints first, and expose isPending through a separate RouteNavigationPendingContext (the navigate context identity stays stable, so navigate consumers still never re-render per navigation). Replace the raw react-router in RootComposeMobileRecents with RouteAnchor so the mobile recents rows take the same path. New test proves ordering: the tap's commit shows pending with the old route still mounted, and the destination lands in a later transition commit. Co-Authored-By: Claude Fable 5 (cherry picked from commit 2ca2f1342c187024780fadb8ce3bb59215726211) --- .../components/ui/app-route-anchor.test.tsx | 65 +++++++++++++++++++ .../src/components/ui/app-route-anchor.tsx | 47 +++++++++++--- .../src/views/RootComposeMobileRecents.tsx | 11 ++-- 3 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/components/ui/app-route-anchor.test.tsx diff --git a/apps/app/src/components/ui/app-route-anchor.test.tsx b/apps/app/src/components/ui/app-route-anchor.test.tsx new file mode 100644 index 0000000000..f346393344 --- /dev/null +++ b/apps/app/src/components/ui/app-route-anchor.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import { + RouteAnchor, + RouteNavigationProvider, + useIsRouteNavigationPending, +} from "./app-route-anchor"; + +afterEach(() => { + cleanup(); +}); + +interface NavigationSample { + isPending: boolean; + pathname: string; +} + +const samples: NavigationSample[] = []; + +/** + * Records every committed (isPending, pathname) pair. The pairs prove commit + * ordering: a `{ isPending: true, pathname: }` sample means the pending + * flag painted while the previous route was still on screen, i.e. the tap's + * event did not flush the destination route synchronously. + */ +function NavigationSampler() { + const isPending = useIsRouteNavigationPending(); + const { pathname } = useLocation(); + samples.push({ isPending, pathname }); + return null; +} + +describe("RouteAnchor transition navigation", () => { + it("swaps the route in a later commit than the tap and signals pending in between", () => { + samples.length = 0; + render( + + + + open thr-new + + , + ); + expect(samples).toEqual([ + { isPending: false, pathname: "/threads/thr-old" }, + ]); + + fireEvent.click(screen.getByRole("link", { name: "open thr-new" })); + + // The tap's urgent commit shows the pending affordance with the old route + // still mounted; the destination route lands in a follow-up transition + // commit, which also clears the pending flag. + expect(samples).toContainEqual({ + isPending: true, + pathname: "/threads/thr-old", + }); + expect(samples.at(-1)).toEqual({ + isPending: false, + pathname: "/threads/thr-new", + }); + }); +}); diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx index 341a02db2f..72658a943d 100644 --- a/apps/app/src/components/ui/app-route-anchor.tsx +++ b/apps/app/src/components/ui/app-route-anchor.tsx @@ -6,6 +6,7 @@ import { useLayoutEffect, useMemo, useRef, + useTransition, type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, type ReactNode, @@ -36,6 +37,23 @@ type RouteNavigate = (path: string, options?: RouteNavigateOptions) => void; const RouteNavigationContext = createContext(null); +// Separate from RouteNavigationContext on purpose: the pending bit flips on +// every navigation, and folding it into the navigate context would re-render +// every navigate consumer (sidebar rows, thread actions) per navigation — +// the exact churn RouteNavigationContext exists to avoid. +const RouteNavigationPendingContext = createContext(false); + +/** + * True while a navigation started through {@link useRouteNavigate} or + * {@link RouteAnchor} is still rendering the destination route. Navigation + * runs at transition priority, so the previous route stays on screen for a + * beat; surfaces read this to show a lightweight pending affordance (e.g. + * keeping the tapped row's active state) instead of appearing unresponsive. + */ +export function useIsRouteNavigationPending(): boolean { + return useContext(RouteNavigationPendingContext); +} + /** * A `navigate` whose identity never changes and whose caller does not * subscribe to the router's location. @@ -94,13 +112,24 @@ export function RouteNavigationProvider({ useLayoutEffect(() => { navigateRef.current = navigate; }, [navigate]); - const navigateRoute = useCallback((path, options) => { - if (options === undefined) { - navigateRef.current(path); - return; - } - navigateRef.current(path, options); - }, []); + // Navigate at transition priority: a tap's urgent commit (active states, + // isNavigationPending) paints first, and the destination route renders in an + // interruptible follow-up commit instead of blocking the tap's frame. + // `startNavigationTransition` has a stable identity, so `navigateRoute` + // keeps the never-changing identity its consumers depend on. + const [isNavigationPending, startNavigationTransition] = useTransition(); + const navigateRoute = useCallback( + (path, options) => { + startNavigationTransition(() => { + if (options === undefined) { + navigateRef.current(path); + return; + } + navigateRef.current(path, options); + }); + }, + [startNavigationTransition], + ); useEffect(() => { const browserApi = getDesktopBrowserApi(); if (browserApi === null) { @@ -116,7 +145,9 @@ export function RouteNavigationProvider({ return ( - {children} + + {children} + ); } diff --git a/apps/app/src/views/RootComposeMobileRecents.tsx b/apps/app/src/views/RootComposeMobileRecents.tsx index 1ad8475780..881c9c53f9 100644 --- a/apps/app/src/views/RootComposeMobileRecents.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { Link } from "react-router-dom"; import type { ThreadListEntry } from "@bb/domain"; +import { RouteAnchor } from "@/components/ui/app-route-anchor"; import { ThreadStatusGlyph } from "@/components/sidebar/ThreadRow"; import { SIDEBAR_WORKING_STATUS_COLOR_CLASS } from "@/components/sidebar/sidebarRowClasses"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; @@ -117,8 +117,11 @@ function MobileRecentThreadRow({ ); return (
  • - - +
  • ); } From 345dfb2e2a87ef7802325fd67cf6fba640f51c1e Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:45:02 +0200 Subject: [PATCH 06/34] Defer expander bodies out of the toggle's click commit Expanding a collapsed ExpandablePanel materialized the whole body subtree inside the click's discrete commit (button.inline-flex stalls in the hang ledger). Drive the expandedBody memo from useDeferredValue(isExpanded) so the caret/header flip paints in the tap's first frame and the body mounts in a follow-up interruptible commit. Header state, the closing-body ref retention, and the layout-animation signal stay on the urgent value; rows that mount already expanded still render their body immediately (useDeferredValue returns the live value on first render). New test fails before this change: with flushSync standing in for the tap's urgent flush, the body used to be mounted in that same commit. Co-Authored-By: Claude Fable 5 (cherry picked from commit 61b765bbed47b322e1672794ca07684d82cc80be) --- .../app/src/components/ui/disclosure.test.tsx | 66 ++++++++++++++++++- apps/app/src/components/ui/disclosure.tsx | 13 +++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/ui/disclosure.test.tsx b/apps/app/src/components/ui/disclosure.test.tsx index 64df40de0d..69844dd0fb 100644 --- a/apps/app/src/components/ui/disclosure.test.tsx +++ b/apps/app/src/components/ui/disclosure.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom -import { act, cleanup, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { flushSync } from "react-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ExpandablePanel } from "./disclosure"; @@ -21,6 +23,7 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + vi.useRealTimers(); }); function renderPanel(isExpanded: boolean) { @@ -85,3 +88,64 @@ describe("ExpandablePanel body height", () => { expect(region.style.transitionDuration).toBe("0s"); }); }); + +/** A toggleable panel driven by its own header, like a timeline tool row. */ +function TogglablePanel() { + const [isExpanded, setIsExpanded] = useState(false); + return ( + setIsExpanded((expanded) => !expanded)} + > + Expanded body + + ); +} + +describe("ExpandablePanel deferred body realization", () => { + it("flips the caret in the tap's commit and mounts the body in a deferred one", () => { + render(); + const header = screen.getByRole("button", { name: "Tool call" }); + + let bodyMountedInToggleCommit: boolean | null = null; + let headerExpandedInToggleCommit: string | null = null; + act(() => { + // flushSync stands in for the tap's discrete event: it flushes only the + // urgent lane, so the deferred body re-render is still pending when the + // samples are taken and lands when act exits. + flushSync(() => { + header.click(); + }); + bodyMountedInToggleCommit = screen.queryByText("Expanded body") !== null; + headerExpandedInToggleCommit = header.getAttribute("aria-expanded"); + }); + + // The tap's synchronous commit flips the caret without paying for the + // body subtree; the body lands in the follow-up interruptible commit. + expect(headerExpandedInToggleCommit).toBe("true"); + expect(bodyMountedInToggleCommit).toBe(false); + expect(screen.getByText("Expanded body")).toBeTruthy(); + }); + + it("keeps the closing body mounted through the collapse animation", () => { + vi.useFakeTimers(); + render(); + const header = screen.getByRole("button", { name: "Tool call" }); + fireEvent.click(header); + expect(screen.getByText("Expanded body")).toBeTruthy(); + + fireEvent.click(header); + + // The collapse animates from the still-rendered subtree: the body must + // stay mounted for the 200ms transition, then unmount. + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(screen.getByText("Expanded body")).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByText("Expanded body")).toBeNull(); + }); +}); diff --git a/apps/app/src/components/ui/disclosure.tsx b/apps/app/src/components/ui/disclosure.tsx index e052238e02..1de25ef77a 100644 --- a/apps/app/src/components/ui/disclosure.tsx +++ b/apps/app/src/components/ui/disclosure.tsx @@ -1,5 +1,6 @@ import { useSetAtom } from "jotai"; import { + useDeferredValue, useEffect, useLayoutEffect, useMemo, @@ -223,12 +224,20 @@ export function ExpandablePanel({ const headerRootClassName = cn("px-2 py-1", headerClassName); const [isClosing, setIsClosing] = useState(false); const renderedBodyRef = useRef(null); + // The header/chevron flip stays urgent (it reads `isExpanded` directly), + // but the body realizes off the deferred value: an expand tap's discrete + // commit paints the caret in the first frame, and the body subtree — the + // expensive part of a large tool section — mounts in a follow-up + // interruptible commit. On first render the deferred value equals + // `isExpanded`, so rows that mount already expanded render their body + // immediately. + const deferredIsExpanded = useDeferredValue(isExpanded); const expandedBody = useMemo(() => { - if (!isExpanded) { + if (!deferredIsExpanded) { return null; } return renderBody ? renderBody() : children; - }, [children, isExpanded, renderBody]); + }, [children, deferredIsExpanded, renderBody]); // Signal to AutoHeightContainer / HeightTransition wrappers that a // CSS-driven layout animation is in flight, so they snap their wrapper to From 42c0a8576367d1f84d2017cb469396687dd7c49d Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:47:16 +0200 Subject: [PATCH 07/34] Realize the mobile sidebar at transition priority When boot's idle pre-realization has not run yet, the sidebar trigger's click flushed the whole ProjectList/ThreadRow subtree synchronously before the slide's first frame could composite. Wrap realizeMobileSidebar() in React.startTransition so the tap's flush only writes the inline drag styles (the slide starts immediately) and the subtree mounts interruptibly during the settle window. The drag-style write order is unchanged, and the settle commit's render-phase latch still realizes the subtree synchronously if it somehow lands first. The other flushSync sites in this file run after the settle window (deferred open/close commits and the swipe settle paths), not in the tap's critical path, and are deliberately untouched. New test fails before this change: with flushSync standing in for the tap's urgent flush, the subtree used to be realized in that same flush. Co-Authored-By: Claude Fable 5 (cherry picked from commit 7b22677671829cf0002a4698474e4935eb98c5ad) --- apps/app/src/components/ui/sidebar.test.tsx | 32 +++++++++++++++++++++ apps/app/src/components/ui/sidebar.tsx | 11 +++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/ui/sidebar.test.tsx b/apps/app/src/components/ui/sidebar.test.tsx index 86f26aff67..0c2932f1d5 100644 --- a/apps/app/src/components/ui/sidebar.test.tsx +++ b/apps/app/src/components/ui/sidebar.test.tsx @@ -8,6 +8,7 @@ import { screen, } from "@testing-library/react"; import { memo } from "react"; +import { flushSync } from "react-dom"; import { renderToString } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; @@ -331,6 +332,37 @@ describe("mobile sidebar deferred realization", () => { expect(getMobilePanel()?.textContent).toContain("Sidebar content"); }); + it("keeps the realize commit out of the open tap's synchronous flush", () => { + vi.useFakeTimers(); + renderCompactSidebarHarness(); + const trigger = screen.getByRole("button", { name: "Toggle Sidebar" }); + + let panelStyledForSlideInTap = false; + let realizedInTapFlush = true; + act(() => { + // flushSync stands in for the tap's discrete event: it flushes only the + // urgent lane, so the transition-priority realize commit is still + // pending when the samples are taken and lands when act exits. + flushSync(() => { + trigger.click(); + }); + const panel = getMobilePanel(); + panelStyledForSlideInTap = panel?.style.translate === "0%"; + realizedInTapFlush = + panel?.textContent?.includes("Sidebar content") ?? false; + }); + + // The tap's own flush only starts the slide (inline drag styles); the + // subtree mounts in the interruptible commit that follows, so the first + // frame of the slide never waits on the realize commit. + expect(panelStyledForSlideInTap).toBe(true); + expect(realizedInTapFlush).toBe(false); + expect(getMobilePanel()?.textContent).toContain("Sidebar content"); + + settleMobileToggle(); + expect(getMobilePanel()?.dataset.state).toBe("open"); + }); + // The width is an inherited custom property unless registered otherwise // (theme.css registers it non-inherited). Either way it must be written on // the elements that read it and never on the provider wrapper: the wrapper diff --git a/apps/app/src/components/ui/sidebar.tsx b/apps/app/src/components/ui/sidebar.tsx index fbfebd844b..f04b1c7292 100644 --- a/apps/app/src/components/ui/sidebar.tsx +++ b/apps/app/src/components/ui/sidebar.tsx @@ -636,8 +636,15 @@ const SidebarProvider = React.forwardRef< } // Mount the subtree now if boot has not realized it yet, so it commits - // during the slide instead of after the settle. - realizeMobileSidebar(); + // during the slide instead of after the settle. Transition priority: + // the realize commit (ProjectList/ThreadRow, thousands of lines on a + // cold route) must not block this tap's frame — the drag-style write + // below composites the slide first and the subtree mounts + // interruptibly during the settle window. If the settle commit beats + // the transition, the render-phase latch below realizes it there. + React.startTransition(() => { + realizeMobileSidebar(); + }); applySidebarMobileDragStyles({ progress: 1, settling: true }); mobileSettleTimeoutRef.current = window.setTimeout(() => { mobileSettleTimeoutRef.current = null; From 69d1e4e68d568ce6d40be913cc3ce9f2bfd93668 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:48:59 +0200 Subject: [PATCH 08/34] Early-exit the maximize restore loop The maximize/restore scroll-preservation loop unconditionally rewrote scrollLeft/scrollTop on every tracked element for 30 animation frames, forcing layout each frame for half a second after every toggle. Make restore() compare before writing and report whether anything needed correction, stop the rAF loop after the first frame with zero corrections, and cap the loop at 5 frames. The pre-paint initial restore() stays. New test fails before this change: the settled case saw 31 scroll writes (pre-paint + 30 frames); now it sees none, and an adversarial scroller that keeps normalizing to zero is corrected at most 6 times. Co-Authored-By: Claude Fable 5 (cherry picked from commit 28f6f596a64624dfc4ee9eb5af1c2de3331f6270) --- .../thread-detail/SplitThreadArea.test.tsx | 45 +++++++++++++++++++ .../views/thread-detail/SplitThreadArea.tsx | 28 +++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 96314a8f48..0e87c509a8 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -783,6 +783,51 @@ describe("SplitThreadArea", () => { await waitFor(() => expect(hiddenScroller.scrollTop).toBe(0)); }); + it("stops the restore loop once positions settle instead of burning 30 frames", async () => { + renderSplitArea({ + path: threadPath("thr-a"), + layout: twoPaneLayout("pane-1"), + }); + const hiddenScroller = screen.getByTestId("scroll-thr-b"); + hiddenScroller.scrollTop = 12; + fireEvent.scroll(hiddenScroller); + + let scrollTopValue = 12; + const writes: number[] = []; + Object.defineProperty(hiddenScroller, "scrollTop", { + configurable: true, + get: () => scrollTopValue, + set: (value: number) => { + writes.push(value); + scrollTopValue = value; + }, + }); + + // Maximize: the tracked element already sits at its saved offset, so the + // pre-paint restore and the first frame find nothing to correct and the + // loop must end without a single scroll write (each write would force + // layout every frame for half a second). + fireEvent.click(screen.getByTestId("maximize-thr-a")); + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(writes).toHaveLength(0); + + // Restore, with the scroller reporting 0 on every read — an adversary + // that keeps normalizing the position. The loop corrects before paint and + // on each frame, but gives up at the frame cap instead of running all 30. + Object.defineProperty(hiddenScroller, "scrollTop", { + configurable: true, + get: () => 0, + set: (value: number) => { + writes.push(value); + }, + }); + fireEvent.click(screen.getByTestId("maximize-thr-a")); + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(writes.length).toBeGreaterThan(0); + // Pre-paint restore + at most 5 frames. + expect(writes.length).toBeLessThanOrEqual(6); + }); + it("toggles the focused pane through the discoverable app command", async () => { const store = renderSplitArea({ path: threadPath("thr-b"), diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 8d0c53b721..e6e122ca8d 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -215,30 +215,44 @@ function usePreservedSplitScrollPositions(maximizedPaneId: string | null) { } previousMaximizedPaneIdRef.current = maximizedPaneId; - const restore = () => { + /** Reapplies saved positions; true when any element needed correction. */ + const restore = (): boolean => { const workspace = workspaceRef.current; + let corrected = false; for (const [element, position] of positionsRef.current) { if (workspace === null || !workspace.contains(element)) { positionsRef.current.delete(element); continue; } + if ( + element.scrollLeft === position.left && + element.scrollTop === position.top + ) { + continue; + } element.scrollLeft = position.left; element.scrollTop = position.top; + corrected = true; } + return corrected; }; // Restore before paint, then briefly across animation frames so passive // timeline effects, virtualization, and browser scroll anchoring cannot - // overwrite the saved position while pane visibility settles. + // overwrite the saved position while pane visibility settles. Each frame + // forces layout on every tracked scroller, so the loop ends after the + // first frame with nothing to correct; the frame cap bounds the + // pathological case where something keeps fighting the restore. restore(); let frame: number | null = null; - let framesRemaining = 30; + let framesRemaining = 5; const restoreUntilSettled = () => { - restore(); + const corrected = restore(); framesRemaining -= 1; - if (framesRemaining > 0) { - frame = window.requestAnimationFrame(restoreUntilSettled); - } + frame = + corrected && framesRemaining > 0 + ? window.requestAnimationFrame(restoreUntilSettled) + : null; }; frame = window.requestAnimationFrame(restoreUntilSettled); return () => { From d4b30dbc443c468aef2246a0c374d226791dc0e2 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 22:21:36 +0200 Subject: [PATCH 09/34] Phase timeline height syncs through one shared ResizeObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ExpandablePanel and HeightTransition/AutoHeightContainer installed its own ResizeObserver whose callback interleaved a layout read with a style write, so one width/height event (iOS keyboard, drawer, orientation, font swap) forced a synchronous layout pass per mounted row. The new src/lib/shared-resize-observer.ts registry runs on a single module-level observer and dispatches each batch in phases — every registration's read completes before any write — bounding a whole batch at one forced layout. Panel body sizing now comes from the entry's border box (offsetHeight's metric, no layout read), preserving the transitionDuration snap semantics, the deferred-body realization, and the borderBoxSize sizing the existing suites pin. Co-Authored-By: Claude Fable 5 (cherry picked from commit 647268178c4fc1261acbbcb2a19a006f356564fe) --- apps/app/src/components/ui/disclosure.tsx | 41 +++- .../src/components/ui/height-transition.tsx | 143 +++++++------ .../src/lib/shared-resize-observer.test.tsx | 192 ++++++++++++++++++ apps/app/src/lib/shared-resize-observer.ts | 143 +++++++++++++ 4 files changed, 453 insertions(+), 66 deletions(-) create mode 100644 apps/app/src/lib/shared-resize-observer.test.tsx create mode 100644 apps/app/src/lib/shared-resize-observer.ts diff --git a/apps/app/src/components/ui/disclosure.tsx b/apps/app/src/components/ui/disclosure.tsx index 1de25ef77a..45d4027af3 100644 --- a/apps/app/src/components/ui/disclosure.tsx +++ b/apps/app/src/components/ui/disclosure.tsx @@ -9,10 +9,20 @@ import { type ReactNode, } from "react"; import { cn } from "@bb/shared-ui/lib/utils"; +import { + observedBorderBoxBlockSize, + observeSharedResize, +} from "@/lib/shared-resize-observer"; import { layoutAnimationInFlightCountAtom } from "./layoutAnimationAtoms.js"; import { CONTROL_HOVER_TRANSITION } from "@bb/shared-ui/motion"; const EXPANDABLE_PANEL_TRANSITION_MS = 200; + +/** Read half of the panel height sync, staged for the shared write phase. */ +interface PanelHeightSync { + isToggleAnimating: boolean; + heightPx: number; +} const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -165,22 +175,37 @@ function AnimatedExpandablePanelContent({ return; } - const syncHeight = () => { - const isToggleAnimating = - performance.now() < toggleAnimationDeadlineRef.current; + const readHeightSync = ( + entry: ResizeObserverEntry | undefined, + ): PanelHeightSync => { + // The entry's border box is offsetHeight's metric without the layout + // read; the mount sync below has no entry and pays that read once. + const observedHeight = + entry === undefined ? undefined : observedBorderBoxBlockSize(entry); + return { + isToggleAnimating: + performance.now() < toggleAnimationDeadlineRef.current, + heightPx: observedHeight ?? target.offsetHeight, + }; + }; + const writeHeightSync = ({ + heightPx, + isToggleAnimating, + }: PanelHeightSync) => { region.style.transitionDuration = isToggleAnimating ? "" : "0s"; - region.style.height = `${target.offsetHeight}px`; + region.style.height = `${heightPx}px`; }; - syncHeight(); + writeHeightSync(readHeightSync(undefined)); if (typeof ResizeObserver === "undefined") { return; } - const resizeObserver = new ResizeObserver(syncHeight); - resizeObserver.observe(target); - return () => resizeObserver.disconnect(); + return observeSharedResize(target, { + read: readHeightSync, + write: writeHeightSync, + }); }, [collapsedContent, isExpanded, renderedBody]); return ( diff --git a/apps/app/src/components/ui/height-transition.tsx b/apps/app/src/components/ui/height-transition.tsx index 81aede0797..ae3702c76d 100644 --- a/apps/app/src/components/ui/height-transition.tsx +++ b/apps/app/src/components/ui/height-transition.tsx @@ -8,6 +8,10 @@ import { subscribeToDocumentVisibility, } from "@/lib/document-visibility"; import { supportsScrollAnchoring } from "@/lib/scroll-anchoring-support"; +import { + observedBorderBoxBlockSize, + observeSharedResize, +} from "@/lib/shared-resize-observer"; import { layoutAnimationInFlightCountAtom } from "./layoutAnimationAtoms.js"; // Shared animation tokens for height transitions across the timeline. @@ -145,8 +149,15 @@ function cancelIntrinsicHeightRestore( // costs nothing, and the border box is the same metric as the offsetHeight // used by the non-observer paths (initial mount, visibility snap) — the two // must agree or a padded inner would get clipped by a content-box height. -function getObservedInnerHeight(entry: ResizeObserverEntry): number { - return entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height; +// A dispatch without an entry (the shared observer's broadcast re-sync) or +// with a box-less synthetic one pays the offsetHeight read instead. +function getObservedInnerHeight( + entry: ResizeObserverEntry | undefined, + inner: HTMLElement, +): number { + const observed = + entry === undefined ? undefined : observedBorderBoxBlockSize(entry); + return observed ?? inner.offsetHeight; } interface HeightTransitionProps { @@ -176,25 +187,34 @@ export function HeightTransition({ visible, children }: HeightTransitionProps) { let lastWidth: number | null = null; let pendingVisibilitySnap = false; const snapState: SnapState = { savedDuration: null, restoreFrame: null }; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width } = entry.contentRect; - const widthChanged = lastWidth !== null && width !== lastWidth; - // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) - // is in flight, the inner is itself animating its size every frame. - // Running our own 180ms transition on top compounds the lag and drags - // scrollHeight, which the bottom-anchor sentinel then chases. - const layoutAnimationActive = - store.get(layoutAnimationInFlightCountAtom) > 0; - const snap = - widthChanged || pendingVisibilitySnap || layoutAnimationActive; - pendingVisibilitySnap = false; - lastWidth = width; - const nextHeight = visible ? `${getObservedInnerHeight(entry)}px` : "0px"; - applyHeight(wrapper, nextHeight, snap, snapState); + const unobserveInner = observeSharedResize(inner, { + read: (entry) => { + const width = entry?.contentRect?.width; + const widthChanged = + lastWidth !== null && width !== undefined && width !== lastWidth; + // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) + // is in flight, the inner is itself animating its size every frame. + // Running our own 180ms transition on top compounds the lag and drags + // scrollHeight, which the bottom-anchor sentinel then chases. + const layoutAnimationActive = + store.get(layoutAnimationInFlightCountAtom) > 0; + const snap = + widthChanged || pendingVisibilitySnap || layoutAnimationActive; + pendingVisibilitySnap = false; + if (width !== undefined) { + lastWidth = width; + } + return { + nextHeight: visible + ? `${getObservedInnerHeight(entry, inner)}px` + : "0px", + snap, + }; + }, + write: ({ nextHeight, snap }) => { + applyHeight(wrapper, nextHeight, snap, snapState); + }, }); - observer.observe(inner); // While a tab is hidden, ResizeObserver delivery is throttled and the CSS // height transition stays armed. If content grew during streaming, the // first observer fire after the user returns interpolates the full delta @@ -211,7 +231,7 @@ export function HeightTransition({ visible, children }: HeightTransitionProps) { const unsubscribeFromDocumentVisibility = subscribeToDocumentVisibility(onVisibility); return () => { - observer.disconnect(); + unobserveInner(); unsubscribeFromDocumentVisibility(); cleanupSnapState(wrapper, snapState); }; @@ -340,44 +360,51 @@ export function AutoHeightContainer({ initialSettleComplete = true; }, AUTO_HEIGHT_INITIAL_SETTLE_MS); }; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width } = entry.contentRect; - const widthChanged = lastWidth !== null && width !== lastWidth; - // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) - // is in flight, the inner is itself animating its size every frame. - // Running our own 180ms transition on top compounds the lag and drags - // scrollHeight, which the bottom-anchor sentinel then chases. - const layoutAnimationActive = - store.get(layoutAnimationInFlightCountAtom) > 0; - const snap = - widthChanged || - pendingVisibilitySnap || - !initialSettleComplete || - layoutAnimationActive; - pendingVisibilitySnap = false; - lastWidth = width; - if (widthChanged || resizeState.usingIntrinsicHeight) { - enterIntrinsicHeightMode(wrapper, resizeState, snapState); - scheduleIntrinsicHeightRestore({ - inner, - resizeState, - snapState, - target: wrapper, - }); + const unobserveInner = observeSharedResize(inner, { + read: (entry) => { + const width = entry?.contentRect?.width; + const widthChanged = + lastWidth !== null && width !== undefined && width !== lastWidth; + // While a CSS layout animation (e.g. ExpandablePanel's grid expansion) + // is in flight, the inner is itself animating its size every frame. + // Running our own 180ms transition on top compounds the lag and drags + // scrollHeight, which the bottom-anchor sentinel then chases. + const layoutAnimationActive = + store.get(layoutAnimationInFlightCountAtom) > 0; + const snap = + widthChanged || + pendingVisibilitySnap || + !initialSettleComplete || + layoutAnimationActive; + pendingVisibilitySnap = false; + if (width !== undefined) { + lastWidth = width; + } + if (widthChanged || resizeState.usingIntrinsicHeight) { + return { useIntrinsicHeight: true as const }; + } + return { + useIntrinsicHeight: false as const, + nextHeight: `${getObservedInnerHeight(entry, inner)}px`, + snap, + }; + }, + write: (sync) => { + if (sync.useIntrinsicHeight) { + enterIntrinsicHeightMode(wrapper, resizeState, snapState); + scheduleIntrinsicHeightRestore({ + inner, + resizeState, + snapState, + target: wrapper, + }); + deferInitialSettleComplete(); + return; + } + applyHeight(wrapper, sync.nextHeight, sync.snap, snapState); deferInitialSettleComplete(); - return; - } - applyHeight( - wrapper, - `${getObservedInnerHeight(entry)}px`, - snap, - snapState, - ); - deferInitialSettleComplete(); + }, }); - observer.observe(inner); // See HeightTransition's matching block: a hidden tab pauses observer // delivery and the height transition, so content streamed in while the // tab was backgrounded would otherwise animate in over 180ms on return @@ -392,7 +419,7 @@ export function AutoHeightContainer({ subscribeToDocumentVisibility(onVisibility); return () => { snapToCurrentHeightRef.current = null; - observer.disconnect(); + unobserveInner(); unsubscribeFromDocumentVisibility(); window.clearTimeout(initialSettleTimerId); cancelIntrinsicHeightRestore(resizeState); diff --git a/apps/app/src/lib/shared-resize-observer.test.tsx b/apps/app/src/lib/shared-resize-observer.test.tsx new file mode 100644 index 0000000000..6492470593 --- /dev/null +++ b/apps/app/src/lib/shared-resize-observer.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ExpandablePanel } from "@/components/ui/disclosure"; +import { observeSharedResize } from "./shared-resize-observer"; + +class ResizeObserverStub implements ResizeObserver { + static instances: ResizeObserverStub[] = []; + + readonly observedTargets: Element[] = []; + + constructor(readonly callback: ResizeObserverCallback) { + ResizeObserverStub.instances.push(this); + } + + observe: ResizeObserver["observe"] = vi.fn((target: Element) => { + this.observedTargets.push(target); + }); + unobserve: ResizeObserver["unobserve"] = vi.fn(); + disconnect: ResizeObserver["disconnect"] = vi.fn(); +} + +afterEach(() => { + ResizeObserverStub.instances.length = 0; + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function lastObserver(): ResizeObserverStub { + const observer = ResizeObserverStub.instances.at(-1); + if (!observer) { + throw new Error("No ResizeObserver was installed"); + } + return observer; +} + +function makeEntry(target: Element, blockSize: number): ResizeObserverEntry { + return { + target, + contentRect: new DOMRect(0, 0, 200, blockSize), + borderBoxSize: [{ blockSize, inlineSize: 200 }], + contentBoxSize: [{ blockSize, inlineSize: 200 }], + devicePixelContentBoxSize: [{ blockSize, inlineSize: 200 }], + }; +} + +describe("observeSharedResize", () => { + it("runs every registration's read before any write within a batch", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + const order: string[] = []; + const first = document.createElement("div"); + const second = document.createElement("div"); + const unobserveFirst = observeSharedResize(first, { + read: () => { + order.push("read:first"); + return "first"; + }, + write: (value) => order.push(`write:${value}`), + }); + const unobserveSecond = observeSharedResize(second, { + read: () => { + order.push("read:second"); + return "second"; + }, + write: (value) => order.push(`write:${value}`), + }); + + // Two registrations, one observer: the whole point of sharing. + expect(ResizeObserverStub.instances).toHaveLength(1); + lastObserver().callback( + [makeEntry(first, 10), makeEntry(second, 20)], + lastObserver(), + ); + + expect(order).toEqual([ + "read:first", + "read:second", + "write:first", + "write:second", + ]); + unobserveFirst(); + unobserveSecond(); + }); + + it("re-syncs every registration when a synthetic batch carries no entries", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + const order: string[] = []; + const seenEntries: (ResizeObserverEntry | undefined)[] = []; + const targets = [document.createElement("div"), document.createElement("div")]; + const unobservers = targets.map((target, index) => + observeSharedResize(target, { + read: (entry) => { + seenEntries.push(entry); + order.push(`read:${index}`); + return index; + }, + write: (value) => order.push(`write:${value}`), + }), + ); + + lastObserver().callback([], lastObserver()); + + // No entries means no target information: every registration re-syncs + // from live layout, still phased. + expect(order).toEqual(["read:0", "read:1", "write:0", "write:1"]); + expect(seenEntries).toEqual([undefined, undefined]); + for (const unobserve of unobservers) unobserve(); + }); + + it("releases the shared observer once the last registration leaves", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + const target = document.createElement("div"); + const phases = { read: () => null, write: () => {} }; + + const unobserve = observeSharedResize(target, phases); + const observer = lastObserver(); + expect(observer.observe).toHaveBeenCalledWith(target); + + unobserve(); + expect(observer.unobserve).toHaveBeenCalledWith(target); + expect(observer.disconnect).toHaveBeenCalled(); + + // The next registration installs a fresh observer, so per-test + // `ResizeObserver` stubs take effect. + const unobserveAgain = observeSharedResize(target, phases); + expect(ResizeObserverStub.instances).toHaveLength(2); + unobserveAgain(); + }); +}); + +describe("ExpandablePanel on the shared observer", () => { + function renderTwoPanels() { + return render( + <> + First summary} + > + First body + + Second summary} + > + Second body + + , + ); + } + + it("mounts many panels onto one observer and sizes each from its own entry", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + const view = renderTwoPanels(); + + // Two panels, one shared observer, one observation per panel body. + expect(ResizeObserverStub.instances).toHaveLength(1); + expect(lastObserver().observedTargets).toHaveLength(2); + + const regionOf = (text: string) => { + const region = + view.getByText(text).parentElement?.parentElement?.parentElement; + if (!region) { + throw new Error("Panel body region was not rendered"); + } + return region; + }; + const firstRegion = regionOf("First body"); + const secondRegion = regionOf("Second body"); + const [firstTarget, secondTarget] = lastObserver().observedTargets; + if (!firstTarget || !secondTarget) { + throw new Error("Panel bodies were not observed"); + } + + // One batch resizing both panels sizes each region from its own entry's + // border box — no per-panel layout read. + act(() => { + lastObserver().callback( + [makeEntry(firstTarget, 40), makeEntry(secondTarget, 60)], + lastObserver(), + ); + }); + + expect(firstRegion.style.height).toBe("40px"); + expect(secondRegion.style.height).toBe("60px"); + }); +}); diff --git a/apps/app/src/lib/shared-resize-observer.ts b/apps/app/src/lib/shared-resize-observer.ts new file mode 100644 index 0000000000..f0c0f21707 --- /dev/null +++ b/apps/app/src/lib/shared-resize-observer.ts @@ -0,0 +1,143 @@ +/** + * One module-level ResizeObserver shared by every registered element, with + * each delivery dispatched in two phases: every registration's `read` runs + * before any registration's `write`. + * + * Per-component observers whose callbacks interleave a layout read with a + * style write defeat the browser's batching: when one event resizes N + * observed elements at once (viewport resize, iOS keyboard, font swap), each + * callback's read forces layout against the previous callback's write — N + * synchronous layout passes over the document. Phasing the shared batch + * bounds that at one forced layout no matter how many elements resized. + * + * Same registry shape as `conversation-message-overflow.tsx`'s shared + * overflow observer; this module generalizes it to arbitrary read/write + * pairs and is the sanctioned pattern for per-row measurement. + */ + +export interface SharedResizePhases { + /** + * Gather everything `write` needs, preferring the entry's already-measured + * boxes over live layout reads. Runs with `undefined` when the dispatch + * carries no entry for the target (a broadcast re-sync) — read live layout + * (`offsetHeight`) then. Must not write styles. + */ + read: (entry: ResizeObserverEntry | undefined) => T; + /** Apply the value `read` produced. Must not read layout. */ + write: (value: T) => void; +} + +/** + * Type-erased registration: `read` closes over its typed value by returning + * the matching `write` as a thunk, so the registry needs no generics. + */ +interface RegisteredPhases { + read: (entry: ResizeObserverEntry | undefined) => () => void; +} + +interface PhaseDispatch { + registration: RegisteredPhases; + entry: ResizeObserverEntry | undefined; +} + +const phasesByTarget = new Map>(); +let sharedResizeObserver: ResizeObserver | null = null; + +function collectDispatches( + entries: readonly ResizeObserverEntry[], +): PhaseDispatch[] { + if (entries.length === 0) { + // The platform always delivers at least one entry. An empty batch only + // comes from synthetic dispatch (test stubs, polyfills) and carries no + // target information, so conservatively re-sync every registration from + // live layout. + return [...phasesByTarget.values()].flatMap((registrations) => + [...registrations].map((registration) => ({ + registration, + entry: undefined, + })), + ); + } + const dispatches: PhaseDispatch[] = []; + for (const entry of entries) { + for (const registration of phasesByTarget.get(entry.target) ?? []) { + dispatches.push({ registration, entry }); + } + } + return dispatches; +} + +function dispatchPhased(dispatches: readonly PhaseDispatch[]): void { + // Complete every read before any write can dirty layout for the next one. + const writes = dispatches.map(({ registration, entry }) => + registration.read(entry), + ); + for (const write of writes) { + write(); + } +} + +function getSharedResizeObserver(): ResizeObserver { + sharedResizeObserver ??= new ResizeObserver((entries) => { + dispatchPhased(collectDispatches(entries)); + }); + return sharedResizeObserver; +} + +/** + * Observe `target` on the shared observer. Returns the unregister function; + * the last unregistration for a target unobserves it, and the last overall + * releases the observer entirely (so per-test `ResizeObserver` stubs take + * effect on the next registration). + */ +export function observeSharedResize( + target: Element, + phases: SharedResizePhases, +): () => void { + const registration: RegisteredPhases = { + read: (entry) => { + const value = phases.read(entry); + return () => phases.write(value); + }, + }; + let registrations = phasesByTarget.get(target); + const isFirstForTarget = registrations === undefined; + if (registrations === undefined) { + registrations = new Set(); + phasesByTarget.set(target, registrations); + } + registrations.add(registration); + if (isFirstForTarget) { + // Register before observing: the initial observation can deliver + // synchronously in some environments and must reach this registration. + getSharedResizeObserver().observe(target); + } + + return () => { + const currentRegistrations = phasesByTarget.get(target); + currentRegistrations?.delete(registration); + if (currentRegistrations?.size === 0) { + phasesByTarget.delete(target); + sharedResizeObserver?.unobserve?.(target); + if (phasesByTarget.size === 0) { + sharedResizeObserver?.disconnect?.(); + sharedResizeObserver = null; + } + } + }; +} + +/** + * Border-box block size carried by an entry — `offsetHeight`'s metric without + * the layout read (the observer already measured this frame). The two must + * agree wherever an observer path and a direct path size the same element, or + * a padded box would get clipped by a content-box height. Returns `undefined` + * when the entry carries no usable box: entries cross a platform boundary and + * synthetic ones (test doubles, polyfills) omit boxes the spec guarantees — + * fall back to a live layout read then. + */ +export function observedBorderBoxBlockSize( + entry: ResizeObserverEntry, +): number | undefined { + return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect?.height; +} From 2fa119a60313e7330705d90a5a45cf94e4108c92 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Tue, 25 Aug 2026 08:32:38 +0200 Subject: [PATCH 10/34] Halve the per-message action-bar ResizeObservers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every message mounted two width observers: one for the action row's slot and one for the enclosing [data-message-column], even though the mobile overflow branch renders a constant layout that never reads the slot width, and the column width is the same number for every top-level row. useMeasuredWidth gains an `enabled` option (hook order stable, no observer constructed when disabled); the overflow branch disables the slot observer; and the top-level TimelineRowsList measures its root once and shares it through MessageColumnWidthContext, so one observer serves every bar. Without a provider (stories, unit renders) or inside nested, narrower lists — which shadow the context with null — a bar measures its own column exactly as before. Desktop inline/overflow layout is pinned by the existing width-driven tests. Co-Authored-By: Claude Fable 5 (cherry picked from commit b1b2497bba745ade17f8f3b5a3f9a4e3fd3f9b3f) --- .../thread/timeline/MessageActionBar.test.tsx | 79 +++++++++ .../thread/timeline/MessageActionBar.tsx | 58 +++++-- .../ThreadTimelineRows.actions.test.tsx | 83 ++++++++++ .../thread/timeline/ThreadTimelineRows.tsx | 151 ++++++++++-------- 4 files changed, 296 insertions(+), 75 deletions(-) diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx index 6eb9086c74..1d910deca8 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx @@ -16,6 +16,7 @@ import { computeMessageActionRowLayout, findMessageActionTooltipCollisionBoundary, MessageActionBar, + MessageColumnWidthContext, } from "./MessageActionBar"; afterEach(() => { @@ -638,6 +639,84 @@ describe("MessageActionBar", () => { }); }); +describe("MessageActionBar observer budget", () => { + /** Counts `ResizeObserver` constructions without ever delivering entries. */ + function spyResizeObserverConstructions(): () => number { + let constructions = 0; + class CountingResizeObserver { + constructor(_callback: ResizeObserverCallback) { + constructions += 1; + } + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", CountingResizeObserver); + return () => constructions; + } + + it("constructs only the column fallback observer for a mobile overflow bar without a provider", () => { + mockMobileCoarsePointer(); + const constructionCount = spyResizeObserverConstructions(); + render( +
    + +
    , + ); + + // The overflow branch renders a constant layout, so the slot-width + // observer is skipped; only the column fallback remains. + expect(constructionCount()).toBe(1); + }); + + it("creates no per-bar observer on the mobile overflow branch under the shared column width", () => { + mockMobileCoarsePointer(); + const constructionCount = spyResizeObserverConstructions(); + render( + + + , + ); + expect(constructionCount()).toBe(0); + + // The shared width is what admits the in-place expansion: three 28px + // touch actions (100px with gaps) fit the 358px column comfortably. + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")), + ).toEqual(["Copy message", "Add to chat", "Fork into new thread"]); + }); + + it("constructs only the slot observer for a desktop bar under the shared column width", () => { + const constructionCount = spyResizeObserverConstructions(); + render( + + + , + ); + expect(constructionCount()).toBe(1); + }); +}); + describe("computeMessageActionRowLayout", () => { const metrics = { actionWidth: 20, overflowTriggerWidth: 20 }; diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx index eb189eee5b..e93ed78a09 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx @@ -1,5 +1,7 @@ import { + createContext, useCallback, + useContext, useEffect, useRef, useState, @@ -174,11 +176,18 @@ export function computeMessageActionRowLayout({ * Width of the action row's slot. A callback ref (rather than an object ref * plus a mount effect) so the observer re-attaches when the bar swaps between * its desktop and touch trees — an effect keyed on mount would keep observing - * the unmounted tree's detached node. + * the unmounted tree's detached node. `enabled: false` keeps the hook (and a + * branch-stable hook order) without constructing an observer, for branches + * whose layout never reads the width. */ -function useMeasuredWidth( - resolveTarget?: (node: HTMLElement) => Element | null, -): { +export function useMeasuredWidth({ + enabled, + resolveTarget, +}: { + enabled: boolean; + /** Measure a related element (e.g. the message column) instead of the attached node. */ + resolveTarget?: (node: HTMLElement) => Element | null; +}): { measureRef: (node: HTMLElement | null) => void; width: number | undefined; } { @@ -188,7 +197,7 @@ function useMeasuredWidth( (node: HTMLElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; - if (node === null || typeof ResizeObserver === "undefined") { + if (!enabled || node === null || typeof ResizeObserver === "undefined") { return; } const target = resolveTarget ? resolveTarget(node) : node; @@ -204,11 +213,29 @@ function useMeasuredWidth( observer.observe(target); observerRef.current = observer; }, - [resolveTarget], + [enabled, resolveTarget], ); return { measureRef, width }; } +/** + * Timeline-list-level share of the message column width. + * + * Every top-level row's `[data-message-column]` spans the full list width, so + * per-bar column observers would all report the same number. The top-level + * `TimelineRowsList` measures its root once and provides it here. `null` — no + * provider (stories, isolated renders) or a nested, narrower list shadowing + * the top-level value — means no shared measurement applies and the bar + * observes its own column. + */ +export interface SharedMessageColumnWidth { + /** Measured width; undefined until the observer first reports. */ + width: number | undefined; +} + +export const MessageColumnWidthContext = + createContext(null); + /** * The message column this row belongs to — the full timeline width, which for * a right-aligned user message is much wider than its bubble. Module-level so @@ -478,9 +505,21 @@ export function MessageActionBar({ const [collisionBoundary, setCollisionBoundary] = useState< HTMLElement | undefined >(); - const { measureRef, width: availableWidth } = useMeasuredWidth(); - const { measureRef: measureColumnRef, width: columnWidth } = - useMeasuredWidth(resolveMessageColumn); + const useMobileOverflowPopover = isCompactViewport && isPointerCoarse; + // The mobile overflow branch lays out a constant row (every action behind + // the "⋯" trigger), so the measured slot width feeds nothing there — skip + // that observer entirely. + const { measureRef, width: availableWidth } = useMeasuredWidth({ + enabled: !(useMobileOverflowPopover && mobileActionDisplay === "overflow"), + }); + const sharedColumnWidth = useContext(MessageColumnWidthContext); + const { measureRef: measureColumnRef, width: ownColumnWidth } = + useMeasuredWidth({ + enabled: sharedColumnWidth === null, + resolveTarget: resolveMessageColumn, + }); + const columnWidth = + sharedColumnWidth === null ? ownColumnWidth : sharedColumnWidth.width; // Touch-only: the hidden actions revealed in place by the "⋯" trigger. const [expanded, setExpanded] = useState(false); const expandedRowRef = useRef(null); @@ -601,7 +640,6 @@ export function MessageActionBar({ onSelect: action.onSelect, })), ]; - const useMobileOverflowPopover = isCompactViewport && isPointerCoarse; if (actions.length === 0) { return null; diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx index 092b1798f3..d1d4444d5c 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx @@ -1522,3 +1522,86 @@ describe("ThreadTimelineRows actions", () => { ); }); }); + +describe("ThreadTimelineRows shared message column width", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("expands overflow actions in place from the row list's one column measurement", () => { + mockSelectionMenuMedia({ isCompactViewport: true, isPointerCoarse: true }); + const observations: { callback: ResizeObserverCallback; node: Element }[] = + []; + class ControlledResizeObserver { + readonly #callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + } + observe(node: Element) { + observations.push({ callback: this.#callback, node }); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ControlledResizeObserver); + + const { container } = renderWithRouter( + , + ); + + // Report a width only for the top-level row list: the bars' own columns + // are never observed here, so an in-place expansion can only come from + // the shared list-level measurement flowing down through context. + act(() => { + for (const { callback, node } of observations) { + if (!node.hasAttribute("data-timeline-row-list")) continue; + callback( + [ + { + target: node, + contentRect: { width: 358, height: 600 }, + } as unknown as ResizeObserverEntry, + ], + undefined as unknown as ResizeObserver, + ); + } + }); + + const earlierMessage = container.querySelector( + '[data-timeline-row-id="earlier_agent_message"]', + ); + const trigger = earlierMessage?.querySelector( + '[aria-label="Message actions"]', + ); + if (!trigger) throw new Error("Missing overflow trigger"); + fireEvent.click(trigger); + + // In-place expansion, not the popover: the 358px column fits all three + // 28px touch actions with the comfort margin to spare. + expect(document.body.querySelector('[data-side="top"]')).toBeNull(); + expect( + earlierMessage?.querySelector('[aria-label="Copy message"]'), + ).not.toBeNull(); + expect( + earlierMessage?.querySelector('[aria-label="Fork into new thread"]'), + ).not.toBeNull(); + }); +}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index c0d1ae1fc4..4808f7209e 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -66,6 +66,10 @@ import type { UserAttachmentImageSrcResolver, } from "./types.js"; import { ConversationMessageContent } from "./ConversationMessageContent.js"; +import { + MessageColumnWidthContext, + useMeasuredWidth, +} from "./MessageActionBar.js"; import { TimelineSelectionMenu } from "./TimelineSelectionMenu.js"; import type { MessageProseSelection } from "./SelectableMessageProse.js"; import { ExpandableTimelineRow } from "./ExpandableTimelineRow.js"; @@ -2144,81 +2148,98 @@ function TimelineRowsList({ detailScrollRoot?.getScrollElement ?? bottomAnchor?.getScrollElement ?? null; + const isTopLevelList = spacing === "top-level"; + // One observer for every action bar below: each top-level row's message + // column spans this list's full width, so the bars read this shared + // measurement (MessageColumnWidthContext) instead of observing their own + // columns. Nested lists are narrower, so they shadow the value with null + // and their bars fall back to per-bar measurement. + const { measureRef: messageColumnWidthSourceRef, width: messageColumnWidth } = + useMeasuredWidth({ enabled: isTopLevelList }); + const messageColumnWidthValue = useMemo( + () => ({ width: messageColumnWidth }), + [messageColumnWidth], + ); return ( -
    - { - const item = items[index]; - return item?.kind === "row" - ? estimateTimelineWindowedRowHeight(item.row, spacing) - : 28; - }} - gap={spacing === "bundle" ? 0 : 8} - getScrollElement={getWindowingScrollElement} - itemKeys={itemKeys} - measurements={measurements} - minItemCount={ - spacing === "top-level" ? (isCompactViewport ? 40 : 60) : 20 - } - renderItem={(index, windowedState) => { - const item = items[index]; - if (item === undefined) { - return null; +
    + { + const item = items[index]; + return item?.kind === "row" + ? estimateTimelineWindowedRowHeight(item.row, spacing) + : 28; + }} + gap={spacing === "bundle" ? 0 : 8} + getScrollElement={getWindowingScrollElement} + itemKeys={itemKeys} + measurements={measurements} + minItemCount={ + spacing === "top-level" ? (isCompactViewport ? 40 : 60) : 20 } - if (item.kind === "unread-divider") { + renderItem={(index, windowedState) => { + const item = items[index]; + if (item === undefined) { + return null; + } + if (item.kind === "unread-divider") { + return ( +
    + {windowedState.isRealized ? ( + + ) : null} +
    + ); + } return ( -
    {windowedState.isRealized ? ( - ) : null} -
    + ); - } - return ( - - {windowedState.isRealized ? ( - - ) : null} - - ); - }} - /> -
    + }} + /> +
    +
    ); } From 292a7b084dee1eaf55589c4a45b57ce928831ceb Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Tue, 25 Aug 2026 08:33:37 +0200 Subject: [PATCH 11/34] Mark the shared selection pointer listeners passive The shared document pointerdown/pointerup/pointercancel handlers in SelectableMessageProse never call preventDefault, but without the passive flag the browser must still treat every tap as potentially blocking. Declare { passive: true } on the three pointer listeners and pin the flag with a test; removal matching is unaffected (only the capture flag participates), so the shared teardown behavior is unchanged. Co-Authored-By: Claude Fable 5 (cherry picked from commit e1d74128e637a1c47f7ed3dc2f3a3c2d4c3f0ff9) --- .../SelectableMessageProse.events.test.tsx | 20 +++++++++++++++++++ .../timeline/SelectableMessageProse.tsx | 14 ++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx index c832b02ff7..69f3753c86 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx @@ -410,4 +410,24 @@ describe("SelectableMessageProse", () => { ), ); }); + + it("registers the shared pointer listeners as passive", () => { + const addSpy = vi.spyOn(document, "addEventListener"); + const view = render( + Answer prose, + ); + + // None of the pointer handlers call preventDefault; the passive flag is a + // perf contract (it keeps taps off the blocking-handler list), so pin it. + const optionsByType = new Map( + addSpy.mock.calls.map(([type, , options]) => [type, options]), + ); + for (const type of ["pointerdown", "pointerup", "pointercancel"]) { + expect(optionsByType.get(type), type).toEqual({ passive: true }); + } + + // Detach still matches (removeEventListener ignores `passive`): the + // shared-listener teardown test above covers the counts. + view.unmount(); + }); }); diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx index 95e197a5c1..b987daf572 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx @@ -406,9 +406,17 @@ function handleSharedKeyUp(): void { } function attachSharedDocumentListeners(): void { - document.addEventListener("pointerdown", handleSharedPointerDown); - document.addEventListener("pointerup", handleSharedPointerRelease); - document.addEventListener("pointercancel", handleSharedPointerCancel); + // Passive: none of the pointer handlers call preventDefault, so declare it + // and keep every tap off the compositor's blocking-handler list. + document.addEventListener("pointerdown", handleSharedPointerDown, { + passive: true, + }); + document.addEventListener("pointerup", handleSharedPointerRelease, { + passive: true, + }); + document.addEventListener("pointercancel", handleSharedPointerCancel, { + passive: true, + }); document.addEventListener("mouseup", handleSharedPointerRelease); document.addEventListener("selectionchange", handleSharedSelectionChange); document.addEventListener("keyup", handleSharedKeyUp); From 69a16d0a25b104e046e6998c21dc9a315a34fd61 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:46:00 +0200 Subject: [PATCH 12/34] Attach statusChange metadata at host-runtime status publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host connectivity fan-out (daemon socket close, disconnect grace, host removal) and the post-commit interruption publish sent bare status-changed notifications, so every client fell back to refetching every active thread list once per thread on the host. Build the row snapshot with the existing buildThreadStatusChangeMetadata builder at the sites that run outside a transaction with a real hub, and mark the in-transaction sites (buffered DbNotifier, no runtime hub or provider registry) as deliberately bare — the client-side throttled fallback covers those. thread-send.ts, parent-system-messages.ts and queued-messages.ts have attached the builder since #2169; the queued auto-send path gains a guard test modeled on the lifecycle-outcome coverage. Co-Authored-By: Claude Fable 5 (cherry picked from commit 07f8adfc5509783669603aa9fffeadd0fdcbbdee) --- .../internal/session-owner-side-effects.ts | 34 +++- .../environment-cleanup-internal.ts | 3 + .../environment-provisioning-internal.ts | 3 + .../src/services/threads/thread-lifecycle.ts | 29 +++- apps/server/src/ws/daemon-protocol.ts | 1 + .../session-owner-runtime-status.test.ts | 162 ++++++++++++++++++ .../test/threads/thread-send-dispatch.test.ts | 53 +++++- 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 apps/server/test/internal/session-owner-runtime-status.test.ts diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts index 7bb6b5d0dc..f509c05dc1 100644 --- a/apps/server/src/internal/session-owner-side-effects.ts +++ b/apps/server/src/internal/session-owner-side-effects.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { closeSession, + getThread, hostDaemonSessions, listHostThreadIds, type HostDaemonSessionRow, @@ -18,6 +19,7 @@ import { interruptActiveThreadsForHost, reconcileDaemonReportedThreads, } from "../services/threads/thread-lifecycle.js"; +import { buildThreadStatusChangeMetadata } from "../services/threads/thread-runtime-display.js"; import { settleDanglingBackgroundTasks } from "../services/threads/background-task-reconciliation.js"; const DAEMON_RESTARTED_PENDING_INTERACTION_REASON = @@ -32,12 +34,18 @@ type DaemonSocketClosedDeps = Pick< | "hub" | "logger" | "pendingInteractions" + | "providerRegistry" | "sharedPorts" | "terminalSessions" >; type DaemonDisconnectGraceDeps = Pick< AppDeps, - "db" | "hub" | "logger" | "pendingInteractions" | "terminalSessions" + | "db" + | "hub" + | "logger" + | "pendingInteractions" + | "providerRegistry" + | "terminalSessions" >; interface HandleHostSessionOpenedArgs { @@ -236,7 +244,10 @@ function completeDaemonDisconnectGrace( } function completeDaemonActiveWorkDisconnectGrace( - deps: Pick, + deps: Pick< + AppDeps, + "db" | "hub" | "logger" | "pendingInteractions" | "providerRegistry" + >, args: CompleteDaemonActiveWorkDisconnectGraceArgs, ): void { if (deps.hub.hasDaemonForHost(args.hostId)) { @@ -249,12 +260,27 @@ function completeDaemonActiveWorkDisconnectGrace( }); } +/** + * Host connectivity is part of every thread row's displayed runtime, so each + * notification carries the post-change `statusChange` snapshot: without it, + * every client falls back to refetching every active thread list once per + * thread on this host, twice per disconnect (close + grace). + */ function notifyHostThreadRuntimeStatusChanged( - deps: Pick, + deps: Pick, hostId: string, ): void { for (const threadId of listHostThreadIds(deps.db, { hostId })) { - deps.hub.notifyThread(threadId, ["status-changed"]); + const thread = getThread(deps.db, threadId); + if (!thread) { + deps.hub.notifyThread(threadId, ["status-changed"]); + continue; + } + deps.hub.notifyThread( + threadId, + ["status-changed"], + buildThreadStatusChangeMetadata(deps, thread), + ); } } diff --git a/apps/server/src/services/environments/environment-cleanup-internal.ts b/apps/server/src/services/environments/environment-cleanup-internal.ts index 4939310ad4..4f2260a10e 100644 --- a/apps/server/src/services/environments/environment-cleanup-internal.ts +++ b/apps/server/src/services/environments/environment-cleanup-internal.ts @@ -157,6 +157,9 @@ function markLiveThreadsErroredAfterDestroySuccess( threadId: thread.id, }); if (outcome.applied) { + // Bare on purpose: in-transaction producers cannot build `statusChange` + // metadata (see buildThreadStatusChangeMetadata); clients fall back to + // the throttled thread-list refetch. deps.hub.notifyThread(thread.id, ["status-changed"]); } } diff --git a/apps/server/src/services/environments/environment-provisioning-internal.ts b/apps/server/src/services/environments/environment-provisioning-internal.ts index b75e4a0c03..79a8f67129 100644 --- a/apps/server/src/services/environments/environment-provisioning-internal.ts +++ b/apps/server/src/services/environments/environment-provisioning-internal.ts @@ -553,6 +553,9 @@ function recordEnvironmentProvisioningFailureInTransaction( threadId: thread.id, }); if (outcome.applied) { + // Bare on purpose: in-transaction producers cannot build `statusChange` + // metadata (see buildThreadStatusChangeMetadata); clients fall back to + // the throttled thread-list refetch. deps.hub.notifyThread(thread.id, ["status-changed"]); } } diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts index 1768362a8f..b937221579 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -70,6 +70,7 @@ import { applyLoggedThreadLifecycleEvent, applyLoggedThreadLifecycleEventInTransaction, } from "./lifecycle-outcome.js"; +import { buildThreadStatusChangeMetadata } from "./thread-runtime-display.js"; import { addRequestIdToTurnSubmitCommandPayload, buildThreadStartCommand, @@ -535,6 +536,9 @@ function markThreadStoppingWithEventInTransaction( if (!outcome.applied) { return false; } + // Bare on purpose: an in-transaction producer with a buffered notifier + // cannot build `statusChange` metadata (see buildThreadStatusChangeMetadata); + // clients fall back to the throttled thread-list refetch. deps.hub.notifyThread(args.threadId, ["status-changed"]); appendThreadInterruptedEventInTransaction(deps.db, { threadId: args.threadId, @@ -807,6 +811,8 @@ function settleThreadCommandFailure( threadId: thread.id, }); if (outcome.applied) { + // Bare on purpose: in-transaction producers cannot build `statusChange` + // metadata; clients fall back to the throttled thread-list refetch. args.deps.hub.notifyThread(thread.id, ["status-changed"]); } // Forks / side chats are user-initiated branches, not agent-delegated @@ -877,6 +883,8 @@ export function settleThreadStartCommandResult( threadId: currentThread.id, }); if (outcome.applied) { + // Bare on purpose: in-transaction producers cannot build `statusChange` + // metadata; clients fall back to the throttled thread-list refetch. args.deps.hub.notifyThread(currentThread.id, ["status-changed"]); if (shouldAutoSendQueuedMessagesAfterThreadStart(args.command)) { postCommitActions.push({ @@ -1563,6 +1571,8 @@ function interruptActiveTurnForThreadInTransaction( if (appendedThreadInterruptedEvent) { eventTypes.push("system/thread/interrupted"); } + // No `statusChange` on purpose: in-transaction producers cannot build it; + // clients fall back to the throttled thread-list refetch. deps.hub.notifyThread(args.threadId, ["events-appended", "status-changed"], { eventTypes, }); @@ -1576,7 +1586,10 @@ function interruptActiveTurnForThreadInTransaction( * threads with an open turn also get an interrupted turn completion event. */ function interruptActiveThreads( - deps: Pick, + deps: Pick< + AppDeps, + "db" | "hub" | "logger" | "pendingInteractions" | "providerRegistry" + >, args: InterruptActiveThreadsArgs, ): InterruptActiveThreadsResult { if (args.threads.length === 0) { @@ -1673,11 +1686,16 @@ function interruptActiveThreads( if (result.interruptedTurnId !== null) { eventTypes.unshift("turn/completed"); } + // Published after the transaction committed, so the row snapshot is the + // settled post-interruption state and clients patch their list rows + // instead of refetching every thread list once per interrupted thread. + const thread = getThread(deps.db, result.threadId); deps.hub.notifyThread( result.threadId, ["events-appended", "status-changed"], { eventTypes, + ...(thread ? buildThreadStatusChangeMetadata(deps, thread) : {}), }, ); } @@ -1686,7 +1704,10 @@ function interruptActiveThreads( } export function interruptActiveThreadsForHost( - deps: Pick, + deps: Pick< + AppDeps, + "db" | "hub" | "logger" | "pendingInteractions" | "providerRegistry" + >, args: InterruptActiveThreadsForHostArgs, ): InterruptActiveThreadsResult { const activeThreads = deps.db @@ -1773,6 +1794,9 @@ export function finalizeStoppedThreadInTransaction( threadId: currentThread.id, }); if (outcome.applied) { + // Bare on purpose: in-transaction producers cannot build + // `statusChange` metadata; clients fall back to the throttled + // thread-list refetch. deps.hub.notifyThread(currentThread.id, ["status-changed"]); } } @@ -1782,6 +1806,7 @@ export function finalizeStoppedThreadInTransaction( threadId: currentThread.id, }); if (outcome.applied) { + // Bare on purpose: see the active/stopping branch above. deps.hub.notifyThread(currentThread.id, ["status-changed"]); } } diff --git a/apps/server/src/ws/daemon-protocol.ts b/apps/server/src/ws/daemon-protocol.ts index c760176b21..729667b19e 100644 --- a/apps/server/src/ws/daemon-protocol.ts +++ b/apps/server/src/ws/daemon-protocol.ts @@ -241,6 +241,7 @@ export function onDaemonSocketClose( | "hub" | "logger" | "pendingInteractions" + | "providerRegistry" | "sharedPorts" | "terminalSessions" >, diff --git a/apps/server/test/internal/session-owner-runtime-status.test.ts b/apps/server/test/internal/session-owner-runtime-status.test.ts new file mode 100644 index 0000000000..7572ffac83 --- /dev/null +++ b/apps/server/test/internal/session-owner-runtime-status.test.ts @@ -0,0 +1,162 @@ +import { changedMessageSchema, type ThreadChangedMessage } from "@bb/domain"; +import { getThread } from "@bb/db"; +import { describe, expect, it } from "vitest"; +import { + handleDaemonSocketClosed, + handleHostRemoved, +} from "../../src/internal/session-owner-side-effects.js"; +import { createMockHubSocket } from "../helpers/mock-hub-socket.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../helpers/seed.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; + +interface HostThreadsFixture { + activeThreadId: string; + hostId: string; + idleThreadId: string; + sessionId: string; +} + +function seedHostThreadsFixture( + harness: TestAppHarness, + value: number, +): HostThreadsFixture { + const { host, session } = seedHostSession(harness.deps, { + id: `host-runtime-status-${value}`, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: `/tmp/runtime-status-${value}`, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: `/tmp/runtime-status-${value}`, + status: "ready", + }); + const activeThread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "active", + }); + const idleThread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + return { + activeThreadId: activeThread.id, + hostId: host.id, + idleThreadId: idleThread.id, + sessionId: session.id, + }; +} + +function statusChangedMessagesFor( + messages: readonly string[], + threadId: string, +): ThreadChangedMessage[] { + return messages.flatMap((raw) => { + const message = changedMessageSchema.parse(JSON.parse(raw)); + return message.entity === "thread" && + message.id === threadId && + message.changes.includes("status-changed") + ? [message] + : []; + }); +} + +function lastStatusChange( + messages: readonly string[], + threadId: string, +): ThreadChangedMessage { + const statusMessages = statusChangedMessagesFor(messages, threadId); + const last = statusMessages.at(-1); + if (!last) { + throw new Error(`no status-changed message for thread ${threadId}`); + } + return last; +} + +describe("host thread runtime status notifications", () => { + it("carries a statusChange snapshot for every host thread when the daemon socket closes", async () => { + await withTestHarness(async (harness) => { + const fixture = seedHostThreadsFixture(harness, 1); + const socket = createMockHubSocket(); + harness.hub.subscribe(socket, { kind: "thread-list" }); + + handleDaemonSocketClosed(harness.deps, { sessionId: fixture.sessionId }); + // The disconnect schedules the grace callbacks with real timers; drop + // them so the harness does not fire interruptions after cleanup. + harness.hub.cancelPendingDaemonDisconnect(fixture.sessionId); + + // Bare notifications here would make every client refetch every active + // thread list once per host thread; the snapshot is what lets them + // patch rows in place. + const activeMessage = lastStatusChange( + socket.messages, + fixture.activeThreadId, + ); + expect(activeMessage.metadata?.statusChange).toMatchObject({ + status: "active", + runtime: { + displayStatus: "host-reconnecting", + hostReconnectGraceExpiresAt: expect.any(Number), + }, + }); + const idleMessage = lastStatusChange( + socket.messages, + fixture.idleThreadId, + ); + expect(idleMessage.metadata?.statusChange).toMatchObject({ + status: "idle", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + }); + }); + }); + + it("carries the settled post-interruption snapshot when the host is removed", async () => { + await withTestHarness(async (harness) => { + const fixture = seedHostThreadsFixture(harness, 2); + const socket = createMockHubSocket(); + harness.hub.subscribe(socket, { kind: "thread-list" }); + + handleHostRemoved(harness.deps, { + hostId: fixture.hostId, + sessionId: fixture.sessionId, + }); + + // Removal interrupts the active thread (run.failed) before the runtime + // fan-out, so every status-changed for it must carry a snapshot and the + // final one must show the settled error state. + const activeMessages = statusChangedMessagesFor( + socket.messages, + fixture.activeThreadId, + ); + expect(activeMessages.length).toBeGreaterThan(0); + for (const message of activeMessages) { + expect(message.metadata?.statusChange).toBeDefined(); + } + expect(getThread(harness.db, fixture.activeThreadId)?.status).toBe( + "error", + ); + expect( + activeMessages.at(-1)?.metadata?.statusChange, + ).toMatchObject({ + status: "error", + runtime: { displayStatus: "error" }, + }); + expect( + lastStatusChange(socket.messages, fixture.idleThreadId).metadata + ?.statusChange, + ).toMatchObject({ + status: "idle", + runtime: { displayStatus: "idle" }, + }); + }); + }); +}); diff --git a/apps/server/test/threads/thread-send-dispatch.test.ts b/apps/server/test/threads/thread-send-dispatch.test.ts index 6d2c5aaa2c..9245cfa05f 100644 --- a/apps/server/test/threads/thread-send-dispatch.test.ts +++ b/apps/server/test/threads/thread-send-dispatch.test.ts @@ -6,7 +6,13 @@ import { listQueuedThreadMessages, markThreadDeleted, } from "@bb/db"; -import { turnScope, type Environment, type Thread } from "@bb/domain"; +import { + changedMessageSchema, + turnScope, + type Environment, + type Thread, + type ThreadChangedMessage, +} from "@bb/domain"; import { describe, expect, it, vi } from "vitest"; import type { TelemetryService } from "../../src/services/system/telemetry.js"; import { sendQueuedMessage } from "../../src/services/threads/queued-messages.js"; @@ -17,6 +23,7 @@ import { reportQueuedCommandError, waitForQueuedCommand, } from "../helpers/commands.js"; +import { createMockHubSocket } from "../helpers/mock-hub-socket.js"; import { textInput } from "../helpers/prompt-input.js"; import { seedEnvironment, @@ -114,6 +121,15 @@ function installTelemetryCaptureSpy(harness: TestAppHarness) { return capture; } +function parseThreadMessages( + messages: readonly string[], +): ThreadChangedMessage[] { + return messages.flatMap((raw) => { + const message = changedMessageSchema.parse(JSON.parse(raw)); + return message.entity === "thread" ? [message] : []; + }); +} + describe("queued message dispatch gate", () => { it("rolls back and sends no host command when the idle thread was archived between claim and dispatch", async () => { await withTestHarness(async (harness) => { @@ -195,6 +211,41 @@ describe("queued message dispatch gate", () => { }); }); +describe("queued message auto-send notification", () => { + it("carries the statusChange row snapshot when the auto-send activates the thread", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedProviderThreadFixture({ harness, value: 41 }); + const queued = seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: textInput("queued while idle"), + }); + const socket = createMockHubSocket(); + harness.hub.subscribe(socket, { kind: "thread-list" }); + + await sendQueuedMessage(harness.deps, { + threadId: thread.id, + queuedMessageId: queued.id, + mode: "auto", + }); + + // Every status flip must carry the row snapshot, or the client falls + // back to refetching every active thread list for this transition. + const statusMessages = parseThreadMessages(socket.messages).filter( + (message) => + message.id === thread.id && + message.changes.includes("status-changed"), + ); + expect(statusMessages.length).toBeGreaterThan(0); + for (const message of statusMessages) { + expect(message.metadata?.statusChange).toMatchObject({ + status: "active", + runtime: { displayStatus: "active" }, + }); + } + }); + }); +}); + describe("user message telemetry", () => { it("captures direct user sends", async () => { await withTestHarness(async (harness) => { From edf918dc6be4d4a746d17ec6498d714a398fefad Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:47:54 +0200 Subject: [PATCH 13/34] Throttle the metadata-less status-changed list refetch to 1 Hz A status-changed push without a row snapshot falls back to refetching every active thread list plus the sidebar bootstrap on the immediate path. Bare pushes arrive in bursts (in-transaction writers publish one per thread), so route the fallback through the existing throttled active-refetch machinery: everything still goes stale immediately, the first push refetches right away, later pushes inside the second coalesce into one trailing refetch, and no fetch in flight is cancelled. Archived pages keep their stale-only treatment. Co-Authored-By: Claude Fable 5 (cherry picked from commit 53da3a56e96d7d8a3ed70a13d287679caf60ec88) --- .../cache-owners/realtime-cache-registry.ts | 67 ++++++++++++++++++- .../src/hooks/realtime-cache-effects.test.ts | 50 ++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index a4a849dee4..92b173297d 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -167,6 +167,17 @@ interface ThrottledActiveRefetchArgs { */ const WORK_STATUS_REFETCH_MIN_INTERVAL_MS = 1_000; +/** + * A `status-changed` push without a row snapshot falls back to refetching the + * active thread lists. Bare pushes arrive in bursts — writers inside a + * transaction publish one per thread, and a host disconnect used to publish + * one per thread on the host — and every full list response is ~1 KB per + * unarchived thread, so the fallback refetch is throttled to one per second + * per query. Rows still go stale immediately; only the active refetch + * coalesces. + */ +const THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS = 1_000; + /** * The trailing refetch is self-clocking: it fires as soon as the in-flight * fetch settles and any event arrived meanwhile. During a streaming turn events @@ -831,6 +842,54 @@ function dirtyActiveThreadListQueries({ return [sidebarNavigationQueryKey(), threadSearchQueryKeyPrefix()]; } +/** + * Same scope as {@link dirtyActiveThreadListQueries}, but the active refetches + * run through the throttle machinery: everything goes stale immediately (a + * remount refetches), while active list/sidebar/search observers refetch at + * most once per {@link THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS}, + * with later changes coalescing into one trailing refetch and no fetch in + * flight ever cancelled. Archived pages keep their stale-only treatment. + */ +function dirtyActiveThreadListQueriesWithThrottledRefetch({ + projectId, + queryClient, +}: ThreadRealtimeDirtyContext): void { + const listQueryKeys = projectId + ? [ + ...getCachedProjectThreadListInvalidationQueryKeys({ + projectId, + queryClient, + }), + ...getCachedGlobalThreadListInvalidationQueryKeys({ queryClient }), + ] + : getCachedThreadListQueryKeys(queryClient); + for (const queryKey of listQueryKeys) { + if (isArchivedThreadListQueryKey(queryKey)) { + queryClient.invalidateQueries({ + exact: true, + queryKey, + refetchType: "none", + }); + continue; + } + invalidateQueryKeyWithThrottledActiveRefetch({ + minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS, + queryClient, + queryKey, + }); + } + for (const queryKey of [ + sidebarNavigationQueryKey(), + threadSearchQueryKeyPrefix(), + ]) { + invalidateQueryKeyWithThrottledActiveRefetch({ + minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS, + queryClient, + queryKey, + }); + } +} + function dirtyThreadListQueriesForBackgroundActivity( context: ThreadRealtimeDirtyContext, ): QueryKey[] { @@ -1108,7 +1167,8 @@ function patchThreadListPendingInteractionState({ * patched in place. The alternative is what the fallback still does for * pushes without the row (older servers, writers inside a transaction that * cannot resolve the runtime): refetch every active thread list plus the - * sidebar bootstrap, which is ~1 KB per unarchived thread, twice per turn. + * sidebar bootstrap, which is ~1 KB per unarchived thread — throttled to one + * active refetch per second so a burst of bare pushes coalesces. * * A list fetch already in flight read the database before this transition * and would overwrite the patch when it lands, so those queries are @@ -1116,10 +1176,11 @@ function patchThreadListPendingInteractionState({ */ function patchThreadListStatusState( context: ThreadRealtimeDirtyContext, -): QueryKey[] { +): QueryKey[] | undefined { const { queryClient, statusChange, threadId } = context; if (!threadId || !statusChange) { - return dirtyActiveThreadListQueries(context); + dirtyActiveThreadListQueriesWithThrottledRefetch(context); + return undefined; } updateCachedThreadListStatusState(queryClient, threadId, statusChange); for (const queryKey of getFetchingThreadListQueryKeys(queryClient)) { diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 284b7e378e..dff0f6cbb8 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -2082,6 +2082,56 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("throttles the metadata-less status fallback to one active refetch per second", async () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const sidebarNavigationKey = sidebarNavigationQueryKey(); + const sidebarQueryFn = vi.fn(async () => ({ + projects: [{ threads: [{ id: "thr_1", status: "idle" }] }], + personalProject: { threads: [] }, + })); + const observer = new QueryObserver(queryClient, { + queryKey: sidebarNavigationKey, + queryFn: sidebarQueryFn, + staleTime: Infinity, + }); + const unsubscribe = observer.subscribe(() => {}); + await vi.advanceTimersByTimeAsync(0); + expect(sidebarQueryFn).toHaveBeenCalledTimes(1); + + const emitBareStatusChange = () => { + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { projectId: "project-1" }, + changes: ["status-changed"], + }); + }; + + // The first bare push after a quiet period still refetches immediately. + emitBareStatusChange(); + await vi.advanceTimersByTimeAsync(0); + expect(sidebarQueryFn).toHaveBeenCalledTimes(2); + + // A second push inside the same second coalesces instead of fanning out… + await vi.advanceTimersByTimeAsync(100); + emitBareStatusChange(); + await vi.advanceTimersByTimeAsync(100); + expect(sidebarQueryFn).toHaveBeenCalledTimes(2); + // …while the row still goes stale immediately for the next mount. + expect(queryClient.getQueryState(sidebarNavigationKey)?.isInvalidated).toBe( + true, + ); + + // The coalesced change lands as one trailing refetch at the 1s boundary. + await vi.advanceTimersByTimeAsync(1_000); + expect(sidebarQueryFn).toHaveBeenCalledTimes(3); + + unsubscribe(); + effects.dispose(); + }); + it("refetches over a patched row when a bare status-changed arrives while visible", async () => { // Stop requests, command failures and host interruptions push the bare // kind. On the visible path status-changed never enters the debounce From 1d4fd10294ce148eea5e422f72b3a703c7b4e278 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:49:57 +0200 Subject: [PATCH 14/34] Stop cancelling in-flight searches from the immediate status patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata branch of patchThreadListStatusState returned the search prefix for the generic handler loop to invalidate, which uses the default cancelRefetch and aborts a search request already in flight. Status changes ride the un-debounced immediate path, so on a slow link a streaming thread's flips could re-issue (and starve) an open search indefinitely — the same starvation the debounced path already fixed for turn completion. Invalidate the prefix directly with cancelRefetch: false, once per flush. Co-Authored-By: Claude Fable 5 (cherry picked from commit 615f16a7925793c77dd7278b773e42b6d77a1cf3) --- .../cache-owners/realtime-cache-registry.ts | 17 +++-- .../src/hooks/realtime-cache-effects.test.ts | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 92b173297d..99ed87dd44 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -1176,17 +1176,26 @@ function patchThreadListPendingInteractionState({ */ function patchThreadListStatusState( context: ThreadRealtimeDirtyContext, -): QueryKey[] | undefined { - const { queryClient, statusChange, threadId } = context; +): void { + const { flushOnce, queryClient, statusChange, threadId } = context; if (!threadId || !statusChange) { dirtyActiveThreadListQueriesWithThrottledRefetch(context); - return undefined; + return; } updateCachedThreadListStatusState(queryClient, threadId, statusChange); for (const queryKey of getFetchingThreadListQueryKeys(queryClient)) { queryClient.invalidateQueries({ exact: true, queryKey }); } - return [threadSearchQueryKeyPrefix()]; // Result rows render status but are not list-shaped. + // Result rows render status but are not list-shaped, so search refreshes + // rather than patches — once per flush and without aborting a request in + // flight: status changes ride the immediate path, and the default + // cancelling invalidation could starve an open search on a slow link. + if (flushOnce("thread-search:status-changed")) { + queryClient.invalidateQueries( + { queryKey: threadSearchQueryKeyPrefix() }, + { cancelRefetch: false }, + ); + } } function dirtyEnvironmentRecordQueries( diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index dff0f6cbb8..97601489ae 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -569,6 +569,73 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("does not abort an in-flight search when a status change patches the row", async () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const threadSearchKey = threadSearchQueryKey({ + limitPerGroup: 20, + query: "needle", + }); + // Cached data matters: the default cancelling invalidation only aborts + // and re-issues a fetch when the query already holds data — exactly the + // open-search-refreshing case a streaming turn's status flips would starve. + queryClient.setQueryData(threadSearchKey, { + active: { results: [], total: 0 }, + archived: { results: [], total: 0 }, + }); + const signals: AbortSignal[] = []; + const resolveFetches: Array<(value: unknown) => void> = []; + const searchQueryFn = vi.fn(({ signal }: { signal: AbortSignal }) => { + signals.push(signal); + return new Promise((resolve) => { + resolveFetches.push(resolve); + }); + }); + const searchObserver = new QueryObserver(queryClient, { + queryKey: threadSearchKey, + queryFn: searchQueryFn, + staleTime: Infinity, + }); + const unsubscribeSearch = searchObserver.subscribe(() => {}); + void searchObserver.refetch(); + await vi.advanceTimersByTimeAsync(0); + expect(searchQueryFn).toHaveBeenCalledTimes(1); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { + projectId: "project-1", + statusChange: { + activity: NO_THREAD_ACTIVITY, + latestAttentionAt: 100, + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + status: "active", + updatedAt: 200, + }, + }, + changes: ["status-changed"], + }); + await vi.advanceTimersByTimeAsync(0); + + // Status changes ride the immediate path, so a cancelling invalidation + // here could starve an open search forever; the request keeps running. + expect(signals[0]?.aborted).toBe(false); + expect(searchQueryFn).toHaveBeenCalledTimes(1); + + resolveFetches[0]?.({ + active: { results: [], total: 0 }, + archived: { results: [], total: 0 }, + }); + await vi.advanceTimersByTimeAsync(0); + unsubscribeSearch(); + effects.dispose(); + }); + it("marks the timeline of an unviewed thread stale without scheduling a refetch", async () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext(); From 14ec59a3c03c880f122e0f7999c15a16be4e8c7c Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:53:36 +0200 Subject: [PATCH 15/34] Gate the default reconnect refetch on lost realtime coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAppQueryClient gated the focus refetch (#2278) but left refetchOnReconnect at TanStack's default true, so every browser `online` event — which mobile Safari re-fires around the same suspensions the focus gate absorbs — refetched every active query while the socket and reconnect watermark already owned freshness. Apply the same lost-coverage gate to reconnect. The two policies with explicit refetchOnReconnect: true keep it deliberately: provider usage limits have no realtime change kind, and the resume-refetch surfaces (thread tabs, host file preview) opt out of the gate by design. Co-Authored-By: Claude Fable 5 (cherry picked from commit 4dfd6cde106b9ee34ecba5b00244bd5a512cd9fc) --- apps/app/src/hooks/queries/query-policies.ts | 13 ++++ apps/app/src/lib/query-client.test.ts | 81 ++++++++++++++++++++ apps/app/src/lib/query-client.ts | 22 ++++-- apps/app/src/main.tsx | 5 +- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/apps/app/src/hooks/queries/query-policies.ts b/apps/app/src/hooks/queries/query-policies.ts index 803f186ffb..4e6ca17406 100644 --- a/apps/app/src/hooks/queries/query-policies.ts +++ b/apps/app/src/hooks/queries/query-policies.ts @@ -21,12 +21,25 @@ export const SERVER_SESSION_QUERY_POLICY = { staleTime: SERVER_SESSION_STALE_TIME_MS, } as const; +/** + * Live values with no realtime change kind (provider usage limits): focus and + * reconnect are their only freshness sources, so the explicit `true`s + * deliberately bypass the app-level lost-realtime-coverage gate that + * `createAppQueryClient` applies to the defaults. + */ export const FOCUS_OWNED_LIVE_QUERY_POLICY = { refetchOnReconnect: true, refetchOnWindowFocus: true, staleTime: FOCUS_OWNED_LIVE_STALE_TIME_MS, } as const; +/** + * Explicit resume opt-in for queries whose realtime coverage has gaps: thread + * tabs are absent from the reconnect-watermark catch-up list, and the thread + * host file preview backs an open pane that must not keep stale bytes after + * an offline stretch. Deliberately bypasses the app-level + * lost-realtime-coverage gate (per-query options win over the defaults). + */ export const RESUME_REFETCH_QUERY_POLICY = { refetchOnReconnect: true, refetchOnWindowFocus: true, diff --git a/apps/app/src/lib/query-client.test.ts b/apps/app/src/lib/query-client.test.ts index 66b1b187da..6d49d3e16f 100644 --- a/apps/app/src/lib/query-client.test.ts +++ b/apps/app/src/lib/query-client.test.ts @@ -194,6 +194,87 @@ describe("createAppQueryClient", () => { queryClient.clear(); }); + it("keeps the default reconnect refetch when no gate is configured", async () => { + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + showMutationErrorToasts: false, + }); + queryClient.mount(); + + const queryFn = vi.fn(() => Promise.resolve("data")); + const observer = new QueryObserver(queryClient, { + queryKey: ["reconnect-ungated"], + queryFn, + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + + await vi.waitFor(() => { + expect(observer.getCurrentResult().data).toBe("data"); + }); + + window.dispatchEvent(new Event("offline")); + window.dispatchEvent(new Event("online")); + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2); + }); + + unsubscribe(); + queryClient.unmount(); + queryClient.clear(); + }); + + it("skips the default reconnect refetch while the gate reports realtime coverage", async () => { + let realtimeConnected = true; + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + shouldRefetchOnWindowFocus: () => !realtimeConnected, + showMutationErrorToasts: false, + }); + queryClient.mount(); + + const queryFn = vi.fn(() => Promise.resolve("data")); + const observer = new QueryObserver(queryClient, { + queryKey: ["reconnect-gated"], + queryFn, + // Instantly stale so a permitted reconnect refetch always fires. + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + + await vi.waitFor(() => { + expect(observer.getCurrentResult().data).toBe("data"); + }); + expect(queryFn).toHaveBeenCalledTimes(1); + + // Connected: realtime owns freshness, so the browser `online` blip that + // mobile Safari fires around suspensions must not refetch. + window.dispatchEvent(new Event("offline")); + window.dispatchEvent(new Event("online")); + await Promise.resolve(); + expect(queryFn).toHaveBeenCalledTimes(1); + + // Coverage lost: the reconnect refetch is the freshness fallback again. + realtimeConnected = false; + window.dispatchEvent(new Event("offline")); + window.dispatchEvent(new Event("online")); + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2); + }); + + unsubscribe(); + queryClient.unmount(); + queryClient.clear(); + }); + it("resumes a suspend-cancelled fetch that no focus refetch would restart", async () => { const queryClient = createAppQueryClient({ defaultOptions: { diff --git a/apps/app/src/lib/query-client.ts b/apps/app/src/lib/query-client.ts index 5f7c905576..e7eeb00063 100644 --- a/apps/app/src/lib/query-client.ts +++ b/apps/app/src/lib/query-client.ts @@ -18,14 +18,16 @@ interface CreateAppQueryClientOptions { defaultOptions?: QueryClientConfig["defaultOptions"]; showMutationErrorToasts?: boolean; /** - * Gate for the default focus refetch. Focus refetch is the freshness - * fallback for when realtime coverage is lost; while the socket is - * connected, change events keep the cache correct and the reconnect - * watermark repairs any gap, so a focus event (every phone unlock and - * app switch) must not refetch every active query on top of that wave. - * Defaults to always refetching. A `defaultOptions.queries.refetchOnWindowFocus` - * passed alongside this gate wins over it (caller defaults are spread last), - * so pass one or the other. + * Gate for the default focus and reconnect refetches. Both are the + * freshness fallback for when realtime coverage is lost; while the socket + * is connected, change events keep the cache correct and the reconnect + * watermark repairs any gap, so neither a focus event (every phone unlock + * and app switch) nor a browser `online` event (mobile Safari re-fires it + * around the same suspensions) must refetch every active query on top of + * that wave. Defaults to always refetching. A + * `defaultOptions.queries.refetchOnWindowFocus`/`refetchOnReconnect` + * passed alongside this gate wins over it (caller defaults are spread + * last), so pass one or the other. */ shouldRefetchOnWindowFocus?: () => boolean; } @@ -149,6 +151,10 @@ export function createAppQueryClient( shouldRefetchOnWindowFocus === undefined ? true : () => shouldRefetchOnWindowFocus(), + refetchOnReconnect: + shouldRefetchOnWindowFocus === undefined + ? true + : () => shouldRefetchOnWindowFocus(), retry: shouldRetryTransientReadQuery, retryDelay: TRANSIENT_READ_RETRY_DELAY_MS, ...defaultOptions?.queries, diff --git a/apps/app/src/main.tsx b/apps/app/src/main.tsx index 4d1c8fd865..f28358c4db 100644 --- a/apps/app/src/main.tsx +++ b/apps/app/src/main.tsx @@ -31,8 +31,9 @@ Error.stackTraceLimit = 50; const queryClient = createAppQueryClient({ // While the realtime socket is connected, change events and the reconnect - // watermark own cache freshness; a focus refetch on top would re-request - // every active query on each phone unlock and app switch. + // watermark own cache freshness; a focus or browser-online refetch on top + // would re-request every active query on each phone unlock, app switch, + // and mobile-Safari `online` blip. shouldRefetchOnWindowFocus: () => wsManager.getConnectionState() !== "connected", }); From ce28c928033e6860d1ab8b749cf2f0e73100afea Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:57:06 +0200 Subject: [PATCH 16/34] Share one visibility-gated 1 Hz ticker and widen the touch debounce LiveDurationText owned a setInterval per in-flight timeline row while the shared useSecondTick ticker existed for exactly this; every badge now rides one interval, and that interval stops entirely while the document is hidden, with an immediate tick on resume so durations jump to current truth. The realtime thread-invalidation debounce also reads the pointer class once at module init: coarse-pointer (touch) devices widen 50/200 to 150/400 so streaming reconciles stop competing with scrolling on a phone core, while desktop keeps the existing cadence. Co-Authored-By: Claude Fable 5 (cherry picked from commit 691dd0215783fdd24aa84ad196a66f532e07ef59) --- .../thread/timeline/TimelineTitleView.tsx | 31 ++++---- .../src/hooks/realtime-cache-effects.test.ts | 66 +++++++++++++++++ apps/app/src/hooks/realtime-cache-effects.ts | 36 +++++++++- apps/app/src/hooks/useSecondTick.test.ts | 71 +++++++++++++++++++ apps/app/src/hooks/useSecondTick.ts | 46 ++++++++++-- 5 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 apps/app/src/hooks/useSecondTick.test.ts diff --git a/apps/app/src/components/thread/timeline/TimelineTitleView.tsx b/apps/app/src/components/thread/timeline/TimelineTitleView.tsx index d6eaf56605..fbf93ab58f 100644 --- a/apps/app/src/components/thread/timeline/TimelineTitleView.tsx +++ b/apps/app/src/components/thread/timeline/TimelineTitleView.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useState } from "react"; +import { Fragment } from "react"; import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; import { assertNever, @@ -15,6 +15,7 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import { DiffStatsTally } from "@/components/ui/diff-stats-tally.js"; import { RouteAnchor } from "@/components/ui/app-route-anchor.js"; +import { useSecondTick } from "@/hooks/useSecondTick"; /** * Resolves a title's declared action to a click callback. Return `null` to @@ -209,26 +210,20 @@ function renderSegment( } /** - * Ticks the displayed elapsed time locally while the row is still active. - * The truth is `startedAt` (the wall-clock when the work began); the App - * derives `now - startedAt` and ticks once per second until the row reaches - * a terminal status (at which point a static `completedAt - startedAt` is - * shown by the caller instead). Stays empty until the elapsed time crosses - * the visible threshold (>1s) to avoid sub-second flicker on row entry. + * Ticks the displayed elapsed time while the row is still active. The truth + * is `startedAt` (the wall-clock when the work began); the App derives + * `now - startedAt` from the shared 1 Hz ticker — one interval for every + * in-flight row on screen, paused while the document is hidden — until the + * row reaches a terminal status (at which point a static + * `completedAt - startedAt` is shown by the caller instead). Stays empty + * until the elapsed time crosses the visible threshold (>1s) to avoid + * sub-second flicker on row entry. */ function LiveDurationText({ startedAt }: { startedAt: number }) { - const [tick, setTick] = useState(() => Date.now() - startedAt); + const elapsedMs = useSecondTick() - startedAt; - useEffect(() => { - setTick(Date.now() - startedAt); - const interval = window.setInterval(() => { - setTick(Date.now() - startedAt); - }, 1_000); - return () => window.clearInterval(interval); - }, [startedAt]); - - if (tick <= 1_000) return null; - return <>{durationToCompactString(tick)}; + if (elapsedMs <= 1_000) return null; + return <>{durationToCompactString(elapsedMs)}; } function renderDecoration( diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 97601489ae..95b15478fb 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -42,6 +42,7 @@ import { import { pluginContributionsQueryKey } from "./queries/query-keys"; import { createRealtimeCacheEffects, + resolveThreadInvalidationDebounce, type RealtimeCacheEffectsVisibility, } from "./realtime-cache-effects"; import { @@ -2563,6 +2564,71 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("keeps the fine-pointer cadence and widens it for coarse pointers", () => { + expect(resolveThreadInvalidationDebounce(false)).toEqual({ + debounceMs: 50, + maxWaitMs: 200, + }); + expect(resolveThreadInvalidationDebounce(true)).toEqual({ + debounceMs: 150, + maxWaitMs: 400, + }); + }); + + it("widens the thread invalidation debounce on coarse pointers", async () => { + vi.useFakeTimers(); + // The pointer class is read from matchMedia once at module init, so the + // coarse branch needs a fresh module instance with a stubbed window. + // `location` rides along because the re-imported graph reaches the sdk + // module, which resolves its base URL from the window at init. + vi.stubGlobal("window", { + location: { origin: "http://localhost" }, + matchMedia: (query: string) => ({ + matches: query === "(pointer: coarse)", + }), + }); + vi.resetModules(); + try { + const coarseModule = await import("./realtime-cache-effects"); + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + }, + }, + showMutationErrorToasts: false, + }); + const effects = coarseModule.createRealtimeCacheEffects({ queryClient }); + const timelineKey = threadTimelineQueryKey("thr_1"); + queryClient.setQueryData(timelineKey, { rows: [] }); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { + eventTypes: ["item/agentMessage/delta"], + projectId: "project-1", + }, + changes: ["events-appended"], + }); + + // The fine-pointer cadence would have flushed at 50 ms. + vi.advanceTimersByTime(50); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).not.toBe( + true, + ); + vi.advanceTimersByTime(100); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + + effects.dispose(); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + it("applies the reconnect watermark from the connected event", () => { const { effects, queryClient } = createRealtimeEffectsTestContext(); const disconnectedAt = Date.now(); diff --git a/apps/app/src/hooks/realtime-cache-effects.ts b/apps/app/src/hooks/realtime-cache-effects.ts index 215748164a..5e13dc4eff 100644 --- a/apps/app/src/hooks/realtime-cache-effects.ts +++ b/apps/app/src/hooks/realtime-cache-effects.ts @@ -34,8 +34,40 @@ import { partitionThreadChangesByFlushPriority, } from "./cache-owners/realtime-cache-registry"; -const INVALIDATION_DEBOUNCE_MS = 50; -const INVALIDATION_MAX_WAIT_MS = 200; +interface ThreadInvalidationDebounce { + debounceMs: number; + maxWaitMs: number; +} + +/** + * Streaming publishes arrive up to every 50 ms per thread, and each flush + * reconciles the whole unwindowed timeline/list state. Desktops absorb the + * 50/200 cadence (up to 20 reconciles/s); on coarse-pointer (touch) devices + * the same cadence competes with scroll and input handling on a phone core, + * so the window widens to 150/400 — still well inside perceived-live + * territory. Exported for tests; production reads the pointer class once at + * module init (it does not change mid-session). + */ +export function resolveThreadInvalidationDebounce( + isCoarsePointer: boolean, +): ThreadInvalidationDebounce { + return isCoarsePointer + ? { debounceMs: 150, maxWaitMs: 400 } + : { debounceMs: 50, maxWaitMs: 200 }; +} + +function detectCoarsePointer(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(pointer: coarse)").matches + ); +} + +const { + debounceMs: INVALIDATION_DEBOUNCE_MS, + maxWaitMs: INVALIDATION_MAX_WAIT_MS, +} = resolveThreadInvalidationDebounce(detectCoarsePointer()); const ENVIRONMENT_INVALIDATION_DEBOUNCE_MS = 250; const ENVIRONMENT_INVALIDATION_MAX_WAIT_MS = 500; diff --git a/apps/app/src/hooks/useSecondTick.test.ts b/apps/app/src/hooks/useSecondTick.test.ts new file mode 100644 index 0000000000..edc8ac3383 --- /dev/null +++ b/apps/app/src/hooks/useSecondTick.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSecondTick } from "./useSecondTick"; + +function setDocumentVisibility(state: "hidden" | "visible"): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: state, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +describe("useSecondTick", () => { + afterEach(() => { + vi.useRealTimers(); + Reflect.deleteProperty(document, "visibilityState"); + }); + + it("pauses the shared ticker while hidden and jumps to now on resume", () => { + vi.useFakeTimers(); + const { result, unmount } = renderHook(() => useSecondTick()); + const initial = result.current; + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(result.current).toBe(initial + 1_000); + + // Hidden: the interval stops entirely — no timer wakes a suspended phone + // to re-render durations nothing can see. + act(() => { + setDocumentVisibility("hidden"); + }); + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(result.current).toBe(initial + 1_000); + + // Visible again: one immediate tick jumps durations to current truth + // instead of waiting out the next second, then the cadence resumes. + act(() => { + setDocumentVisibility("visible"); + }); + expect(result.current).toBe(initial + 6_000); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(result.current).toBe(initial + 7_000); + + unmount(); + }); + + it("shares one interval across subscribers and stops with the last one", () => { + vi.useFakeTimers(); + const first = renderHook(() => useSecondTick()); + const second = renderHook(() => useSecondTick()); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + // One shared tick value, not two phase-shifted timers. + expect(first.result.current).toBe(second.result.current); + + first.unmount(); + second.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/app/src/hooks/useSecondTick.ts b/apps/app/src/hooks/useSecondTick.ts index e96c0b3fe0..aa6c1003e4 100644 --- a/apps/app/src/hooks/useSecondTick.ts +++ b/apps/app/src/hooks/useSecondTick.ts @@ -1,4 +1,8 @@ import { useSyncExternalStore } from "react"; +import { + isDocumentVisible, + subscribeToDocumentVisibility, +} from "@/lib/document-visibility"; /** * One 1 Hz ticker shared by every live-duration label. Each label used to own @@ -6,27 +10,61 @@ import { useSyncExternalStore } from "react"; * mounted that is many timers firing at slightly different phases, each a * separate render. One interval, one notification per second, and it stops * when the last subscriber leaves. + * + * The interval also stops while the document is hidden: nothing it drives can + * be seen, and on phones the pending timer only queues work for the resume. + * The first tick after becoming visible fires immediately so durations jump + * to the current truth instead of waiting out the next second. */ const listeners = new Set<() => void>(); let lastTickMs = 0; let intervalId: ReturnType | null = null; +let unsubscribeVisibility: (() => void) | null = null; function tick(): void { lastTickMs = Date.now(); for (const listener of listeners) listener(); } +function startInterval(): void { + if (intervalId === null && isDocumentVisible()) { + intervalId = setInterval(tick, 1_000); + } +} + +function stopInterval(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } +} + +function handleVisibilityChange(): void { + if (!isDocumentVisible()) { + stopInterval(); + return; + } + if (listeners.size > 0 && intervalId === null) { + tick(); + startInterval(); + } +} + function subscribe(listener: () => void): () => void { if (listeners.size === 0) { lastTickMs = Date.now(); - intervalId = setInterval(tick, 1_000); + unsubscribeVisibility = subscribeToDocumentVisibility( + handleVisibilityChange, + ); + startInterval(); } listeners.add(listener); return () => { listeners.delete(listener); - if (listeners.size === 0 && intervalId !== null) { - clearInterval(intervalId); - intervalId = null; + if (listeners.size === 0) { + stopInterval(); + unsubscribeVisibility?.(); + unsubscribeVisibility = null; } }; } From b7c699c78c6664d277644ddd9977535988812af2 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:49:11 +0200 Subject: [PATCH 17/34] Single-flight the connect gate's per-isolate caches (plan 007 step 5) A cold isolate's page-load burst (100+ authenticated requests) paid one label-resolve and one session-verify D1 round trip per request until the first one settled, because labelCache/sessionCache stored only settled values. Store the in-flight promise at query start instead, so request 2..N of a burst join request 1's round trip; a rejected lookup is evicted on settle so a D1 hiccup cannot poison a key for its TTL. TTLs and the fresh-read bypass are unchanged (they encode revocation latency). New tests fail before (6 D1 selects for a 6-request burst) and pass after (1 select), for both resolveLabel and verifySessionCookie. Co-Authored-By: Claude Fable 5 (cherry picked from commit c11a1cc8f6d2251794ef3d37194571abc604911a) --- apps/connect/src/session.test.ts | 113 +++++++++++++++++++++++++ apps/connect/src/session.ts | 139 +++++++++++++++++++++---------- 2 files changed, 206 insertions(+), 46 deletions(-) diff --git a/apps/connect/src/session.test.ts b/apps/connect/src/session.test.ts index 585fb66ac3..137ffbfd44 100644 --- a/apps/connect/src/session.test.ts +++ b/apps/connect/src/session.test.ts @@ -24,6 +24,7 @@ import { markMachineSeen, resolveLabel, verifyMachineCredentialDetails, + verifySessionCookie, verifySessionCookieDetails, } from "./session.js"; import { refreshAccountSessionCookies } from "./account-session.js"; @@ -547,6 +548,118 @@ describe("account session refresh", () => { }); }); +/** + * Counts D1 round trips without mocking the database: every drizzle query + * starts with `db.select(...)`, so counting reads of the `select` property + * counts queries. Methods are bound to the real db so drizzle internals never + * re-enter the proxy. + */ +function countingDb(target: typeof db): { + db: typeof db; + counts: { select: number }; +} { + const counts = { select: 0 }; + const proxied = new Proxy(target, { + get(t, prop) { + if (prop === "select") counts.select += 1; + const value = Reflect.get(t, prop); + return typeof value === "function" ? value.bind(t) : value; + }, + }); + return { db: proxied, counts }; +} + +describe("single-flight gate caches", () => { + it("collapses a cold burst of label lookups into one D1 round trip", async () => { + seedUser("acct-flight"); + seedServer({ + id: "srv-flight", + userId: "acct-flight", + name: "default", + subdomain: "flight-label", + }); + const counted = countingDb(db); + + const resolved = await Promise.all( + Array.from({ length: 6 }, () => resolveLabel("flight-label", counted.db)), + ); + + expect(counted.counts.select).toBe(1); + for (const label of resolved) { + expect(label).toMatchObject({ kind: "server", userId: "acct-flight" }); + } + }); + + it("does not cache a failed lookup: the next request retries D1", async () => { + seedUser("acct-flight-retry"); + seedServer({ + id: "srv-flight-retry", + userId: "acct-flight-retry", + name: "default", + subdomain: "flight-retry", + }); + let failNext = true; + const failingOnce = new Proxy(db, { + get(t, prop) { + if (prop === "select" && failNext) { + failNext = false; + throw new Error("d1 hiccup"); + } + const value = Reflect.get(t, prop); + return typeof value === "function" ? value.bind(t) : value; + }, + }); + + await expect(resolveLabel("flight-retry", failingOnce)).rejects.toThrow( + "d1 hiccup", + ); + await expect(resolveLabel("flight-retry", failingOnce)).resolves.toMatchObject( + { kind: "server", userId: "acct-flight-retry" }, + ); + }); + + it("collapses a cold burst of session verifications into one D1 round trip", async () => { + seedUser("acct-cookie-flight"); + const token = `sess_flight_${crypto.randomUUID()}`; + const secret = "flight-secret"; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sigBuf = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(token), + ); + const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); + const cookieValue = `${token}.${sig}`; + db.insert(session) + .values({ + id: `sess-${token}`, + token, + // Must be relative to wall clock: verifySessionCookie uses Date.now(). + expiresAt: new Date(Date.now() + 60_000), + userId: "acct-cookie-flight", + createdAt: now, + updatedAt: now, + }) + .run(); + const counted = countingDb(db); + + const verified = await Promise.all( + Array.from({ length: 6 }, () => + verifySessionCookie(cookieValue, secret, counted.db), + ), + ); + + expect(counted.counts.select).toBe(1); + expect(verified).toEqual(Array.from({ length: 6 }, () => "acct-cookie-flight")); + }); +}); + describe("machine credential presence", () => { it("verifies the owning machine and throttles lastSeenAt writes", async () => { seedUser("acct-machine"); diff --git a/apps/connect/src/session.ts b/apps/connect/src/session.ts index c6d36dfd2a..0b9637bd19 100644 --- a/apps/connect/src/session.ts +++ b/apps/connect/src/session.ts @@ -18,6 +18,12 @@ import { // D1. TTLs are short so sign-out / disconnect take effect quickly (and the DO // already severs a live tunnel on revoke, so a stale-cached label still can't // reach a disconnected server). +// +// Entries hold the lookup's promise, stored the moment the query starts, so a +// COLD isolate's request burst is also one D1 round trip per key: request +// 2..N join request 1's in-flight lookup instead of issuing their own. A +// rejected lookup is evicted when it settles — only successful lookups (and +// deliberate cached negatives) live out the TTL. const LABEL_TTL_MS = 15_000; const SESSION_TTL_MS = 20_000; const SESSION_REFRESH_BEFORE_EXPIRY_MS = @@ -25,7 +31,7 @@ const SESSION_REFRESH_BEFORE_EXPIRY_MS = 1000; interface CacheEntry { - value: T; + value: Promise; expires: number; } const labelCache = new Map>(); @@ -45,13 +51,38 @@ function cacheGet( map: Map>, key: string, now: number, -): T | undefined { +): Promise | undefined { const hit = map.get(key); if (hit && hit.expires > now) return hit.value; if (hit) map.delete(key); return undefined; } +function cacheStore( + map: Map>, + key: string, + value: Promise, + expires: number, + /** Recomputes the entry's expiry once the lookup lands (e.g. clamping a + * session entry to its D1 row's own expiration). */ + settledExpires?: (value: T) => number, +): Promise { + const entry: CacheEntry = { value, expires }; + map.set(key, entry); + value.then( + (settled) => { + // Guard the identity: a fresh read or an invalidation may have + // replaced this entry already. + if (settledExpires === undefined || map.get(key) !== entry) return; + entry.expires = settledExpires(settled); + }, + () => { + if (map.get(key) === entry) map.delete(key); + }, + ); + return value; +} + interface ResolvedServer { kind: "server"; /** @@ -107,7 +138,18 @@ export async function resolveLabel( const cached = cacheGet(labelCache, label, now); if (cached !== undefined) return cached; } + return cacheStore( + labelCache, + label, + lookupLabel(label, db), + now + LABEL_TTL_MS, + ); +} +async function lookupLabel( + label: string, + db: ConnectDb, +): Promise { const serverRow = await db .select({ userId: server.userId, @@ -120,7 +162,7 @@ export async function resolveLabel( .where(eq(server.subdomain, label)) .get(); if (serverRow) { - const resolvedServer: ResolvedServer = { + return { kind: "server", userId: serverRow.userId, server: { @@ -130,11 +172,6 @@ export async function resolveLabel( lastSeenAt: serverRow.lastSeenAt, }, }; - labelCache.set(label, { - value: resolvedServer, - expires: now + LABEL_TTL_MS, - }); - return resolvedServer; } const machineRow = await db @@ -159,25 +196,19 @@ export async function resolveLabel( ) .where(eq(machine.subdomain, label)) .get(); - const resolvedMachine: ResolvedMachine | null = machineRow - ? { - kind: "machine", - routingKey: machineRoutingKey(label, machineRow.generation), - userId: machineRow.userId, - accountHandle: machineRow.accountHandle, - machine: { - id: machineRow.machineId, - credentialHash: machineRow.credentialHash, - revokedAt: machineRow.revokedAt, - lastSeenAt: machineRow.lastSeenAt, - }, - } - : null; - labelCache.set(label, { - value: resolvedMachine, - expires: now + LABEL_TTL_MS, - }); - return resolvedMachine; + if (!machineRow) return null; + return { + kind: "machine", + routingKey: machineRoutingKey(label, machineRow.generation), + userId: machineRow.userId, + accountHandle: machineRow.accountHandle, + machine: { + id: machineRow.machineId, + credentialHash: machineRow.credentialHash, + revokedAt: machineRow.revokedAt, + lastSeenAt: machineRow.lastSeenAt, + }, + }; } export interface VerifiedSessionCookie { @@ -214,8 +245,6 @@ export async function verifySessionCookieDetails( const decoded = safeDecode(cookieValue); const dot = decoded.lastIndexOf("."); if (dot <= 0) return null; - const token = decoded.slice(0, dot); - const providedSig = decoded.slice(dot + 1); const now = Date.now(); // Cache on the full `token.sig` value, not the token alone: keying on the @@ -224,9 +253,38 @@ export async function verifySessionCookieDetails( // one would negative-poison the real token). The full-cookie key makes the // cache reflect exactly what passed verification. const cached = cacheGet(sessionCache, decoded, now); - if (cached !== undefined) - return cached === null ? null : verifiedSession(cached, now); + const cachedSession = + cached !== undefined + ? await cached + : await cacheStore( + sessionCache, + decoded, + lookupCachedSession( + decoded.slice(0, dot), + decoded.slice(dot + 1), + secret, + db, + now, + ), + now + SESSION_TTL_MS, + // A positive entry must not outlive its D1 session row; the clamp + // runs at settle time because a single-flight entry is stored + // before the row is known. + (looked) => + looked === null + ? now + SESSION_TTL_MS + : Math.min(now + SESSION_TTL_MS, looked.expiresAt), + ); + return cachedSession === null ? null : verifiedSession(cachedSession, now); +} +async function lookupCachedSession( + token: string, + providedSig: string, + secret: string, + db: ConnectDb, + now: number, +): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), @@ -240,27 +298,16 @@ export async function verifySessionCookieDetails( new TextEncoder().encode(token), ); const expectedSig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); - if (!constantTimeEqual(providedSig, expectedSig)) { - sessionCache.set(decoded, { value: null, expires: now + SESSION_TTL_MS }); - return null; - } + // A bad signature is a deliberate cached negative: it costs no D1 read and + // keeps a forged cookie from hammering the crypto path per request. + if (!constantTimeEqual(providedSig, expectedSig)) return null; const row = await db .select({ expiresAt: session.expiresAt, userId: session.userId }) .from(session) .where(and(eq(session.token, token), gt(session.expiresAt, new Date(now)))) .get(); - const cachedSession = row - ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } - : null; - sessionCache.set(decoded, { - value: cachedSession, - expires: - row === undefined - ? now + SESSION_TTL_MS - : Math.min(now + SESSION_TTL_MS, row.expiresAt.getTime()), - }); - return cachedSession === null ? null : verifiedSession(cachedSession, now); + return row ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } : null; } /** Returns the owning user for callers that do not participate in refresh. */ From 791d9bcdaeddbe1d82e50d8ea8f53ed6e62e043d Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:52:27 +0200 Subject: [PATCH 18/34] Front-load the stylesheet and font preload in the built document (plan 007 step 1) Vite appends its built asset tags as [entry script, 69 modulepreloads, stylesheet], and the font preload plugin appended after that, so the render-blocking stylesheet was the 68th resource the preload scanner discovered and the Inter preload was dead last. The connect tunnel serializes responses FIFO on one WebSocket, so discovery order is delivery order: first paint sat behind ~1.5 MB of JavaScript. The bb:font-preload post transform now performs the head surgery itself: it moves the stylesheet (with fetchpriority=high) and the font preload ahead of the entry script and modulepreload block, keeps the pre-paint theme script ahead of the stylesheet (build fails loudly if that ever inverts), and leaves the body palette script's append-last contract intact. The new emitted-order test asserts against the real dist/index.html: before this change it failed with stylesheet at byte 9654 vs first modulepreload at 3861; after, the order is theme script (2891) < font preload (3801) < stylesheet (3922) < entry (3985) < modulepreloads (4065). Co-Authored-By: Claude Fable 5 (cherry picked from commit cb6c05e0bae00b256e1fb42dcb4729471a85ebc8) --- apps/app/src/vite-font-preload.test.ts | 94 +++++++++++++++++++++++++- apps/app/vite-font-preload.ts | 92 +++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/apps/app/src/vite-font-preload.test.ts b/apps/app/src/vite-font-preload.test.ts index 0d6f401274..899ab5ccef 100644 --- a/apps/app/src/vite-font-preload.test.ts +++ b/apps/app/src/vite-font-preload.test.ts @@ -1,5 +1,10 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveFontPreloadTags } from "../vite-font-preload.js"; +import { + reorderHeadForFirstPaint, + resolveFontPreloadTags, +} from "../vite-font-preload.js"; const bundle = [ "assets/index-rXrqkkAU.js", @@ -37,3 +42,90 @@ describe("resolveFontPreloadTags", () => { expect(resolveFontPreloadTags(["assets/index-abc.js"], "/")).toEqual([]); }); }); + +/** The head layout Vite emits: theme script, then entry + preloads + css. */ +const builtHtml = [ + "", + '', + '', + '', + '', + '', + "", +].join(""); + +describe("reorderHeadForFirstPaint", () => { + const fontTags = resolveFontPreloadTags(bundle, "/"); + + it("moves the stylesheet and font preload ahead of the script and preload block", () => { + const html = reorderHeadForFirstPaint(builtHtml, fontTags); + + const themeAt = html.indexOf("bb.theme"); + const fontAt = html.search(/]*as="font"/); + const stylesheetAt = html.search(/]*rel="stylesheet"/); + const entryAt = html.search(/"; + expect(reorderHeadForFirstPaint(bare, [])).toBe(bare); + }); + + it("refuses to move the stylesheet ahead of the pre-paint theme script", () => { + const themeless = builtHtml.replace("bb.theme", "bb.other"); + expect(() => reorderHeadForFirstPaint(themeless, fontTags)).toThrow( + /pre-paint theme script/, + ); + }); +}); + +const distIndexHtmlPath = resolve(import.meta.dirname, "../dist/index.html"); + +/** + * The built document, not a fixture: the tunnel serializes responses FIFO on + * one WebSocket, so discovery order in dist/index.html IS delivery order on + * the relayed mobile path. The render-blocking stylesheet and the font + * preload must be discovered before the modulepreload block, and the + * pre-paint theme script must still run before the stylesheet applies. + * Skipped when dist/ is absent (test runs without a build). + */ +describe.skipIf(!existsSync(distIndexHtmlPath))( + "emitted dist/index.html head order", + () => { + it("puts the stylesheet and font preload before every modulepreload, after the theme script", () => { + const html = readFileSync(distIndexHtmlPath, "utf8"); + const stylesheetAt = html.search(/]*rel="stylesheet"/); + const fontPreloadAt = html.search(/]*as="font"/); + const firstModulepreloadAt = html.search(/ value !== undefined && value !== false) + .map(([name, value]) => (value === true ? name : `${name}="${value}"`)) + .join(" "); + return `<${tag.tag} ${attrs}>`; +} + +/** + * Moves the render-blocking stylesheet and the font preload ahead of the + * modulepreload block in the built document. + * + * Vite appends its asset tags in [entry script, modulepreload…, stylesheet] + * order, which put the stylesheet 68th and the font preload last among the + * document's resources. The tunnel relay serializes responses FIFO on one + * WebSocket, so discovery order is delivery order: first paint waited for + * ~1.5 MB of JavaScript to clear the wire before the CSS arrived. + * + * The pre-paint theme script (`bb.theme` in index.html) must keep running + * before the stylesheet applies — otherwise every dark-mode cold load + * flashes the light palette — so this refuses to move the stylesheet ahead + * of it and fails the build rather than shipping the flash. + */ +export function reorderHeadForFirstPaint( + html: string, + fontPreloadTags: HtmlTagDescriptor[], +): string { + const stylesheets: string[] = []; + const withoutStylesheets = html.replace( + /[ \t]*]*rel="stylesheet"[^>]*>\n?/g, + (tag) => { + stylesheets.push(tag.trim()); + return ""; + }, + ); + + const block = [ + ...fontPreloadTags.map(serializeTag), + ...stylesheets.map((tag) => + tag.includes("fetchpriority") + ? tag + : tag.replace("]*src=/), + html.indexOf(""), + ].filter((index) => index >= 0); + if (candidates.length === 0) { + throw new Error("bb:font-preload: built index.html has no "); + } + return Math.min(...candidates); +} + /** - * Preloads the Inter latin woff2 from index.html. Without it the font request - * starts only once the CSS has parsed and the first text node needs it, which - * on a phone is after ~1.5 MB of JavaScript. Build-only: the dev server has no - * hashed asset to point at, and dev has no first-paint budget. + * Build-only head surgery for first paint: preloads the Inter latin woff2 and + * moves it plus the app stylesheet ahead of the modulepreload block (see + * reorderHeadForFirstPaint). Without the preload the font request starts only + * once the CSS has parsed and the first text node needs it, which on a phone + * is after ~1.5 MB of JavaScript. The dev server has no hashed asset to point + * at, and dev has no first-paint budget. */ export function fontPreload(): Plugin { let base = "/"; @@ -59,9 +134,12 @@ export function fontPreload(): Plugin { }, transformIndexHtml: { order: "post", - handler(_html, ctx) { - if (ctx.bundle === undefined) return []; - return resolveFontPreloadTags(Object.keys(ctx.bundle), base); + handler(html, ctx) { + if (ctx.bundle === undefined) return html; + return reorderHeadForFirstPaint( + html, + resolveFontPreloadTags(Object.keys(ctx.bundle), base), + ); }, }, }; From 347aeddfc8496db7af1760901d5042e51c00ee48 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:54:15 +0200 Subject: [PATCH 19/34] Merge boot micro-chunks with rolldown advancedChunks (plan 007 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolldown's automatic splitting left the boot payload as 70 chunks, 34 of them under 4 KB and 18 under the 1 KiB precompress floor — half the boot requests carried ~2% of the bytes, and on the relayed mobile path each one is a full worker → DO → tunnel → laptop round trip. Two advancedChunks groups tagged $initial (the entry's static-import closure) merge that graph: a vendor group so app-only releases keep the vendor hash cacheable, and an app group for the rest. Lazy-route and on-demand facades are untouched (their modules are not $initial), so the budget's closure walk, forbiddenPackages and onDemandPackages gates hold unchanged. Measured (bundle-stats.json + check-bundle-budget): boot chunks 70 -> 3 boot raw 1575.8 KB -> 1548.2 KB boot brotli 443.0 KB -> 381.2 KB SplitWorkspaceRoute closure 2018.4/538.6 KB -> 2001.1/533.5 KB (45 chunks) index.html 10.8 KB -> 5.2 KB (69 -> 2 modulepreloads) bundle budget OK Co-Authored-By: Claude Fable 5 (cherry picked from commit 0203c0b0429bf51a883647e65b36c4389fe8aba3) --- apps/app/vite.config.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index a9b3515d1e..7c93173792 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -33,6 +33,37 @@ export const sharedViteConfig = { // and let the browser fetch only the ones a menu actually renders. assetsInlineLimit: (filePath) => filePath.includes("/workspace-open-target-icons/") ? false : undefined, + rolldownOptions: { + output: { + // Merge the boot payload's micro-chunks. Rolldown's automatic + // splitting left half the boot-path requests carrying ~2% of the + // bytes (sub-4 KB shared chunks, many below the 1 KiB precompress + // floor), and on the relayed mobile path every request is a full + // worker → DO → tunnel → laptop round trip. The `$initial` tag + // captures exactly the entry's static-import closure, so lazy-route + // and on-demand facades (and the budget's closure walk and + // forbidden-package gates over them) are untouched. Two groups so a + // release that only touches app code leaves the vendor chunk's hash + // — the bulk of the boot bytes — cacheable across updates. + advancedChunks: { + groups: [ + { + name: "boot-vendor", + test: /node_modules/, + tags: ["$initial"], + priority: 2, + minSize: 12 * 1024, + }, + { + name: "boot-app", + tags: ["$initial"], + priority: 1, + minSize: 12 * 1024, + }, + ], + }, + }, + }, }, optimizeDeps: { // The terminal imports xterm lazily when the panel mounts. Pre-optimize From 41df3e3b0f78c79766187407b8a4e098bca39932 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 13:55:52 +0200 Subject: [PATCH 20/34] Precompress the document and serve its sidecar on the SPA fallback (plan 007 step 3) .html was missing from COMPRESSIBLE_EXTENSIONS, so the app shell had no .br/.gz sidecar and every cold navigation shipped it re-compressed on the fly at gzip stream quality through the tunnel. The SPA fallback (the document response for every client route a phone opens) also bypassed findPrecompressedStaticFile entirely, reading index.html as utf8. Add .html to the precompress set (index.html.br: 5.2 KB -> 1.6 KB) and route the fallback through the same sidecar-aware serving path as a direct file hit. text/html already passes the precompressed content-type allowlist, so a direct /index.html hit picks the sidecar up unchanged. Co-Authored-By: Claude Fable 5 (cherry picked from commit 06c0ebd0aceab79b4ef164d8762fa41b31466a4f) --- apps/app/src/precompress-app-dist.test.ts | 9 ++- apps/server/src/server.ts | 67 ++++++++++++++--------- scripts/precompress-app-dist.mjs | 1 + 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/apps/app/src/precompress-app-dist.test.ts b/apps/app/src/precompress-app-dist.test.ts index b661a1f6bd..7343760d4d 100644 --- a/apps/app/src/precompress-app-dist.test.ts +++ b/apps/app/src/precompress-app-dist.test.ts @@ -28,10 +28,14 @@ describe("app asset precompression", () => { const distDir = await mkdtemp(resolve(tmpdir(), "bb-precompress-test-")); const compressibleBody = Buffer.from("compressible bb asset\n".repeat(400)); const assetPath = resolve(distDir, "app.js"); + // The document itself: without a sidecar every cold navigation on the + // relayed mobile path ships the shell uncompressed through the tunnel. + const documentPath = resolve(distDir, "index.html"); const smallPath = resolve(distDir, "small.js"); const binaryPath = resolve(distDir, "image.png"); await Promise.all([ writeFile(assetPath, compressibleBody), + writeFile(documentPath, compressibleBody), writeFile(smallPath, "small"), writeFile(binaryPath, compressibleBody), ]); @@ -41,13 +45,16 @@ describe("app asset precompression", () => { distDir, ]); - expect(stdout).toContain("precompressed 1 files (1 br, 1 gzip)"); + expect(stdout).toContain("precompressed 2 files (2 br, 2 gzip)"); await expect( decompressBrotli(await readFile(`${assetPath}.br`)), ).resolves.toEqual(compressibleBody); await expect( decompressGzip(await readFile(`${assetPath}.gz`)), ).resolves.toEqual(compressibleBody); + await expect( + decompressBrotli(await readFile(`${documentPath}.br`)), + ).resolves.toEqual(compressibleBody); await expect(pathExists(`${smallPath}.br`)).resolves.toBe(false); await expect(pathExists(`${binaryPath}.br`)).resolves.toBe(false); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b60e561c77..53656643c8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -634,6 +634,37 @@ export function createApp( ".map": "application/json", }; + const serveStaticAppFile = async (args: { + acceptEncodingHeader: string | undefined; + contentType: string; + filePath: string; + urlPath: string; + }): Promise => { + const precompressedFile = await findPrecompressedStaticFile({ + acceptEncodingHeader: args.acceptEncodingHeader, + contentType: args.contentType, + filePath: args.filePath, + }); + if (precompressedFile !== null) { + const content = await readFile(precompressedFile.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentEncoding: precompressedFile.encoding, + contentLength: precompressedFile.contentLength, + contentType: args.contentType, + urlPath: args.urlPath, + }), + }); + } + const content = await readFile(args.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentType: args.contentType, + urlPath: args.urlPath, + }), + }); + }; + app.get("*", async (context) => { const root = shippedRoot; const urlPath = @@ -645,27 +676,11 @@ export function createApp( try { const fileStat = await stat(filePath); if (fileStat.isFile()) { - const contentType = - MIME[extname(filePath)] ?? "application/octet-stream"; - const precompressedFile = await findPrecompressedStaticFile({ + return await serveStaticAppFile({ acceptEncodingHeader: context.req.header("accept-encoding"), - contentType, + contentType: MIME[extname(filePath)] ?? "application/octet-stream", filePath, - }); - if (precompressedFile !== null) { - const content = await readFile(precompressedFile.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentEncoding: precompressedFile.encoding, - contentLength: precompressedFile.contentLength, - contentType, - urlPath, - }), - }); - } - const content = await readFile(filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ contentType, urlPath }), + urlPath, }); } } catch { @@ -679,12 +694,14 @@ export function createApp( if (urlPath.startsWith("/assets/")) { return context.notFound(); } - const indexHtml = await readFile(join(root, "index.html"), "utf8"); - return new Response(indexHtml, { - headers: createStaticResponseHeaders({ - contentType: "text/html", - urlPath: "/index.html", - }), + // The SPA fallback is the document response for every client route + // (every thread page a phone opens), so it serves the same sidecar as + // a direct /index.html hit. + return serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: "text/html", + filePath: join(root, "index.html"), + urlPath: "/index.html", }); }); } diff --git a/scripts/precompress-app-dist.mjs b/scripts/precompress-app-dist.mjs index 90a9bf493a..5177b4e7b8 100644 --- a/scripts/precompress-app-dist.mjs +++ b/scripts/precompress-app-dist.mjs @@ -11,6 +11,7 @@ const DEFAULT_COMPRESSION_CONCURRENCY = 8; const MIN_COMPRESS_BYTES = 1024; const COMPRESSIBLE_EXTENSIONS = new Set([ ".css", + ".html", ".js", ".json", ".mjs", From 1e1f6a34e6ab9f9f45352a90b2580ee37804ca81 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 17:31:26 +0200 Subject: [PATCH 21/34] Edge-cacheable app shell: build-id ETag + connect revalidation (plan 007 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell was no-cache, which apps/connect/src/cache.ts treats as never-cacheable: every cold navigation paid a full worker -> DO -> tunnel -> laptop round trip for the document before the browser learned what to fetch. no-cache existed so a new build is picked up immediately — that property had to survive. Server half: the shell (direct and SPA-fallback, unified behind registerStaticAppRoutes) now carries max-age=300, must-revalidate plus a weak build-id ETag derived from the served file's bytes (index.html embeds every hashed asset URL, so the content hash IS the build id; cached per path, revalidated by size+mtime). If-None-Match answers with an empty 304 carrying the same validator and cache-control. Connect half: serveWithCache learns a revalidated-shell flavor, checked before plain cacheability (the shell's max-age=300 would otherwise be cached without the revalidation its must-revalidate demands). The last confirmed document is stored in caches.default with its origin headers intact — its own max-age bounds storage at <=300s — and every navigation revalidates through the tunnel: the visitor's If-None-Match is forwarded when present (304 relayed), otherwise the stored ETag makes the round trip a 304 and the body is served from the edge, rebuilt pre-encoded exactly like an asset hit. A fresh 200 replaces the stored copy, so a new build takes effect on the next navigation; an origin that stops speaking the contract (dev server) gets its stored copy dropped. Deviation from the plan sketch: one self-describing cache entry (ETag in the stored response's own header, confirmed by the origin before every serve) instead of separate (label, ETag)-keyed body + pointer entries. Equivalent consistency, and it keeps cache.put on the proven clone-of-subrequest path — workerd's put of a header-rewritten rebuild has exactly the encoding ambiguity response-encoding.ts exists to avoid. Old worker + new server skew is safe (the old worker plain-caches the shell for at most 300s); new worker + old server is inert (no ETag, no must-revalidate -> no shell flow). No server<->host-daemon wire change, so no HOST_DAEMON_PROTOCOL_VERSION bump. Verified: new apps/server/src/static-shell.test.ts (sidecar + ETag + 304 on both paths, ETag rotation on a new build); static-cache.test.ts updated from the old no-cache pin; new apps/connect/src/ document-cache.test.ts in real workerd via the tunnel fixture — repeat navigation served from caches.default with only a 304 on the tunnel, build change shipped on the next navigation, visitor 304 relayed. Co-Authored-By: Claude Fable 5 (cherry picked from commit f70f363243d06fd0a00f933b3c86cf10c6158fe6) --- apps/connect/src/cache.ts | 132 +++++++++- apps/connect/src/document-cache.test.ts | 228 ++++++++++++++++++ apps/connect/src/worker.ts | 9 +- apps/connect/test/encoding-fixture.ts | 22 +- apps/server/src/server.ts | 279 +++++++++++++++------- apps/server/src/static-shell.test.ts | 117 +++++++++ apps/server/test/app/static-cache.test.ts | 19 +- 7 files changed, 708 insertions(+), 98 deletions(-) create mode 100644 apps/connect/src/document-cache.test.ts create mode 100644 apps/server/src/static-shell.test.ts diff --git a/apps/connect/src/cache.ts b/apps/connect/src/cache.ts index 762d338a94..1250bd1bd6 100644 --- a/apps/connect/src/cache.ts +++ b/apps/connect/src/cache.ts @@ -3,6 +3,14 @@ // the tunnel round-trip entirely — turning a page's hundreds of asset requests // into a handful of dynamic API calls plus edge hits. // +// The app shell (index.html on every client route) gets a second, revalidated +// flavor: the origin serves it with `max-age=300, must-revalidate` plus a +// build-id ETag, so the worker keeps the last confirmed document at the edge +// and asks the laptop only "is still current?" on each navigation. A +// 304 costs the tunnel a handful of header bytes instead of the document, and +// a new build still takes effect on the next navigation because the origin +// answers that conditional request with the fresh 200. +// // Only called AFTER the gate has verified the requester owns the label. Server // cache namespaces remain the bare/full host label exactly as on main; new // machine labels include their ownership generation. Caching is opt-in via the @@ -12,8 +20,17 @@ import { rebuiltResponse } from "./response-encoding.js"; const CACHE_HOST = "https://bb-connect-asset-cache.internal"; +// A separate host keeps shell entries from ever colliding with asset entries +// for the same namespace + path. +const SHELL_CACHE_HOST = "https://bb-connect-shell-cache.internal"; const MIN_CACHEABLE_MAX_AGE = 300; +/** + * The origin fetch for a gated request. `ifNoneMatch` asks the tunnel client + * to make the request conditional so an unchanged shell answers with a 304. + */ +export type FetchOrigin = (init?: { ifNoneMatch: string }) => Promise; + /** Build the edge-cache Request key for a namespace label + visitor URL. */ export function cacheKey(namespace: string, url: URL): Request { return new Request(`${CACHE_HOST}/${namespace}${url.pathname}${url.search}`, { @@ -21,6 +38,14 @@ export function cacheKey(namespace: string, url: URL): Request { }); } +/** Edge-cache key for the revalidated shell copy of a namespace + URL. */ +export function shellCacheKey(namespace: string, url: URL): Request { + return new Request( + `${SHELL_CACHE_HOST}/${namespace}${url.pathname}${url.search}`, + { method: "GET" }, + ); +} + function isCacheable(resp: Response): boolean { if (!resp.ok) return false; if (resp.headers.has("set-cookie")) return false; @@ -36,6 +61,95 @@ export interface CacheResult { response: Response; } +/** + * A response the origin wants cached only under revalidation: a build-id ETag + * plus `must-revalidate` with a short freshness window. The bb server marks + * exactly one response this way — the app shell — but the check is + * header-driven, so any origin (including a port share) opting in with the + * same contract gets the same treatment. Checked before `isCacheable`: the + * shell's `max-age=300` would otherwise be cached plainly and served without + * the revalidation its `must-revalidate` demands. + */ +function isRevalidatableShell(resp: Response): boolean { + if (!resp.ok) return false; + if (resp.headers.has("set-cookie")) return false; + if (resp.headers.get("etag") === null) return false; + const cc = resp.headers.get("cache-control") ?? ""; + if (/\b(no-store|no-cache|private)\b/i.test(cc)) return false; + if (!/\bmust-revalidate\b/i.test(cc)) return false; + const maxAge = cc.match(/max-age=(\d+)/i); + return maxAge !== null && Number(maxAge[1]) >= 1; +} + +/** + * Store the shell response and build the visitor's copy. The clone is stored + * with its origin headers intact — including the ETag the entry is keyed to + * in spirit: a stored shell is only ever served after the origin confirms + * that exact ETag with a 304 — and its `max-age=300` bounds the storage, so + * nothing stale outlives the origin's own freshness window. + */ +function storeShellAndServe( + resp: Response, + namespace: string, + url: URL, + ctx: ExecutionContext, +): CacheResult { + ctx.waitUntil(caches.default.put(shellCacheKey(namespace, url), resp.clone())); + // Same encoding rule as the asset miss below: a body read out of a + // subrequest is already plain bytes, so automatic encoding is correct. + const r = new Response(resp.body, resp); + r.headers.set("x-bb-cache", "miss"); + return { cacheable: true, response: r }; +} + +/** + * A shell copy exists at the edge: revalidate it against the origin before + * serving. The visitor's own If-None-Match wins when present (the origin + * validates it and a relayed 304 is the cheapest possible answer); otherwise + * the stored copy's ETag makes the round trip a 304 whenever the build is + * unchanged. + */ +async function serveRevalidatedShell( + request: Request, + shellHit: Response, + namespace: string, + url: URL, + ctx: ExecutionContext, + fetchOrigin: FetchOrigin, +): Promise { + const storedEtag = shellHit.headers.get("etag"); + const visitorEtag = request.headers.get("if-none-match"); + const conditionalEtag = visitorEtag ?? storedEtag; + const resp = await fetchOrigin( + conditionalEtag === null ? undefined : { ifNoneMatch: conditionalEtag }, + ); + if (resp.status === 304) { + if (visitorEtag !== null) { + // The origin confirmed the visitor's own copy — relay the 304. + const r = rebuiltResponse(null, resp); + r.headers.set("x-bb-cache", "revalidated"); + return { cacheable: true, response: r }; + } + // The stored bytes are still encoded exactly like an asset hit's (the + // cache keeps the origin's encoding), so rebuild as pre-encoded. + // `cacheable: true` is load-bearing beyond refresh semantics: the + // session-refresh path rebuilds non-cacheable responses to append + // Set-Cookie, and that rebuild would strip this body's pre-encoded flag. + const r = rebuiltResponse(shellHit.body, shellHit); + r.headers.set("x-bb-cache", "revalidated"); + return { cacheable: true, response: r }; + } + if (isRevalidatableShell(resp)) { + return storeShellAndServe(resp, namespace, url, ctx); + } + if (resp.ok) { + // The origin stopped speaking the shell contract (say a dev server took + // over the label) — drop the stored copy so requests stop revalidating. + ctx.waitUntil(caches.default.delete(shellCacheKey(namespace, url))); + } + return { cacheable: false, response: resp }; +} + /** * Serve `request` from the edge cache when possible, else run `fetchOrigin` * (the tunnel) and populate the cache when the response is cacheable. @@ -47,7 +161,7 @@ export async function serveWithCache( request: Request, namespace: string, ctx: ExecutionContext, - fetchOrigin: () => Promise, + fetchOrigin: FetchOrigin, ): Promise { if (request.method !== "GET") { return { cacheable: false, response: await fetchOrigin() }; @@ -68,7 +182,23 @@ export async function serveWithCache( return { cacheable: true, response: r }; } + // Not an immutable asset — maybe a previously stored shell document. + const shellHit = await cache.match(shellCacheKey(namespace, url)); + if (shellHit) { + return serveRevalidatedShell( + request, + shellHit, + namespace, + url, + ctx, + fetchOrigin, + ); + } + const resp = await fetchOrigin(); + if (isRevalidatableShell(resp)) { + return storeShellAndServe(resp, namespace, url, ctx); + } if (isCacheable(resp)) { // clone() before the body is consumed by the returned response. ctx.waitUntil(cache.put(key, resp.clone())); diff --git a/apps/connect/src/document-cache.test.ts b/apps/connect/src/document-cache.test.ts new file mode 100644 index 0000000000..4f58046e45 --- /dev/null +++ b/apps/connect/src/document-cache.test.ts @@ -0,0 +1,228 @@ +// The revalidated shell cache, exercised through the real TunnelDO and the +// real serveWithCache inside workerd (miniflare) — the same harness as +// response-encoding.test.ts, because the cache stores still-encoded bytes and +// `encodeBody` exists only in workerd. +// +// The fake tunnel client plays a bb server that speaks the shell contract: +// `max-age=300, must-revalidate` plus a build-id ETag, 304 for a matching +// If-None-Match. The tests pin the design's three properties: a repeat +// navigation is served from caches.default with only a 304 on the tunnel, a +// build change takes effect on the next navigation, and a visitor's own +// conditional request relays the origin's 304. +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { build } from "esbuild"; +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { decodeFrame, encodeFrame, type Frame } from "@bb/tunnel-contract"; + +const SHELL_CACHE_CONTROL = "max-age=300, must-revalidate"; + +const BUILD_A = { + etag: 'W/"build-a"', + html: `bb${"

    build a

    ".repeat(40)}`, +}; +const BUILD_B = { + etag: 'W/"build-b"', + html: `bb${"

    build b — new hashes

    ".repeat(40)}`, +}; + +type ClientWebSocket = NonNullable< + Awaited>["webSocket"] +>; + +let mf: Miniflare; +let tunnel: ClientWebSocket; + +/** What the fake bb server serves right now; tests flip it to ship a build. */ +let currentBuild = BUILD_A; +/** One entry per relayed request: what the origin saw and had to send. */ +const originLog: { ifNoneMatch: string | null; sentBody: boolean }[] = []; + +async function bundleFixture(): Promise { + const result = await build({ + entryPoints: [ + fileURLToPath(new URL("../test/encoding-fixture.ts", import.meta.url)), + ], + bundle: true, + format: "esm", + target: "esnext", + conditions: ["workerd", "worker", "browser"], + write: false, + }); + return result.outputFiles[0].text; +} + +/** A tunnel client whose origin serves the shell contract for every path. */ +function serveShellOverTunnel(ws: ClientWebSocket): void { + const send = (frame: Frame) => ws.send(new Uint8Array(encodeFrame(frame))); + ws.addEventListener("message", (event) => { + if (typeof event.data === "string") return; + const frame = decodeFrame(event.data as ArrayBuffer); + if (frame.type !== "open-http") return; + const ifNoneMatch = + frame.headers.find(([name]) => name.toLowerCase() === "if-none-match")?.[1] ?? + null; + if (ifNoneMatch === currentBuild.etag) { + originLog.push({ ifNoneMatch, sentBody: false }); + send({ + type: "resp-head", + streamId: frame.streamId, + status: 304, + headers: [ + ["etag", currentBuild.etag], + ["cache-control", SHELL_CACHE_CONTROL], + ], + }); + send({ type: "body-end", streamId: frame.streamId }); + return; + } + originLog.push({ ifNoneMatch, sentBody: true }); + const gzip = gzipSync(Buffer.from(currentBuild.html)); + send({ + type: "resp-head", + streamId: frame.streamId, + status: 200, + headers: [ + ["content-type", "text/html; charset=utf-8"], + ["content-encoding", "gzip"], + ["content-length", String(gzip.byteLength)], + ["cache-control", SHELL_CACHE_CONTROL], + ["etag", currentBuild.etag], + ], + }); + send({ + type: "body-chunk", + streamId: frame.streamId, + data: new Uint8Array(gzip), + }); + send({ type: "body-end", streamId: frame.streamId }); + }); +} + +async function get( + path: string, + headers: Record = {}, +): Promise<{ + status: number; + cacheMarker: string | null; + etag: string | null; + body: string; +}> { + const res = await mf.dispatchFetch(`https://relay.test${path}`, { + headers: { "accept-encoding": "gzip", ...headers }, + }); + return { + status: res.status, + cacheMarker: res.headers.get("x-bb-cache"), + etag: res.headers.get("etag"), + body: Buffer.from(await res.arrayBuffer()).toString("utf8"), + }; +} + +/** Cache writes ride ctx.waitUntil; poll the fixture's probe before relying on them. */ +async function waitForShellCached(path: string): Promise { + for (let i = 0; i < 50; i += 1) { + const res = await mf.dispatchFetch( + `https://relay.test/shell-cached?for=${encodeURIComponent(path)}`, + ); + if (res.status === 200) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`shell copy for ${path} never landed in caches.default`); +} + +beforeAll(async () => { + mf = new Miniflare({ + modules: [ + { + type: "ESModule", + path: "/fixture.js", + contents: await bundleFixture(), + }, + ], + modulesRoot: "/", + scriptPath: "/fixture.js", + compatibilityDate: "2026-06-11", + compatibilityFlags: ["nodejs_compat"], + durableObjects: { TUNNEL_DO: "TunnelDO" }, + d1Databases: { DB: "fixture-db" }, + bindings: { + BASE_DOMAIN: "relay.test", + BETTER_AUTH_SECRET: "fixture-secret", + GZIP_BODY_B64: gzipSync(Buffer.from("unused")).toString("base64"), + }, + }); + await mf.ready; + + const dial = await mf.dispatchFetch("https://relay.test/__tunnel", { + headers: { Upgrade: "websocket" }, + }); + if (!dial.webSocket) throw new Error(`tunnel dial failed: ${dial.status}`); + tunnel = dial.webSocket; + tunnel.accept(); + serveShellOverTunnel(tunnel); +}, 60_000); + +afterAll(async () => { + tunnel?.close(); + await mf?.dispose(); +}); + +describe("revalidated shell cache", () => { + it("serves repeats from caches.default with only a 304 on the tunnel, and ships a new build on the next navigation", async () => { + // Cold: full document through the tunnel, stored at the edge. + const cold = await get("/threads/t1"); + expect(cold.status).toBe(200); + expect(cold.body).toBe(BUILD_A.html); + expect(cold.cacheMarker).toBe("miss"); + expect(originLog.at(-1)).toEqual({ ifNoneMatch: null, sentBody: true }); + await waitForShellCached("/threads/t1"); + + // Repeat: the origin only confirms the ETag; the body comes from the + // edge cache. + const repeat = await get("/threads/t1"); + expect(repeat.status).toBe(200); + expect(repeat.body).toBe(BUILD_A.html); + expect(repeat.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_A.etag, + sentBody: false, + }); + + // Ship a build: the same conditional request now returns the fresh 200, + // so the next navigation renders the new shell. + currentBuild = BUILD_B; + const upgraded = await get("/threads/t1"); + expect(upgraded.status).toBe(200); + expect(upgraded.body).toBe(BUILD_B.html); + expect(upgraded.etag).toBe(BUILD_B.etag); + expect(upgraded.cacheMarker).toBe("miss"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_A.etag, + sentBody: true, + }); + await waitForShellCached("/threads/t1"); + + // And the new build revalidates from the edge like the old one did. + const settled = await get("/threads/t1"); + expect(settled.body).toBe(BUILD_B.html); + expect(settled.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_B.etag, + sentBody: false, + }); + }, 30_000); + + it("relays the origin's 304 when the visitor presents a current validator", async () => { + currentBuild = BUILD_B; + const res = await get("/threads/t1", { "if-none-match": BUILD_B.etag }); + expect(res.status).toBe(304); + expect(res.body).toBe(""); + expect(res.cacheMarker).toBe("revalidated"); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_B.etag, + sentBody: false, + }); + }, 30_000); +}); diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts index 4bc7896d59..32ce7cf89b 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -502,7 +502,14 @@ export default { request, cacheNamespace(routingKey, target), ctx, - () => stub.fetch(doRequest), + (init) => { + if (init === undefined) return stub.fetch(doRequest); + // Shell revalidation: the edge holds the last confirmed document, so + // ask the origin to confirm its ETag instead of resending the body. + const headers = new Headers(doRequest.headers); + headers.set("if-none-match", init.ifNoneMatch); + return stub.fetch(new Request(doRequest, { headers })); + }, ); let response = cached.response; // Tunnel down + a browser navigation → the styled offline page, using the diff --git a/apps/connect/test/encoding-fixture.ts b/apps/connect/test/encoding-fixture.ts index 02226767b7..2e6df09622 100644 --- a/apps/connect/test/encoding-fixture.ts +++ b/apps/connect/test/encoding-fixture.ts @@ -5,7 +5,7 @@ // // This wires up the production pieces themselves: the real TunnelDO (driven by // a fake tunnel client over a real WebSocket) behind the real serveWithCache. -import { cacheKey, serveWithCache } from "../src/cache.js"; +import { cacheKey, serveWithCache, shellCacheKey } from "../src/cache.js"; export { TunnelDO } from "../src/tunnel-do.js"; @@ -67,6 +67,19 @@ export default { }); } + // Probe: whether the revalidated shell copy for a path has landed in the + // edge cache yet. cache writes ride ctx.waitUntil, so tests poll this + // instead of racing the put. + if (url.pathname === "/shell-cached") { + const target = url.searchParams.get("for") ?? "/"; + const cached = await caches.default.match( + shellCacheKey(NAMESPACE, new URL(`${url.origin}${target}`)), + ); + return new Response(cached ? "cached" : "absent", { + status: cached ? 200 : 404, + }); + } + // Control: the pre-fix cache-hit rebuild, over the entry serveWithCache // stored for another path. if (url.pathname === "/legacy-cache-hit") { @@ -79,7 +92,12 @@ export default { } return ( - await serveWithCache(request, NAMESPACE, ctx, () => stub.fetch(request)) + await serveWithCache(request, NAMESPACE, ctx, (init) => { + if (init === undefined) return stub.fetch(request); + const headers = new Headers(request.headers); + headers.set("if-none-match", init.ifNoneMatch); + return stub.fetch(new Request(request, { headers })); + }) ).response; }, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 53656643c8..8bd7efc68f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,4 +1,5 @@ import { createNodeWebSocket } from "@hono/node-ws"; +import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { extname, join, resolve } from "node:path"; @@ -133,13 +134,21 @@ interface StaticResponseHeadersArgs { contentEncoding?: string; contentLength?: number; contentType: string; + /** Present only for the app shell; other static files rely on hashes/TTLs. */ + etag?: string; urlPath: string; } -// `no-cache` (not `no-store`): the document is revalidated on every -// navigation, so a new build is picked up immediately, but WebKit may still -// keep the page in the back/forward cache and restore it without a reload. -const STATIC_INDEX_CACHE_CONTROL = "no-cache"; +// The document travels with a build-id ETag (see shellEtag): the browser may +// reuse it for five minutes, then must revalidate — an If-None-Match answered +// with a 304, which costs the connect tunnel a handful of header bytes — and +// the connect worker revalidates its edge copy on every navigation, so a new +// build still takes effect on the next navigation there. This replaces +// `no-cache`, whose "new build picked up immediately" property the ETag +// preserves without re-sending the document each time. Still not `no-store`: +// WebKit may keep the page in the back/forward cache and restore it without a +// reload. +const STATIC_INDEX_CACHE_CONTROL = "max-age=300, must-revalidate"; const STATIC_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"; // Icons and manifests under public/ are not content-hashed but change only // with a release; a day of caching keeps favicon/badge flips and PWA @@ -188,6 +197,9 @@ function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers { const headers = new Headers(); headers.set("content-type", args.contentType); headers.set("cache-control", staticCacheControlForPath(args.urlPath)); + if (args.etag !== undefined) { + headers.set("etag", args.etag); + } if (args.contentEncoding !== undefined) { headers.set("content-encoding", args.contentEncoding); headers.set("vary", "Accept-Encoding"); @@ -198,6 +210,178 @@ function createStaticResponseHeaders(args: StaticResponseHeadersArgs): Headers { return headers; } +/** + * Build-id ETag for the app shell, derived from the served file's bytes: + * index.html embeds every content-hashed asset URL, so its content changes + * exactly when a build does. Cached per path and revalidated by (size, + * mtime) so an in-place dist swap gets a fresh tag without hashing every + * request. Weak, because the precompressed sidecars are equivalent — not + * byte-identical — representations of the same document. + */ +const shellEtagCache = new Map< + string, + { etag: string; mtimeMs: number; size: number } +>(); + +async function shellEtag(filePath: string): Promise { + try { + const fileStat = await stat(filePath); + const cached = shellEtagCache.get(filePath); + if ( + cached !== undefined && + cached.size === fileStat.size && + cached.mtimeMs === fileStat.mtimeMs + ) { + return cached.etag; + } + const digest = createHash("sha256") + .update(await readFile(filePath)) + .digest("hex"); + const etag = `W/"${digest.slice(0, 32)}"`; + shellEtagCache.set(filePath, { + etag, + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + }); + return etag; + } catch { + // Unreadable file: serve without a validator rather than failing the + // request here; the read below will surface the real error. + return undefined; + } +} + +/** RFC 9110 §13.1.2: If-None-Match always compares weakly for GET. */ +export function ifNoneMatchSatisfied( + ifNoneMatchHeader: string, + etag: string, +): boolean { + if (ifNoneMatchHeader.trim() === "*") return true; + const opaque = (tag: string): string => tag.trim().replace(/^W\//u, ""); + const target = opaque(etag); + return ifNoneMatchHeader + .split(",") + .some((candidate) => opaque(candidate) === target); +} + +const STATIC_MIME_TYPES: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".webmanifest": "application/manifest+json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".webp": "image/webp", + ".map": "application/json", +}; + +/** + * Serves the built app from `staticDir`: content-hashed assets, public files, + * and the shell (index.html — directly and as the single-page-app fallback + * for every client route). Registered by createApp; exported so tests can + * exercise the shell contract (sidecar, ETag, 304) against a bare Hono app. + */ +export function registerStaticAppRoutes(app: Hono, staticDir: string): void { + const root = resolve(staticDir); + + const serveStaticAppFile = async (args: { + acceptEncodingHeader: string | undefined; + contentType: string; + filePath: string; + ifNoneMatchHeader: string | undefined; + urlPath: string; + }): Promise => { + // Only the shell carries a validator: assets are immutable by hash and + // public files by TTL, but the document must revalidate cheaply — a 304 + // here is what keeps `max-age=300, must-revalidate` as prompt as the old + // `no-cache` without resending the document every navigation. + const etag = + args.contentType === "text/html" + ? await shellEtag(args.filePath) + : undefined; + if ( + etag !== undefined && + args.ifNoneMatchHeader !== undefined && + ifNoneMatchSatisfied(args.ifNoneMatchHeader, etag) + ) { + const headers = new Headers(); + headers.set("cache-control", staticCacheControlForPath(args.urlPath)); + headers.set("etag", etag); + return new Response(null, { status: 304, headers }); + } + const precompressedFile = await findPrecompressedStaticFile({ + acceptEncodingHeader: args.acceptEncodingHeader, + contentType: args.contentType, + filePath: args.filePath, + }); + if (precompressedFile !== null) { + const content = await readFile(precompressedFile.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentEncoding: precompressedFile.encoding, + contentLength: precompressedFile.contentLength, + contentType: args.contentType, + etag, + urlPath: args.urlPath, + }), + }); + } + const content = await readFile(args.filePath); + return new Response(content, { + headers: createStaticResponseHeaders({ + contentType: args.contentType, + etag, + urlPath: args.urlPath, + }), + }); + }; + + app.get("*", async (context) => { + const urlPath = context.req.path === "/" ? "/index.html" : context.req.path; + const filePath = join(root, urlPath); + if (!filePath.startsWith(root)) { + return context.notFound(); + } + try { + const fileStat = await stat(filePath); + if (fileStat.isFile()) { + return await serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: + STATIC_MIME_TYPES[extname(filePath)] ?? "application/octet-stream", + filePath, + ifNoneMatchHeader: context.req.header("if-none-match"), + urlPath, + }); + } + } catch { + // File not found — fall through to SPA fallback + } + // /assets/ holds content-hashed build output, never a client route, so + // a miss there is a stale reference rather than a page to render. The + // single-page-app fallback would answer it with index.html at status + // 200, and the browser would report a confusing MIME type error for a + // script instead of a plain 404. Mirrors the /api/v1/* guard above. + if (urlPath.startsWith("/assets/")) { + return context.notFound(); + } + // The SPA fallback is the document response for every client route + // (every thread page a phone opens), so it serves the same sidecar and + // validator as a direct /index.html hit. + return serveStaticAppFile({ + acceptEncodingHeader: context.req.header("accept-encoding"), + contentType: "text/html", + filePath: join(root, "index.html"), + ifNoneMatchHeader: context.req.header("if-none-match"), + urlPath: "/index.html", + }); + }); +} + function canServePrecompressedStaticFile(contentType: string): boolean { return ( contentType.startsWith("text/") || @@ -618,92 +802,7 @@ export function createApp( } if (options?.staticDir) { - const shippedRoot = resolve(options.staticDir); - const MIME: Record = { - ".html": "text/html", - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", - ".webmanifest": "application/manifest+json", - ".png": "image/png", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".webp": "image/webp", - ".map": "application/json", - }; - - const serveStaticAppFile = async (args: { - acceptEncodingHeader: string | undefined; - contentType: string; - filePath: string; - urlPath: string; - }): Promise => { - const precompressedFile = await findPrecompressedStaticFile({ - acceptEncodingHeader: args.acceptEncodingHeader, - contentType: args.contentType, - filePath: args.filePath, - }); - if (precompressedFile !== null) { - const content = await readFile(precompressedFile.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentEncoding: precompressedFile.encoding, - contentLength: precompressedFile.contentLength, - contentType: args.contentType, - urlPath: args.urlPath, - }), - }); - } - const content = await readFile(args.filePath); - return new Response(content, { - headers: createStaticResponseHeaders({ - contentType: args.contentType, - urlPath: args.urlPath, - }), - }); - }; - - app.get("*", async (context) => { - const root = shippedRoot; - const urlPath = - context.req.path === "/" ? "/index.html" : context.req.path; - const filePath = join(root, urlPath); - if (!filePath.startsWith(root)) { - return context.notFound(); - } - try { - const fileStat = await stat(filePath); - if (fileStat.isFile()) { - return await serveStaticAppFile({ - acceptEncodingHeader: context.req.header("accept-encoding"), - contentType: MIME[extname(filePath)] ?? "application/octet-stream", - filePath, - urlPath, - }); - } - } catch { - // File not found — fall through to SPA fallback - } - // /assets/ holds content-hashed build output, never a client route, so - // a miss there is a stale reference rather than a page to render. The - // single-page-app fallback would answer it with index.html at status - // 200, and the browser would report a confusing MIME type error for a - // script instead of a plain 404. Mirrors the /api/v1/* guard above. - if (urlPath.startsWith("/assets/")) { - return context.notFound(); - } - // The SPA fallback is the document response for every client route - // (every thread page a phone opens), so it serves the same sidecar as - // a direct /index.html hit. - return serveStaticAppFile({ - acceptEncodingHeader: context.req.header("accept-encoding"), - contentType: "text/html", - filePath: join(root, "index.html"), - urlPath: "/index.html", - }); - }); + registerStaticAppRoutes(app, options.staticDir); } return { diff --git a/apps/server/src/static-shell.test.ts b/apps/server/src/static-shell.test.ts new file mode 100644 index 0000000000..573a545db1 --- /dev/null +++ b/apps/server/src/static-shell.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { brotliCompressSync } from "node:zlib"; +import { Hono } from "hono"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ifNoneMatchSatisfied, registerStaticAppRoutes } from "./server.js"; + +/** + * The shell contract the connect worker's edge cache builds on: the document + * (served directly and as the SPA fallback for every client route) carries a + * build-id ETag and `max-age=300, must-revalidate`, answers If-None-Match + * with a cheap 304, and ships its precompressed sidecar when the client + * accepts it. A regression here silently turns every relayed navigation back + * into a full-document tunnel round trip. + */ +describe("app shell serving", () => { + const shellHtml = "bb

    build-a

    "; + const shellBrotli = brotliCompressSync(Buffer.from(shellHtml)); + let dir: string; + let app: Hono; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "bb-static-shell-")); + await writeFile(join(dir, "index.html"), shellHtml); + await writeFile(join(dir, "index.html.br"), shellBrotli); + app = new Hono(); + registerStaticAppRoutes(app, dir); + }); + + it("serves the brotli sidecar with the shell headers, directly and on the SPA fallback", async () => { + for (const path of ["/", "/threads/some-thread"]) { + const res = await app.request(path, { + headers: { "accept-encoding": "br, gzip" }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-type")).toBe("text/html"); + expect(res.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect(res.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); + expect(Buffer.from(await res.arrayBuffer())).toEqual(shellBrotli); + } + }); + + it("serves the identity document when the client accepts no encodings", async () => { + const res = await app.request("/threads/t"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("etag")).toMatch(/^W\//u); + expect(await res.text()).toBe(shellHtml); + }); + + it("answers a matching If-None-Match with an empty 304 on both paths", async () => { + const first = await app.request("/"); + const etag = first.headers.get("etag"); + expect(etag).not.toBeNull(); + + for (const path of ["/", "/threads/some-thread"]) { + const res = await app.request(path, { + headers: { "accept-encoding": "br", "if-none-match": etag ?? "" }, + }); + expect(res.status).toBe(304); + expect(res.headers.get("etag")).toBe(etag); + expect(res.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect((await res.arrayBuffer()).byteLength).toBe(0); + } + }); + + it("serves the full document for a stale validator", async () => { + const res = await app.request("/", { + headers: { "if-none-match": 'W/"0000000000000000000000000000dead"' }, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(shellHtml); + }); + + it("rotates the ETag when a new build lands, so old validators refetch", async () => { + const first = await app.request("/"); + const oldEtag = first.headers.get("etag") ?? ""; + + const nextBuild = + "bb

    build-b with new hashed assets

    "; + await writeFile(join(dir, "index.html"), nextBuild); + + const res = await app.request("/", { + headers: { "if-none-match": oldEtag }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("etag")).not.toBe(oldEtag); + expect(await res.text()).toBe(nextBuild); + }); + + it("keeps /assets/ misses as 404 instead of the SPA fallback", async () => { + const res = await app.request("/assets/stale-chunk.js"); + expect(res.status).toBe(404); + }); +}); + +describe("ifNoneMatchSatisfied", () => { + const etag = 'W/"abc123"'; + + it("compares weakly and accepts lists and wildcards", () => { + expect(ifNoneMatchSatisfied('W/"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied('"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied('"zzz", W/"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied("*", etag)).toBe(true); + }); + + it("rejects a different validator", () => { + expect(ifNoneMatchSatisfied('W/"other"', etag)).toBe(false); + expect(ifNoneMatchSatisfied('"abc1234"', etag)).toBe(false); + }); +}); diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts index 12180811ca..26ac7d523e 100644 --- a/apps/server/test/app/static-cache.test.ts +++ b/apps/server/test/app/static-cache.test.ts @@ -38,13 +38,24 @@ describe("production static cache headers", () => { const harness = await createTestAppHarness(); const serverApp = createApp(harness.deps, { staticDir }); try { - // `no-cache` revalidates on every navigation but, unlike `no-store`, - // leaves the document eligible for the WebKit back/forward cache. + // The shell travels with max-age=300 + must-revalidate + a build-id + // ETag: browsers and the connect edge revalidate with If-None-Match + // (a cheap 304) instead of refetching the document, and unlike + // `no-store` it stays eligible for the WebKit back/forward cache. const rootResponse = await serverApp.app.request("/"); - expect(rootResponse.headers.get("cache-control")).toBe("no-cache"); + expect(rootResponse.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + expect(rootResponse.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); const fallbackResponse = await serverApp.app.request("/threads/thr_123"); - expect(fallbackResponse.headers.get("cache-control")).toBe("no-cache"); + expect(fallbackResponse.headers.get("cache-control")).toBe( + "max-age=300, must-revalidate", + ); + // Same document, same validator: the fallback IS the shell. + expect(fallbackResponse.headers.get("etag")).toBe( + rootResponse.headers.get("etag"), + ); const assetResponse = await serverApp.app.request( "/assets/index-test.js", From 5a51b64f2275499c0888ca9de2168996fb367a80 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:35:13 +0000 Subject: [PATCH 22/34] Show the transient scrollbar on coarse pointers again PR #2385 stopped writing data-scrollbar-scrolling on coarse pointers on the assumption that the attribute only feeds desktop ::-webkit-scrollbar rules. The .thread-scrollbar/.transient-scrollbar rules in app.css are not pointer-gated and set `scrollbar-color: transparent transparent` at rest, and Android Chrome and iOS apply scrollbar-color to their overlay indicators, so a touch flick never showed a thumb at all. Write the attribute on every pointer again, but only when it is not already set: the thumb toggles once per scroll burst (plus the 600ms idle clear) instead of re-invalidating style on every scroll event. The coarse-pointer test now asserts one attribute write per burst through a MutationObserver, and the cadence test settles its first capture by advancing the clock rather than running pending timers, which would now also fire the idle timeout and move the faked clock past the write. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- ...chored-scroll-body.coarse-pointer.test.tsx | 38 ++++++++++++++----- .../ui/bottom-anchored-scroll-body.tsx | 20 +++++----- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx index 0863cf478e..acc7d6fe23 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.coarse-pointer.test.tsx @@ -6,8 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BottomAnchoredScrollBody } from "@/components/ui/bottom-anchored-scroll-body"; import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; -// Per-scroll-event costs that differ by pointer type: the transient-scrollbar -// attribute (desktop-scrollbar-only CSS) is skipped on coarse pointers, the +// Per-scroll-event costs on coarse pointers: the transient-scrollbar attribute +// is written once per scroll burst (the at-rest `.thread-scrollbar` rules hide +// the thumb on every platform, so a touch flick still has to flip it), the // scroll-anchor capture throttle relaxes to the coarse cadence, and captures // reuse a cached row NodeList that the ResizeObserver invalidates. @@ -145,15 +146,32 @@ afterEach(() => { }); describe("BottomAnchoredScrollBody on coarse pointers", () => { - it("never writes the transient scrollbar attribute", () => { + it("shows the transient scrollbar with one attribute write per scroll burst", () => { stubMediaQueries(new Set(["(pointer: coarse)"])); + vi.useFakeTimers(); const { scrollArea } = renderTimeline("coarse-thread", ["row-a"]); + const attributeWrites = new MutationObserver(() => {}); + attributeWrites.observe(scrollArea, { + attributes: true, + attributeFilter: ["data-scrollbar-scrolling"], + }); - fireEvent.scroll(scrollArea); + // `.thread-scrollbar` paints the thumb transparent at rest on every + // platform, and Android Chrome / iOS apply `scrollbar-color` to their + // overlay indicators, so a touch flick still has to flip the attribute — + // once per burst: the steady-state events must not re-set it, since each + // attribute write is a style invalidation while the browser is scrolling. + for (let scrollTop = 10; scrollTop <= 50; scrollTop += 10) { + scrollArea.scrollTop = scrollTop; + fireEvent.scroll(scrollArea); + } + expect(scrollArea.getAttribute("data-scrollbar-scrolling")).toBe("true"); + expect(attributeWrites.takeRecords()).toHaveLength(1); - // The attribute only feeds desktop ::-webkit-scrollbar rules; on touch it - // would be a per-scroll-event style invalidation with no visible effect. + // The thumb hides again once scrolling goes idle. + vi.runAllTimers(); expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); + attributeWrites.disconnect(); }); it("captures scroll anchors at the relaxed coarse cadence", () => { @@ -162,10 +180,12 @@ describe("BottomAnchoredScrollBody on coarse pointers", () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); const { scrollArea } = renderTimeline("coarse-thread", ["row-a"]); - // Settle the first capture (immediate or trailing, depending on where the - // faked clock started) so the throttle's lastWriteAt equals now. + // Move the clock past the throttle window so the first capture writes + // immediately, leaving the throttle's lastWriteAt equal to now. (Running + // pending timers instead would also fire the scrollbar idle timeout and + // advance the clock 600ms past that write.) + vi.advanceTimersByTime(1_000); fireEvent.scroll(scrollArea); - vi.runOnlyPendingTimers(); expect(readAnchor("coarse-thread")).not.toBeNull(); getDefaultStore().set( threadTimelineScrollAnchorAtomFamily("coarse-thread"), diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 1e010f375b..3d5458a773 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -1048,8 +1048,17 @@ export function BottomAnchoredScrollBody({ if (!scrollArea || !scrollContent) return; let scrollbarIdleTimeout: number | null = null; - const handleScrollWithTransientScrollbar = () => { - scrollArea.dataset.scrollbarScrolling = "true"; + const handleScrollEvent = () => { + // `.thread-scrollbar` hides the thumb at rest on every platform + // (app.css sets `scrollbar-color: transparent transparent` with no + // pointer gate, and Android Chrome / iOS paint their overlay indicators + // with it), so this attribute is what shows the thumb during a touch + // flick too. Write it once per burst: a same-value write is still a + // style invalidation, and that per-event cost is what a scrolling phone + // cannot afford. + if (scrollArea.dataset.scrollbarScrolling !== "true") { + scrollArea.dataset.scrollbarScrolling = "true"; + } if (scrollbarIdleTimeout !== null) { window.clearTimeout(scrollbarIdleTimeout); } @@ -1059,12 +1068,6 @@ export function BottomAnchoredScrollBody({ }, SCROLLBAR_IDLE_DELAY_MS); handleScroll(); }; - // The transient-scrollbar attribute only feeds the desktop-only - // ::-webkit-scrollbar rules; on coarse pointers (overlay scrollbars) the - // write would be a pure per-scroll-event style invalidation. - const handleScrollEvent = isPointerCoarse - ? handleScroll - : handleScrollWithTransientScrollbar; let resizeObserver: ResizeObserver | undefined; if (typeof ResizeObserver !== "undefined") { @@ -1118,7 +1121,6 @@ export function BottomAnchoredScrollBody({ endPointerScrollIntent, handleScroll, handleScrollAreaResize, - isPointerCoarse, markKeyboardScrollIntent, markTouchMoveScrollIntent, markTouchStartScrollIntent, From f1b735d6e84fc27e978c2f7ac397fd5fd01bb749 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:35:14 +0000 Subject: [PATCH 23/34] Re-query scroll-anchor rows on a windowed timeline The row NodeList cache from PR #2385 was invalidated only by a ResizeObserver delivery or by its first/last row disconnecting. On the TanStack-windowed timeline neither happens for a window slide: rows are absolutely positioned inside a spacer whose height does not change when already-measured rows swap, and the last row is force-mounted (alwaysMountedKeys), so the stale list was reused and getTopMostVisibleRow treated the unmounted rows' empty rects as "above", recording the always-mounted last row (or the old window's first row) as the anchor. The throttled captures, the trailing write and the unmount flush all persisted it, so returning to the thread restored the wrong row. Report from getScrollAnchorRows whether the top-level list holds a [data-timeline-virtual-spacer] and skip the cache in that case: only the rows near the viewport are mounted there, so the query is cheap, while the unwindowed timeline keeps the cache. The scroll-preservation suite now mounts a spacer, slides the window without a resize delivery and checks that both the next capture and the unmount flush follow the new top-most row; its row-rect mock reports an empty rect for a disconnected row, as a browser does. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- ...d-scroll-body.scroll-preservation.test.tsx | 72 ++++++++++++++++++- .../ui/bottom-anchored-scroll-body.tsx | 48 +++++++++---- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index d7962a1165..2f121bee5d 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -80,8 +80,12 @@ function mockScrollAreaRect(scrollArea: HTMLElement) { } function mockRowRect(row: HTMLElement, rect: RowRect) { - vi.spyOn(row, "getBoundingClientRect").mockReturnValue( - new DOMRect(0, rect.top, 100, rect.bottom - rect.top), + // A row the timeline has since unmounted reports an empty rect, as a + // browser's disconnected element does. + vi.spyOn(row, "getBoundingClientRect").mockImplementation(() => + row.isConnected + ? new DOMRect(0, rect.top, 100, rect.bottom - rect.top) + : new DOMRect(0, 0, 0, 0), ); } @@ -171,6 +175,11 @@ function renderTimeline({ getByRole: view.getByRole, scrollArea, rowElements, + // Rows mounted by a later rerender are not in `rowElements`. + getRow: (rowId: string) => + requireHTMLElement( + view.container.querySelector(`[data-timeline-row-id="${rowId}"]`), + ), rerenderRows: (nextRowIds: string[]) => view.rerender(timeline(nextRowIds)), unmount: view.unmount, }; @@ -297,6 +306,65 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { }); }); + it("follows the row window when a windowed timeline slides it without a resize", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + const { scrollArea, getRow, rerenderRows, unmount } = renderTimeline({ + threadId: "thread-a", + // row-z is the timeline's last row, which the windowed list keeps + // mounted whatever the window holds. + rowIds: ["row-a", "row-b", "row-c", "row-z"], + virtualized: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(getRow("row-a"), { top: -120, bottom: -20 }); + mockRowRect(getRow("row-b"), { top: -20, bottom: 80 }); + mockRowRect(getRow("row-c"), { top: 80, bottom: 180 }); + mockRowRect(getRow("row-z"), { top: 5_000, bottom: 5_100 }); + setScrollMetrics(scrollArea, { + scrollHeight: 6_000, + clientHeight: 100, + scrollTop: 5_900, + }); + getLatestResizeObserver().trigger(); + + // Past the throttle window, so each capture below writes immediately. + vi.advanceTimersByTime(1_000); + scrollArea.scrollTop = 1_000; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + + // A flick shorter than the overscan slides the window inside the + // fixed-height spacer: row-b and row-c unmount, row-x and row-y mount + // above row-a, and row-a and row-z stay connected. No observed box + // changed size, so the ResizeObserver never fires. + rerenderRows(["row-x", "row-y", "row-a", "row-z"]); + mockRowRect(getRow("row-x"), { top: -130, bottom: -30 }); + mockRowRect(getRow("row-y"), { top: -30, bottom: 70 }); + mockRowRect(getRow("row-a"), { top: 70, bottom: 170 }); + vi.advanceTimersByTime(1_000); + scrollArea.scrollTop = 900; + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-y", + offsetWithinRow: 30, + atBottom: false, + }); + + // Leaving the thread flushes a final capture from the same row set. + unmount(); + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-y", + offsetWithinRow: 30, + atBottom: false, + }); + }); + it("finds the visible anchor with logarithmic row measurements", () => { const rowIds = Array.from({ length: 128 }, (_, index) => `row-${index}`); const { scrollArea, rowElements } = renderTimeline({ diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 3d5458a773..65d562a3fd 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -103,9 +103,11 @@ const SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS = 8; const TIMELINE_ROW_ID_SELECTOR = "[data-timeline-row-id]"; const TOP_LEVEL_TIMELINE_ROW_LIST_SELECTOR = '[data-timeline-row-list="top-level"]'; +const TIMELINE_VIRTUAL_SPACER_SELECTOR = + ":scope > [data-timeline-virtual-spacer]"; const DIRECT_TIMELINE_ROW_SELECTOR = [ `:scope > ${TIMELINE_ROW_ID_SELECTOR}`, - `:scope > [data-timeline-virtual-spacer] > ${TIMELINE_ROW_ID_SELECTOR}`, + `${TIMELINE_VIRTUAL_SPACER_SELECTOR} > ${TIMELINE_ROW_ID_SELECTOR}`, ].join(", "); const SCROLL_INTENT_KEYS = new Set([ "ArrowDown", @@ -174,18 +176,32 @@ interface TopMostVisibleRow { offsetWithinRow: number; } -function getScrollAnchorRows(scrollArea: HTMLElement): NodeListOf { +interface ScrollAnchorRowQuery { + rows: NodeListOf; + // A windowed timeline mounts only the rows near the viewport (plus a few + // pinned ones) inside a fixed-height spacer. + windowed: boolean; +} + +function getScrollAnchorRows(scrollArea: HTMLElement): ScrollAnchorRowQuery { const topLevelList = scrollArea.querySelector( TOP_LEVEL_TIMELINE_ROW_LIST_SELECTOR, ); if (topLevelList) { - return topLevelList.querySelectorAll( - DIRECT_TIMELINE_ROW_SELECTOR, - ); + return { + rows: topLevelList.querySelectorAll( + DIRECT_TIMELINE_ROW_SELECTOR, + ), + windowed: + topLevelList.querySelector(TIMELINE_VIRTUAL_SPACER_SELECTOR) !== null, + }; } // Embedded/test surfaces may render timeline rows without the app's // top-level list wrapper. - return scrollArea.querySelectorAll(TIMELINE_ROW_ID_SELECTOR); + return { + rows: scrollArea.querySelectorAll(TIMELINE_ROW_ID_SELECTOR), + windowed: false, + }; } // The top-most timeline row whose bottom edge is below the scroll area's top @@ -340,11 +356,11 @@ export function BottomAnchoredScrollBody({ scrollAreaClientHeight: number | null; scrollContentHeight: number | null; }>({ scrollAreaClientHeight: null, scrollContentHeight: null }); - // The row NodeList behind scroll-anchor capture, so throttled samples don't - // repeat a querySelectorAll over the scroll subtree. Row-set changes surface - // as content size changes, so the ResizeObserver invalidates it; the - // connectivity check in getScrollAnchorRowsCached guards the windowed - // timeline, where a row swap at a window edge can keep the size constant. + // The row NodeList behind scroll-anchor capture on an unwindowed timeline, + // so throttled samples don't repeat a querySelectorAll over the whole + // thread. Row-set changes surface as content size changes there, so the + // ResizeObserver invalidates it. getScrollAnchorRowsCached never fills it + // for a windowed timeline, whose row swaps change no observed size. const scrollAnchorRowsRef = useRef | null>(null); const [isAtBottom, setIsAtBottom] = useState(true); const initialScrollRestoreRowId = useMemo(() => { @@ -577,8 +593,14 @@ export function BottomAnchoredScrollBody({ ) { return cached; } - const rows = getScrollAnchorRows(scrollArea); - scrollAnchorRowsRef.current = rows; + const { rows, windowed } = getScrollAnchorRows(scrollArea); + // A windowed timeline slides its row window inside a fixed-height spacer: + // rows mount and unmount with no observed size change, and its last row + // is force-mounted, so neither the ResizeObserver nor the edge-row + // connectivity check above sees the swap. Only the rows near the viewport + // are mounted there, so querying on every capture is cheap; the cache is + // for the unwindowed timeline, where the query spans the whole thread. + scrollAnchorRowsRef.current = windowed ? null : rows; return rows; }, []); From 16534169833188aaebab9e19d9de80ac8dee31b2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:35:15 +0000 Subject: [PATCH 24/34] Pin the entry-derived resize path and keyboard pan in tests The scroll-preservation suite's ResizeObserver stub delivered no entries, so every #2280/#2300 contract case ran the entry-less live-read fallback rather than the entry-derived cache refresh that every browser takes since PR #2385; the one settle-tail case on that path used identical content and border boxes, so swapping the two box reads went unnoticed. The stub now delivers an entry per observed target with the box sizes a browser reports (scroll port content box = client height, content wrapper border box = scroll height) and the other box of each pair offset by 8px, and the settle-tail case uses distinct boxes plus a stays-detached step that a wrong box read would re-attach. The keyboard half of the visual-viewport scroll-gate test passed even with the keyboard branch of handleVisualViewportScroll removed, because focusing the editor already scheduled the compensating pass. It now flushes the focus pass with no pan, then delivers the pan as a scroll-only tick, so the 340px compensation can only come from that branch. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../useMobileVisualViewportHeight.test.tsx | 8 +- ...d-scroll-body.scroll-preservation.test.tsx | 40 +++++++++- ...-anchored-scroll-body.settle-tail.test.tsx | 74 +++++++++++++++---- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx index fad08417d8..3af15d9e79 100644 --- a/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx +++ b/apps/app/src/components/layout/useMobileVisualViewportHeight.test.tsx @@ -442,9 +442,15 @@ describe("useMobileVisualViewportHeight", () => { expect(shell.style.top).toBe(""); // With a keyboard editor focused, the same pan is Safari's - // focus-reveal pan and must still be compensated. + // focus-reveal pan and must still be compensated. Let the pan + // settle and the focus-scheduled pass run first, so that only the + // scroll handler's keyboard branch can produce the compensation. + visualViewport.offsetTop = 0; act(() => editor.focus()); + await flushScheduledViewportPass(); + expect(shell.style.top).toBe(""); act(() => { + visualViewport.offsetTop = 340; visualViewport.dispatchEvent(new Event("scroll")); }); await waitFor(() => expect(shell.style.top).toBe("340px")); diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index 2f121bee5d..2c719442c5 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -11,8 +11,10 @@ import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scro // Real externals only: the ResizeObserver/rAF used by the scroll body are // browser primitives jsdom omits, so they are stubbed; nothing in our own code -// is mocked. The atom is read back from the real default jotai store the -// component writes to. +// is mocked. The ResizeObserver stub delivers what a browser delivers — an +// entry per observed target carrying its box sizes — so the resize path under +// test is the one production runs. The atom is read back from the real default +// jotai store the component writes to. interface ScrollMetrics { scrollHeight: number; @@ -32,16 +34,46 @@ const SCROLL_AREA_HEIGHT = 100; class ResizeObserverMock implements ResizeObserver { static instances: ResizeObserverMock[] = []; readonly callback: ResizeObserverCallback; + readonly targets: Element[] = []; constructor(callback: ResizeObserverCallback) { this.callback = callback; ResizeObserverMock.instances.push(this); } - observe() {} + observe(target: Element) { + this.targets.push(target); + } unobserve() {} disconnect() {} trigger() { - this.callback([], this); + this.callback(this.targets.map(makeResizeEntry), this); + } +} + +// The scroll port's content box is its client height and the content wrapper's +// border box is the port's scroll height: the pair the component derives its +// cached max offset from. The other box of each entry differs by 8px so a +// refresh that reads the wrong one shows up as a skewed max offset. +function makeResizeEntry(target: Element): ResizeObserverEntry { + const isScrollPort = target.classList.contains(SCROLL_AREA_CLASS); + const scrollPort = isScrollPort ? target : target.parentElement; + if (!scrollPort) { + throw new Error("Expected the content wrapper inside the scroll port."); } + const contentBlockSize = isScrollPort + ? scrollPort.clientHeight + : scrollPort.scrollHeight - 8; + const borderBlockSize = isScrollPort + ? scrollPort.clientHeight + 8 + : scrollPort.scrollHeight; + return { + target, + contentRect: new DOMRect(0, 0, 100, contentBlockSize), + borderBoxSize: [{ blockSize: borderBlockSize, inlineSize: 100 }], + contentBoxSize: [{ blockSize: contentBlockSize, inlineSize: 100 }], + devicePixelContentBoxSize: [ + { blockSize: contentBlockSize, inlineSize: 100 }, + ], + }; } function getLatestResizeObserver(): ResizeObserverMock { diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx index 85cae0d2b9..bbd03f0c8b 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.settle-tail.test.tsx @@ -117,17 +117,32 @@ function installGeometryReadCounters( return { readScrollHeight, readClientHeight }; } +interface ResizeEntryBoxes { + contentBlockSize: number; + borderBlockSize: number; +} + +// The two boxes differ so a test can tell which one the component reads: the +// scroll port's content box is its client height (padding excluded) and the +// content wrapper's border box is the scroll height. function makeResizeEntry( target: Element, - blockSize: number, + boxes: ResizeEntryBoxes, ): ResizeObserverEntry { - const boxSize: ResizeObserverSize = { blockSize, inlineSize: 100 }; + const contentBoxSize: ResizeObserverSize = { + blockSize: boxes.contentBlockSize, + inlineSize: 100, + }; + const borderBoxSize: ResizeObserverSize = { + blockSize: boxes.borderBlockSize, + inlineSize: 100, + }; return { target, - contentRect: new DOMRect(0, 0, 100, blockSize), - borderBoxSize: [boxSize], - contentBoxSize: [boxSize], - devicePixelContentBoxSize: [boxSize], + contentRect: new DOMRect(0, 0, 100, boxes.contentBlockSize), + borderBoxSize: [borderBoxSize], + contentBoxSize: [contentBoxSize], + devicePixelContentBoxSize: [contentBoxSize], }; } @@ -265,17 +280,44 @@ describe("BottomAnchoredScrollBody settle tail", () => { // Content grows to 900 while detached. The delivery carries the observer's // own box sizes, so the cache refresh needs no scrollHeight/clientHeight. + // The refresh must read the scroll port's content box (100) and the + // content wrapper's border box (900); the other box of each pair is off. getLatestResizeObserver().trigger([ - makeResizeEntry(scrollArea, 100), - makeResizeEntry(scrollContent, 900), + makeResizeEntry(scrollArea, { + contentBlockSize: 100, + borderBlockSize: 108, + }), + makeResizeEntry(scrollContent, { + contentBlockSize: 880, + borderBlockSize: 900, + }), ]); expect(readScrollHeight).not.toHaveBeenCalled(); expect(readClientHeight).not.toHaveBeenCalled(); // The derived max offset (800) is what scroll classification runs on: - // 797 is within the 4px threshold, so this scroll re-attaches — still - // without a live read. - scrollArea.scrollTop = 797; + // 790 is outside the 4px threshold, so the viewport stays detached and + // the next growth (max offset 850) leaves it where it is... + scrollArea.scrollTop = 790; + fireEvent.scroll(scrollArea); + liveMetrics.scrollHeight = 950; + getLatestResizeObserver().trigger([ + makeResizeEntry(scrollArea, { + contentBlockSize: 100, + borderBlockSize: 108, + }), + makeResizeEntry(scrollContent, { + contentBlockSize: 930, + borderBlockSize: 950, + }), + ]); + expect(scrollArea.scrollTop).toBe(790); + expect(readScrollHeight).not.toHaveBeenCalled(); + expect(readClientHeight).not.toHaveBeenCalled(); + + // ...while 847 is within it, so this scroll re-attaches — still without + // a live read. + scrollArea.scrollTop = 847; fireEvent.scroll(scrollArea); expect(readScrollHeight).not.toHaveBeenCalled(); expect(readClientHeight).not.toHaveBeenCalled(); @@ -284,8 +326,14 @@ describe("BottomAnchoredScrollBody settle tail", () => { // restore legitimately reads fresh geometry). liveMetrics.scrollHeight = 1_000; getLatestResizeObserver().trigger([ - makeResizeEntry(scrollArea, 100), - makeResizeEntry(scrollContent, 1_000), + makeResizeEntry(scrollArea, { + contentBlockSize: 100, + borderBlockSize: 108, + }), + makeResizeEntry(scrollContent, { + contentBlockSize: 980, + borderBlockSize: 1_000, + }), ]); expect(scrollArea.scrollTop).toBe(900); }); From f281b33da9d8d6136ddfa177454c424cc1c5551e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:32:03 +0000 Subject: [PATCH 25/34] Open the expander region on the body's commit, not the tap's The body of ExpandablePanel realizes off useDeferredValue(isExpanded), but the region around it (AnimatedExpandablePanelContent's content branch and height sync, the toggle deadline, the grid/opacity classes and the layoutAnimationInFlightCount window) still keyed on the urgent isExpanded. A tap's commit therefore dropped the collapsed preview for an empty wrapper, armed the 200ms height tween toward that wrapper and opened the in-flight window; the body then landed a commit later, re-targeting the tween mid-flight or, past the deadline, snapping in with transitionDuration 0s. Reopening inside the 200ms close window also rendered the still-null deferred body in the tap's commit, so the retained subtree unmounted and a fresh one mounted a commit later. Derive isBodyExpanded = isExpanded && (deferredIsExpanded || isClosing) and key every region concern on it, so the region opens in the commit that mounts the body (the deferred one, or the reopen tap's own while the close window retains the subtree) and closes in the collapse tap's commit. The caret and aria-expanded stay on the urgent value. The rendered body follows the deferred value, with the retained ref as the fallback, and the ref sync is gated on the deferred value so the tap-to-body gap can no longer clear it. As a consequence a collapse now animates out from the same element instead of remounting it. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../app/src/components/ui/disclosure.test.tsx | 136 +++++++++++++++++- apps/app/src/components/ui/disclosure.tsx | 48 +++++-- 2 files changed, 163 insertions(+), 21 deletions(-) diff --git a/apps/app/src/components/ui/disclosure.test.tsx b/apps/app/src/components/ui/disclosure.test.tsx index 69844dd0fb..add7a27587 100644 --- a/apps/app/src/components/ui/disclosure.test.tsx +++ b/apps/app/src/components/ui/disclosure.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { useState } from "react"; +import { Provider, createStore } from "jotai"; +import { useState, type ReactNode } from "react"; import { flushSync } from "react-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ExpandablePanel } from "./disclosure"; +import { layoutAnimationInFlightCountAtom } from "./layoutAnimationAtoms.js"; class ResizeObserverStub implements ResizeObserver { static instances: ResizeObserverStub[] = []; @@ -89,8 +91,15 @@ describe("ExpandablePanel body height", () => { }); }); -/** A toggleable panel driven by its own header, like a timeline tool row. */ -function TogglablePanel() { +/** + * A toggleable panel driven by its own header, like a timeline tool row. + * With `collapsedContent` it is a row that shows a preview while collapsed. + */ +function TogglablePanel({ + collapsedContent, +}: { + collapsedContent?: ReactNode; +}) { const [isExpanded, setIsExpanded] = useState(false); return ( setIsExpanded((expanded) => !expanded)} + collapsedContent={collapsedContent} > Expanded body @@ -134,18 +144,130 @@ describe("ExpandablePanel deferred body realization", () => { render(); const header = screen.getByRole("button", { name: "Tool call" }); fireEvent.click(header); - expect(screen.getByText("Expanded body")).toBeTruthy(); + const body = screen.getByText("Expanded body"); fireEvent.click(header); - // The collapse animates from the still-rendered subtree: the body must - // stay mounted for the 200ms transition, then unmount. + // The collapse animates from the still-rendered subtree: the same body + // element stays mounted for the 200ms transition, then unmounts. expect(header.getAttribute("aria-expanded")).toBe("false"); - expect(screen.getByText("Expanded body")).toBeTruthy(); + expect(screen.getByText("Expanded body")).toBe(body); act(() => { vi.advanceTimersByTime(200); }); expect(screen.queryByText("Expanded body")).toBeNull(); }); + + it("keeps the preview, its height and the in-flight window until the body's commit", () => { + // jsdom lays nothing out: stand in a text-length metric so the region's + // height write tells the preview, an empty body wrapper and the body apart. + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + function (this: HTMLElement) { + return this.textContent?.length ?? 0; + }, + ); + vi.useFakeTimers(); + const store = createStore(); + render( + + Collapsed summary} /> + , + ); + const header = screen.getByRole("button", { name: "Tool call" }); + const preview = screen.getByText("Collapsed summary"); + const region = preview.parentElement?.parentElement; + if (!region) { + throw new Error("Panel body region was not rendered"); + } + const previewHeight = `${"Collapsed summary".length}px`; + const bodyHeight = `${"Expanded body".length}px`; + expect(region.style.height).toBe(previewHeight); + + let toggleCommit: { + ariaExpanded: string | null; + previewMounted: boolean; + height: string; + transitionDuration: string; + inFlight: number; + } | null = null; + act(() => { + flushSync(() => { + header.click(); + }); + toggleCommit = { + ariaExpanded: header.getAttribute("aria-expanded"), + previewMounted: preview.isConnected, + height: region.style.height, + transitionDuration: region.style.transitionDuration, + inFlight: store.get(layoutAnimationInFlightCountAtom), + }; + }); + + // The tap's commit flips the caret only. The preview stays on screen at + // its own height; no tween or in-flight window opens against the empty + // wrapper the body has not filled yet. + expect(toggleCommit).toEqual({ + ariaExpanded: "true", + previewMounted: true, + height: previewHeight, + transitionDuration: "0s", + inFlight: 0, + }); + + // The deferred commit swaps the preview for the body, eases the region + // toward the body's real height and opens the 200ms in-flight window. + expect(screen.queryByText("Collapsed summary")).toBeNull(); + expect(screen.getByText("Expanded body")).toBeTruthy(); + expect(region.style.height).toBe(bodyHeight); + expect(region.style.transitionDuration).toBe(""); + expect(store.get(layoutAnimationInFlightCountAtom)).toBe(1); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(store.get(layoutAnimationInFlightCountAtom)).toBe(0); + }); + + it("reuses the retained body when a reopen lands inside the close window", () => { + vi.useFakeTimers(); + render(); + const header = screen.getByRole("button", { name: "Tool call" }); + fireEvent.click(header); + const region = screen.getByText("Expanded body").closest("[aria-hidden]"); + if (!region) { + throw new Error("Panel body region was not rendered"); + } + + fireEvent.click(header); + expect(region.getAttribute("aria-hidden")).toBe("true"); + act(() => { + vi.advanceTimersByTime(100); + }); + // The element the close window is retaining while it animates out. + const body = screen.getByText("Expanded body"); + + let reopenCommitBody: Element | null = null; + let reopenCommitAriaHidden: string | null = null; + act(() => { + flushSync(() => { + header.click(); + }); + reopenCommitBody = screen.queryByText("Expanded body"); + reopenCommitAriaHidden = region.getAttribute("aria-hidden"); + }); + + // The reopen tap's commit reopens the region around the subtree the close + // window retained instead of blanking it until the deferred body lands, + // and that deferred commit reconciles into the same element. + expect(reopenCommitBody).toBe(body); + expect(reopenCommitAriaHidden).toBe("false"); + expect(screen.getByText("Expanded body")).toBe(body); + + // The cancelled close no longer unmounts the body when its timer would + // have fired. + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.getByText("Expanded body")).toBe(body); + }); }); diff --git a/apps/app/src/components/ui/disclosure.tsx b/apps/app/src/components/ui/disclosure.tsx index 45d4027af3..0d92b28735 100644 --- a/apps/app/src/components/ui/disclosure.tsx +++ b/apps/app/src/components/ui/disclosure.tsx @@ -140,14 +140,15 @@ interface ExpandablePanelProps { interface AnimatedExpandablePanelContentProps { collapsedContent: ReactNode; contentClassName?: string; - isExpanded: boolean; + /** The body is mounted; see `ExpandablePanel`'s `isBodyExpanded`. */ + isBodyExpanded: boolean; renderedBody: ReactNode; } function AnimatedExpandablePanelContent({ collapsedContent, contentClassName, - isExpanded, + isBodyExpanded, renderedBody, }: AnimatedExpandablePanelContentProps) { const regionRef = useRef(null); @@ -166,7 +167,7 @@ function AnimatedExpandablePanelContent({ } toggleAnimationDeadlineRef.current = performance.now() + EXPANDABLE_PANEL_TRANSITION_MS; - }, [isExpanded]); + }, [isBodyExpanded]); useBrowserLayoutEffect(() => { const region = regionRef.current; @@ -206,7 +207,7 @@ function AnimatedExpandablePanelContent({ read: readHeightSync, write: writeHeightSync, }); - }, [collapsedContent, isExpanded, renderedBody]); + }, [collapsedContent, isBodyExpanded, renderedBody]); return (
    - {isExpanded ? ( + {isBodyExpanded ? (
    {renderedBody}
    @@ -263,6 +264,16 @@ export function ExpandablePanel({ } return renderBody ? renderBody() : children; }, [children, deferredIsExpanded, renderBody]); + // The body region follows the body, not the tap: its content branch, + // transition classes, height tween, toggle deadline and in-flight window + // all key on this flag. It opens in the commit that mounts the body — the + // deferred one, or the reopen tap's own commit while the close window + // still retains the subtree (`isClosing` holds only while + // `renderedBodyRef` does) — and closes in the collapse tap's commit. + // Opening it on the urgent `isExpanded` would swap the collapsed preview + // for an empty wrapper and tween toward that, with the body landing a + // commit later, possibly after the 200ms window has already closed. + const isBodyExpanded = isExpanded && (deferredIsExpanded || isClosing); // Signal to AutoHeightContainer / HeightTransition wrappers that a // CSS-driven layout animation is in flight, so they snap their wrapper to @@ -291,14 +302,18 @@ export function ExpandablePanel({ window.clearTimeout(timer); release(); }; - }, [isExpanded, setLayoutAnimationInFlightCount]); + }, [isBodyExpanded, setLayoutAnimationInFlightCount]); + // Retain the last realized body for the close window. Gated on the + // deferred value: between an expand tap and its body commit `expandedBody` + // is still null, and writing that would drop the subtree a reopen inside + // the close window reuses. useBrowserLayoutEffect(() => { - if (!isExpanded) { + if (!deferredIsExpanded) { return; } renderedBodyRef.current = expandedBody; - }, [expandedBody, isExpanded]); + }, [deferredIsExpanded, expandedBody]); useBrowserLayoutEffect(() => { if (isExpanded) { @@ -319,7 +334,10 @@ export function ExpandablePanel({ }, EXPANDABLE_PANEL_TRANSITION_MS); return () => clearTimeout(timeout); }, [hasCollapsedContent, isExpanded]); - const renderedBody = isExpanded + // While the deferred value still says expanded the realized body is the + // freshest subtree (a collapse tap animates out from it without a + // remount); otherwise the close window's retained one. + const renderedBody = deferredIsExpanded ? expandedBody : isClosing ? renderedBodyRef.current @@ -348,15 +366,17 @@ export function ExpandablePanel({ ) : (
    Date: Tue, 25 Aug 2026 21:28:19 +0000 Subject: [PATCH 26/34] Subtract the assistant column inset from the shared list width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level TimelineRowsList measures its own unpadded root and shares that width with every action bar through MessageColumnWidthContext, on the premise that each row's [data-message-column] content box equals the list width. The assistant column carries px-2, and useMeasuredWidth reads the content box, so the per-bar observer it replaced reported 16px less for assistant rows. The shared value was therefore 16px too wide for alignment="start" bars — exactly EXPANDED_ROW_COMFORT_PX — and the in-place expansion gate admitted rows it was designed to send to the popover. A start-aligned bar now subtracts PROSE_COLUMN_INSET_PX from the shared width; end-aligned (user) bars keep it, since that column is unpadded. The inset's class and pixel value sit together in MessageActionBar and ConversationMessageContent applies the exported class, so the pair stays in sync. Boundary tests (131px list: popover; 132px: in place) pin the shared path to the threshold the per-bar observer produced, in both the bar unit tests and a ThreadTimelineRows render with the real column markup. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../timeline/ConversationMessageContent.tsx | 10 ++- .../thread/timeline/MessageActionBar.test.tsx | 61 +++++++++++++ .../thread/timeline/MessageActionBar.tsx | 37 ++++++-- .../ThreadTimelineRows.actions.test.tsx | 89 +++++++++++++++++++ .../thread/timeline/ThreadTimelineRows.tsx | 5 +- 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index 4b372e139e..415d83be9d 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -54,7 +54,10 @@ import { import { turnRequestLabel } from "@bb/client-core"; import { splitStreamingMarkdown } from "./streaming-markdown-split.js"; import { TurnRequestLabel } from "./TurnRequestLabel.js"; -import { MessageActionBar } from "./MessageActionBar.js"; +import { + MessageActionBar, + PROSE_COLUMN_INSET_CLASS, +} from "./MessageActionBar.js"; import { ConversationMessageOverflowToggle, useIsOverflowing, @@ -678,7 +681,10 @@ function AssistantConversationMessage({ return (
    {/* diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx index 1d910deca8..b4cda7ce11 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx @@ -717,6 +717,67 @@ describe("MessageActionBar observer budget", () => { }); }); +describe("MessageActionBar shared column width", () => { + /** + * Mounts a mobile bar with three 28px touch actions (a 100px row, so the + * in-place expansion needs a 116px column with its 16px comfort margin) + * under the shared list width, taps "⋯", and reports whether the actions + * expanded in place rather than opening the popover. + */ + function expandsInPlaceAt({ + alignment, + listWidth, + }: { + alignment: "start" | "end"; + listWidth: number; + }): boolean { + const { container, unmount } = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Message actions" })); + // The popover portals its items outside the container, so only a button + // inside the bar's own tree is the in-place row. + const popover = document.body.querySelector('[data-side="top"]'); + const inPlaceRow = within(container).queryByRole("button", { + name: "Fork into new thread", + }); + unmount(); + if (popover === null) { + expect(inPlaceRow).not.toBeNull(); + return true; + } + expect(inPlaceRow).toBeNull(); + return false; + } + + it("reads the assistant column as the shared list width minus its px-2 padding", () => { + mockMobileCoarsePointer(); + // The bar's own column observer reports the padded assistant column's + // content box: 16px narrower than the list root the shared measurement + // observes. The shared path has to land on the same threshold. + expect(expandsInPlaceAt({ alignment: "start", listWidth: 131 })).toBe( + false, + ); + expect(expandsInPlaceAt({ alignment: "start", listWidth: 132 })).toBe( + true, + ); + }); + + it("reads the unpadded user column at the full shared list width", () => { + mockMobileCoarsePointer(); + expect(expandsInPlaceAt({ alignment: "end", listWidth: 115 })).toBe(false); + expect(expandsInPlaceAt({ alignment: "end", listWidth: 116 })).toBe(true); + }); +}); + describe("computeMessageActionRowLayout", () => { const metrics = { actionWidth: 20, overflowTriggerWidth: 20 }; diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx index e93ed78a09..ada6d2e683 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx @@ -221,12 +221,15 @@ export function useMeasuredWidth({ /** * Timeline-list-level share of the message column width. * - * Every top-level row's `[data-message-column]` spans the full list width, so - * per-bar column observers would all report the same number. The top-level - * `TimelineRowsList` measures its root once and provides it here. `null` — no - * provider (stories, isolated renders) or a nested, narrower list shadowing - * the top-level value — means no shared measurement applies and the bar - * observes its own column. + * Every top-level row's `[data-message-column]` is as wide as the list root, + * so one list-level measurement stands in for the per-bar column observers. + * The top-level `TimelineRowsList` measures its root once and provides that + * width here; each bar recovers its column's content box from it by + * subtracting its column's own padding (`PROSE_COLUMN_INSET_PX` for the + * assistant column), which is what its own observer would have reported. + * `null` — no provider (stories, isolated renders) or a nested, narrower list + * shadowing the top-level value — means no shared measurement applies and the + * bar observes its own column. */ export interface SharedMessageColumnWidth { /** Measured width; undefined until the observer first reports. */ @@ -393,6 +396,18 @@ const BUBBLE_ALIGN_OFFSET_CLASS = "right-[13px] max-md:pointer-coarse:right-[11px]"; // Prose rows have no bubble padding, so only the hit-box slack is corrected. const PROSE_ALIGN_INSET_CLASS = "-ml-1 max-md:pointer-coarse:-ml-1.5"; +/** + * Horizontal padding of the assistant (prose) `[data-message-column]` in + * ConversationMessageContent, as the class it applies and the pixels it + * removes from the column's content box (8px a side). A bar observing that + * column reads its content box, so the shared list-level width — the + * unpadded list root — is this much wider than what a `start` bar's own + * observer would report; the bar subtracts it to land on the same number. + * The user column is unpadded (its bubble insets itself), so `end` bars take + * the shared width as is. Keep the pair in sync. + */ +export const PROSE_COLUMN_INSET_CLASS = "px-2"; +const PROSE_COLUMN_INSET_PX = 16; export function findMessageActionTooltipCollisionBoundary( node: HTMLElement | null, @@ -518,8 +533,16 @@ export function MessageActionBar({ enabled: sharedColumnWidth === null, resolveTarget: resolveMessageColumn, }); + // The shared value is the unpadded list root's width; the own observer + // reports the column's content box, which the assistant column's padding + // narrows — subtract it so both paths gate the expansion identically. const columnWidth = - sharedColumnWidth === null ? ownColumnWidth : sharedColumnWidth.width; + sharedColumnWidth === null + ? ownColumnWidth + : sharedColumnWidth.width === undefined + ? undefined + : sharedColumnWidth.width - + (alignment === "start" ? PROSE_COLUMN_INSET_PX : 0); // Touch-only: the hidden actions revealed in place by the "⋯" trigger. const [expanded, setExpanded] = useState(false); const expandedRowRef = useRef(null); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx index d1d4444d5c..ac9cc17fdf 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx @@ -1604,4 +1604,93 @@ describe("ThreadTimelineRows shared message column width", () => { earlierMessage?.querySelector('[aria-label="Fork into new thread"]'), ).not.toBeNull(); }); + + it("subtracts the assistant column's padding from the shared list width", () => { + mockSelectionMenuMedia({ isCompactViewport: true, isPointerCoarse: true }); + const observations: { callback: ResizeObserverCallback; node: Element }[] = + []; + class ControlledResizeObserver { + readonly #callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + } + observe(node: Element) { + observations.push({ callback: this.#callback, node }); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ControlledResizeObserver); + + const { container } = renderWithRouter( + , + ); + const reportListWidth = (width: number) => { + act(() => { + for (const { callback, node } of observations) { + if (!node.hasAttribute("data-timeline-row-list")) continue; + callback( + [ + { + target: node, + contentRect: { width, height: 600 }, + } as unknown as ResizeObserverEntry, + ], + undefined as unknown as ResizeObserver, + ); + } + }); + }; + const earlierMessage = container.querySelector( + '[data-timeline-row-id="earlier_agent_message"]', + ); + if (!earlierMessage) throw new Error("Missing earlier assistant row"); + const clickTrigger = () => { + const trigger = earlierMessage.querySelector( + '[aria-label="Message actions"]', + ); + if (!trigger) throw new Error("Missing overflow trigger"); + fireEvent.click(trigger); + }; + + // The assistant `[data-message-column]` is `px-2`, so a 131px list leaves + // it a 115px content box: one short of the 116px the three 28px touch + // actions need with their comfort margin. The popover must win, exactly + // as it did when each bar observed its own column. + reportListWidth(131); + clickTrigger(); + expect(document.body.querySelector('[data-side="top"]')).not.toBeNull(); + expect( + earlierMessage.querySelector('[aria-label="Copy message"]'), + ).toBeNull(); + + // One more pixel of list width clears the threshold: in place, no popover. + reportListWidth(132); + clickTrigger(); + expect(document.body.querySelector('[data-side="top"]')).toBeNull(); + expect( + earlierMessage.querySelector('[aria-label="Copy message"]'), + ).not.toBeNull(); + expect( + earlierMessage.querySelector('[aria-label="Fork into new thread"]'), + ).not.toBeNull(); + }); }); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index 4808f7209e..f6fbb5b7a2 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -2150,8 +2150,9 @@ function TimelineRowsList({ null; const isTopLevelList = spacing === "top-level"; // One observer for every action bar below: each top-level row's message - // column spans this list's full width, so the bars read this shared - // measurement (MessageColumnWidthContext) instead of observing their own + // column is as wide as this list, so the bars derive their column's content + // width from this shared measurement (MessageColumnWidthContext; the bar + // subtracts its own column's padding) instead of observing their own // columns. Nested lists are narrower, so they shadow the value with null // and their bars fall back to per-bar measurement. const { measureRef: messageColumnWidthSourceRef, width: messageColumnWidth } = From 0851ab646191c288e1575f82613b3aef81406a3d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:33:49 +0000 Subject: [PATCH 27/34] Batch the host fan-out statusChange snapshots to active threads Root cause: notifyHostThreadRuntimeStatusChanged built a full statusChange snapshot one thread at a time (getThread plus buildThreadStatusChangeMetadata, about six SQLite statements each) for every thread joined to the host, including idle, archived and deleted rows whose displayed runtime does not depend on host connectivity. The daemon socket close handler, the disconnect grace callback and host removal all run it synchronously, so a host with a few hundred threads stalled the server event loop for hundreds of milliseconds per disconnect and pushed a snapshot's worth of bytes per row to every thread-list subscriber. Fix: load the host's non-deleted `active` threads with one targeted query (listActiveHostThreads) and build their snapshots in a single batched pass (buildThreadStatusChangeMetadataByThreadId resolves host connectivity and the latest session once and runs the activity helpers over the whole array, as the list endpoints do). Only those rows carry statusChange; every other host thread keeps the bare status-changed push it received before the snapshot existed. The metadata shape is unchanged and shared with buildThreadStatusChangeMetadata. The new test seeds a 4-thread host and a 306-thread host and asserts the daemon-close fan-out issues the same number of SQL statements for both (1234 vs 16 before), that active rows carry the host-reconnecting snapshot, and that idle and deleted rows get a bare push. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../internal/session-owner-side-effects.ts | 27 ++-- .../threads/thread-runtime-display.ts | 87 +++++++++-- .../session-owner-runtime-status.test.ts | 147 +++++++++++++++--- packages/db/src/data/index.ts | 1 + packages/db/src/data/threads.ts | 25 +++ 5 files changed, 241 insertions(+), 46 deletions(-) diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts index f509c05dc1..bc8fb94acc 100644 --- a/apps/server/src/internal/session-owner-side-effects.ts +++ b/apps/server/src/internal/session-owner-side-effects.ts @@ -1,8 +1,8 @@ import { eq } from "drizzle-orm"; import { closeSession, - getThread, hostDaemonSessions, + listActiveHostThreads, listHostThreadIds, type HostDaemonSessionRow, } from "@bb/db"; @@ -19,7 +19,7 @@ import { interruptActiveThreadsForHost, reconcileDaemonReportedThreads, } from "../services/threads/thread-lifecycle.js"; -import { buildThreadStatusChangeMetadata } from "../services/threads/thread-runtime-display.js"; +import { buildThreadStatusChangeMetadataByThreadId } from "../services/threads/thread-runtime-display.js"; import { settleDanglingBackgroundTasks } from "../services/threads/background-task-reconciliation.js"; const DAEMON_RESTARTED_PENDING_INTERACTION_REASON = @@ -261,25 +261,28 @@ function completeDaemonActiveWorkDisconnectGrace( } /** - * Host connectivity is part of every thread row's displayed runtime, so each - * notification carries the post-change `statusChange` snapshot: without it, - * every client falls back to refetching every active thread list once per - * thread on this host, twice per disconnect (close + grace). + * Host connectivity is part of an `active` thread row's displayed runtime, so + * those rows' notifications carry the post-change `statusChange` snapshot + * clients patch in place instead of refetching every active thread list. + * Every other row renders the same whether or not the host is connected; it + * keeps the bare notification it always received, which clients coalesce into + * one throttled list refetch. The snapshots come from one batched pass: this + * runs synchronously in the daemon socket's close handler and again when the + * grace elapses, and a host can carry hundreds of threads. */ function notifyHostThreadRuntimeStatusChanged( deps: Pick, hostId: string, ): void { + const metadataByThreadId = buildThreadStatusChangeMetadataByThreadId(deps, { + environmentHostId: hostId, + threads: listActiveHostThreads(deps.db, { hostId }), + }); for (const threadId of listHostThreadIds(deps.db, { hostId })) { - const thread = getThread(deps.db, threadId); - if (!thread) { - deps.hub.notifyThread(threadId, ["status-changed"]); - continue; - } deps.hub.notifyThread( threadId, ["status-changed"], - buildThreadStatusChangeMetadata(deps, thread), + metadataByThreadId.get(threadId), ); } } diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index b221d101ff..a47bd88247 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -92,6 +92,18 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { thread: ThreadWithPendingInteractionState; } +interface BuildThreadStatusChangeMetadataByThreadIdArgs { + /** The host every listed thread's environment belongs to. */ + environmentHostId: string; + threads: readonly Thread[]; +} + +interface ToThreadStatusChangeMetadataArgs { + activity: ThreadActivityState; + runtime: ThreadRuntimeState; + thread: Thread; +} + interface PromptBannerActivityState extends Pick< ThreadActivityState, "activeGoalCount" | "activePlanModeCount" @@ -258,19 +270,72 @@ export function buildThreadStatusChangeMetadata( deps: ThreadPromptBannerDeps, thread: Thread, ): ThreadChangeMetadata { - return { - projectId: thread.projectId, - statusChange: { + return toThreadStatusChangeMetadata({ + activity: + buildThreadActivityStateByThreadId(deps, [thread]).get(thread.id) ?? + EMPTY_THREAD_ACTIVITY, + runtime: resolveThreadRuntimeState(deps, { + environmentHostId: resolveThreadEnvironmentHostId(deps, thread), status: thread.status, - runtime: resolveThreadRuntimeState(deps, { - environmentHostId: resolveThreadEnvironmentHostId(deps, thread), - status: thread.status, + }), + thread, + }); +} + +/** + * `buildThreadStatusChangeMetadata` for many threads on one host in a fixed + * number of queries: host connectivity and the latest session are resolved + * once for the host and the activity helpers run over the whole array, as the + * list endpoints do. The host fan-outs (daemon close, disconnect grace, host + * removal) run synchronously on the event loop, so they must not pay one + * snapshot's worth of queries per thread on a host with hundreds of threads. + */ +export function buildThreadStatusChangeMetadataByThreadId( + deps: ThreadPromptBannerDeps, + args: BuildThreadStatusChangeMetadataByThreadIdArgs, +): Map { + if (args.threads.length === 0) { + return new Map(); + } + const activityByThreadId = buildThreadActivityStateByThreadId( + deps, + args.threads, + ); + const hostConnected = hasOpenDaemonSessionForHost( + deps, + args.environmentHostId, + ); + const latestSession = hostConnected + ? null + : getLatestSessionForHost(deps.db, { hostId: args.environmentHostId }); + return new Map( + args.threads.map((thread) => [ + thread.id, + toThreadStatusChangeMetadata({ + activity: activityByThreadId.get(thread.id) ?? EMPTY_THREAD_ACTIVITY, + runtime: resolveThreadRuntimeStateFromLatestSession({ + environmentHostId: args.environmentHostId, + hostConnected, + latestSession, + status: thread.status, + }), + thread, }), - activity: buildThreadActivityStateByThreadId(deps, [thread]).get( - thread.id, - ) ?? EMPTY_THREAD_ACTIVITY, - latestAttentionAt: thread.latestAttentionAt, - updatedAt: thread.updatedAt, + ]), + ); +} + +function toThreadStatusChangeMetadata( + args: ToThreadStatusChangeMetadataArgs, +): ThreadChangeMetadata { + return { + projectId: args.thread.projectId, + statusChange: { + status: args.thread.status, + runtime: args.runtime, + activity: args.activity, + latestAttentionAt: args.thread.latestAttentionAt, + updatedAt: args.thread.updatedAt, }, }; } diff --git a/apps/server/test/internal/session-owner-runtime-status.test.ts b/apps/server/test/internal/session-owner-runtime-status.test.ts index 7572ffac83..ff4e444828 100644 --- a/apps/server/test/internal/session-owner-runtime-status.test.ts +++ b/apps/server/test/internal/session-owner-runtime-status.test.ts @@ -1,6 +1,6 @@ import { changedMessageSchema, type ThreadChangedMessage } from "@bb/domain"; -import { getThread } from "@bb/db"; -import { describe, expect, it } from "vitest"; +import { getThread, markThreadDeleted } from "@bb/db"; +import { describe, expect, it, vi } from "vitest"; import { handleDaemonSocketClosed, handleHostRemoved, @@ -16,8 +16,10 @@ import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; interface HostThreadsFixture { activeThreadId: string; + environmentId: string; hostId: string; idleThreadId: string; + projectId: string; sessionId: string; } @@ -50,12 +52,51 @@ function seedHostThreadsFixture( }); return { activeThreadId: activeThread.id, + environmentId: environment.id, hostId: host.id, idleThreadId: idleThread.id, + projectId: project.id, sessionId: session.id, }; } +function seedHostThreads( + harness: TestAppHarness, + args: { + count: number; + fixture: Pick; + status: "active" | "idle"; + }, +): string[] { + return Array.from( + { length: args.count }, + () => + seedThread(harness.deps, { + projectId: args.fixture.projectId, + environmentId: args.fixture.environmentId, + status: args.status, + }).id, + ); +} + +/** + * Drizzle and the raw data helpers prepare every statement through the + * better-sqlite3 client, so the number of `prepare` calls is the number of + * SQL statements `work` issued. + */ +function countPreparedStatements( + harness: TestAppHarness, + work: () => void, +): number { + const prepare = vi.spyOn(harness.db.$client, "prepare"); + try { + work(); + return prepare.mock.calls.length; + } finally { + prepare.mockRestore(); + } +} + function statusChangedMessagesFor( messages: readonly string[], threadId: string, @@ -83,7 +124,7 @@ function lastStatusChange( } describe("host thread runtime status notifications", () => { - it("carries a statusChange snapshot for every host thread when the daemon socket closes", async () => { + it("carries a statusChange snapshot for the active host threads when the daemon socket closes", async () => { await withTestHarness(async (harness) => { const fixture = seedHostThreadsFixture(harness, 1); const socket = createMockHubSocket(); @@ -94,9 +135,9 @@ describe("host thread runtime status notifications", () => { // them so the harness does not fire interruptions after cleanup. harness.hub.cancelPendingDaemonDisconnect(fixture.sessionId); - // Bare notifications here would make every client refetch every active - // thread list once per host thread; the snapshot is what lets them - // patch rows in place. + // Host connectivity only changes an active row's displayed runtime. A + // bare notification there would make every client refetch every active + // thread list; the snapshot is what lets them patch the row in place. const activeMessage = lastStatusChange( socket.messages, fixture.activeThreadId, @@ -108,20 +149,85 @@ describe("host thread runtime status notifications", () => { hostReconnectGraceExpiresAt: expect.any(Number), }, }); + // An idle row renders the same whether or not its host is connected, so + // it keeps the bare notification it always received rather than a + // snapshot that costs per-thread queries and bytes on every disconnect. const idleMessage = lastStatusChange( socket.messages, fixture.idleThreadId, ); - expect(idleMessage.metadata?.statusChange).toMatchObject({ + expect(idleMessage.metadata?.statusChange).toBeUndefined(); + }); + }); + + it("publishes the disconnect fan-out in a statement count that does not grow with the host's thread count", async () => { + await withTestHarness(async (harness) => { + const small = seedHostThreadsFixture(harness, 2); + const large = seedHostThreadsFixture(harness, 3); + const largeActiveThreadIds = [ + large.activeThreadId, + ...seedHostThreads(harness, { + count: 2, + fixture: large, + status: "active", + }), + ]; + const largeIdleThreadIds = seedHostThreads(harness, { + count: 300, + fixture: large, status: "idle", - runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, }); + // Joined to the host like any other row, but never rendered: no snapshot. + const deletedActiveThread = seedThread(harness.deps, { + projectId: large.projectId, + environmentId: large.environmentId, + status: "active", + }); + markThreadDeleted(harness.db, harness.hub, { + threadId: deletedActiveThread.id, + }); + const socket = createMockHubSocket(); + harness.hub.subscribe(socket, { kind: "thread-list" }); + + const largeStatements = countPreparedStatements(harness, () => + handleDaemonSocketClosed(harness.deps, { sessionId: large.sessionId }), + ); + harness.hub.cancelPendingDaemonDisconnect(large.sessionId); + const largeMessages = [...socket.messages]; + socket.messages.length = 0; + + const smallStatements = countPreparedStatements(harness, () => + handleDaemonSocketClosed(harness.deps, { sessionId: small.sessionId }), + ); + harness.hub.cancelPendingDaemonDisconnect(small.sessionId); + + // The daemon WebSocket close handler runs this synchronously on the + // event loop: the snapshot inputs must come from a fixed handful of + // batched statements, not ~6 statements per thread on the host. + expect(largeStatements).toBe(smallStatements); + + for (const threadId of largeActiveThreadIds) { + expect( + lastStatusChange(largeMessages, threadId).metadata?.statusChange, + ).toMatchObject({ + status: "active", + runtime: { displayStatus: "host-reconnecting" }, + }); + } + for (const threadId of [...largeIdleThreadIds, deletedActiveThread.id]) { + expect( + lastStatusChange(largeMessages, threadId).metadata?.statusChange, + ).toBeUndefined(); + } + expect( + statusChangedMessagesFor(largeMessages, small.activeThreadId), + ).toEqual([]); }); }); it("carries the settled post-interruption snapshot when the host is removed", async () => { await withTestHarness(async (harness) => { - const fixture = seedHostThreadsFixture(harness, 2); + const fixture = seedHostThreadsFixture(harness, 4); const socket = createMockHubSocket(); harness.hub.subscribe(socket, { kind: "thread-list" }); @@ -131,32 +237,27 @@ describe("host thread runtime status notifications", () => { }); // Removal interrupts the active thread (run.failed) before the runtime - // fan-out, so every status-changed for it must carry a snapshot and the - // final one must show the settled error state. - const activeMessages = statusChangedMessagesFor( + // fan-out. The interruption publishes the settled error snapshot; by the + // time the fan-out runs the row is no longer active, so it gets the same + // bare notification as every other non-active thread on the host. + const activeSnapshots = statusChangedMessagesFor( socket.messages, fixture.activeThreadId, + ).flatMap((message) => + message.metadata?.statusChange ? [message.metadata.statusChange] : [], ); - expect(activeMessages.length).toBeGreaterThan(0); - for (const message of activeMessages) { - expect(message.metadata?.statusChange).toBeDefined(); - } + expect(activeSnapshots.length).toBeGreaterThan(0); expect(getThread(harness.db, fixture.activeThreadId)?.status).toBe( "error", ); - expect( - activeMessages.at(-1)?.metadata?.statusChange, - ).toMatchObject({ + expect(activeSnapshots.at(-1)).toMatchObject({ status: "error", runtime: { displayStatus: "error" }, }); expect( lastStatusChange(socket.messages, fixture.idleThreadId).metadata ?.statusChange, - ).toMatchObject({ - status: "idle", - runtime: { displayStatus: "idle" }, - }); + ).toBeUndefined(); }); }); }); diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 4cadc15b1a..9c9f1c6d70 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -63,6 +63,7 @@ export { hasPendingThreadShutdownInEnvironment, hasRevivableArchivedThreadInEnvironment, listHostThreadIds, + listActiveHostThreads, listActiveVisiblePinnedThreadRootsWithPendingInteractionState, listLiveThreadsInEnvironment, listThreadMentionRowsByIds, diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 38c866a03b..33ceac44d2 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -635,6 +635,10 @@ export interface ListHostThreadIdsArgs { hostId: string; } +export interface ListActiveHostThreadsArgs { + hostId: string; +} + export interface ThreadEnvironmentAssignmentRow { environmentId: string; threadId: string; @@ -1430,6 +1434,27 @@ export function listHostThreadIds( .map((row) => row.id); } +/** + * Full rows for the host's non-deleted `active` threads: the only rows whose + * displayed runtime depends on whether the host is connected. + */ +export function listActiveHostThreads( + db: DbConnection, + args: ListActiveHostThreadsArgs, +): ThreadRow[] { + return db + .select(getTableColumns(threads)) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + nonDeletedThreads( + eq(environments.hostId, args.hostId), + eq(threads.status, "active"), + ), + ) + .all(); +} + export function hasPendingThreadShutdownInEnvironment( db: DbConnection, args: HasPendingThreadShutdownInEnvironmentArgs, From 83c2ca1b4d7b9331d98833b6ad59cadff1da6d38 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:29:09 +0000 Subject: [PATCH 28/34] Refetch an in-flight search after a status change settles The metadata branch of patchThreadListStatusState invalidated the thread-search prefix with cancelRefetch: false but, unlike every other non-cancelling path in the registry, scheduled no trailing refetch. When a status change arrived while a search request was already in flight, TanStack deduped onto that request; its response (read before the transition) then landed and cleared the invalidation, so the search rows kept the old status until some unrelated change refreshed them. Route the prefix through invalidateQueryKeysWithoutCancelingActiveFetches, which keeps the request running and queues one refetch for after it settles. The throttled metadata-less fallback also fed fully-specified list keys through the prefix-matching throttle helpers. Thread list filters are sparse, so a project list key is a prefix of that project's forks-row key: the project key's refetch also fetched the forks list, and the forks key's own run then saw a fetch in flight and scheduled a trailing one, two list requests where main's exact invalidation issued one. The throttle and trailing-refetch helpers now carry an explicit exact flag; leaf list keys enumerated from the cache match exactly, while the sidebar, search and work-status prefixes keep partial matching. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../cache-owners/realtime-cache-registry.ts | 67 +++++++++---- .../src/hooks/realtime-cache-effects.test.ts | 95 +++++++++++++++++-- 2 files changed, 133 insertions(+), 29 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 99ed87dd44..87233a0828 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -127,6 +127,14 @@ interface TimelineInvalidationQueryKeysArgs { } interface ScheduleTrailingActiveRefetchArgs { + /** + * Match `queryKey` exactly instead of as a prefix. Leaf thread-list keys + * enumerated from the cache must be exact: list filters are sparse, so a + * project list key is a prefix of that project's forks-row key, and a + * prefix match would refetch the forks list with the project list and then + * again from the forks key's own run. + */ + exact: boolean; queryClient: QueryClient; queryKey: QueryKey; } @@ -152,6 +160,8 @@ const throttledActiveRefetchEntries = new WeakMap< >(); interface ThrottledActiveRefetchArgs { + /** See {@link ScheduleTrailingActiveRefetchArgs.exact}. */ + exact: boolean; minIntervalMs: number; queryClient: QueryClient; queryKey: QueryKey; @@ -209,10 +219,11 @@ function timelineInvalidationKey(queryKey: QueryKey): string { function hasActiveFetchingQueries( queryClient: QueryClient, queryKey: QueryKey, + exact: boolean, ): boolean { return queryClient .getQueryCache() - .findAll({ queryKey, type: "active" }) + .findAll({ exact, queryKey, type: "active" }) .some((query) => query.state.fetchStatus !== "idle"); } @@ -226,18 +237,22 @@ function hasActiveQueries( } function refetchActiveQueriesWithoutCanceling({ + exact, queryClient, queryKey, }: ScheduleTrailingActiveRefetchArgs): void { - const hadActiveFetch = hasActiveFetchingQueries(queryClient, queryKey); + const hadActiveFetch = hasActiveFetchingQueries(queryClient, queryKey, exact); void queryClient - .refetchQueries({ queryKey, type: "active" }, { cancelRefetch: false }) + .refetchQueries( + { exact, queryKey, type: "active" }, + { cancelRefetch: false }, + ) .catch(() => { // Individual query state already captures the refetch error. }); if (hadActiveFetch) { // A change that raced the in-flight read must not be lost. - scheduleTrailingActiveRefetch({ queryClient, queryKey }); + scheduleTrailingActiveRefetch({ exact, queryClient, queryKey }); } } @@ -248,11 +263,12 @@ function refetchActiveQueriesWithoutCanceling({ * coalesce into one trailing refetch. Never cancels an in-flight fetch. */ function invalidateQueryKeyWithThrottledActiveRefetch({ + exact, minIntervalMs, queryClient, queryKey, }: ThrottledActiveRefetchArgs): void { - queryClient.invalidateQueries({ queryKey, refetchType: "none" }); + queryClient.invalidateQueries({ exact, queryKey, refetchType: "none" }); const scheduleKey = timelineInvalidationKey(queryKey); let entries = throttledActiveRefetchEntries.get(queryClient); @@ -267,7 +283,7 @@ function invalidateQueryKeyWithThrottledActiveRefetch({ } const run = () => { entries.set(scheduleKey, { lastRunAt: Date.now(), timer: null }); - refetchActiveQueriesWithoutCanceling({ queryClient, queryKey }); + refetchActiveQueriesWithoutCanceling({ exact, queryClient, queryKey }); }; const lastRunAt = entry?.lastRunAt ?? Number.NEGATIVE_INFINITY; const delayMs = Math.max(0, lastRunAt + minIntervalMs - Date.now()); @@ -279,6 +295,7 @@ function invalidateQueryKeyWithThrottledActiveRefetch({ } function scheduleTrailingActiveRefetch({ + exact, queryClient, queryKey, }: ScheduleTrailingActiveRefetchArgs): void { @@ -299,7 +316,7 @@ function scheduleTrailingActiveRefetch({ const waitingSince = Date.now(); const unsubscribe = queryClient.getQueryCache().subscribe(() => { - if (hasActiveFetchingQueries(queryClient, queryKey)) { + if (hasActiveFetchingQueries(queryClient, queryKey, exact)) { return; } @@ -309,7 +326,10 @@ function scheduleTrailingActiveRefetch({ const timer = setTimeout(() => { unsubscribers.delete(scheduleKey); void queryClient - .refetchQueries({ queryKey, type: "active" }, { cancelRefetch: false }) + .refetchQueries( + { exact, queryKey, type: "active" }, + { cancelRefetch: false }, + ) .catch(() => { // Individual query state already captures the refetch error. }); @@ -344,12 +364,16 @@ function invalidateQueryKeysWithoutCancelingActiveFetches({ queryKeys, }: TimelineInvalidationQueryKeysArgs): void { for (const queryKey of queryKeys) { - const hadActiveFetch = hasActiveFetchingQueries(queryClient, queryKey); - // Avoid aborting the active timeline request on every event batch, but keep - // one trailing refetch so an event that raced the in-flight read is not lost. + const hadActiveFetch = hasActiveFetchingQueries( + queryClient, + queryKey, + false, + ); + // Avoid aborting the active request on every event batch, but keep one + // trailing refetch so an event that raced the in-flight read is not lost. queryClient.invalidateQueries({ queryKey }, { cancelRefetch: false }); if (hadActiveFetch) { - scheduleTrailingActiveRefetch({ queryClient, queryKey }); + scheduleTrailingActiveRefetch({ exact: false, queryClient, queryKey }); } } } @@ -873,6 +897,7 @@ function dirtyActiveThreadListQueriesWithThrottledRefetch({ continue; } invalidateQueryKeyWithThrottledActiveRefetch({ + exact: true, minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS, queryClient, queryKey, @@ -883,6 +908,7 @@ function dirtyActiveThreadListQueriesWithThrottledRefetch({ threadSearchQueryKeyPrefix(), ]) { invalidateQueryKeyWithThrottledActiveRefetch({ + exact: false, minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS, queryClient, queryKey, @@ -1174,9 +1200,7 @@ function patchThreadListPendingInteractionState({ * and would overwrite the patch when it lands, so those queries are * invalidated, which cancels and restarts them. */ -function patchThreadListStatusState( - context: ThreadRealtimeDirtyContext, -): void { +function patchThreadListStatusState(context: ThreadRealtimeDirtyContext): void { const { flushOnce, queryClient, statusChange, threadId } = context; if (!threadId || !statusChange) { dirtyActiveThreadListQueriesWithThrottledRefetch(context); @@ -1189,12 +1213,14 @@ function patchThreadListStatusState( // Result rows render status but are not list-shaped, so search refreshes // rather than patches — once per flush and without aborting a request in // flight: status changes ride the immediate path, and the default - // cancelling invalidation could starve an open search on a slow link. + // cancelling invalidation could starve an open search on a slow link. A + // request already in flight read the index before this transition, and + // landing it clears the invalidation, so one trailing refetch follows it. if (flushOnce("thread-search:status-changed")) { - queryClient.invalidateQueries( - { queryKey: threadSearchQueryKeyPrefix() }, - { cancelRefetch: false }, - ); + invalidateQueryKeysWithoutCancelingActiveFetches({ + queryClient, + queryKeys: [threadSearchQueryKeyPrefix()], + }); } } @@ -1221,6 +1247,7 @@ function dirtyEnvironmentLiveWorkspaceStateQueries({ queryClient, }: EnvironmentRealtimeDirtyContext): void { invalidateQueryKeyWithThrottledActiveRefetch({ + exact: false, minIntervalMs: WORK_STATUS_REFETCH_MIN_INTERVAL_MS, queryClient, queryKey: environmentWorkStatusQueryKeyPrefix(environmentId), diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 95b15478fb..bbfcc638cd 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -570,20 +570,25 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); - it("does not abort an in-flight search when a status change patches the row", async () => { + it("refetches an in-flight search once it settles after a status change instead of aborting it", async () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext(); const threadSearchKey = threadSearchQueryKey({ limitPerGroup: 20, query: "needle", }); + const idleResponse = { + active: { results: [{ id: "thr_1", status: "idle" }], total: 1 }, + archived: { results: [], total: 0 }, + }; + const activeResponse = { + active: { results: [{ id: "thr_1", status: "active" }], total: 1 }, + archived: { results: [], total: 0 }, + }; // Cached data matters: the default cancelling invalidation only aborts // and re-issues a fetch when the query already holds data — exactly the // open-search-refreshing case a streaming turn's status flips would starve. - queryClient.setQueryData(threadSearchKey, { - active: { results: [], total: 0 }, - archived: { results: [], total: 0 }, - }); + queryClient.setQueryData(threadSearchKey, idleResponse); const signals: AbortSignal[] = []; const resolveFetches: Array<(value: unknown) => void> = []; const searchQueryFn = vi.fn(({ signal }: { signal: AbortSignal }) => { @@ -628,11 +633,26 @@ describe("createRealtimeCacheEffects", () => { expect(signals[0]?.aborted).toBe(false); expect(searchQueryFn).toHaveBeenCalledTimes(1); - resolveFetches[0]?.({ - active: { results: [], total: 0 }, - archived: { results: [], total: 0 }, - }); + // The request read the index before the transition, so its response + // still carries the old status. Landing it clears the invalidation, which + // would lose the change for good without a trailing refetch. + resolveFetches[0]?.(idleResponse); await vi.advanceTimersByTimeAsync(0); + expect(queryClient.getQueryData(threadSearchKey)).toEqual(idleResponse); + await vi.advanceTimersByTimeAsync(1_000); + expect(searchQueryFn).toHaveBeenCalledTimes(2); + expect(signals[0]?.aborted).toBe(false); + + resolveFetches[1]?.(activeResponse); + await vi.advanceTimersByTimeAsync(0); + expect(queryClient.getQueryData(threadSearchKey)).toEqual(activeResponse); + expect(queryClient.getQueryState(threadSearchKey)?.isInvalidated).toBe( + false, + ); + // One trailing read is enough: nothing changed while it ran. + await vi.advanceTimersByTimeAsync(2_000); + expect(searchQueryFn).toHaveBeenCalledTimes(2); + unsubscribeSearch(); effects.dispose(); }); @@ -2200,6 +2220,63 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("refetches a project list and its forks list once each for a bare status change", async () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const projectListKey = threadListQueryKey({ + archived: false, + projectId: "project-1", + }); + // The forks row's filters extend the project list's, so a partial key + // match on the project list key also selects the forks query. + const forksListKey = threadListQueryKey({ + archived: false, + originKind: "fork", + projectId: "project-1", + sourceThreadId: "thr_1", + }); + const createListQueryFn = () => + vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve([]), 20); + }), + ); + const projectQueryFn = createListQueryFn(); + const forksQueryFn = createListQueryFn(); + const projectObserver = new QueryObserver(queryClient, { + queryKey: projectListKey, + queryFn: projectQueryFn, + staleTime: Infinity, + }); + const forksObserver = new QueryObserver(queryClient, { + queryKey: forksListKey, + queryFn: forksQueryFn, + staleTime: Infinity, + }); + const unsubscribeProject = projectObserver.subscribe(() => {}); + const unsubscribeForks = forksObserver.subscribe(() => {}); + await vi.advanceTimersByTimeAsync(20); + expect(projectQueryFn).toHaveBeenCalledTimes(1); + expect(forksQueryFn).toHaveBeenCalledTimes(1); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_2", + metadata: { projectId: "project-1" }, + changes: ["status-changed"], + }); + await vi.advanceTimersByTimeAsync(1_500); + + expect(projectQueryFn).toHaveBeenCalledTimes(2); + expect(forksQueryFn).toHaveBeenCalledTimes(2); + + unsubscribeProject(); + unsubscribeForks(); + effects.dispose(); + }); + it("refetches over a patched row when a bare status-changed arrives while visible", async () => { // Stop requests, command failures and host interruptions push the bare // kind. On the visible path status-changed never enters the debounce From 9a36390b69b79d4eb264fd340d09fb722e498924 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:29:09 +0000 Subject: [PATCH 29/34] Move the coarse-pointer debounce test to its own isolated file The coarse-pointer case stubs window and resets the module registry to re-import the effects module with a touch pointer class. vitest.shared.ts treats vi.stubGlobal/vi.resetModules as isolation markers, so that one test moved the whole 65-test realtime-cache-effects suite out of the shared node worker into its own isolated worker, paying the module-graph re-import for every run. Keep the case in a small dedicated file so only it runs isolated and the main suite returns to the shared worker. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- ...ltime-cache-effects.coarse-pointer.test.ts | 68 +++++++++++++++++++ .../src/hooks/realtime-cache-effects.test.ts | 54 --------------- 2 files changed, 68 insertions(+), 54 deletions(-) create mode 100644 apps/app/src/hooks/realtime-cache-effects.coarse-pointer.test.ts diff --git a/apps/app/src/hooks/realtime-cache-effects.coarse-pointer.test.ts b/apps/app/src/hooks/realtime-cache-effects.coarse-pointer.test.ts new file mode 100644 index 0000000000..01bb06f7ef --- /dev/null +++ b/apps/app/src/hooks/realtime-cache-effects.coarse-pointer.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAppQueryClient } from "@/lib/query-client"; +import { threadTimelineQueryKey } from "./queries/query-keys"; + +/** + * Kept apart from `realtime-cache-effects.test.ts`: the pointer class is read + * from `matchMedia` once at module init, so the coarse branch needs a fresh + * module instance with a stubbed window, and `vi.stubGlobal`/`vi.resetModules` + * move a file out of the shared vitest worker (see vitest.shared.ts). Only + * this case pays for the module-graph re-import; the main suite stays shared. + */ +describe("createRealtimeCacheEffects on coarse pointers", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("widens the thread invalidation debounce on coarse pointers", async () => { + vi.useFakeTimers(); + // `location` rides along because the re-imported graph reaches the sdk + // module, which resolves its base URL from the window at init. + vi.stubGlobal("window", { + location: { origin: "http://localhost" }, + matchMedia: (query: string) => ({ + matches: query === "(pointer: coarse)", + }), + }); + vi.resetModules(); + try { + const coarseModule = await import("./realtime-cache-effects"); + const queryClient = createAppQueryClient({ + defaultOptions: { + queries: { + gcTime: Infinity, + retry: false, + }, + }, + showMutationErrorToasts: false, + }); + const effects = coarseModule.createRealtimeCacheEffects({ queryClient }); + const timelineKey = threadTimelineQueryKey("thr_1"); + queryClient.setQueryData(timelineKey, { rows: [] }); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { + eventTypes: ["item/agentMessage/delta"], + projectId: "project-1", + }, + changes: ["events-appended"], + }); + + // The fine-pointer cadence would have flushed at 50 ms. + vi.advanceTimersByTime(50); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).not.toBe( + true, + ); + vi.advanceTimersByTime(100); + expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); + + effects.dispose(); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); +}); diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index bbfcc638cd..db795a7ffd 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -2652,60 +2652,6 @@ describe("createRealtimeCacheEffects", () => { }); }); - it("widens the thread invalidation debounce on coarse pointers", async () => { - vi.useFakeTimers(); - // The pointer class is read from matchMedia once at module init, so the - // coarse branch needs a fresh module instance with a stubbed window. - // `location` rides along because the re-imported graph reaches the sdk - // module, which resolves its base URL from the window at init. - vi.stubGlobal("window", { - location: { origin: "http://localhost" }, - matchMedia: (query: string) => ({ - matches: query === "(pointer: coarse)", - }), - }); - vi.resetModules(); - try { - const coarseModule = await import("./realtime-cache-effects"); - const queryClient = createAppQueryClient({ - defaultOptions: { - queries: { - gcTime: Infinity, - retry: false, - }, - }, - showMutationErrorToasts: false, - }); - const effects = coarseModule.createRealtimeCacheEffects({ queryClient }); - const timelineKey = threadTimelineQueryKey("thr_1"); - queryClient.setQueryData(timelineKey, { rows: [] }); - - effects.handleChanged({ - type: "changed", - entity: "thread", - id: "thr_1", - metadata: { - eventTypes: ["item/agentMessage/delta"], - projectId: "project-1", - }, - changes: ["events-appended"], - }); - - // The fine-pointer cadence would have flushed at 50 ms. - vi.advanceTimersByTime(50); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).not.toBe( - true, - ); - vi.advanceTimersByTime(100); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); - - effects.dispose(); - } finally { - vi.unstubAllGlobals(); - vi.resetModules(); - } - }); - it("applies the reconnect watermark from the connected event", () => { const { effects, queryClient } = createRealtimeEffectsTestContext(); const disconnectedAt = Date.now(); From 3b55fee8bfb166ca39235077c424109fd03141e2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:30:37 +0000 Subject: [PATCH 30/34] Refetch an in-flight search after a completed turn settles dirtyThreadSearchQueriesForCompletedTurn had the same gap as the status patch: it invalidated the search prefix with cancelRefetch: false and no trailing refetch, so a turn that completed while a search request was in flight deduped onto that request, whose pre-completion response cleared the invalidation and left the newly indexed content out of the results. Use the same non-cancelling helper so the running request is kept and one refetch follows once it settles. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- .../cache-owners/realtime-cache-registry.ts | 12 +++++++----- .../src/hooks/realtime-cache-effects.test.ts | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 87233a0828..d2d1822cf3 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -980,7 +980,9 @@ function dirtyThreadSearchQueries(): QueryKey[] { * `events-appended` batches. Re-issuing (and, by default, aborting) the open * search request on each 50-100 ms flush can starve it forever on a slow link, * so search only goes stale when a turn completes, once per flush, and without - * cancelling a request in flight. Thread list changes cover the rest. + * cancelling a request in flight — a request already running read the index + * before the turn settled, so one trailing refetch follows it. Thread list + * changes cover the rest. */ function dirtyThreadSearchQueriesForCompletedTurn({ eventTypes, @@ -993,10 +995,10 @@ function dirtyThreadSearchQueriesForCompletedTurn({ if (!flushOnce("thread-search:turn-completed")) { return; } - queryClient.invalidateQueries( - { queryKey: threadSearchQueryKeyPrefix() }, - { cancelRefetch: false }, - ); + invalidateQueryKeysWithoutCancelingActiveFetches({ + queryClient, + queryKeys: [threadSearchQueryKeyPrefix()], + }); } function dirtyThreadTimelineQueries({ diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index db795a7ffd..1a11ca700e 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -506,7 +506,7 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); - it("refreshes an open search once per flush without aborting the request in flight", async () => { + it("refreshes an open search once per flush without aborting the request in flight, then once it settles", async () => { vi.useFakeTimers(); const visibility = createFakeVisibility(); const { effects, queryClient } = @@ -560,11 +560,26 @@ describe("createRealtimeCacheEffects", () => { expect(signals[0]?.aborted).toBe(false); expect(searchQueryFn).toHaveBeenCalledTimes(1); + // That request read the index before the turns completed, and landing + // it clears the invalidation: one trailing refetch picks up the newly + // indexed content. resolveFetches[0]?.({ active: { results: [], total: 0 }, archived: { results: [], total: 0 }, }); await vi.advanceTimersByTimeAsync(0); + expect(searchQueryFn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000); + expect(searchQueryFn).toHaveBeenCalledTimes(2); + expect(signals[0]?.aborted).toBe(false); + + resolveFetches[1]?.({ + active: { results: [], total: 0 }, + archived: { results: [], total: 0 }, + }); + await vi.advanceTimersByTimeAsync(2_000); + expect(searchQueryFn).toHaveBeenCalledTimes(2); + invalidateSpy.mockRestore(); unsubscribeSearch(); effects.dispose(); From dd52a39f565fb284416c813d87cdfb61085a394b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:33:30 +0000 Subject: [PATCH 31/34] Serve the app shell no-cache again; keep the edge copy's bound internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell had moved from `no-cache` to `max-age=300, must-revalidate` so the connect worker could keep a copy in caches.default. But that header is obeyed by every client, not just the worker: a browser, a PWA launch or the desktop window reuses a fresh index.html for up to five minutes without a conditional request (`must-revalidate` only governs stale entries), so after an in-place bb update they booted the previous build whose content-hashed assets now 404 — a blank page until the window ran out. The ETag/304 machinery never ran inside that window, and the "new build picked up immediately" property the comment claimed was gone. The server serves the document `no-cache` again, with the same build-id ETag and If-None-Match -> empty 304 handling, so every navigation on every client is a header-only revalidation and bfcache stays eligible. The connect worker now treats `no-cache` + ETag as the revalidated-shell contract (still rejecting no-store/private/set-cookie; the plain asset path keeps rejecting no-cache). Because caches.default will not hold a `no-cache` response, the stored copy is rewritten to an internal `max-age=300` — dropping content-encoding/content-length, since a subrequest body is read as identity bytes — while the visitor always receives the origin's `no-cache`: on the miss path verbatim, and on the edge-served path by freshening the stored headers with the origin's 304 (RFC 9111 §4.3.4). The stored copy is still only served after the origin confirms its ETag, so the tunnel saving is unchanged. Tests: static-shell.test.ts and static-cache.test.ts pin `no-cache` on the 200 and the 304 (they failed on the previous header); the workerd document-cache test now runs the origin on the `no-cache` contract, asserts every visitor response carries `no-cache` and the stored copy a max-age bound, makes the visitor-304 test self-contained, and pins that a pre-contract server (`no-cache` without a validator) stays uncached. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- apps/connect/src/cache.ts | 93 ++++++++++++++++------- apps/connect/src/document-cache.test.ts | 93 ++++++++++++++++++----- apps/connect/test/encoding-fixture.ts | 12 +-- apps/server/src/server.ts | 28 +++---- apps/server/src/static-shell.test.ts | 22 +++--- apps/server/test/app/static-cache.test.ts | 19 ++--- 6 files changed, 182 insertions(+), 85 deletions(-) diff --git a/apps/connect/src/cache.ts b/apps/connect/src/cache.ts index 1250bd1bd6..182afa9a6f 100644 --- a/apps/connect/src/cache.ts +++ b/apps/connect/src/cache.ts @@ -4,12 +4,14 @@ // into a handful of dynamic API calls plus edge hits. // // The app shell (index.html on every client route) gets a second, revalidated -// flavor: the origin serves it with `max-age=300, must-revalidate` plus a -// build-id ETag, so the worker keeps the last confirmed document at the edge -// and asks the laptop only "is still current?" on each navigation. A -// 304 costs the tunnel a handful of header bytes instead of the document, and -// a new build still takes effect on the next navigation because the origin -// answers that conditional request with the fresh 200. +// flavor: the origin serves it `no-cache` plus a build-id ETag, so the worker +// keeps the last confirmed document at the edge and asks the laptop only "is +// still current?" on each navigation. A 304 costs the tunnel a handful +// of header bytes instead of the document, and a new build still takes effect +// on the next navigation because the origin answers that conditional request +// with the fresh 200. The visitor always receives the origin's `no-cache`, so +// its browser revalidates on the next navigation too — exactly as a direct +// client does — instead of booting a stale shell whose hashed assets are gone. // // Only called AFTER the gate has verified the requester owns the label. Server // cache namespaces remain the bare/full host label exactly as on main; new @@ -24,6 +26,13 @@ const CACHE_HOST = "https://bb-connect-asset-cache.internal"; // for the same namespace + path. const SHELL_CACHE_HOST = "https://bb-connect-shell-cache.internal"; const MIN_CACHEABLE_MAX_AGE = 300; +// The shell contract's Cache-Control, and what the visitor gets back. +const SHELL_CACHE_CONTROL = "no-cache"; +// Freshness bound for the edge copy of the shell. caches.default will not hold +// a `no-cache` response, so the copy is stored under this internal TTL. It +// never reaches a visitor: the copy is served only after the origin confirms +// its ETag, and the visitor's response takes the origin's own Cache-Control. +const SHELL_STORE_CACHE_CONTROL = "max-age=300"; /** * The origin fetch for a gated request. `ifNoneMatch` asks the tunnel client @@ -62,31 +71,50 @@ export interface CacheResult { } /** - * A response the origin wants cached only under revalidation: a build-id ETag - * plus `must-revalidate` with a short freshness window. The bb server marks - * exactly one response this way — the app shell — but the check is - * header-driven, so any origin (including a port share) opting in with the - * same contract gets the same treatment. Checked before `isCacheable`: the - * shell's `max-age=300` would otherwise be cached plainly and served without - * the revalidation its `must-revalidate` demands. + * A response the origin wants reused only after revalidation: `no-cache` plus + * an ETag. The bb server marks exactly one response this way — the app shell + * — but the check is header-driven, so any origin (including a port share) + * opting in with the same contract gets the same treatment. `no-cache` is + * what every browser and the desktop window obey on each navigation; the + * edge copy obeys it the same way, so a new build is picked up on the very + * next navigation everywhere. `isCacheable` keeps rejecting `no-cache`: a + * plain edge hit would skip exactly that revalidation. */ function isRevalidatableShell(resp: Response): boolean { if (!resp.ok) return false; if (resp.headers.has("set-cookie")) return false; if (resp.headers.get("etag") === null) return false; const cc = resp.headers.get("cache-control") ?? ""; - if (/\b(no-store|no-cache|private)\b/i.test(cc)) return false; - if (!/\bmust-revalidate\b/i.test(cc)) return false; - const maxAge = cc.match(/max-age=(\d+)/i); - return maxAge !== null && Number(maxAge[1]) >= 1; + if (/\b(no-store|private)\b/i.test(cc)) return false; + return /\bno-cache\b/i.test(cc); } /** - * Store the shell response and build the visitor's copy. The clone is stored - * with its origin headers intact — including the ETag the entry is keyed to - * in spirit: a stored shell is only ever served after the origin confirms - * that exact ETag with a 304 — and its `max-age=300` bounds the storage, so - * nothing stale outlives the origin's own freshness window. + * The copy of a shell response that goes into caches.default. It cannot be a + * plain clone: the origin's `no-cache` would keep the cache from holding it, + * so the copy carries the internal freshness bound instead. And because a + * body read out of a subrequest is already plain bytes whatever the origin's + * content-encoding says (see the miss path in serveWithCache), the copy drops + * content-encoding and content-length and is stored identity — the one form + * that is unambiguous both for the put and for the pre-encoded rebuild on the + * revalidated path. + */ +function shellCopyForStorage(resp: Response): Response { + const headers = new Headers(resp.headers); + headers.set("cache-control", SHELL_STORE_CACHE_CONTROL); + headers.delete("content-encoding"); + headers.delete("content-length"); + return new Response(resp.clone().body, { + status: resp.status, + statusText: resp.statusText, + headers, + }); +} + +/** + * Store the shell response and build the visitor's copy. A stored shell is + * only ever served after the origin confirms its ETag with a 304; the + * visitor's copy keeps the origin's own headers, `no-cache` included. */ function storeShellAndServe( resp: Response, @@ -94,7 +122,12 @@ function storeShellAndServe( url: URL, ctx: ExecutionContext, ): CacheResult { - ctx.waitUntil(caches.default.put(shellCacheKey(namespace, url), resp.clone())); + ctx.waitUntil( + caches.default.put( + shellCacheKey(namespace, url), + shellCopyForStorage(resp), + ), + ); // Same encoding rule as the asset miss below: a body read out of a // subrequest is already plain bytes, so automatic encoding is correct. const r = new Response(resp.body, resp); @@ -130,12 +163,20 @@ async function serveRevalidatedShell( r.headers.set("x-bb-cache", "revalidated"); return { cacheable: true, response: r }; } - // The stored bytes are still encoded exactly like an asset hit's (the - // cache keeps the origin's encoding), so rebuild as pre-encoded. - // `cacheable: true` is load-bearing beyond refresh semantics: the + // The stored bytes are identity and the stored headers say so (see + // shellCopyForStorage), so the pre-encoded rebuild ships them as they + // are. `cacheable: true` is load-bearing beyond refresh semantics: the // session-refresh path rebuilds non-cacheable responses to append // Set-Cookie, and that rebuild would strip this body's pre-encoded flag. const r = rebuiltResponse(shellHit.body, shellHit); + // A 304 freshens the stored response (RFC 9111 §4.3.4): the origin's + // current Cache-Control replaces the internal bound the copy was stored + // under, so the visitor's browser revalidates next time like a direct + // client's does. + r.headers.set( + "cache-control", + resp.headers.get("cache-control") ?? SHELL_CACHE_CONTROL, + ); r.headers.set("x-bb-cache", "revalidated"); return { cacheable: true, response: r }; } diff --git a/apps/connect/src/document-cache.test.ts b/apps/connect/src/document-cache.test.ts index 4f58046e45..7b3bb8b08a 100644 --- a/apps/connect/src/document-cache.test.ts +++ b/apps/connect/src/document-cache.test.ts @@ -1,14 +1,17 @@ // The revalidated shell cache, exercised through the real TunnelDO and the // real serveWithCache inside workerd (miniflare) — the same harness as -// response-encoding.test.ts, because the cache stores still-encoded bytes and -// `encodeBody` exists only in workerd. +// response-encoding.test.ts, because `encodeBody` and caches.default's +// storage rules exist only in workerd. // // The fake tunnel client plays a bb server that speaks the shell contract: -// `max-age=300, must-revalidate` plus a build-id ETag, 304 for a matching -// If-None-Match. The tests pin the design's three properties: a repeat -// navigation is served from caches.default with only a 304 on the tunnel, a -// build change takes effect on the next navigation, and a visitor's own -// conditional request relays the origin's 304. +// `Cache-Control: no-cache` plus a build-id ETag, 304 for a matching +// If-None-Match. The tests pin the design's properties: a repeat navigation +// is served from caches.default with only a 304 on the tunnel, a build change +// takes effect on the next navigation, every visitor response carries the +// origin's `no-cache` (so the browser revalidates too, instead of booting a +// stale shell whose hashed assets are gone), a visitor's own conditional +// request relays the origin's 304, and a server from before the contract +// (`no-cache` without a validator) is proxied uncached. import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; import { build } from "esbuild"; @@ -16,7 +19,7 @@ import { Miniflare } from "miniflare"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { decodeFrame, encodeFrame, type Frame } from "@bb/tunnel-contract"; -const SHELL_CACHE_CONTROL = "max-age=300, must-revalidate"; +const SHELL_CACHE_CONTROL = "no-cache"; const BUILD_A = { etag: 'W/"build-a"', @@ -53,17 +56,26 @@ async function bundleFixture(): Promise { return result.outputFiles[0].text; } -/** A tunnel client whose origin serves the shell contract for every path. */ +/** + * A tunnel client whose origin serves the shell contract for every path — + * except under /legacy/, where it plays a bb server from before the contract: + * `no-cache` with no validator and no 304. + */ function serveShellOverTunnel(ws: ClientWebSocket): void { const send = (frame: Frame) => ws.send(new Uint8Array(encodeFrame(frame))); ws.addEventListener("message", (event) => { if (typeof event.data === "string") return; const frame = decodeFrame(event.data as ArrayBuffer); if (frame.type !== "open-http") return; + const legacy = new URL( + frame.path, + "http://origin.local", + ).pathname.startsWith("/legacy/"); const ifNoneMatch = - frame.headers.find(([name]) => name.toLowerCase() === "if-none-match")?.[1] ?? - null; - if (ifNoneMatch === currentBuild.etag) { + frame.headers.find( + ([name]) => name.toLowerCase() === "if-none-match", + )?.[1] ?? null; + if (!legacy && ifNoneMatch === currentBuild.etag) { originLog.push({ ifNoneMatch, sentBody: false }); send({ type: "resp-head", @@ -88,7 +100,7 @@ function serveShellOverTunnel(ws: ClientWebSocket): void { ["content-encoding", "gzip"], ["content-length", String(gzip.byteLength)], ["cache-control", SHELL_CACHE_CONTROL], - ["etag", currentBuild.etag], + ...(legacy ? [] : [["etag", currentBuild.etag] as [string, string]]), ], }); send({ @@ -106,6 +118,7 @@ async function get( ): Promise<{ status: number; cacheMarker: string | null; + cacheControl: string | null; etag: string | null; body: string; }> { @@ -115,18 +128,22 @@ async function get( return { status: res.status, cacheMarker: res.headers.get("x-bb-cache"), + cacheControl: res.headers.get("cache-control"), etag: res.headers.get("etag"), body: Buffer.from(await res.arrayBuffer()).toString("utf8"), }; } -/** Cache writes ride ctx.waitUntil; poll the fixture's probe before relying on them. */ -async function waitForShellCached(path: string): Promise { +/** + * Cache writes ride ctx.waitUntil; poll the fixture's probe before relying on + * them. Resolves to the Cache-Control the copy was stored under. + */ +async function waitForShellCached(path: string): Promise { for (let i = 0; i < 50; i += 1) { const res = await mf.dispatchFetch( `https://relay.test/shell-cached?for=${encodeURIComponent(path)}`, ); - if (res.status === 200) return; + if (res.status === 200) return await res.text(); await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`shell copy for ${path} never landed in caches.default`); @@ -171,20 +188,26 @@ afterAll(async () => { describe("revalidated shell cache", () => { it("serves repeats from caches.default with only a 304 on the tunnel, and ships a new build on the next navigation", async () => { - // Cold: full document through the tunnel, stored at the edge. + // Cold: full document through the tunnel, stored at the edge. The + // visitor gets the origin's own `no-cache`, so its browser revalidates + // on the next navigation exactly like a direct client would. const cold = await get("/threads/t1"); expect(cold.status).toBe(200); expect(cold.body).toBe(BUILD_A.html); expect(cold.cacheMarker).toBe("miss"); + expect(cold.cacheControl).toBe("no-cache"); expect(originLog.at(-1)).toEqual({ ifNoneMatch: null, sentBody: true }); - await waitForShellCached("/threads/t1"); + // caches.default would not hold a `no-cache` response at all: the edge + // copy is stored under an internal freshness bound instead. + expect(await waitForShellCached("/threads/t1")).toMatch(/^max-age=\d+$/u); // Repeat: the origin only confirms the ETag; the body comes from the - // edge cache. + // edge cache, still under the origin's `no-cache`. const repeat = await get("/threads/t1"); expect(repeat.status).toBe(200); expect(repeat.body).toBe(BUILD_A.html); expect(repeat.cacheMarker).toBe("revalidated"); + expect(repeat.cacheControl).toBe("no-cache"); expect(originLog.at(-1)).toEqual({ ifNoneMatch: BUILD_A.etag, sentBody: false, @@ -198,6 +221,7 @@ describe("revalidated shell cache", () => { expect(upgraded.body).toBe(BUILD_B.html); expect(upgraded.etag).toBe(BUILD_B.etag); expect(upgraded.cacheMarker).toBe("miss"); + expect(upgraded.cacheControl).toBe("no-cache"); expect(originLog.at(-1)).toEqual({ ifNoneMatch: BUILD_A.etag, sentBody: true, @@ -208,6 +232,7 @@ describe("revalidated shell cache", () => { const settled = await get("/threads/t1"); expect(settled.body).toBe(BUILD_B.html); expect(settled.cacheMarker).toBe("revalidated"); + expect(settled.cacheControl).toBe("no-cache"); expect(originLog.at(-1)).toEqual({ ifNoneMatch: BUILD_B.etag, sentBody: false, @@ -216,13 +241,41 @@ describe("revalidated shell cache", () => { it("relays the origin's 304 when the visitor presents a current validator", async () => { currentBuild = BUILD_B; - const res = await get("/threads/t1", { "if-none-match": BUILD_B.etag }); + // Cold path: nothing stored yet, the visitor's own validator rides the + // proxied request and the origin's 304 comes straight back. + const cold = await get("/threads/t2", { "if-none-match": BUILD_B.etag }); + expect(cold.status).toBe(304); + expect(cold.body).toBe(""); + expect(originLog.at(-1)).toEqual({ + ifNoneMatch: BUILD_B.etag, + sentBody: false, + }); + + // Stored path: the visitor's validator still wins over the stored ETag. + const miss = await get("/threads/t2"); + expect(miss.cacheMarker).toBe("miss"); + await waitForShellCached("/threads/t2"); + const res = await get("/threads/t2", { "if-none-match": BUILD_B.etag }); expect(res.status).toBe(304); expect(res.body).toBe(""); expect(res.cacheMarker).toBe("revalidated"); + expect(res.cacheControl).toBe("no-cache"); expect(originLog.at(-1)).toEqual({ ifNoneMatch: BUILD_B.etag, sentBody: false, }); }, 30_000); + + it("proxies a no-cache document without a validator uncached (a server from before the contract)", async () => { + // Twice: were `no-cache` enough for either cache flavor, the second + // navigation would be a hit or a revalidation instead of a full relay. + for (let i = 0; i < 2; i += 1) { + const res = await get("/legacy/threads/t1"); + expect(res.status).toBe(200); + expect(res.body).toBe(currentBuild.html); + expect(res.cacheMarker).toBeNull(); + expect(res.cacheControl).toBe("no-cache"); + expect(originLog.at(-1)).toEqual({ ifNoneMatch: null, sentBody: true }); + } + }, 30_000); }); diff --git a/apps/connect/test/encoding-fixture.ts b/apps/connect/test/encoding-fixture.ts index 2e6df09622..c1399bf8bf 100644 --- a/apps/connect/test/encoding-fixture.ts +++ b/apps/connect/test/encoding-fixture.ts @@ -68,16 +68,18 @@ export default { } // Probe: whether the revalidated shell copy for a path has landed in the - // edge cache yet. cache writes ride ctx.waitUntil, so tests poll this - // instead of racing the put. + // edge cache yet, and the Cache-Control it was stored under (the worker's + // internal freshness bound, not the origin's `no-cache`). Cache writes + // ride ctx.waitUntil, so tests poll this instead of racing the put. if (url.pathname === "/shell-cached") { const target = url.searchParams.get("for") ?? "/"; const cached = await caches.default.match( shellCacheKey(NAMESPACE, new URL(`${url.origin}${target}`)), ); - return new Response(cached ? "cached" : "absent", { - status: cached ? 200 : 404, - }); + return new Response( + cached ? (cached.headers.get("cache-control") ?? "") : "absent", + { status: cached ? 200 : 404 }, + ); } // Control: the pre-fix cache-hit rebuild, over the entry serveWithCache diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8bd7efc68f..dd197e1474 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -139,16 +139,18 @@ interface StaticResponseHeadersArgs { urlPath: string; } -// The document travels with a build-id ETag (see shellEtag): the browser may -// reuse it for five minutes, then must revalidate — an If-None-Match answered -// with a 304, which costs the connect tunnel a handful of header bytes — and -// the connect worker revalidates its edge copy on every navigation, so a new -// build still takes effect on the next navigation there. This replaces -// `no-cache`, whose "new build picked up immediately" property the ETag -// preserves without re-sending the document each time. Still not `no-store`: -// WebKit may keep the page in the back/forward cache and restore it without a -// reload. -const STATIC_INDEX_CACHE_CONTROL = "max-age=300, must-revalidate"; +// `no-cache` (not `no-store`): every client — browsers, the desktop window, +// the connect worker's edge copy — revalidates the document on every +// navigation, so a new build is picked up immediately. The document travels +// with a build-id ETag (see shellEtag), so that revalidation is an +// If-None-Match answered with an empty 304: a handful of header bytes, also +// through the connect tunnel, where the worker keeps the last confirmed +// document at the edge. A positive max-age would let a browser reuse the +// shell without asking (`must-revalidate` only governs stale entries) and +// boot a stale build — whose hashed assets no longer exist after an in-place +// update — for the whole window. Not `no-store`: WebKit may still keep the +// page in the back/forward cache and restore it without a reload. +const STATIC_INDEX_CACHE_CONTROL = "no-cache"; const STATIC_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"; // Icons and manifests under public/ are not content-hashed but change only // with a release; a day of caching keeps favicon/badge flips and PWA @@ -296,9 +298,9 @@ export function registerStaticAppRoutes(app: Hono, staticDir: string): void { urlPath: string; }): Promise => { // Only the shell carries a validator: assets are immutable by hash and - // public files by TTL, but the document must revalidate cheaply — a 304 - // here is what keeps `max-age=300, must-revalidate` as prompt as the old - // `no-cache` without resending the document every navigation. + // public files by TTL, but the document is `no-cache` and revalidated on + // every navigation — the 304 here is what keeps that revalidation a few + // header bytes instead of the document each time. const etag = args.contentType === "text/html" ? await shellEtag(args.filePath) diff --git a/apps/server/src/static-shell.test.ts b/apps/server/src/static-shell.test.ts index 573a545db1..feff01d6bb 100644 --- a/apps/server/src/static-shell.test.ts +++ b/apps/server/src/static-shell.test.ts @@ -9,10 +9,16 @@ import { ifNoneMatchSatisfied, registerStaticAppRoutes } from "./server.js"; /** * The shell contract the connect worker's edge cache builds on: the document * (served directly and as the SPA fallback for every client route) carries a - * build-id ETag and `max-age=300, must-revalidate`, answers If-None-Match - * with a cheap 304, and ships its precompressed sidecar when the client - * accepts it. A regression here silently turns every relayed navigation back - * into a full-document tunnel round trip. + * build-id ETag and `Cache-Control: no-cache`, answers If-None-Match with a + * cheap 304, and ships its precompressed sidecar when the client accepts it. + * + * `no-cache` is load-bearing: any positive freshness lifetime lets a browser + * or the desktop window reuse the shell after a bb update without asking + * (`must-revalidate` only governs stale entries), and a stale shell + * references hashed assets that no longer exist — a blank page until the + * window expires. A regression here either masks a new build for the whole + * window or turns every relayed navigation back into a full-document tunnel + * round trip. */ describe("app shell serving", () => { const shellHtml = "bb

    build-a

    "; @@ -36,9 +42,7 @@ describe("app shell serving", () => { expect(res.status).toBe(200); expect(res.headers.get("content-encoding")).toBe("br"); expect(res.headers.get("content-type")).toBe("text/html"); - expect(res.headers.get("cache-control")).toBe( - "max-age=300, must-revalidate", - ); + expect(res.headers.get("cache-control")).toBe("no-cache"); expect(res.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); expect(Buffer.from(await res.arrayBuffer())).toEqual(shellBrotli); } @@ -63,9 +67,7 @@ describe("app shell serving", () => { }); expect(res.status).toBe(304); expect(res.headers.get("etag")).toBe(etag); - expect(res.headers.get("cache-control")).toBe( - "max-age=300, must-revalidate", - ); + expect(res.headers.get("cache-control")).toBe("no-cache"); expect((await res.arrayBuffer()).byteLength).toBe(0); } }); diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts index 26ac7d523e..eba5854ba6 100644 --- a/apps/server/test/app/static-cache.test.ts +++ b/apps/server/test/app/static-cache.test.ts @@ -8,7 +8,7 @@ import { createApp } from "../../src/server.js"; import { createTestAppHarness } from "../helpers/test-app.js"; describe("production static cache headers", () => { - it("keeps index.html fresh while allowing immutable hashed assets", async () => { + it("revalidates index.html on every navigation while allowing immutable hashed assets", async () => { const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-")); await mkdir(join(staticDir, "assets"), { recursive: true }); await writeFile( @@ -38,20 +38,17 @@ describe("production static cache headers", () => { const harness = await createTestAppHarness(); const serverApp = createApp(harness.deps, { staticDir }); try { - // The shell travels with max-age=300 + must-revalidate + a build-id - // ETag: browsers and the connect edge revalidate with If-None-Match - // (a cheap 304) instead of refetching the document, and unlike - // `no-store` it stays eligible for the WebKit back/forward cache. + // The shell is `no-cache` plus a build-id ETag: browsers, the desktop + // window and the connect edge revalidate with If-None-Match on every + // navigation (a cheap 304), so a new build is never masked by a fresh + // private copy whose hashed assets are gone, and unlike `no-store` it + // stays eligible for the WebKit back/forward cache. const rootResponse = await serverApp.app.request("/"); - expect(rootResponse.headers.get("cache-control")).toBe( - "max-age=300, must-revalidate", - ); + expect(rootResponse.headers.get("cache-control")).toBe("no-cache"); expect(rootResponse.headers.get("etag")).toMatch(/^W\/"[0-9a-f]{32}"$/u); const fallbackResponse = await serverApp.app.request("/threads/thr_123"); - expect(fallbackResponse.headers.get("cache-control")).toBe( - "max-age=300, must-revalidate", - ); + expect(fallbackResponse.headers.get("cache-control")).toBe("no-cache"); // Same document, same validator: the fallback IS the shell. expect(fallbackResponse.headers.get("etag")).toBe( rootResponse.headers.get("etag"), From 1df5682d8dff68949684551b27c005211bae68b7 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:28:51 +0000 Subject: [PATCH 32/34] Make the shell 304-relay test store its own edge copy The "relays the origin's 304 when the visitor presents a current validator" test sent its conditional request for /threads/t1 without ever storing a shell for that path, so it only passed because the preceding test had left build B's copy in caches.default. Run alone (`vitest -t "relays the origin"`) it took the cold path, where serveWithCache forwards the visitor's If-None-Match straight to the origin and returns that 304 without the x-bb-cache marker: `AssertionError: expected null to be 'revalidated'`. The test now navigates cold on its own path (/threads/t2), waits for the edge put, and only then sends the conditional request, so it holds whatever ran before it. The first test pins currentBuild to BUILD_A at its start for the same reason, instead of relying on module-load state. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- apps/connect/src/document-cache.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/connect/src/document-cache.test.ts b/apps/connect/src/document-cache.test.ts index 7b3bb8b08a..47dcb53da2 100644 --- a/apps/connect/src/document-cache.test.ts +++ b/apps/connect/src/document-cache.test.ts @@ -188,6 +188,7 @@ afterAll(async () => { describe("revalidated shell cache", () => { it("serves repeats from caches.default with only a 304 on the tunnel, and ships a new build on the next navigation", async () => { + currentBuild = BUILD_A; // Cold: full document through the tunnel, stored at the edge. The // visitor gets the origin's own `no-cache`, so its browser revalidates // on the next navigation exactly like a direct client would. @@ -240,6 +241,8 @@ describe("revalidated shell cache", () => { }, 30_000); it("relays the origin's 304 when the visitor presents a current validator", async () => { + // Own path, stored first: the relay only consults the visitor's validator + // once an edge copy exists, so this must not lean on the previous test. currentBuild = BUILD_B; // Cold path: nothing stored yet, the visitor's own validator rides the // proxied request and the origin's 304 comes straight back. From 9d0821f0ff505870625beb81e3c97bfba8769d9d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:28:51 +0000 Subject: [PATCH 33/34] Lower the boot brotli ratchet to 10% above the new payload Merging the boot micro-chunks (plan 007 step 2) cut the measured boot payload to 1,584,595 B raw / 390,065 B brotli (3 chunks), but bundle-budget.json still carried the ceilings computed from the previous 1,566,924 / 435,515 B measurement. That left 23% brotli headroom where the file's own rule says 10%, so ~86 KB brotli of boot regression could have landed with "Bundle budget OK". Set maxBootBrotliBytes to ceil(measured x 1.1): 479,067 -> 429,072. maxBootBytes stays at 1,723,617: the chunk merge grew the raw payload by 17.7 KB while cutting brotli by 45 KB, which leaves 8.8% raw headroom, and a ceiling is never raised without a separate reason. `node apps/app/scripts/check-bundle-budget.mjs` after `turbo run build --filter=@bb/app`: boot payload 1547.5 KB raw / 380.9 KB brotli, budget 1683.2 KB raw / 419.0 KB brotli, OK. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- apps/app/bundle-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 7032e9dcae..b667739ad9 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -47,7 +47,7 @@ "to print the static chain that pulled a package into the closure." ], "maxBootBytes": 1723617, - "maxBootBrotliBytes": 479067, + "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs", "@pierre/theming", From 3388bcb45dd45a59a0b1857a3a19b1c8a302bd98 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 25 Aug 2026 21:28:51 +0000 Subject: [PATCH 34/34] Pin the built head order without a build in the font-preload test The "emitted dist/index.html head order" suite is describe.skipIf(!existsSync(dist/index.html)). The CI tests job runs `turbo run test --filter=@bb/app` with no build step and the `test` task has no edge to `@bb/app#build`, so that suite reports "1 skipped" on every CI run and the ordering this plugin exists for was only checked locally, after a build. Add a unit test that applies reorderHeadForFirstPaint with resolveFontPreloadTags (the composition the plugin's transformIndexHtml handler performs) to the committed index.html with Vite 8's injected tags: entry script, modulepreloads, stylesheet, one per indented line, the shape dist/index.html shows. It asserts the font preload and the stylesheet follow the pre-paint theme script and precede the entry script and every modulepreload, and that the stylesheet is moved rather than duplicated. The existing fixture has no newlines or indentation, so the regex that consumes Vite's indented stylesheet line was untested. With the insertion anchor swapped back to (the pre-PR order) the new test fails with "expected 4128 to be less than 3777" while the dist-gated suite still passes against the already-built dist; with dist/index.html absent the file runs 8 passed | 1 skipped. The dist-gated suite is kept as-is. Co-authored-by: Vedran Burojevic Co-Authored-By: Claude --- apps/app/src/vite-font-preload.test.ts | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/app/src/vite-font-preload.test.ts b/apps/app/src/vite-font-preload.test.ts index 899ab5ccef..f1f2b9d631 100644 --- a/apps/app/src/vite-font-preload.test.ts +++ b/apps/app/src/vite-font-preload.test.ts @@ -96,6 +96,72 @@ describe("reorderHeadForFirstPaint", () => { }); }); +/** + * The document Vite 8 hands post-order transforms for the real index.html: + * the source entry tag is lifted out of , and the built entry, its + * modulepreloads and the stylesheet are appended to in that order, + * one per indented line. Built from the committed source so the test runs + * without a build (dist/ is absent on the CI test runners) and still covers + * the real pre-paint theme script and Vite's whitespace around the tags. + */ +function viteEmittedIndexHtml(): string { + const source = readFileSync( + resolve(import.meta.dirname, "../index.html"), + "utf8", + ); + const sourceEntryTag = + /[ \t]*', + '', + '', + '', + ] + .map((tag) => ` ${tag}\n`) + .join(""); + return source + .replace(sourceEntryTag, "") + .replace(" ", `${injected} `); +} + +describe("reorderHeadForFirstPaint on the document Vite emits from index.html", () => { + it("front-loads the stylesheet and font preload, after the theme script and ahead of every script and modulepreload", () => { + const emitted = viteEmittedIndexHtml(); + const html = reorderHeadForFirstPaint( + emitted, + resolveFontPreloadTags(bundle, "/"), + ); + + const themeScriptAt = html.indexOf("bb.theme"); + const fontPreloadAt = html.search(/]*as="font"/); + const stylesheetAt = html.search(/]*rel="stylesheet"/); + const entryAt = html.search(/