Skip to content

Calm the iOS shell-geometry handler and the timeline resize cascade - #2385

Closed
vburojevic wants to merge 4 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/ios-shell-geometry
Closed

Calm the iOS shell-geometry handler and the timeline resize cascade#2385
vburojevic wants to merge 4 commits into
get-bb:mainfrom
vburojevic:bb/mobile-perf2/ios-shell-geometry

Conversation

@vburojevic

Copy link
Copy Markdown
Contributor

What was wrong

Two independent audits traced the worst mobile hangs (38.9s/30.7s/28.5s traces on div[app-layout-content-shell]) to the same mechanism: on every iOS visual-viewport tick (keyboard animation, URL-bar collapse) the shell hook forced a full-document layout (document.body.clientHeight) inside a rAF and rewrote shell top/height plus the inherited --bb-shell-height on an app-root ancestor, invalidating computed style for the entire 2,000–5,200-node tree at animation cadence. Each content-size change then re-entered a second cascade: the scroll body's ResizeObserver refreshed geometry live, the observed frame's bottom restore read again, and each of the three rAF settle-tail frames forced another layout — up to five forced layouts per size change at streaming cadence. Every scroll event also wrote data-scrollbar-scrolling (desktop-scrollbar-only CSS) and 100ms anchor samples re-ran querySelectorAll over the scroll subtree.

What changed

  • useMobileVisualViewportHeight.ts: style writes skipped when a pass recomputes unchanged geometry; document.body.clientHeight cached and re-read only on window.resize/orientationchange/focusin (never on visualViewport ticks); visualViewport.scroll ticks gated to run only when an override is applied or a keyboard-focusable element is focused. Embedded-browser overrides keep tracking pans; the focus-reveal pan compensation, native-layout early-exit, focusout fast-restore, pinch-zoom guard, and rAF coalescing are unchanged; programmatic focus still triggers one freshly measured pass (new test).
  • bottom-anchored-scroll-body.tsx: the ResizeObserver path refreshes the cached max offset from the observer's own box sizes when entries are present; settle-tail frames reuse the cache with at most one live verification read after a corrected drift. Coarse pointers skip the scrollbar-attribute write, capture anchors at 250ms with a cached row NodeList invalidated by the ResizeObserver.
  • height-transition.tsx: observer callbacks size the wrapper from the entry's borderBoxSize.

How you verified

  • New tests proven fail-before/pass-after: idempotent-write (setProperty spy), containing-block read-count, scroll-tick gating, settle-tail read-count, entry-derived cache, border-box sizing, coarse-pointer trims, cached-NodeList invalidation; a programmatic-focus guard passes in both states by design.
  • bottom-anchored-scroll-body.scroll-preservation.test.tsx (the Stop forced layout reads in the bottom-anchored scroll hot path #2280/Land the mobile-performance batch (#2277-#2281) and fix a scroll re-attach edge #2300 contract) passes with zero diff.
  • pnpm exec turbo run typecheck --filter=@bb/app exit 0; full @bb/app suite 433 files / 3,352 passed.
  • Merged-tree WebKit QA (iPhone 390×844@3, perf fixture DB): long-thread scroll, keyboard open/close via composer tap, no persistent blanking, zero console errors.

AGENT GENERATED

vburojevic and others added 4 commits August 24, 2026 13:45
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <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 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 on Android and iOS.
  • The scroll-anchor row cache is bypassed when the top-level list holds a [data-timeline-virtual-spacer]; on a windowed timeline the last row is force-mounted, so the edge-row connectivity check never invalidated it.
  • The scroll-preservation contract suite now exercises the entries-derived resize path, and the scroll-gate test pins the keyboard-pan compensation.

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