diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 87c9dda66..c9d111ef1 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -65,7 +65,7 @@ document.addEventListener('webjs:navigation-error', (e) => { }); ``` -**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints. +**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`, `pre-boot-navigation`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints. ```ts document.addEventListener('webjs:navigation-fallback', (e) => { @@ -74,6 +74,8 @@ document.addEventListener('webjs:navigation-fallback', (e) => { }); ``` +**`pre-boot-navigation` reports ABOUT a load, not during one (#1118).** The boot is a module script, which the HTML spec defers until parsing finishes, while the links it will intercept are clickable from first paint. A click in that window is a plain browser navigation, and the ARRIVING document reports it with `willReload: false` (the load already happened). The window is a few tens of milliseconds warm and network-sized on a cold, throttled first visit, which is why `@webjsdev/core` is hinted in the head with `` (emitted only when the page actually ships a boot module) instead of being discovered a round trip later. Read the cause as a RATE: the check knows only that this document arrived by a same-origin navigation that was not a soft nav, so a `data-no-router` link, a `target="_blank"` open, a cross-document form post, and a `clientRouter: false` app all land here too. Excluded: a reload, a back/forward restore, an external or typed entry, and a full load the router itself chose (already reported under its own cause). The report rides the router's own boot, so a fully elided page that ships no client runtime reports nothing. + **Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events. **Inline scripts in a swapped range re-execute, so write them to be re-runnable (#1102).** A script the swap brings in runs again on every navigation that swaps its range, whether it sits inside the swapped content or is a top-level node of the range itself (a layout emitting its enhancement script as a sibling of `${children}`). A script parsed out of the response carries the HTML spec's already-started flag and is inert, so the router replaces it with a fresh clone, and the clone is what runs; the clone carries the page-load CSP nonce rather than the one the response was rendered with. Giving the script an `id` does NOT make it run once: the keyed differ reuses the live element and the router still re-emits it. So a script that installs a listener or a `MutationObserver` must be idempotent or guard on a flag it sets the first time. The alternative default, running once and then never again, is the failure this replaced (a progressive-enhancement highlighter that stopped working after the first soft nav). When work genuinely must happen once, put it in the ROOT layout, whose markup is never swapped. `data-webjs-permanent` splits into two cases (#1252). A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself: its regraft only fires when the node exists on both sides, so exempting it would leave a script that runs on a cold load and never on a soft nav. A script INSIDE a marked element the swap actually preserved is left alone, because the attribute is SUBTREE-scoped and that node survived by identity. The exemption is conditional on real preservation, so a permanent element arriving for the first time, or one with no `id` (which can never be regrafted), still runs its scripts. diff --git a/packages/core/src/router-client.js b/packages/core/src/router-client.js index 2dc465ed0..a03c1cff4 100644 --- a/packages/core/src/router-client.js +++ b/packages/core/src/router-client.js @@ -345,6 +345,11 @@ export function enableClientRouter() { // Seed the "current page" tracker so the first navigation can // snapshot the page the user is leaving. if (typeof location !== 'undefined') currentPageUrl = location.href; + // Last, once the listeners are on: report whether the load that got us here + // was a same-origin navigation the router never saw (#1118). Running it after + // the listeners means a throw inside a diagnostic can never leave the router + // half-installed. + reportPreBootNavigation(); } /** Disable the client router. */ @@ -791,6 +796,105 @@ function shouldFullLoadDuringParse(isPopState, frameId) { ); } +/** + * `sessionStorage` key holding the destination of a full load the ROUTER + * itself chose (#1118). Written by `reportFallback` when `willReload` is true, + * consumed once by the next document's boot. Per-tab and cleared with the tab, + * which is the right lifetime for a marker about one navigation. + */ +const FALLBACK_MARKER_KEY = 'webjs:nav-fallback'; + +/** + * Has the pre-boot check already run for THIS document (#1118)? Module scope, + * so it resets with the document, which is the lifetime the report is about. + */ +let reportedPreBoot = false; + +/** + * Was THIS document load a same-origin navigation the client router never saw? + * + * Pure so the branch logic is testable without driving a real navigation + * (#1118). Every argument is read from the environment by the one caller. + * + * @param {string} navType `performance.getEntriesByType('navigation')[0].type`. + * Only `'navigate'` qualifies: a `'reload'` and a `'back_forward'` restore are + * things the browser does, not clicks the router could have intercepted. + * @param {string} referrer `document.referrer`. Must parse to the same origin as + * `href`: an empty referrer means a typed URL or an external entry (no router + * was running to miss the click), and a cross-origin one means the previous + * page was not ours. + * @param {string} href `location.href` of the document that just loaded. + * @param {string | null} marker the consumed `FALLBACK_MARKER_KEY` value. When + * it equals `href` the router already reported this load under its own cause, + * so counting it again would double-count a known degradation as an unknown. + * @returns {boolean} + */ +function isPreBootNavigation(navType, referrer, href, marker) { + if (navType !== 'navigate') return false; + if (!referrer) return false; + if (marker && marker === href) return false; + try { + return new URL(referrer).origin === new URL(href).origin; + } catch { + return false; + } +} + +/** + * Report a document load that reached us by a same-origin navigation the router + * did not soft-navigate (#1118). + * + * A module script is deferred by spec, so it runs only after HTML parsing + * completes, while the links it will intercept are clickable from first paint. + * That window cannot be closed from inside the router (see #1118 for why an + * inline capture shim was rejected), so it is MEASURED instead: this turns the + * frequency into a production number a deployed app can read off the existing + * `webjs:navigation-fallback` channel, rather than folklore. + * + * Deliberately imprecise, and the docs say so: a `data-no-router` link, a + * cross-document form post, and an app that opted out of the client router all + * land here too. The signal is the RATE, not any single event. + * + * `willReload` is false because the document load has already happened. That is + * exactly the distinction the flag was added for. + */ +function reportPreBootNavigation() { + // Same guard the scroll/current-page seeding above uses: a DOM shim without a + // `location` (linkedom under the unit runner) is not a document load to + // report on, and reading through would throw inside the boot. + if (typeof location === 'undefined') return; + // Once per DOCUMENT, not once per enable. `enableClientRouter` is re-callable + // after `disableClientRouter()` (the documented per-moment opt-out), and this + // reports on the load that produced the document, which does not happen again + // when the router is toggled back on. Without this, an app that toggles would + // emit a duplicate for a single load and inflate the very rate the report + // exists to measure. The marker is already consumed by then, so it cannot + // suppress the duplicate on its own. + if (reportedPreBoot) return; + reportedPreBoot = true; + /** @type {string | null} */ + let marker = null; + try { + marker = sessionStorage.getItem(FALLBACK_MARKER_KEY); + // Consume unconditionally, even when it does not match: a stale marker left + // by an earlier navigation must never suppress a later real one. + sessionStorage.removeItem(FALLBACK_MARKER_KEY); + } catch { + // No marker available. Treated as absent, which can only over-report. + } + let navType = ''; + try { + const nav = performance.getEntriesByType('navigation')[0]; + navType = nav ? /** @type {PerformanceNavigationTiming} */ (nav).type : ''; + } catch { + // No Navigation Timing Level 2 entry. Without a nav type the check cannot + // exclude a reload, so it reports nothing rather than guessing. + } + if (isPreBootNavigation(navType, document.referrer, location.href, marker)) { + reportFallback('pre-boot-navigation', location.href, false); + } +} + /** * The client router degraded a soft navigation. Records WHY (the `cause`), so * "why did my SPA nav do a full reload?" is answerable instead of guessed at. @@ -824,6 +928,21 @@ function shouldFullLoadDuringParse(isPopState, frameId) { * dropped", which are very different user-visible events. */ function reportFallback(cause, href, willReload = true) { + if (willReload) { + // Leave a marker naming the destination this full load is going to + // (#1118). The next document's boot reads it to tell "the router itself + // chose this full load, and already reported it under its own cause" from + // "a same-origin navigation the router never saw", which is the pre-boot + // click window. Best-effort: `sessionStorage` throws in some privacy modes + // and partitioned contexts, and a diagnostic must never break a navigation. + try { + sessionStorage.setItem(FALLBACK_MARKER_KEY, href); + } catch { + // Without the marker the next boot may attribute this load to the + // pre-boot window. That is a false positive in a diagnostic, which is + // strictly better than a thrown navigation. + } + } if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') { try { document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', { @@ -4542,6 +4661,8 @@ export { addNewHeadElements as _addNewHeadElements, mergeHead as _mergeHead, reactivateScripts as _reactivateScripts, + isPreBootNavigation as _isPreBootNavigation, + FALLBACK_MARKER_KEY as _FALLBACK_MARKER_KEY, activateSwappedRange as _activateSwappedRange, findAnchorInPath as _findAnchorInPath, activeFrameId as _activeFrameId, diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index d76da9669..2ebcf3047 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -37,6 +37,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch, _viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements, _regraftPermanentInSlice, _applyStreamedResolve, + _isPreBootNavigation, _FALLBACK_MARKER_KEY, enableClientRouter, disableClientRouter, revalidate, WebComponent, html; @@ -84,6 +85,8 @@ before(async () => { _mergeHead: _merge, _isNonHtmlPath, _reactivateScripts, + _isPreBootNavigation, + _FALLBACK_MARKER_KEY, _activateSwappedRange, _findAnchorInPath, _activeFrameId, @@ -4417,3 +4420,108 @@ test('a prefetch that reveals a NEW app-source id evicts stale caches, no build _prefetchCache.clear(); } }); + +/* ========================================================================== + * Pre-boot navigation reporting (#1118) + * + * A module script is deferred by spec, so links are clickable before the router + * listens. The window cannot be closed from inside the router, so it is + * measured: a same-origin document load the router never soft-navigated is + * reported through the existing `webjs:navigation-fallback` channel. These pin + * the branch logic; the headline behaviour is the e2e assertion. + * ========================================================================== */ + +test('#1118: a same-origin navigate with no router marker is a pre-boot navigation', () => { + assert.equal( + _isPreBootNavigation('navigate', 'https://app.test/from', 'https://app.test/to', null), + true, + ); +}); + +test('#1118: a reload and a back/forward restore are NOT pre-boot navigations', () => { + // Neither is a click the router could have intercepted, so counting them + // would make the production number meaningless. + for (const navType of ['reload', 'back_forward', 'prerender', '']) { + assert.equal( + _isPreBootNavigation(navType, 'https://app.test/from', 'https://app.test/to', null), + false, + `${navType || '(empty)'} must not report`, + ); + } +}); + +test('#1118: a cross-origin or absent referrer is NOT a pre-boot navigation', () => { + // An external entry or a typed URL had no router running to miss the click. + assert.equal( + _isPreBootNavigation('navigate', 'https://other.test/x', 'https://app.test/to', null), + false, + 'cross-origin referrer', + ); + assert.equal(_isPreBootNavigation('navigate', '', 'https://app.test/to', null), false, 'empty referrer'); + assert.equal( + _isPreBootNavigation('navigate', 'not a url', 'https://app.test/to', null), + false, + 'an unparseable referrer reports nothing rather than throwing', + ); +}); + +test('#1118: a marker matching this href means the ROUTER chose the load, so it does not double-count', () => { + // `reportFallback` already dispatched its own cause for this load. + assert.equal( + _isPreBootNavigation('navigate', 'https://app.test/from', 'https://app.test/to', 'https://app.test/to'), + false, + 'the router-chosen full load is not re-reported as pre-boot', + ); + // A STALE marker naming some other destination must not suppress a real one. + assert.equal( + _isPreBootNavigation('navigate', 'https://app.test/from', 'https://app.test/to', 'https://app.test/elsewhere'), + true, + 'a marker for a different href does not suppress the report', + ); +}); + +test('#1118: the marker key is a stable literal', () => { + // The write and the read are in different documents, so the key cannot be + // derived or renamed on one side only. + assert.equal(_FALLBACK_MARKER_KEY, 'webjs:nav-fallback'); +}); + +test('#1118: the report is once per DOCUMENT, not once per enable', () => { + // `enableClientRouter` is re-callable after `disableClientRouter()`, the + // documented per-moment opt-out. The report describes the load that produced + // this document, which does not happen again when the router is toggled back + // on, so a toggling app must not inflate the rate the report exists to + // measure. The consumed marker cannot prevent this on its own: it is gone + // after the first read, so the second enable would see a clean slate. + const savedLocation = globalThis.location; + const savedGet = globalThis.performance.getEntriesByType; + const savedReferrer = Object.getOwnPropertyDescriptor(globalThis.document, 'referrer'); + /** @type {any[]} */ + const seen = []; + const onFallback = (e) => { if (e.detail.cause === 'pre-boot-navigation') seen.push(e.detail); }; + document.addEventListener('webjs:navigation-fallback', onFallback); + try { + globalThis.location = /** @type any */ ({ href: 'http://x/to', origin: 'http://x' }); + Object.defineProperty(globalThis.document, 'referrer', { + configurable: true, get: () => 'http://x/from', + }); + globalThis.performance.getEntriesByType = (t) => (t === 'navigation' ? [{ type: 'navigate' }] : []); + globalThis.sessionStorage.clear(); + + disableClientRouter(); + enableClientRouter(); + assert.equal(seen.length, 1, 'the first enable of this document reports once'); + + disableClientRouter(); + enableClientRouter(); + assert.equal(seen.length, 1, 'a re-enable does not re-report the same document load'); + } finally { + disableClientRouter(); + document.removeEventListener('webjs:navigation-fallback', onFallback); + globalThis.performance.getEntriesByType = savedGet; + if (savedReferrer) Object.defineProperty(globalThis.document, 'referrer', savedReferrer); + else delete (/** @type any */ (globalThis.document)).referrer; + globalThis.location = savedLocation; + globalThis.sessionStorage.clear(); + } +}); diff --git a/packages/server/src/ssr.js b/packages/server/src/ssr.js index 69886e794..e12e1a5c8 100644 --- a/packages/server/src/ssr.js +++ b/packages/server/src/ssr.js @@ -1,7 +1,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core'; -import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js'; +import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js'; import { withBasePath } from './base-path.js'; import { withAssetHash } from './asset-hash.js'; import { jsonForScriptTag } from './script-tag-json.js'; @@ -1660,6 +1660,36 @@ function wrapHead(opts) { // importmap-rails) applies nonce on every modulepreload tag for // the same reason. const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : ''; + // Core runtime modulepreload (#1118). Every module the boot imports pulls + // `@webjsdev/core`, but the boot script names only page and component URLs, + // so without this hint the browser discovers core only after one of those is + // fetched AND parsed: one full round trip into the load, which is exactly + // where the pre-boot click window lives on a cold, throttled connection. + // + // The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the + // map's targets are already base-path-prefixed and content-hashed), so it is + // byte-identical to what the import resolves to. A differing href makes the + // browser treat the preload and the import as two resources and fetch core + // twice. That is why this copies the vendor loop below, not the module loop + // above. `vendorPreloadTargets` still excludes core deliberately; this hint + // is emitted from the head builder, not the vendor path. + // + // Gated on the boot actually shipping something. A fully elided page ships no + // boot module and must not be handed a preload for a runtime it never loads + // (#780), which also keeps it off `global-error.{js,ts}`, whose document is + // returned verbatim with no importmap and no boot script. + if (opts.moduleUrls.length || lazyEntries) { + const coreMap = buildImportMap(); + const coreHref = coreMap.imports['@webjsdev/core']; + if (coreHref) { + const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined; + const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : ''; + linkTags.push( + ``, + ); + } + } // Sub-path deployment (issue #256): the modulepreload href is prefixed with // the base path (a no-op when empty), but `crossorigin` / `integrity` are // decided on the ORIGINAL url, so the integrity lookup still keys on the diff --git a/packages/server/test/importmap/vendor-preload.test.js b/packages/server/test/importmap/vendor-preload.test.js index 493301692..de8fe3ea0 100644 --- a/packages/server/test/importmap/vendor-preload.test.js +++ b/packages/server/test/importmap/vendor-preload.test.js @@ -12,6 +12,10 @@ * cross-origin vendor emits `` for the reached vendor URL; an elided/unused vendor is NOT * preloaded; the modulepreload href is byte-identical to the importmap target. + * + * Also covers the CORE runtime hint (#1118), which lives in the same head + * builder but deliberately NOT in `vendorPreloadTargets` (core is same-origin, + * not a CDN-waterfall vendor). Same byte-identity rule, same over-fetch rule. */ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -352,3 +356,68 @@ test('import-only page: skipping the dropped page module does NOT drop its shipp 'the shipping component\'s vendor IS preloaded (skipping the dropped page did not drop it)'); await setCoreInstall(CORE_DIR, true); }); + +/* ---------------- core runtime modulepreload (#1118) ---------------- */ + +test('a page shipping a boot module preloads @webjsdev/core, byte-identical to the importmap target', async () => { + // The boot script names only page and component URLs, so without this hint the + // browser discovers core one round trip later (fetch a component -> parse -> + // fetch core). That round trip is the pre-boot click window on a cold link. + const appDir = makeApp(); + writeVendorWidget(appDir); + writeFileSync( + join(appDir, 'app', 'page.js'), + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `import './widget.js';\n` + + `export default () => html\`\`;\n`, + ); + const app = await createRequestHandler({ appDir, dev: false }); + await app.warmup(); + const html = await (await app.handle(new Request('http://x/'))).text(); + + const target = importmapTarget(html, '@webjsdev/core'); + assert.ok(target, '@webjsdev/core is in the served importmap'); + const core = modulepreloadLinks(html).filter((l) => l.includes(`href="${target}"`)); + assert.equal(core.length, 1, 'exactly one modulepreload for the core runtime'); + // Byte-identity is the whole correctness condition: a differing href makes the + // browser treat the preload and the import as two resources and fetch core + // twice, silently (the page still works, it just pays double). + assert.ok(core[0].includes(`href="${target}"`), 'preload href === importmap target (no double fetch)'); + + // The hint is emitted FIRST, ahead of the page/component preloads, since every + // one of them imports it. + const links = modulepreloadLinks(html); + assert.ok(links[0].includes(`href="${target}"`), 'the core hint is the first modulepreload'); + assert.ok(links.some((l) => l.includes('/app/widget.js')), 'the component preloads are still emitted'); +}); + +test('a fully elided page (no boot module) does NOT preload core (#780 no over-fetch)', async () => { + // A display-only page ships nothing, so a preload for the runtime it never + // loads is pure over-fetch. Same rule that keeps a core hint off + // `global-error.{js,ts}`, whose document is returned verbatim with no boot. + const appDir = makeApp(); + writeFileSync( + join(appDir, 'app', 'page.js'), + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export default () => html\`
static
\`;\n`, + ); + const app = await createRequestHandler({ appDir, dev: false }); + await app.warmup(); + const html = await (await app.handle(new Request('http://x/'))).text(); + + // Precondition: nothing ships, so the assertion below cannot pass vacuously + // for the wrong reason (a rendered page that simply had no modules at all). + assert.ok(!/