fix: view-transition soft-nav meta leak and stuck Suspense skeleton#1049
Conversation
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
…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.
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
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
vivek7405
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
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 The drop-if-absent in |
vivek7405
left a comment
There was a problem hiding this comment.
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.
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.
…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).
Two related client-router defects, both triggered by view transitions during a soft navigation.
Closes #1046andCloses #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 (theview-transitionopt-in, a per-pagerobots/theme-color/description, anog:*property) leaked onto every later page.reconcileHeadMetasnow gives each keyed meta full add / update / remove treatment (keyed byname/property/http-equiv/charset, grouped so a repeated key likeog:imagereconciles as a set). It touches ONLY<meta>tags, so theX-Webjs-Havereduced-head optimization (which omits the shared stylesheet, #936) is unaffected. Two carve-outs from review: thecsp-noncemeta 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 afterrunWithTransitionreturned, against the pre-swap DOM (no#sNplaceholder yet), so the skeleton stuck. Fixed on every swap path:forwardSuspenseResolvers) runs INSIDE the swap thunk and resolves synchronously.runWithTransitionreturns (updateCallbackDone), captured as_swapCommiton 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:
view-transitionmeta is now removed (was leaked); counterfactual holds (hard load of the target has none).Tests
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;runWithTransitioncommit-promise ordering (sync + async-VT). 211/211 pass; counterfactuals verified (each fix reds its test when reverted).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.main(pre-existingif (!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.