Skip to content

fix: view-transition soft-nav meta leak and stuck Suspense skeleton#1049

Merged
vivek7405 merged 7 commits into
mainfrom
fix/meta-leak-soft-nav
Jul 22, 2026
Merged

fix: view-transition soft-nav meta leak and stuck Suspense skeleton#1049
vivek7405 merged 7 commits into
mainfrom
fix/meta-leak-soft-nav

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Two related client-router defects, both triggered by view transitions during a soft navigation. Closes #1046 and Closes #1048.

#1046 - page-scoped meta leaks across soft navs

The boundary-swap head merge (addNewHeadElements) was add-only: it appended new head elements but never removed a stale one. So a page-scoped <meta> the previous page declared (the view-transition opt-in, a per-page robots / theme-color / description, an og:* property) leaked onto every later page. reconcileHeadMetas now gives each keyed meta full add / update / remove treatment (keyed by name / property / http-equiv / charset, grouped so a repeated key like og:image reconciles as a set). It touches ONLY <meta> tags, so the X-Webjs-Have reduced-head optimization (which omits the shared stylesheet, #936) is unaffected. Two carve-outs from review: the csp-nonce meta is excluded (its per-request nonce must stay the original page-load value), and a headless fragment response (a <webjs-frame> subtree with no <head>) is skipped so a frame swap never strips live metas.

#1048 - Suspense skeleton sticks under view transitions

With view transitions on, the swap runs inside startViewTransition, which defers the DOM mutation a frame. The Suspense resolve ran synchronously right after runWithTransition returned, against the pre-swap DOM (no #sN placeholder yet), so the skeleton stuck. Fixed on every swap path:

  • buffered resolve (forwardSuspenseResolvers) runs INSIDE the swap thunk and resolves synchronously.
  • progressive streamed resolve is gated on a swap-commit promise runWithTransition returns (updateCallbackDone), captured as _swapCommit on the boundary, full-body, AND frame paths.

#1046 is what turns view transitions on across the whole gallery, so it is the trigger that exposed #1048 everywhere; fixing #1046 removes the gallery symptom and #1048 fixes the underlying race for any legitimately-VT-enabled streamed page.

Verification

Reproduced and fixed against a scaffolded app (framework 0.8.55), real browser:

Tests

  • Unit (packages/core/test/routing/router-client.test.js): meta-reconcile (remove stale, update, add, og:property, repeated og:image set, headless-fragment guard, csp-nonce never touched) + the dogfood: CSS drops on client-router soft nav on real Android Chrome (styled on refresh) #936 stylesheet guard; runWithTransition commit-promise ordering (sync + async-VT). 211/211 pass; counterfactuals verified (each fix reds its test when reverted).
  • Browser (packages/core/test/routing/browser/view-transition-head-and-suspense.test.js, Chromium/Firefox/WebKit): a page-scoped meta is removed on soft nav, a frame nav does not strip live metas, and a streamed Suspense boundary resolves under an async view transition. 5/5 pass; the async-VT streamed case fails when the swap-commit gate is reverted.
  • Dogfood: website / docs / ui-website boot 200 with no broken preloads; blog e2e 78/78.
  • Docs: client-router reference + docs-site page updated (page-scoped meta reconcile, VT composes with Suspense streaming).
  • Meta element identity: a changed 1:1 keyed meta is synced IN PLACE (identity preserved for app scripts holding a reference); wholesale replacement only for repeated-key sets with no unambiguous mapping.
  • Dropped absent boundary: unchanged posture from main (pre-existing if (!boundary) return;); every remaining absent case is benign by construction (superseded nav, degrading full load, discarded revalidation, frame-missing no-swap). A retry would run outside the nav-token fence. Observability follow-up (dev-warn on a dropped resolve, so a future wrongly-absent-boundary bug is visible at a glance): Dev-warn when a streamed Suspense resolve is dropped (absent boundary) #1051.
  • Self-review loop converged (last round clean); adjacent pre-existing mergeHead csp-nonce gap filed as mergeHead replaces the csp-nonce meta on a background full-body swap #1050.

Two related client-router defects, both triggered by view transitions
during a soft navigation.

#1046: the boundary-swap head merge (addNewHeadElements) was add-only, so
a page-scoped <meta> the previous page declared (a view-transition opt-in,
a per-page robots / theme-color / description, an og:* property) was never
removed and leaked onto every later page. reconcileHeadMetas now gives each
keyed meta add/update/remove treatment, keyed by name/property/http-equiv/
charset. It touches only <meta> tags, so the X-Webjs-Have reduced-head
optimization (which omits the shared stylesheet, #936) is unaffected.

#1048: with view transitions on, the swap runs inside startViewTransition,
which defers the DOM mutation a frame. The Suspense resolve ran
synchronously right after, against the pre-swap DOM (no placeholder yet),
so the skeleton stuck. The buffered resolve (forwardSuspenseResolvers) now
runs inside the swap thunk and resolves synchronously; the progressive
streamed resolve is gated on a new swap-commit promise runWithTransition
returns (updateCallbackDone), so it applies only after the placeholder is
live. Verified against a scaffolded app: 8/8 stuck before, 0/8 after.

Closes #1046
Closes #1048
@vivek7405 vivek7405 self-assigned this Jul 22, 2026
…solve

Real-browser (Chromium/Firefox/WebKit) coverage for #1046 and #1048: a
stale page-scoped meta is removed on a soft nav, and a streamed Suspense
boundary resolves under an async view transition instead of sticking.
Counterfactual: the async-VT streamed case fails when the swap-commit gate
is reverted.
Update the client-router reference and docs-site page to reflect that the
view-transition opt-in is a page-scoped meta reconciled on a soft nav
(#1046), and that view transitions compose with Suspense streaming because
the streamed resolve waits for the swap commit (#1048).
Review findings on the #1046/#1048 fix:
- reconcileHeadMetas overwrote the csp-nonce meta with the incoming soft-nav
  response's fresh per-request nonce, so getCspNonce() returned a nonce the
  original page's CSP header does not allow and every later nonce-stamped
  script/preload was blocked. Exclude csp-nonce from add/update/remove so the
  original page-load nonce survives verbatim.
- applyStreamedResolve's one-frame retry ran outside the streamer's isCurrent()
  nav-token fence, so a superseded nav could splice stale content into a
  recycled boundary id. It is redundant now that the stream is gated on the
  swap commit, so remove it and restore the non-destructive drop-if-absent.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went over both fixes end to end. The head-reconcile and the swap-commit gate are the right shape, but the meta reconcile was too broad: it treated the csp-nonce meta like any other keyed meta and overwrote it with the incoming nav's fresh nonce, which breaks strict-CSP apps after the first soft nav. Also the stream retry I added slipped outside the nav-token fence. Both flagged inline; both now fixed.

Comment thread packages/core/src/router-client.js Outdated
Comment thread packages/core/src/router-client.js
…peated metas

Review findings on the meta reconcile:
- A <webjs-frame> nav response is a bare subtree with no <head>, so the
  incoming head parsed empty and the remove pass stripped EVERY live
  page-scoped meta (viewport, description, og:*). A real full head always
  emits charset + viewport, so skip the reconcile when the incoming head has
  no <meta> at all (a headless fragment), mirroring the importmap guard.
- A meta key can repeat (multiple og:image); reconcile now groups both sides
  by key and compares the whole set, so a second og:image is added and a
  stale extra is removed instead of only the first being handled.

Tests: node cases for the headless-fragment guard and repeated og:image; a
browser frame-nav test asserting viewport survives (counterfactual: fails
without the guard).

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, focused on the reconcile now that csp-nonce is excluded. Found a real regression on the frame path and a smaller repeated-key gap. Both fixed.

Comment thread packages/core/src/router-client.js
Comment thread packages/core/src/router-client.js
The frame-swap path called runWithTransition without capturing _swapCommit,
so a frame nav that also progressively streams a Suspense boundary read a
stale (already-resolved) commit promise and could apply a resolve before the
deferred frame swap committed, sticking the skeleton for frame-internal
Suspense under view transitions. Capture _swapCommit in the frame branch too,
matching the boundary and full-body paths.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third pass over the swap-commit gating. The reconcile and the boundary/full-body gating hold up; one asymmetry left on the frame path. Fixed.

Comment thread packages/core/src/router-client.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final pass: reconcile (csp-nonce excluded, headless-fragment guarded, repeated keys set-reconciled), the swap-commit gate on all three swap paths, and the reduced-head assumption all check out. Nothing left to fix.

@vivek7405
vivek7405 marked this pull request as ready for review July 22, 2026 17:10

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took another full pass over the diff with fresh eyes: the reconcile grouping (repeated keys, theme-color media variants), the swap-commit capture on all three paths, the resolve-inside-thunk ordering (the resolved content now even gets the afterFinished upgrade pass, which the old post-return resolve missed), and the boundary templates sitting outside the marker ranges so a swap never consumes them. Nothing wrong in this PR. One adjacent pre-existing gap surfaced while checking the csp-nonce carve-out: mergeHead (the background full-body path, untouched here) still replaces the csp-nonce meta when the incoming nonce differs, the same class this PR fixes on the boundary/frame paths. The existing mergeHead test uses the same nonce on both sides so it never catches it. Filed as #1050 rather than widening this PR.

The reconcile replaced a changed keyed meta by remove + re-append, which
broke DOM identity for any app script holding a reference (a theme manager
caching meta[name=theme-color]). For the common one-live/one-incoming case,
sync attributes in place instead; wholesale replacement remains only for
repeated-key sets (multiple og:image) where no unambiguous element mapping
exists. Test pins the identity guarantee.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decision: hardened meta identity in place; the dropped-boundary posture stays

Two risks from the review deserved a closer look before merge.

The changed-meta remove+re-append broke DOM identity for any app script holding a meta reference (a theme manager caching meta[name=theme-color]). The mitigation was cheap, so 0dd907e8 syncs the common one-live/one-incoming case in place (identity preserved, no churn on a content-only change) and keeps wholesale replacement only for repeated-key sets, where no unambiguous element mapping exists. A test pins the identity guarantee.

The drop-if-absent in applyStreamedResolve is unchanged from main (the same if (!boundary) return; predates this PR); this PR only fixed the one case where a VALID boundary was wrongly absent (the VT race). The remaining absent cases are all benign by construction: a superseded nav (the isCurrent fence stops it by design), a degraded full page load (the page is unloading), a discarded revalidation (returns before the streaming block, reader cancelled), and a frame-missing response (no swap happened, so no skeleton is even on screen). A retry would run outside the nav-token fence and could splice a superseded nav's content into a recycled boundary id, which is strictly worse.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the in-place sync commit on its own: the attribute snapshot/copy is mutation-safe, the 1:1 guard sends every asymmetric set to the wholesale branch, key identity is stable by construction of the grouping, and csp-nonce remains unreachable from the sync. Clean.

@vivek7405
vivek7405 merged commit e9af348 into main Jul 22, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/meta-leak-soft-nav branch July 22, 2026 18:18
vivek7405 added a commit that referenced this pull request Jul 22, 2026
Add a 'CSP and the client router' subsection to the security docs page and a
paragraph to the built-ins reference: enforcement is the HTTP header (not a
meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on
a soft navigation the original page-load nonce stays authoritative and is
re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives
users setting up strict CSP the why behind the model.
vivek7405 added a commit that referenced this pull request Jul 22, 2026
…ns (#1055)

* docs: explain the CSP nonce model across client-router soft navigations

Add a 'CSP and the client router' subsection to the security docs page and a
paragraph to the built-ins reference: enforcement is the HTTP header (not a
meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on
a soft navigation the original page-load nonce stays authoritative and is
re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives
users setting up strict CSP the why behind the model.

* docs: scope the soft-nav nonce claim to the document, not the session

Review: 'the ORIGINAL nonce stays authoritative for the whole session'
overstated. It holds for the life of the current document. A hard reload or
full page load creates a new document with the response's own fresh nonce.
Reword and add the harmless-full-load case so soft-nav vs hard-reload is
clear (the built-ins reference already stated this conservatively).
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.

1 participant