Skip to content

Boot and delivery quick wins for the relayed mobile path - #2389

Closed
vburojevic wants to merge 5 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/boot-delivery
Closed

Boot and delivery quick wins for the relayed mobile path#2389
vburojevic wants to merge 5 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/boot-delivery

Conversation

@vburojevic

Copy link
Copy Markdown
Contributor

What was wrong

A thread page on the relayed mobile path (CF worker → Durable Object → tunnel WebSocket → laptop) was 113 requests / ~3.5 MB raw JS, and the tunnel serializes responses FIFO — so discovery order and request count are the latency. Four root causes: the render-blocking stylesheet was the 68th resource discovered in the built index.html (font preload dead last); no chunk-merging config existed (70 boot chunks, half carrying ~2% of the bytes, 18 below the precompress floor); index.html was no-cache — which the connect worker treats as never-cacheable — with no brotli sidecar, and the SPA fallback bypassed the precompressed path entirely; the connect gate's per-isolate caches stored only settled values, so a cold isolate's burst paid one D1 round trip per request instead of per key.

What changed

  • vite-font-preload.ts: the stylesheet (with fetchpriority="high") and font preload now precede the entry script and modulepreload block; the transform fails the build if the stylesheet would ever precede the pre-paint theme script.
  • vite.config.ts: advancedChunks with two $initial-tagged groups (vendor/app) merges the boot graph 70 → 3 chunks (boot brotli 443.0 → 381.2 KB); lazy-route and on-demand facades untouched, all budget gates hold.
  • precompress-app-dist.mjs: .html added (index.html.br ships); the server's SPA fallback unified with the sidecar-aware serving path.
  • Edge-cacheable shell: the server serves max-age=300, must-revalidate plus a weak build-id ETag (sha256 of the served file — index.html embeds every hashed asset URL, so its content hash IS the build id) and answers If-None-Match with 304. The connect worker keeps the last confirmed document in caches.default and revalidates through the tunnel on every navigation, so new builds still land on the next navigation; storage bounded ≤300s.
  • session.ts: label/session caches store the in-flight promise (single-flight); rejected lookups evicted on settle; TTLs unchanged. Rebased over Refresh active bb Connect sessions #2357 — its session-refresh semantics preserved (identity-guarded settle handlers; edge-served shells deliberately don't count as session activity).
  • Plugin boot cap (planned step) was already shipped by Defer plugin frontend boot past first route paint with bounded, ordered loads (A3, B23) #1896 — verified, contract tests pass unmodified.

Deploy note: the connect worker deploys separately; either order is safe (bounded ≤300s skew), worker-first avoids even that.

How you verified

Fail-before/pass-after: emitted head-order test against the real dist/index.html (expected 9654 to be less than 3861 before); single-flight burst tests (expected 6 to be 1 D1 selects) for both lookups. Document cache proven in real workerd via the tunnel fixture (repeat navigation = edge body + 304 on the tunnel; build change ships next navigation; visitor 304 relayed). Bundle budget OK before/after (boot 70→3 chunks). After the #2357 rebase: @bb/connect 119/119 including main's new session-refresh suite; head-order/precompress/plugin-frontend suites 54/54; check:bundle OK. Green on the merged integration tree.

AGENT GENERATED

vburojevic and others added 5 commits August 25, 2026 09:14
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 <noreply@anthropic.com>
…n 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…lan 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 <noreply@anthropic.com>
…007 step 4)

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 <noreply@anthropic.com>
@SawyerHood

Copy link
Copy Markdown
Collaborator

Closing in favor of #2435, which integrates this PR on current main together with #2385, #2386, #2388, #2389 and #2392 and the fixes from the adversarial review. Your commits are cherry-picked unchanged with you as author, and every fix commit carries a Co-authored-by trailer for you, so credit follows the work whichever merge method is used. The branch stays as-is.

Fix landed there for this PR:

  • The app shell is served Cache-Control: no-cache + weak build-id ETag again (max-age=300 let browsers and the Electron window boot a stale shell whose hashed assets 404 for up to five minutes after an update). The connect worker's revalidated-shell contract is now no-cache + ETag: the edge copy is stored with an internal 300 s bound, served only after the origin's 304, and the visitor always receives the origin's no-cache. Proven in real workerd.
  • document-cache.test.ts: each test stores its own edge copy. bundle-budget.json: maxBootBrotliBytes lowered to 429,072 (10% above the new payload); maxBootBytes unchanged. vite-font-preload.test.ts: head order pinned against a synthetic document, since the dist-gated suite is skipped in CI.

Thanks for the work: the design held up under review; only the items above needed changing.

AGENT GENERATED

@SawyerHood SawyerHood closed this Aug 25, 2026
SawyerHood added a commit that referenced this pull request Aug 25, 2026
…iew fixes (#2435)

## Human comments

## What was wrong

Five of the mobile-perf2 PRs by @vburojevic (#2385, #2386, #2388, #2389,
#2392) were each sound in design but carried one or two confirmed
defects that an adversarial review found: the touch scrollbar thumb
never showed after the per-scroll trim, the cached scroll-anchor row
list went stale on windowed timelines, the deferred expander body
blanked its preview and animated toward an empty region, the
host-disconnect status fan-out ran ~5–7 synchronous queries for every
thread on the host, a status change that raced an in-flight search was
never refetched, the `max-age=300` app shell let browsers and the
Electron window boot a stale shell whose hashed assets 404 for up to
five minutes after an update, and the shared action-bar width was 16 px
wider than the assistant column. This PR integrates the five on current
`main` and fixes each finding, so the set can land together instead of
one at a time.

Credit: every original commit keeps @vburojevic as author, and every fix
commit carries a `Co-authored-by` trailer for them. A squash merge
credits them through those trailers; a merge or rebase keeps the
authorship as-is.

## What changed

Original work (21 commits, cherry-picked in PR order, unchanged):

- #2385 Calm the iOS shell-geometry handler and the timeline resize
cascade.
- #2386 Let taps paint: transition-priority navigation, deferred
expanders and sidebar realization.
- #2392 Consolidate per-row ResizeObservers into shared
read/write-phased observers.
- #2388 Realtime round 2: status-change metadata everywhere, reconnect
gating, coalesced fallback refetches.
- #2389 Boot and delivery quick wins for the relayed mobile path.

Fixes (13 commits; each names the finding it closes):

- `bottom-anchored-scroll-body.tsx`: the `data-scrollbar-scrolling`
write is back on every pointer, idempotent (one write per scroll burst);
`.thread-scrollbar`/`.transient-scrollbar` are not pointer-gated, so
skipping it on touch hid the thumb. The scroll-anchor row cache is
bypassed when the top-level list holds a
`[data-timeline-virtual-spacer]`; unwindowed timelines keep it. The
scroll-preservation suite now exercises the entries-derived resize path
and the scroll-gate test pins the keyboard-pan compensation.
- `disclosure.tsx`: the region's content branch, height sync, transition
classes, deadline and in-flight accounting key on the deferred expanded
value, so the collapsed preview stays until the body exists and the
tween starts from the real body; re-expanding inside the 200 ms close
window keeps the retained body.
- `session-owner-side-effects.ts` + `packages/db`
(`listActiveHostThreads`): daemon close, disconnect grace and host
removal build `statusChange` metadata only for active threads, fetched
in batched queries; idle threads keep the pre-PR bare push.
- `realtime-cache-registry.ts`: the `statusChange` search invalidation
keeps `cancelRefetch: false` and schedules one trailing refetch after
the in-flight search settles; the same two-line gap in the
completed-turn path is fixed in its own commit. The coarse-pointer
debounce test moved to its own file so the 65-test suite runs in the
shared vitest worker again.
- `server.ts`: the app shell is served `Cache-Control: no-cache` + weak
build-id ETag again; the If-None-Match → 304 path is unchanged.
`apps/connect/src/cache.ts`: the worker's revalidated-shell contract is
now `no-cache` + ETag; the edge copy is stored with an internal 300 s
bound, served only after the origin's 304, and the visitor always
receives the origin's `no-cache`. `no-store`/`private`/set-cookie still
bypass; the plain asset path still rejects `no-cache`. Known limit
(unchanged from #2389): the edge document copy is rarely served in the
mobile flow because browsers keep their own copy.
- `document-cache.test.ts`: each test stores its own edge copy.
`bundle-budget.json`: `maxBootBrotliBytes` 479,067 → 429,072 (10% above
the measured boot payload); `maxBootBytes` unchanged.
`vite-font-preload.test.ts`: head order pinned against a synthetic
document, since the dist-gated suite is skipped in CI.
- `MessageActionBar.tsx` / `ThreadTimelineRows.tsx` /
`ConversationMessageContent.tsx`: the shared list width subtracts the
assistant column's `px-2` inset; the class and the 16 px constant are
declared together.

No wire change between server and host daemon
(`HOST_DAEMON_PROTOCOL_VERSION` unchanged; #2388's one-line
`daemon-protocol.ts` edit adds no field). No CLI, guide, or doc surface
changes.

Not addressed, by decision: a deferred expander body can lag behind
heavy streaming updates until React's transition expiry (a timeout
fallback changes when the expensive render blocks the main thread, which
is the trade-off #2386 is about); the transition-priority navigation in
#2386 is a no-op because react-router already transitions (harmless,
left as-is).

## How you verified

Review: two independent multi-agent passes over each PR (correctness,
claims audit with the PR's tests run, repo-rule/contract audit, one
PR-specific lens), every finding checked by three refuters (code trace,
a throwaway experiment against the real code, an impact judge); only
findings that survived at least two of three were fixed.

Fixes: each fix commit came with a test proven to fail before and pass
after, and passed two independent verifiers (a diff reviewer and a
runner that re-proved fail-before by restoring the pre-fix sources and
ran the package suite) in one round.

Final branch (`3388bcb45`):

- `pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/server
--filter=@bb/connect --filter=@bb/db --filter=@bb/desktop
--filter=@bb/mobile --filter=@bb/cli --filter=@bb/sdk --continue` — exit
0.
- `pnpm exec turbo run lint --filter=@bb/app --filter=@bb/server
--filter=@bb/connect --continue` — 0 errors.
- Full suites: `@bb/db` 409/409, `@bb/connect` 120/120 (the
document-cache suite runs the real worker in workerd: cold store,
304-only repeat, new build on next navigation, visitor 304 relayed,
pre-contract server proxied uncached), `@bb/server` 2,047/2,047,
`@bb/app` 3,404 passed / 4 skipped / 0 failed.
- `pnpm exec turbo run build --filter=@bb/app` + `node
apps/app/scripts/check-bundle-budget.mjs` — OK: boot 1,547.5 KB raw /
381.1 KB brotli, 3 boot chunks.
- EAP codename scan: clean for the working tree, tracked files, and the
added lines and commit messages of `origin/main..HEAD`.

Still to do by hand before merge: a physical iPhone and Android pass for
#2385/#2392 (keyboard open/close, URL-bar collapse, rotation with many
expanded rows); the CI iOS-simulator job is skipped. The `@bb/mobile`,
`@bb/cli`, `@bb/sdk`, `@bb/integration-tests` suites and the packaged
tarball smoke were green on an earlier nine-PR merge tree, not yet
re-run on this exact branch.

Supersedes #2385, #2386, #2388, #2389, #2392.

> AGENT GENERATED

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants