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', '
x
'); + assert.equal(drops(), 1, 'warns once on a dropped boundary in dev'); + assert.ok(warnings.some((w) => w.includes('"s1"')), 'the warning names the dropped boundary id'); + _applyStreamedResolve('s1', '
x
'); + assert.equal(drops(), 1, 'fire-once per id: a second drop of the same id does not warn again'); + + // a SUCCESSFUL resolve never warns. + _resetWarnOnce(); warnings.length = 0; + document.body.innerHTML = '
SKELETON
'; + _applyStreamedResolve('s2', '
DONE
'); + assert.equal(drops(), 0, 'a resolved boundary emits no drop warning'); + assert.ok(document.getElementById('done'), 'and it applied the content'); + + // production: suppressed even on a drop. + _resetWarnOnce(); warnings.length = 0; + process.env.NODE_ENV = 'production'; + document.body.innerHTML = ''; + _applyStreamedResolve('s3', '
y
'); + assert.equal(drops(), 0, 'no drop warning in production'); + } finally { + console.warn = origWarn; + if (origNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = origNodeEnv; + _resetWarnOnce(); + document.body.innerHTML = ''; + } +}); + test('addNewHeadElements: dynamically-created scripts get the meta csp-nonce, not the source page\'s per-request nonce', () => { // Set up the meta tag the server emits for the original page load. document.head.innerHTML = ''; @@ -959,6 +1004,41 @@ test('mergeHead: applies meta csp-nonce to created scripts (replaces source nonc 'mergeHead must apply the meta nonce, not the source-page nonce'); }); +test('mergeHead: keeps the ORIGINAL csp-nonce meta when the incoming nonce differs (#1050)', () => { + // A real full-body response carries a DIFFERENT per-request nonce. The browser + // enforces CSP against the original page-load nonce, so mergeHead must NOT + // replace the live meta, or getCspNonce() would then hand out a nonce the + // active policy rejects and later nonce-stamped scripts/preloads get blocked. + document.head.innerHTML = 'T'; + const before = document.head.querySelector('meta[name="csp-nonce"]'); + const newHead = document.createElement('head'); + newHead.innerHTML = + 'T' + + ''; + _merge(newHead); + const nonce = document.head.querySelectorAll('meta[name="csp-nonce"]'); + assert.equal(nonce.length, 1, 'exactly one csp-nonce meta (not duplicated)'); + assert.strictEqual(nonce[0], before, 'the original meta element is kept, not replaced'); + assert.equal(nonce[0].getAttribute('content'), 'ORIGINAL', 'the original nonce content is preserved'); + // A script created by the merge is stamped with the ORIGINAL nonce, not the incoming one. + assert.equal(document.head.querySelector('script[src="/after.js"]').getAttribute('nonce'), 'ORIGINAL', + 'created scripts get the original page-load nonce'); +}); + +test('mergeHead: keeps the csp-nonce meta when the incoming head omits it (#1050)', () => { + // A reduced / partial incoming head that carries no csp-nonce must not cause + // the removal loop to drop the live original (it is "absent from newSet"). + document.head.innerHTML = 'T'; + const before = document.head.querySelector('meta[name="csp-nonce"]'); + const newHead = document.createElement('head'); + newHead.innerHTML = 'T'; // no csp-nonce declared + _merge(newHead); + const nonce = document.head.querySelectorAll('meta[name="csp-nonce"]'); + assert.equal(nonce.length, 1, 'the original csp-nonce meta survives a head that omits it'); + assert.strictEqual(nonce[0], before, 'same element, not re-created'); + assert.equal(nonce[0].getAttribute('content'), 'ORIGINAL'); +}); + test('addNewHeadElements + mergeHead: nonce-only diff on tags does not duplicate preloads', () => { // Browsers gate cross-origin modulepreload by script-src nonce, so // preload links also carry per-request nonces after the recent CSP