diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index ea77050b..13df132b 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -780,6 +780,24 @@ function devWarnFallback(cause, href) { ); } +/** + * Dev-only, fire-once-per-id hint: a streamed Suspense resolution arrived but + * its boundary placeholder was not in the DOM, so it was dropped (#1051). This + * is benign when the navigation was superseded, degraded to a full load, or + * discarded, but a stuck skeleton that is NONE of those has no other signal (it + * is what made the #1048 view-transition race hard to diagnose). Never warns in + * production, never throws. + * + * @param {string} id the streamed boundary id that could not be applied + */ +function warnDropped(id) { + if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return; + warnOnce( + `stream-drop:${id}`, + `[webjs] dropped a streamed Suspense resolve for "${id}": no #${id} boundary in the DOM. Benign if this navigation was superseded or degraded. A stuck skeleton here means the shell swap did not place the boundary.` + ); +} + /** * Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a * forward navigation (matching a native page load), so an app-level @@ -3937,6 +3955,12 @@ function mergeHead(newHead) { // headline #936 symptom. Keeping it is safe: a genuinely stale sheet is // dropped by the deploy-level hard reload (build-id mismatch), not here. if (isPersistentHeadStyle(el)) continue; + // Never remove the CSP nonce meta: the incoming full-body response carries a + // FRESH per-request nonce, but the browser enforces CSP against the nonce the + // ORIGINAL page load declared (see `getCspNonce`), so the live one must stay + // (#1050). `outerHTMLForDiff` strips the nonce ATTRIBUTE but not the `content` + // it lives in on this meta, so without this it looks "changed" and is dropped. + if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue; if (!newSet.has(outerHTMLForDiff(el))) el.remove(); } @@ -3944,6 +3968,10 @@ function mergeHead(newHead) { if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue; if (el.tagName === 'BASE') continue; if (el.tagName === 'TITLE') continue; + // Do not append the incoming per-request csp-nonce meta (the live original is + // kept above), or the head would carry two and `getCspNonce` could read the + // wrong one (#1050). + if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue; if (!currentSet.has(outerHTMLForDiff(el))) { if (el.tagName === 'SCRIPT') { currentHead.appendChild( @@ -4113,7 +4141,15 @@ function applyStreamedResolve(id, content) { // resolve is applied. A retry here would run OUTSIDE the streamer's // `isCurrent()` nav-token fence and could splice a superseded nav's content // into a recycled boundary id, so it is deliberately not attempted. - if (!boundary) return; + if (!boundary) { + // Dev-only diagnostic (#1051): the drop is benign for the normal reasons (a + // superseded / degraded / discarded nav), but a stuck skeleton that ISN'T + // one of those is otherwise silent, which is exactly what made #1048 hard to + // find. Surface the dropped boundary so a future regression is one glance + // away. Never in production, never throws, once per id. + warnDropped(id); + return; + } const tpl = document.createElement('template'); tpl.innerHTML = content; const inserted = [...tpl.content.childNodes]; diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 713f9259..5346af32 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -35,6 +35,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch, _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, + _applyStreamedResolve, enableClientRouter, disableClientRouter, revalidate, WebComponent, html; @@ -114,6 +115,7 @@ before(async () => { _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, + _applyStreamedResolve, navigate, revalidate, enableClientRouter, @@ -857,6 +859,49 @@ test('runWithTransition: with an async view transition, the commit promise resol } }); +/* ==================================================================== + * applyStreamedResolve: dev-warn on a dropped boundary (#1051) + * ==================================================================== */ + +test('applyStreamedResolve: warns once in dev when the boundary is absent, silent otherwise (#1051)', () => { + const origWarn = console.warn; + const warnings = []; + console.warn = (...a) => { warnings.push(a.join(' ')); }; + const origNodeEnv = process.env.NODE_ENV; + const drops = () => warnings.filter((w) => /dropped a streamed Suspense resolve/.test(w)).length; + try { + document.body.innerHTML = ''; // no #s1 boundary present -> the resolve drops + + // dev: a dropped resolve warns exactly once, naming the id. + process.env.NODE_ENV = 'development'; + _resetWarnOnce(); warnings.length = 0; + _applyStreamedResolve('s1', '