From 7ad849f91f5b7876ca3b55764846247ae45357d4 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 10:49:33 +0200 Subject: [PATCH 01/14] feat(html): client-side navigation with the Navigation API Assisted-by: Claude Opus 5.5 --- .changeset/client-side-navigation.md | 5 + .oxlintrc.json | 3 +- docs/publishing.md | 33 ++ e2e/client-side-navigation.spec.js | 124 ++++++ packages/react/src/html/README.md | 58 ++- .../src/html/__tests__/generate.test.mjs | 17 +- packages/react/src/html/bundlers/vite.mjs | 33 +- packages/react/src/html/constants.mjs | 23 +- packages/react/src/html/types.d.ts | 2 + .../src/html/ui/__tests__/router.test.mjs | 54 +++ .../src/html/ui/components/SideBar/index.jsx | 1 - packages/react/src/html/ui/hooks/useOrama.mjs | 89 +++-- .../src/html/ui/hooks/useRemoteConfig.mjs | 53 ++- packages/react/src/html/ui/index.css | 6 + .../react/src/html/ui/islands/runtime.mjs | 49 ++- packages/react/src/html/ui/router.mjs | 378 ++++++++++++++++++ .../html/utils/__tests__/processing.test.mjs | 79 +++- packages/react/src/html/utils/generate.mjs | 12 +- packages/react/src/html/utils/processing.mjs | 79 +++- vercel.json | 13 +- 20 files changed, 987 insertions(+), 124 deletions(-) create mode 100644 .changeset/client-side-navigation.md create mode 100644 e2e/client-side-navigation.spec.js create mode 100644 packages/react/src/html/ui/__tests__/router.test.mjs create mode 100644 packages/react/src/html/ui/router.mjs diff --git a/.changeset/client-side-navigation.md b/.changeset/client-side-navigation.md new file mode 100644 index 000000000..2b7b0dc22 --- /dev/null +++ b/.changeset/client-side-navigation.md @@ -0,0 +1,5 @@ +--- +'@doc-kit/generator-react': minor +--- + +feat(html): navigate between pages client-side with the Navigation API, prefetching them on hover, keep the search index and remote config in memory across pages, scope speculation rules to links that leave the site, and hash fonts (reported through the bundler's new `fonts`) so every asset can be cached immutably diff --git a/.oxlintrc.json b/.oxlintrc.json index 41346e31b..a794496da 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -206,7 +206,8 @@ { "files": [ "packages/node-legacy/src/legacy-html/assets/*.js", - "packages/react/src/html/ui/**/*" + "packages/react/src/html/ui/**/*", + "e2e/**/*.spec.js" ], "globals": { "AsyncDisposableStack": "readonly", diff --git a/docs/publishing.md b/docs/publishing.md index 94b475804..5ab4f3233 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -23,6 +23,39 @@ convention). Two things to know about the result: index alongside the pages by targeting both generators — `target: ['html', 'orama-db']`, so the search box has data to query. +## Cache the assets + +Every file the `html` generator writes to `assets/` (scripts, stylesheets, +fonts) is named after a hash of its content, so a changed file always gets a +new name. Serve that directory with a long-lived, immutable cache, and let +everything else (the pages, the search index) revalidate: + +``` +/assets/* Cache-Control: public, max-age=31536000, immutable +``` + +Most hosts default to revalidating every file on every load instead, which +costs a request per asset each time a new tab opens the site. On Vercel: + +```json displayName="vercel.json" +{ + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] +} +``` + +Within a visit, moving between pages loads no assets at all: the site +navigates client-side, swapping in the next page's content. + ## Tell doc-kit its public URL Set `baseURL` to where the site will live. Generators that emit absolute diff --git a/e2e/client-side-navigation.spec.js b/e2e/client-side-navigation.spec.js new file mode 100644 index 000000000..010129800 --- /dev/null +++ b/e2e/client-side-navigation.spec.js @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; + +const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json'; + +/** + * Navigates the way following a link does, and waits for the navigation to + * finish. + */ +const navigate = (page, url) => + page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url); + +test.describe('Client-side navigation', () => { + test.beforeEach(async ({ page }) => { + await page.route(REMOTE_CONFIG_URL, route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + websiteBanners: { index: { text: 'Important announcement' } }, + }), + }) + ); + + await page.goto('/assert.html'); + + // A full load would start a new document, and lose this + await page.evaluate(() => (window.__document = 'first')); + }); + + test('swaps the next page in without loading assets again', async ({ + page, + }) => { + const loaded = await page.evaluate(() => + [ + ...document.querySelectorAll( + 'script[src], link[rel="stylesheet"], link[as="font"]' + ), + ].map(element => element.src || element.href) + ); + + const requests = []; + page.on('request', request => requests.push(request.url())); + + await navigate(page, 'all.html'); + + await expect(page).toHaveURL(/\/all\.html$/); + await expect(page).toHaveTitle(/^All \|/); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( + 'content', + /^All \|/ + ); + expect(await page.evaluate(() => window.__document)).toBe('first'); + + // The scripts, stylesheets and fonts are still loaded + expect(requests.filter(url => loaded.includes(url))).toEqual([]); + }); + + test('goes back to the previous page, where it was scrolled to', async ({ + page, + }) => { + await page.evaluate(() => scrollTo(0, 2000)); + await navigate(page, 'all.html'); + await page.evaluate(() => navigation.back().finished.then(() => {})); + + // Hosts with clean URLs redirect the first page to one without `.html` + await expect(page).toHaveURL(/\/assert(\.html)?$/); + await expect(page).toHaveTitle(/^Assert \|/); + expect(await page.evaluate(() => scrollY)).toBe(2000); + expect(await page.evaluate(() => window.__document)).toBe('first'); + }); + + test('prefetches a page as its link is hovered', async ({ page }) => { + await page.evaluate(() => + document + .querySelector('main') + .insertAdjacentHTML('beforeend', 'All') + ); + + // Hosts with clean URLs answer the prefetch with a redirect first + const prefetched = page.waitForResponse( + response => /\/all(\.html)?$/.test(response.url()) && response.ok() + ); + + await page.hover('#all'); + await prefetched; + + const requests = []; + page.on('request', request => requests.push(request.url())); + + await page.click('#all'); + + await expect(page).toHaveTitle(/^All \|/); + expect(requests).toEqual([]); + }); + + test('keeps the remote config, and shows its banner at once', async ({ + page, + }) => { + const banner = page.getByRole('region', { name: 'Announcement' }); + await expect(banner).toBeVisible(); + + let fetched = 0; + + await page.route(REMOTE_CONFIG_URL, route => { + fetched++; + return route.fallback(); + }); + + await navigate(page, 'all.html'); + + await expect(banner).toBeVisible(); + // It animates in on the first page only + await expect(banner).toHaveCSS('animation-name', 'none'); + expect(fetched).toBe(0); + }); + + test('leaves links to files that are not pages to the browser', async ({ + page, + }) => { + await page.getByRole('link', { name: 'JSON' }).click(); + + await expect(page).toHaveURL(/\/assert\.json$/); + expect(await page.evaluate(() => window.__document)).toBeUndefined(); + }); +}); diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md index 257d45171..cdb7dbc9e 100644 --- a/packages/react/src/html/README.md +++ b/packages/react/src/html/README.md @@ -215,10 +215,12 @@ generator's `constants.mjs`), so that the page and the library share one Preact. `buildClient` receives `{ entry, virtualImports, config }`. The client `entry` is a single program shared by every page. It must be bundled into `config.output` and the call must return -`{ scripts, preloads, stylesheets }`: paths relative to the output root of the -module scripts to load, the chunks they statically import (rendered as -`modulepreload` hints), and the stylesheets. The generator renders those into -every page, resolved against the page's location. +`{ scripts, preloads, stylesheets, fonts }`: paths relative to the output root +of the module scripts to load, the chunks they statically import (rendered as +`modulepreload` hints), the stylesheets, and optionally the fonts to preload. +The generator renders those into every page, resolved against the page's +location. Name every file after its content (a hash), so hosts can cache them +indefinitely (see [Publishing](../../../docs/publishing.md#cache-the-assets)). `config` is the resolved `html` configuration. The adapter must compile the generated Preact JSX and CSS imports and resolve the supplied theme aliases and @@ -309,9 +311,10 @@ plugins see and can transform every module of the client and server builds but never the HTML pages. Customize the pages through the [HTML template](#html-template) instead. -The adapter reads the client asset names from Vite's manifest. A manifest is -written either way; pass `build: { manifest: true }` (or a file name) to -`createViteBundler` to keep it in the output for another tool. +The adapter reads the client asset names from Vite's manifest, including the +hashed names of the fonts to preload. A manifest is written either way; pass +`build: { manifest: true }` (or a file name) to `createViteBundler` to keep it +in the output for another tool. The adapter is only ever used on the main thread, so function-valued plugins and hooks are supported. Worker threads receive the `html` configuration with @@ -488,7 +491,11 @@ The HTML template file (set via `templatePath`) uses JavaScript template literal - `dehydrated` {string} Server-rendered HTML for the page content. - `assets` {string} The `', + // The scripts also tell the client-side router where the site starts + '', '', '', ] diff --git a/packages/react/src/html/utils/generate.mjs b/packages/react/src/html/utils/generate.mjs index da6876d40..2487075fe 100644 --- a/packages/react/src/html/utils/generate.mjs +++ b/packages/react/src/html/utils/generate.mjs @@ -158,17 +158,27 @@ export default () => { ), createImportDeclaration( - 'registerIslands', + 'registerIslands, unmountIslands', resolve(ROOT, './ui/islands/runtime.mjs'), false ), + createImportDeclaration( + 'startRouter', + resolve(ROOT, './ui/router.mjs'), + false + ), + `registerIslands({${componentImports .map( ({ name, source }) => `${JSON.stringify(name)}: () => import(${JSON.stringify(source)})` ) .join(', ')}});`, + + // Navigations between pages swap the page in place, so the islands of the + // page being left have to be unmounted rather than simply dropped + 'startRouter({ unmount: unmountIslands });', ].join('\n'); return { buildLibraryProgram, buildPageProgram, clientProgram }; diff --git a/packages/react/src/html/utils/processing.mjs b/packages/react/src/html/utils/processing.mjs index 9d344e59e..4149a2e60 100644 --- a/packages/react/src/html/utils/processing.mjs +++ b/packages/react/src/html/utils/processing.mjs @@ -1,7 +1,6 @@ import getConfig from '@doc-kit/core/utils/configuration/index.mjs'; import { populate } from '@doc-kit/core/utils/configuration/templates.mjs'; -import { FONT_DIRECTORY, FONTS, SPECULATION_RULES } from '../constants.mjs'; import { THEME_SCRIPT } from '../ui/theme-script.mjs'; import createConfigSource from './config.mjs'; import { relativeOrAbsolute } from './relativeOrAbsolute.mjs'; @@ -85,18 +84,63 @@ const renderTag = (tag, attrs) => { }; /** - * Renders the preload hints for a page + * The attributes of a font's preload hint. `crossorigin` is required: fonts + * are fetched in CORS mode, so without it the stylesheet fetches the font again + * instead of reusing the preloaded one. + * + * @param {string} href - The font's URL + * @returns {Record} */ -export const buildPreloads = root => - FONTS.map(font => - renderTag('link', { - rel: 'preload', - href: `${root}${FONT_DIRECTORY}/${font}`, - as: 'font', - type: 'font/woff2', - crossorigin: true, - }) - ).join('\n '); +const createFontPreload = href => ({ + rel: 'preload', + href, + as: 'font', + type: 'font/woff2', + crossorigin: true, +}); + +/** + * Renders the preload hints for a page's fonts. + * + * @param {Array} fonts - Output-relative font paths + * @param {string} root - The page's root (see {@link resolvePageRoot}) + * @returns {string} + */ +export const buildPreloads = (fonts, root) => + fonts + .map(font => renderTag('link', createFontPreload(`${root}${font}`))) + .join('\n '); + +/** + * Renders a page's speculation rules. + * + * Navigations between the site's own pages happen client-side (see + * `ui/router.mjs`), which prefetches those pages itself: a document the + * browser speculatively fetches can only serve a full navigation, so prefetching + * them here would download each twice. What is left are the links that leave + * the site for other pages on its origin (the rest of nodejs.org, for docs + * served under nodejs.org/docs): those are prefetched when hovered or pressed. + * + * @param {string} root - The page's root (see {@link resolvePageRoot}) + * @returns {string} The rules, as JSON + */ +export const buildSpeculationRules = root => { + // Patterns resolve against the page, but a wildcard after a `/` takes that + // slash as its prefix and leaves the dot segment before it unresolved (`../*` + // matches nothing), while `..*` resolves to the directory, as intended. + const site = root.startsWith('.') ? `${root.slice(0, -1)}*` : `${root}*`; + + return JSON.stringify({ + prefetch: [ + { + where: { + and: [{ href_matches: '/*' }, { not: { href_matches: site } }], + }, + eagerness: 'moderate', + }, + ], + }); +}; /** * Builds the configurable `` markup shared by every page from the @@ -119,6 +163,10 @@ export const buildHead = ({ meta = [], links = [], html = [] }) => * statically import as preload hints (as the bundler would inject them), and * the stylesheets as links. * + * The entry scripts also carry the root itself, which tells the client-side + * router (see `ui/router.mjs`) which links lead to pages of the site. It rides + * along with the scripts because every template has to render them. + * * @param {import('../types').ClientAssets} assets - Output-relative asset paths * @param {string} root - The page's root (see {@link resolvePageRoot}) * @returns {string} @@ -126,7 +174,8 @@ export const buildHead = ({ meta = [], links = [], html = [] }) => export const buildAssetTags = ({ scripts, preloads, stylesheets }, root) => [ scripts.map( - file => `` + file => + `` ), preloads.map(file => renderTag('link', { @@ -180,9 +229,9 @@ export const populatePage = ({ template, data, dehydrated, assets }) => { ), dehydrated, assets: buildAssetTags(assets, root), - speculationRules: SPECULATION_RULES, + speculationRules: buildSpeculationRules(root), themeScript: THEME_SCRIPT, - preloads: buildPreloads(root), + preloads: buildPreloads(assets.fonts ?? [], root), root, metadata: data, config, diff --git a/vercel.json b/vercel.json index bd6fd8faa..deda36bcc 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,14 @@ { - "cleanUrls": true + "cleanUrls": true, + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] } From 1156dec0890589a209d1710fc4724cb575cc8b06 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 11:54:49 +0200 Subject: [PATCH 02/14] refactor(html): use hydrated set for island scroll saves; drop announcePage and view transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export `hydrated` from runtime so the router reads it directly instead of querying `document` for mounted islands before a page swap - Rename `getIslands` → `keyIslands(source)` — takes an explicit iterable; the post-swap restore passes `document.body.querySelectorAll` scoped to the new body - Remove the cross-fade view transition (simpler, no animation on nav) - Remove `announcePage` helper Assisted-by: Claude Sonnet 4.6 --- .../react/src/html/ui/islands/runtime.mjs | 6 +- packages/react/src/html/ui/router.mjs | 79 ++++++------------- packages/react/src/html/utils/generate.mjs | 4 +- 3 files changed, 28 insertions(+), 61 deletions(-) diff --git a/packages/react/src/html/ui/islands/runtime.mjs b/packages/react/src/html/ui/islands/runtime.mjs index bb2e51dc1..6a4c055e1 100644 --- a/packages/react/src/html/ui/islands/runtime.mjs +++ b/packages/react/src/html/ui/islands/runtime.mjs @@ -4,12 +4,12 @@ import { h, hydrate, render } from 'preact'; import loaders from './loaders.mjs'; /** - * The islands hydrated so far, so that those a client-side navigation is about - * to discard can be unmounted first. + * The islands hydrated so far. Exported so the router can save their scroll + * positions before a navigation discards them, without querying the document. * * @type {Set} */ -const hydrated = new Set(); +export const hydrated = new Set(); /** * The components loaded so far, by island name. Even an `import()` of a module diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 9701217d4..b8c5370b6 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -62,26 +62,26 @@ export const withoutFragment = href => href.split('#')[0]; * * @param {Document} doc * @param {string} base - The document's URL, for relative references - * @returns {Array} + * @returns {Array} */ const getAssets = (doc, base) => [...doc.querySelectorAll('script[src], link[rel~="stylesheet"][href]')].map( element => new URL(element.getAttribute('src') ?? element.getAttribute('href'), base) - .href ); /** - * Keys the islands of the document by name and occurrence, so that the same - * island is found again on the next page. + * Keys an iterable of islands by name and occurrence, so that the same island + * is found again on the next page. * + * @param {Iterable} source * @returns {Map} */ -const getIslands = () => { +const keyIslands = source => { const counts = new Map(); return new Map( - [...document.querySelectorAll('is-land[data-island-name]')].map(island => { + [...source].map(island => { const name = island.getAttribute('data-island-name'); counts.set(name, (counts.get(name) ?? 0) + 1); @@ -117,45 +117,11 @@ const updateHead = doc => { }; /** - * Announces the new page to assistive technology, as loading it would have. - */ -const announcePage = () => { - const region = document.createElement('div'); - - region.setAttribute('aria-live', 'assertive'); - region.setAttribute('aria-atomic', 'true'); - region.style.cssText = - 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap'; - - document.body.append(region); - - // A region filled as it is inserted is not announced - setTimeout(() => (region.textContent = document.title), 100); -}; - -/** - * Runs a DOM update inside a view transition, where supported and wanted, so - * the old page cross-fades into the new one. + * Runs a DOM update, keeping the call-site uniform for a future transition. * * @param {() => void} update - * @returns {Promise | undefined} Settles once the DOM is updated */ -const transition = update => { - if ( - !document.startViewTransition || - matchMedia('(prefers-reduced-motion: reduce)').matches - ) { - return update(); - } - - const { ready, updateCallbackDone } = document.startViewTransition(update); - - // Skipped transitions (the tab is hidden, or another navigation started) - // still update the DOM; only their animation is lost - ready.catch(() => {}); - - return updateCallbackDone; -}; +const transition = update => update(); /** * Starts handling navigations between the pages of the site, unless the @@ -165,8 +131,10 @@ const transition = update => { * @param {object} options * @param {(root: Node) => void} options.unmount - Unmounts the components * rendered inside the part of the document that is about to be discarded. + * @param {Set} options.islands - The runtime's set of hydrated + * islands; used to save scroll positions before the body is replaced. */ -export const startRouter = ({ unmount }) => { +export const startRouter = ({ unmount, islands }) => { const script = document.querySelector('script[data-root]'); if (!('navigation' in window) || !script) { @@ -177,7 +145,9 @@ export const startRouter = ({ unmount }) => { // The document's head is never replaced (see `PAGE_HEAD`), so the relative // URLs in it are resolved while they still point where they did at load - const assets = new Set(getAssets(document, location.href)); + const assets = new Set( + getAssets(document, location.href).map(url => url.href) + ); /** @type {Map, expires: number }>} */ const pages = new Map(); @@ -233,7 +203,9 @@ export const startRouter = ({ unmount }) => { const parsePage = ({ url, html }) => { const doc = new DOMParser().parseFromString(html, 'text/html'); - return getAssets(doc, url).every(asset => assets.has(asset)) ? doc : null; + return getAssets(doc, url).every(asset => assets.has(asset.href)) + ? doc + : null; }; /** @@ -244,7 +216,7 @@ export const startRouter = ({ unmount }) => { */ const showPage = (doc, scroll) => { // The sidebar (like any island that scrolls) stays where it was - const scrolled = [...getIslands()] + const scrolled = [...keyIslands(islands)] .filter(([, island]) => island.scrollTop || island.scrollLeft) .map(([key, { scrollLeft, scrollTop }]) => [key, scrollLeft, scrollTop]); @@ -256,14 +228,15 @@ export const startRouter = ({ unmount }) => { // from animating again with every page document.documentElement.setAttribute('data-navigated', ''); - const islands = getIslands(); + const next = keyIslands( + document.body.querySelectorAll('is-land[data-island-name]') + ); for (const [key, left, top] of scrolled) { - islands.get(key)?.scrollTo({ left, top, behavior: 'instant' }); + next.get(key)?.scrollTo({ left, top, behavior: 'instant' }); } scroll(); - announcePage(); }; /** @@ -329,13 +302,7 @@ export const startRouter = ({ unmount }) => { return; } - await transition(() => { - // Another navigation superseded this one while the old page was - // being captured for the transition - if (!event.signal.aborted) { - showPage(doc, () => event.scroll()); - } - }); + transition(() => showPage(doc, () => event.scroll())); }, }); }); diff --git a/packages/react/src/html/utils/generate.mjs b/packages/react/src/html/utils/generate.mjs index 2487075fe..c8b0def57 100644 --- a/packages/react/src/html/utils/generate.mjs +++ b/packages/react/src/html/utils/generate.mjs @@ -158,7 +158,7 @@ export default () => { ), createImportDeclaration( - 'registerIslands, unmountIslands', + 'registerIslands, unmountIslands, hydrated', resolve(ROOT, './ui/islands/runtime.mjs'), false ), @@ -178,7 +178,7 @@ export default () => { // Navigations between pages swap the page in place, so the islands of the // page being left have to be unmounted rather than simply dropped - 'startRouter({ unmount: unmountIslands });', + 'startRouter({ unmount: unmountIslands, islands: hydrated });', ].join('\n'); return { buildLibraryProgram, buildPageProgram, clientProgram }; From 53a3b20b857094a2c4859acb861969ad113bcc4c Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 12:07:11 +0200 Subject: [PATCH 03/14] refactor(html): embed router data in HTML; extract shouldIntercept; drop document queries Replace the `data-root` attribute and runtime `document.querySelectorAll` discovery with a `', + ``, + '', '', '', ] @@ -280,10 +280,10 @@ describe('buildAssetTags', () => { ); }); - it('renders nothing for an empty asset list', () => { + it('renders only the router tag for an empty asset list', () => { assert.strictEqual( buildAssetTags({ scripts: [], preloads: [], stylesheets: [] }, './'), - '' + '' ); }); }); diff --git a/packages/react/src/html/utils/processing.mjs b/packages/react/src/html/utils/processing.mjs index 4149a2e60..fe00c81d0 100644 --- a/packages/react/src/html/utils/processing.mjs +++ b/packages/react/src/html/utils/processing.mjs @@ -163,9 +163,9 @@ export const buildHead = ({ meta = [], links = [], html = [] }) => * statically import as preload hints (as the bundler would inject them), and * the stylesheets as links. * - * The entry scripts also carry the root itself, which tells the client-side - * router (see `ui/router.mjs`) which links lead to pages of the site. It rides - * along with the scripts because every template has to render them. + * Also emits a ``, + ], scripts.map( - file => - `` + file => `` ), preloads.map(file => renderTag('link', { From 3277d1d377347f22f5718c6a375c88be18eac45a Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 16:04:43 +0200 Subject: [PATCH 04/14] =?UTF-8?q?refactor(html):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20move=20router=20constants,=20simplify=20remote=20co?= =?UTF-8?q?nfig=20and=20Orama=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move HOVER_DELAY, PAGE_LIFETIME, MAX_PAGES to constants.mjs as ROUTER_* exports (per review: use the constants file) - Simplify useRemoteConfig: there is only one remote config URL per site, so a single module-level promise replaces the URL-keyed Map - Simplify useOrama: one search client per visit replaces the URL-keyed Map; createClient renamed to getClient, module-level let holds the singleton Assisted-by: Claude Sonnet 4.6 --- packages/react/src/html/constants.mjs | 9 ++++ packages/react/src/html/ui/hooks/useOrama.mjs | 44 +++++++++---------- .../src/html/ui/hooks/useRemoteConfig.mjs | 38 +++++++--------- packages/react/src/html/ui/router.mjs | 22 ++++------ 4 files changed, 55 insertions(+), 58 deletions(-) diff --git a/packages/react/src/html/constants.mjs b/packages/react/src/html/constants.mjs index 63cc661ad..39eef4202 100644 --- a/packages/react/src/html/constants.mjs +++ b/packages/react/src/html/constants.mjs @@ -92,3 +92,12 @@ export const FONTS = [ 'open-sans-latin-wght-italic.woff2', 'ibm-plex-mono-latin-400-normal.woff2', ]; + +// How long a hovered link waits before its page is prefetched. +export const ROUTER_HOVER_DELAY = 80; + +// How long a fetched page is reused for, whether prefetched or visited. +export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; + +// How many fetched pages are kept at once. +export const ROUTER_MAX_PAGES = 10; diff --git a/packages/react/src/html/ui/hooks/useOrama.mjs b/packages/react/src/html/ui/hooks/useOrama.mjs index f9fbedf28..70b33f93c 100644 --- a/packages/react/src/html/ui/hooks/useOrama.mjs +++ b/packages/react/src/html/ui/hooks/useOrama.mjs @@ -4,30 +4,30 @@ import { useState, useEffect } from 'react'; import { relativeOrAbsolute } from '../utils/relativeOrAbsolute.mjs'; /** - * Search clients by the URL of their data, so that the index is downloaded and - * loaded once per visit rather than once per page navigated to. + * The search client for this visit, shared across every page navigated to + * client-side. The Orama index (several MB) is fetched once on the first + * search and reused from then on. * - * @type {Map} + * @type {import('@orama/orama').AnyOrama | null} */ -const clients = new Map(); +let client = null; /** - * Creates a search client whose data is fetched on its first search. + * Returns the shared search client, creating it on the first call. * - * @param {string} url - The search data's absolute URL: the client outlives - * the page it was created on, which a relative URL would resolve against. + * @param {string} url - Absolute URL of the search data, resolved once at + * creation so the client outlives the page it was first used on. */ -const createClient = url => { - const db = create({ - schema: {}, - }); +const getClient = url => { + if (client) { + return client; + } + const db = create({ schema: {} }); let loaded; // TODO(@avivkeller): Ask Orama to support this functionality natively - /** - * @param {any} options - */ + /** @param {any} options */ db.search = async options => { loaded ??= fetch(url) .then(response => response.ok && response.json()) @@ -41,17 +41,19 @@ const createClient = url => { return search(db, options); }; - return db; + client = db; + + return client; }; /** - * Hook for initializing and managing Orama search database. + * Hook for initializing and managing the Orama search client. * The search data is lazily fetched on the first search call. * * @param {string} pathname - The current page's path (e.g., '/api/fs') */ export default pathname => { - const [client, setClient] = useState(null); + const [db, setDb] = useState(null); useEffect(() => { const url = new URL( @@ -59,12 +61,8 @@ export default pathname => { location.href ).href; - if (!clients.has(url)) { - clients.set(url, createClient(url)); - } - - queueMicrotask(() => setClient(clients.get(url))); + queueMicrotask(() => setDb(getClient(url))); }, [pathname]); - return client; + return db; }; diff --git a/packages/react/src/html/ui/hooks/useRemoteConfig.mjs b/packages/react/src/html/ui/hooks/useRemoteConfig.mjs index 233f01a7e..8aff250fa 100644 --- a/packages/react/src/html/ui/hooks/useRemoteConfig.mjs +++ b/packages/react/src/html/ui/hooks/useRemoteConfig.mjs @@ -17,36 +17,30 @@ import { remoteConfigUrl } from '#theme/config'; */ /** - * The remote configs fetched so far, by URL. Each is fetched once per visit and - * shared by every island that reads it, on every page navigated to client-side: - * islands hydrate as separate roots, so no context provider could span them. + * The remote config fetched for this visit, shared by every island that reads + * it. Islands hydrate as separate roots, so no context provider could span + * them; module scope is the shared store. * - * @type {Map>} + * @type {Promise | null} */ -const remoteConfigs = new Map(); +let remoteConfig = null; /** - * Fetches a remote config, unless it is already loaded or on its way. + * Fetches the remote config, unless it is already loaded or on its way. * - * @param {string} url * @returns {Promise} */ -const loadRemoteConfig = url => { - if (!remoteConfigs.has(url)) { - remoteConfigs.set( - url, - fetch(url) - .then(response => response.json()) - .catch(() => { - // Not kept, so that the next island to mount tries again - remoteConfigs.delete(url); +const loadRemoteConfig = () => { + remoteConfig ??= fetch(remoteConfigUrl) + .then(response => response.json()) + .catch(() => { + // Not kept, so that the next island to mount tries again + remoteConfig = null; - return null; - }) - ); - } + return null; + }); - return remoteConfigs.get(url); + return remoteConfig; }; /** @@ -70,7 +64,7 @@ export default () => { let mounted = true; - loadRemoteConfig(remoteConfigUrl).then(loaded => { + loadRemoteConfig().then(loaded => { if (mounted) { setConfig(loaded); } diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 6aada961a..8cf106fd6 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -18,21 +18,17 @@ * navigation in a browser without the Navigation API. */ +import { + ROUTER_HOVER_DELAY, + ROUTER_MAX_PAGES, + ROUTER_PAGE_LIFETIME, +} from '../constants.mjs'; + /** * @typedef {{ url: string, html: string }} Page A fetched page: its final * URL, after redirects, and its markup. */ -// How long a hovered link waits before its page is prefetched, so that links -// the pointer merely crosses on its way somewhere else are skipped. -const HOVER_DELAY = 80; - -// How long a fetched page is reused for, whether it was prefetched or visited. -const PAGE_LIFETIME = 5 * 60 * 1000; - -// How many fetched pages are kept at once. -const MAX_PAGES = 10; - // The `` elements that belong to the page rather than to the site, and // are replaced with it: `` tags (`og:title`) and the links that are not // resources (`canonical`). Scripts and stylesheets run and apply once. @@ -177,9 +173,9 @@ export const startRouter = ({ unmount, islands }) => { .catch(() => null); pages.delete(url); - pages.set(url, { page, expires: Date.now() + PAGE_LIFETIME }); + pages.set(url, { page, expires: Date.now() + ROUTER_PAGE_LIFETIME }); - if (pages.size > MAX_PAGES) { + if (pages.size > ROUTER_MAX_PAGES) { pages.delete(pages.keys().next().value); } @@ -324,7 +320,7 @@ export const startRouter = ({ unmount, islands }) => { getLinkedPage(target); if (url) { - hovered = setTimeout(loadPage, HOVER_DELAY, url); + hovered = setTimeout(loadPage, ROUTER_HOVER_DELAY, url); } }, { passive: true } From 7ba77eb1e408117833a69b3da50b76a4b99ec380 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 16:48:22 +0200 Subject: [PATCH 05/14] refactor(html): split page DOM utilities into page.mjs; extract shouldFollowLink Move fetchPage, keyIslands, parsePage, showPage, transition, and the head diffing helpers out of router.mjs and into a new page.mjs so that router.mjs stays focused on routing decisions (Navigation API wiring, intercept logic, prefetch cache). Extract shouldFollowLink as a named, exported predicate so the anchor guard (download attribute, target != _self) can be unit tested independently. Also simplify useRemoteConfig to a single module-level promise (replacing the URL-keyed Map) and useOrama to a single module-level client via getClient. Assisted-by: Claude Sonnet 4.6 --- packages/react/src/html/ui/hooks/useOrama.mjs | 6 +- .../src/html/ui/hooks/useRemoteConfig.mjs | 14 +- packages/react/src/html/ui/page.mjs | 144 +++++++++++++++++ packages/react/src/html/ui/router.mjs | 153 ++---------------- 4 files changed, 170 insertions(+), 147 deletions(-) create mode 100644 packages/react/src/html/ui/page.mjs diff --git a/packages/react/src/html/ui/hooks/useOrama.mjs b/packages/react/src/html/ui/hooks/useOrama.mjs index 70b33f93c..1709a7ef6 100644 --- a/packages/react/src/html/ui/hooks/useOrama.mjs +++ b/packages/react/src/html/ui/hooks/useOrama.mjs @@ -56,12 +56,12 @@ export default pathname => { const [db, setDb] = useState(null); useEffect(() => { - const url = new URL( + const { href } = new URL( relativeOrAbsolute('/orama-db.json', pathname), location.href - ).href; + ); - queueMicrotask(() => setDb(getClient(url))); + queueMicrotask(() => setDb(getClient(href))); }, [pathname]); return db; diff --git a/packages/react/src/html/ui/hooks/useRemoteConfig.mjs b/packages/react/src/html/ui/hooks/useRemoteConfig.mjs index 8aff250fa..323fdccf8 100644 --- a/packages/react/src/html/ui/hooks/useRemoteConfig.mjs +++ b/packages/react/src/html/ui/hooks/useRemoteConfig.mjs @@ -21,9 +21,9 @@ import { remoteConfigUrl } from '#theme/config'; * it. Islands hydrate as separate roots, so no context provider could span * them; module scope is the shared store. * - * @type {Promise | null} + * @type {Promise | undefined} */ -let remoteConfig = null; +let remoteConfig; /** * Fetches the remote config, unless it is already loaded or on its way. @@ -35,9 +35,7 @@ const loadRemoteConfig = () => { .then(response => response.json()) .catch(() => { // Not kept, so that the next island to mount tries again - remoteConfig = null; - - return null; + remoteConfig = undefined; }); return remoteConfig; @@ -46,12 +44,12 @@ const loadRemoteConfig = () => { /** * Fetches the remote site configuration once the component mounts. * - * @returns {RemoteConfig | null} `null` until loaded, or when there is no - * `remoteConfigUrl` or the fetch fails. + * @returns {RemoteConfig | undefined} `undefined` until loaded, or when there + * is no `remoteConfigUrl` or the fetch fails. */ export default () => { const [config, setConfig] = useState( - /** @type {RemoteConfig | null} */ (null) + /** @type {RemoteConfig | undefined} */ (undefined) ); // A layout effect, so that a page navigated to client-side renders with a diff --git a/packages/react/src/html/ui/page.mjs b/packages/react/src/html/ui/page.mjs new file mode 100644 index 000000000..3c3428cad --- /dev/null +++ b/packages/react/src/html/ui/page.mjs @@ -0,0 +1,144 @@ +/** + * DOM utilities for client-side page swaps: fetching, parsing, and replacing + * the document body and page-specific head elements. + */ + +/** + * @typedef {{ url: string, html: string }} Page A fetched page: its final + * URL, after redirects, and its markup. + */ + +// The `` elements that belong to the page rather than to the site, and +// are replaced with it: `` tags (`og:title`) and the links that are not +// resources (`canonical`). Scripts and stylesheets run and apply once. +const PAGE_HEAD = + ':scope > meta, :scope > link:not([rel~="stylesheet"], [rel~="preload"], [rel~="modulepreload"])'; + +/** + * Fetches a page, returning its final URL and HTML, or `null` on failure or a + * non-HTML response. + * + * @param {string} url + * @returns {Promise} + */ +export const fetchPage = url => + fetch(url, { priority: 'low', headers: { Accept: 'text/html' } }) + .then(async response => + response.ok && + response.headers.get('content-type')?.startsWith('text/html') + ? { url: response.url, html: await response.text() } + : null + ) + .catch(() => null); + +/** + * Keys an iterable of islands by name and occurrence, so that the same island + * is found again on the next page. + * + * @param {Iterable} source + * @returns {Map} + */ +export const keyIslands = source => { + const counts = new Map(); + + return new Map( + [...source].map(island => { + const name = island.getAttribute('data-island-name'); + counts.set(name, (counts.get(name) ?? 0) + 1); + + return [`${name}:${counts.get(name)}`, island]; + }) + ); +}; + +/** + * Replaces the page-specific `` elements with the next page's, leaving + * the ones both pages share in place. + * + * @param {Document} doc - The next page + */ +const updateHead = doc => { + document.title = doc.title; + + const next = new Map( + [...doc.head.querySelectorAll(PAGE_HEAD)].map(element => [ + element.outerHTML, + element, + ]) + ); + + for (const element of document.head.querySelectorAll(PAGE_HEAD)) { + // What is left in `next` afterwards is what the current page lacks + if (!next.delete(element.outerHTML)) { + element.remove(); + } + } + + document.head.append(...next.values()); +}; + +/** + * Runs a DOM update, keeping the call-site uniform for a future transition. + * + * @param {() => void} update + */ +export const transition = update => update(); + +/** + * Parses a fetched page and checks that its assets match this build. + * Returns `null` when the page belongs to a different build (e.g. after a + * new deploy) and only a full load can show it. + * + * @param {Page} page + * @param {Set} assets - Absolute hrefs of this build's scripts and stylesheets + * @returns {Document | null} + */ +export const parsePage = ({ url, html }, assets) => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const tag = doc.querySelector('script[data-router]'); + + if (!tag) { + return null; + } + + /** @type {{ root: string, assets: Array }} */ + const pageConfig = JSON.parse(tag.textContent); + + return pageConfig.assets + .map(href => new URL(href, url).href) + .every(href => assets.has(href)) + ? doc + : null; +}; + +/** + * Swaps the current page for another, preserving island scroll positions. + * + * @param {Document} doc - The next page + * @param {() => void} scroll - Scrolls to where the navigation leads + * @param {(root: Node) => void} unmount - Unmounts islands before the swap + * @param {Set} islands - The hydrated islands to save scroll for + */ +export const showPage = (doc, scroll, unmount, islands) => { + const scrolled = [...keyIslands(islands)] + .filter(([, island]) => island.scrollTop || island.scrollLeft) + .map(([key, { scrollLeft, scrollTop }]) => [key, scrollLeft, scrollTop]); + + unmount(document.body); + updateHead(doc); + document.body.replaceWith(doc.body); + + // Styles can then keep what animates in as the site loads (the banner) + // from animating again with every page + document.documentElement.setAttribute('data-navigated', ''); + + const next = keyIslands( + document.body.querySelectorAll('is-land[data-island-name]') + ); + + for (const [key, left, top] of scrolled) { + next.get(key)?.scrollTo({ left, top, behavior: 'instant' }); + } + + scroll(); +}; diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 8cf106fd6..1981c272f 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -23,17 +23,7 @@ import { ROUTER_MAX_PAGES, ROUTER_PAGE_LIFETIME, } from '../constants.mjs'; - -/** - * @typedef {{ url: string, html: string }} Page A fetched page: its final - * URL, after redirects, and its markup. - */ - -// The `` elements that belong to the page rather than to the site, and -// are replaced with it: `` tags (`og:title`) and the links that are not -// resources (`canonical`). Scripts and stylesheets run and apply once. -const PAGE_HEAD = - ':scope > meta, :scope > link:not([rel~="stylesheet"], [rel~="preload"], [rel~="modulepreload"])'; +import { fetchPage, parsePage, showPage, transition } from './page.mjs'; /** * Whether a URL is a page of the site under `root`: an HTML file, or an @@ -54,57 +44,16 @@ export const isPage = (url, root) => export const withoutFragment = href => href.split('#')[0]; /** - * Keys an iterable of islands by name and occurrence, so that the same island - * is found again on the next page. - * - * @param {Iterable} source - * @returns {Map} - */ -const keyIslands = source => { - const counts = new Map(); - - return new Map( - [...source].map(island => { - const name = island.getAttribute('data-island-name'); - counts.set(name, (counts.get(name) ?? 0) + 1); - - return [`${name}:${counts.get(name)}`, island]; - }) - ); -}; - -/** - * Replaces the page-specific `` elements with the next page's, leaving - * the ones both pages share in place. - * - * @param {Document} doc - The next page - */ -const updateHead = doc => { - document.title = doc.title; - - const next = new Map( - [...doc.head.querySelectorAll(PAGE_HEAD)].map(element => [ - element.outerHTML, - element, - ]) - ); - - for (const element of document.head.querySelectorAll(PAGE_HEAD)) { - // What is left in `next` afterwards is what the current page lacks - if (!next.delete(element.outerHTML)) { - element.remove(); - } - } - - document.head.append(...next.values()); -}; - -/** - * Runs a DOM update, keeping the call-site uniform for a future transition. + * Whether a link element is one the router can follow: an anchor that does + * not trigger a download and does not open in another browsing context. * - * @param {() => void} update + * @param {Element | null} link + * @returns {link is HTMLAnchorElement} */ -const transition = update => update(); +export const shouldFollowLink = link => + link instanceof HTMLAnchorElement && + !link.hasAttribute('download') && + (!link.target || link.target === '_self'); /** * Whether a navigation event should be intercepted by the router. @@ -146,15 +95,15 @@ export const startRouter = ({ unmount, islands }) => { config.assets.map(href => new URL(href, location.href).href) ); - /** @type {Map, expires: number }>} */ + /** @type {Map, expires: number }>} */ const pages = new Map(); /** * Fetches a page, or reuses the copy fetched moments ago. * * @param {string} url - The page's URL, without a fragment - * @returns {Promise} `null` when the response is not a page to - * show: an error, or anything but HTML. + * @returns {Promise} `null` when the + * response is not a page to show: an error, or anything but HTML. */ const loadPage = url => { const cached = pages.get(url); @@ -163,14 +112,7 @@ export const startRouter = ({ unmount, islands }) => { return cached.page; } - const page = fetch(url) - .then(async response => - response.ok && - response.headers.get('content-type')?.startsWith('text/html') - ? { url: response.url, html: await response.text() } - : null - ) - .catch(() => null); + const page = fetchPage(url); pages.delete(url); pages.set(url, { page, expires: Date.now() + ROUTER_PAGE_LIFETIME }); @@ -189,63 +131,6 @@ export const startRouter = ({ unmount, islands }) => { return page; }; - /** - * Parses a page, unless it loads scripts or stylesheets this document does - * not have: it comes from another build (such as a newer deployment), and - * only a full load can show it. - * - * @param {Page} page - * @returns {Document | null} - */ - const parsePage = ({ url, html }) => { - const doc = new DOMParser().parseFromString(html, 'text/html'); - const tag = doc.querySelector('script[data-router]'); - - if (!tag) { - return null; - } - - /** @type {{ root: string, assets: Array }} */ - const pageConfig = JSON.parse(tag.textContent); - - return pageConfig.assets - .map(href => new URL(href, url).href) - .every(href => assets.has(href)) - ? doc - : null; - }; - - /** - * Swaps the current page for another. - * - * @param {Document} doc - The next page - * @param {() => void} scroll - Scrolls to where the navigation leads - */ - const showPage = (doc, scroll) => { - // The sidebar (like any island that scrolls) stays where it was - const scrolled = [...keyIslands(islands)] - .filter(([, island]) => island.scrollTop || island.scrollLeft) - .map(([key, { scrollLeft, scrollTop }]) => [key, scrollLeft, scrollTop]); - - unmount(document.body); - updateHead(doc); - document.body.replaceWith(doc.body); - - // Styles can then keep what animates in as the site loads (the banner) - // from animating again with every page - document.documentElement.setAttribute('data-navigated', ''); - - const next = keyIslands( - document.body.querySelectorAll('is-land[data-island-name]') - ); - - for (const [key, left, top] of scrolled) { - next.get(key)?.scrollTo({ left, top, behavior: 'instant' }); - } - - scroll(); - }; - /** * The page a link leads to, when following it would be handled here. * @@ -255,11 +140,7 @@ export const startRouter = ({ unmount, islands }) => { const getLinkedPage = target => { const link = target instanceof Element ? target.closest('a[href]') : null; - if ( - !(link instanceof HTMLAnchorElement) || - link.hasAttribute('download') || - (link.target && link.target !== '_self') - ) { + if (!shouldFollowLink(link)) { return; } @@ -279,7 +160,7 @@ export const startRouter = ({ unmount, islands }) => { } event.intercept({ - // Scrolling waits for the page to be swapped in (see `showPage`) + // Scrolling waits for the page to be swapped in (see `showPage` in page.mjs) scroll: 'manual', /** @@ -292,7 +173,7 @@ export const startRouter = ({ unmount, islands }) => { return; } - const doc = page && parsePage(page); + const doc = page && parsePage(page, assets); if (!doc) { // The navigation has already moved to the page's URL, so reloading @@ -302,7 +183,7 @@ export const startRouter = ({ unmount, islands }) => { return; } - transition(() => showPage(doc, () => event.scroll())); + transition(() => showPage(doc, () => event.scroll(), unmount, islands)); }, }); }); From 0a59f6e4ee3034a8eedc816ef39569800872b2ad Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 16:48:24 +0200 Subject: [PATCH 06/14] fix(html): move router constants to ui/constants.mjs; keep node: imports out of browser bundle constants.mjs imports node:path and node:url, which break when bundled for the browser. The ROUTER_* constants added in da720ce pulled those Node.js builtins into the client bundle, silently preventing startRouter from loading and causing every client-side navigation to fall back to a full page reload. Move the constants to packages/react/src/html/ui/constants.mjs (browser-only code) and remove their exports from the Node.js-side constants.mjs. Assisted-by: Claude Sonnet 4.6 --- packages/react/src/html/constants.mjs | 9 --------- packages/react/src/html/ui/constants.mjs | 8 ++++++++ packages/react/src/html/ui/router.mjs | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 packages/react/src/html/ui/constants.mjs diff --git a/packages/react/src/html/constants.mjs b/packages/react/src/html/constants.mjs index 39eef4202..63cc661ad 100644 --- a/packages/react/src/html/constants.mjs +++ b/packages/react/src/html/constants.mjs @@ -92,12 +92,3 @@ export const FONTS = [ 'open-sans-latin-wght-italic.woff2', 'ibm-plex-mono-latin-400-normal.woff2', ]; - -// How long a hovered link waits before its page is prefetched. -export const ROUTER_HOVER_DELAY = 80; - -// How long a fetched page is reused for, whether prefetched or visited. -export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; - -// How many fetched pages are kept at once. -export const ROUTER_MAX_PAGES = 10; diff --git a/packages/react/src/html/ui/constants.mjs b/packages/react/src/html/ui/constants.mjs new file mode 100644 index 000000000..1c86502bc --- /dev/null +++ b/packages/react/src/html/ui/constants.mjs @@ -0,0 +1,8 @@ +// How long a hovered link waits before its page is prefetched. +export const ROUTER_HOVER_DELAY = 80; + +// How long a fetched page is reused for, whether prefetched or visited. +export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; + +// How many fetched pages are kept at once. +export const ROUTER_MAX_PAGES = 10; diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 1981c272f..15e57b63f 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -22,7 +22,7 @@ import { ROUTER_HOVER_DELAY, ROUTER_MAX_PAGES, ROUTER_PAGE_LIFETIME, -} from '../constants.mjs'; +} from './constants.mjs'; import { fetchPage, parsePage, showPage, transition } from './page.mjs'; /** From 6433551e34d4f60d76afd00aa459d93ff580e635 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 8 Oct 2026 21:26:25 +0200 Subject: [PATCH 07/14] Apply suggestion from @ovflowd --- docs/publishing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/publishing.md b/docs/publishing.md index 5ab4f3233..a2481b1ba 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -37,7 +37,7 @@ everything else (the pages, the search index) revalidate: Most hosts default to revalidating every file on every load instead, which costs a request per asset each time a new tab opens the site. On Vercel: -```json displayName="vercel.json" +```json { "headers": [ { From ebdd8e7b639db7f568c3c591ab5703d28f7db634 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 8 Oct 2026 22:02:23 +0200 Subject: [PATCH 08/14] fix(html): follow the host's redirect to clean URLs Assisted-by: Claude Opus 5.5 --- e2e/client-side-navigation.spec.js | 14 +++++++++++--- packages/react/src/html/ui/router.mjs | 24 ++++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/e2e/client-side-navigation.spec.js b/e2e/client-side-navigation.spec.js index 010129800..f7d28cbef 100644 --- a/e2e/client-side-navigation.spec.js +++ b/e2e/client-side-navigation.spec.js @@ -4,10 +4,17 @@ const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json'; /** * Navigates the way following a link does, and waits for the navigation to - * finish. + * finish, or for the one replacing it when the router follows a redirect. */ const navigate = (page, url) => - page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url); + page.evaluate( + url => + navigation + .navigate(url) + .finished.catch(() => navigation.transition?.finished) + .then(() => {}), + url + ); test.describe('Client-side navigation', () => { test.beforeEach(async ({ page }) => { @@ -42,7 +49,8 @@ test.describe('Client-side navigation', () => { await navigate(page, 'all.html'); - await expect(page).toHaveURL(/\/all\.html$/); + // `serve` redirects `all.html` to `all`, as hosts with clean URLs do + await expect(page).toHaveURL(/\/all$/); await expect(page).toHaveTitle(/^All \|/); await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( 'content', diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 15e57b63f..3241ddca4 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -113,19 +113,26 @@ export const startRouter = ({ unmount, islands }) => { } const page = fetchPage(url); + const entry = { page, expires: Date.now() + ROUTER_PAGE_LIFETIME }; pages.delete(url); - pages.set(url, { page, expires: Date.now() + ROUTER_PAGE_LIFETIME }); + pages.set(url, entry); - if (pages.size > ROUTER_MAX_PAGES) { + // Redirected pages take two entries (below) + while (pages.size > ROUTER_MAX_PAGES) { pages.delete(pages.keys().next().value); } - // A failure is not kept, so the next attempt fetches again page.then(result => { + // A failure is not kept, so the next attempt fetches again if (!result && pages.get(url)?.page === page) { pages.delete(url); } + + // Following the redirect (see the handler) then needs no second fetch + if (result && result.url !== url) { + pages.set(result.url, entry); + } }); return page; @@ -167,12 +174,21 @@ export const startRouter = ({ unmount, islands }) => { * Swaps in the page the navigation leads to. */ async handler() { - const page = await loadPage(withoutFragment(url.href)); + const href = withoutFragment(url.href); + const page = await loadPage(href); if (event.signal.aborted) { return; } + // Hosts with clean URLs redirect `fs.html` to `fs`: follow the redirect + // as a full load would, replacing this navigation's history entry + if (page && page.url !== href) { + navigation.navigate(page.url + url.hash, { history: 'replace' }); + + return; + } + const doc = page && parsePage(page, assets); if (!doc) { From ddfd9a6823a1310adcfc750cc3b380fe7bc18f89 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 8 Oct 2026 23:31:38 +0200 Subject: [PATCH 09/14] refactor(html): move PAGE_HEAD to the constants, destructure URL hrefs Assisted-by: Claude Opus 5.5 --- packages/react/src/html/ui/constants.mjs | 6 ++++++ packages/react/src/html/ui/page.mjs | 12 ++++-------- packages/react/src/html/ui/router.mjs | 6 ++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/react/src/html/ui/constants.mjs b/packages/react/src/html/ui/constants.mjs index 1c86502bc..0cf7ef440 100644 --- a/packages/react/src/html/ui/constants.mjs +++ b/packages/react/src/html/ui/constants.mjs @@ -6,3 +6,9 @@ export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; // How many fetched pages are kept at once. export const ROUTER_MAX_PAGES = 10; + +// The `` elements that belong to the page rather than to the site, and +// are replaced with it: `` tags (`og:title`) and the links that are not +// resources (`canonical`). Scripts and stylesheets run and apply once. +export const PAGE_HEAD = + ':scope > meta, :scope > link:not([rel~="stylesheet"], [rel~="preload"], [rel~="modulepreload"])'; diff --git a/packages/react/src/html/ui/page.mjs b/packages/react/src/html/ui/page.mjs index 3c3428cad..24916d6ed 100644 --- a/packages/react/src/html/ui/page.mjs +++ b/packages/react/src/html/ui/page.mjs @@ -3,17 +3,13 @@ * the document body and page-specific head elements. */ +import { PAGE_HEAD } from './constants.mjs'; + /** * @typedef {{ url: string, html: string }} Page A fetched page: its final * URL, after redirects, and its markup. */ -// The `` elements that belong to the page rather than to the site, and -// are replaced with it: `` tags (`og:title`) and the links that are not -// resources (`canonical`). Scripts and stylesheets run and apply once. -const PAGE_HEAD = - ':scope > meta, :scope > link:not([rel~="stylesheet"], [rel~="preload"], [rel~="modulepreload"])'; - /** * Fetches a page, returning its final URL and HTML, or `null` on failure or a * non-HTML response. @@ -105,8 +101,8 @@ export const parsePage = ({ url, html }, assets) => { const pageConfig = JSON.parse(tag.textContent); return pageConfig.assets - .map(href => new URL(href, url).href) - .every(href => assets.has(href)) + .map(asset => new URL(asset, url)) + .every(({ href }) => assets.has(href)) ? doc : null; }; diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 3241ddca4..8b062fbde 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -90,9 +90,11 @@ export const startRouter = ({ unmount, islands }) => { /** @type {{ root: string, assets: Array }} */ const config = JSON.parse(tag.textContent); - const root = new URL(config.root, location.href).href; + const { href: root } = new URL(config.root, location.href); const assets = new Set( - config.assets.map(href => new URL(href, location.href).href) + config.assets + .map(asset => new URL(asset, location.href)) + .map(({ href }) => href) ); /** @type {Map, expires: number }>} */ From 3f9fe977ef756a4efb99f230d069ffe1e8a05bc6 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Fri, 9 Oct 2026 13:42:39 +0200 Subject: [PATCH 10/14] fix(html): keep the URL until the page arrives, then move it to where the host redirects Assisted-by: Claude Opus 5.5 --- e2e/client-side-navigation.spec.js | 22 +++++++++++-------- packages/react/src/html/ui/router.mjs | 31 ++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/e2e/client-side-navigation.spec.js b/e2e/client-side-navigation.spec.js index f7d28cbef..d6de24de5 100644 --- a/e2e/client-side-navigation.spec.js +++ b/e2e/client-side-navigation.spec.js @@ -4,17 +4,10 @@ const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json'; /** * Navigates the way following a link does, and waits for the navigation to - * finish, or for the one replacing it when the router follows a redirect. + * finish. */ const navigate = (page, url) => - page.evaluate( - url => - navigation - .navigate(url) - .finished.catch(() => navigation.transition?.finished) - .then(() => {}), - url - ); + page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url); test.describe('Client-side navigation', () => { test.beforeEach(async ({ page }) => { @@ -62,6 +55,17 @@ test.describe('Client-side navigation', () => { expect(requests.filter(url => loaded.includes(url))).toEqual([]); }); + test('moves the URL straight to the one the host redirects to', async ({ + page, + }) => { + // `serve` redirects `all.html` to `all`, as hosts with clean URLs do + const committed = await page.evaluate(() => + navigation.navigate('all.html').committed.then(({ url }) => url) + ); + + expect(committed).toMatch(/\/all$/); + }); + test('goes back to the previous page, where it was scrolled to', async ({ page, }) => { diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index 8b062fbde..f67218eb5 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -168,7 +168,29 @@ export const startRouter = ({ unmount, islands }) => { return; } + const href = withoutFragment(url.href); + const loading = loadPage(href); + + /** + * Holds the URL back until the page arrives, then moves it straight to + * the one the page was served from: hosts with clean URLs redirect + * `fs.html` to `fs`, and a full load shows `fs` without `fs.html` first. + * + * @param {NavigationPrecommitController} controller + */ + const precommitHandler = async controller => { + const page = await loading; + + if (page && page.url !== href) { + controller.redirect(page.url + url.hash); + } + }; + event.intercept({ + // Traversals go back to URLs shown already, which cannot be redirected + precommitHandler: + event.navigationType === 'traverse' ? undefined : precommitHandler, + // Scrolling waits for the page to be swapped in (see `showPage` in page.mjs) scroll: 'manual', @@ -176,16 +198,15 @@ export const startRouter = ({ unmount, islands }) => { * Swaps in the page the navigation leads to. */ async handler() { - const href = withoutFragment(url.href); - const page = await loadPage(href); + const page = await loading; if (event.signal.aborted) { return; } - // Hosts with clean URLs redirect `fs.html` to `fs`: follow the redirect - // as a full load would, replacing this navigation's history entry - if (page && page.url !== href) { + // Browsers without `precommitHandler` (Safari) show the link's URL + // right away: follow the redirect from there, replacing its entry + if (page && page.url !== withoutFragment(location.href)) { navigation.navigate(page.url + url.hash, { history: 'replace' }); return; From e275afa592d89079a8f4021ed248d526d7ee210a Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Fri, 9 Oct 2026 13:42:42 +0200 Subject: [PATCH 11/14] refactor(html): share attribute names and the page pattern as constants, drop transition() Also gives router variables distinct names, documents the event listeners, gives buildAssetTags a block body, and drops the README's cross-fade sentence, untrue since view transitions were removed. Assisted-by: Claude Opus 5.5 --- packages/react/src/html/README.md | 3 +- packages/react/src/html/ui/constants.mjs | 37 +++- .../react/src/html/ui/islands/runtime.mjs | 3 +- .../react/src/html/ui/islands/withIsland.jsx | 8 +- packages/react/src/html/ui/page.mjs | 38 ++-- packages/react/src/html/ui/router.mjs | 176 ++++++++++-------- packages/react/src/html/utils/processing.mjs | 18 +- 7 files changed, 169 insertions(+), 114 deletions(-) diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md index cdb7dbc9e..caa8610f6 100644 --- a/packages/react/src/html/README.md +++ b/packages/react/src/html/README.md @@ -521,8 +521,7 @@ following a link to another page fetches that page and swaps it into the current document instead of loading a new one. Scripts, stylesheets and fonts stay loaded, the search index and the remote config are fetched once per visit, and the sidebar keeps its scroll position. Back and forward, scroll restoration -and focus behave as they do for full loads, and the old page cross-fades into -the new one where view transitions are supported. +and focus behave as they do for full loads. Pages are prefetched into memory when a link is hovered (unless the browser asks to save data) or pressed, so most navigations do not wait on the network. diff --git a/packages/react/src/html/ui/constants.mjs b/packages/react/src/html/ui/constants.mjs index 0cf7ef440..b41a39e23 100644 --- a/packages/react/src/html/ui/constants.mjs +++ b/packages/react/src/html/ui/constants.mjs @@ -1,14 +1,39 @@ -// How long a hovered link waits before its page is prefetched. +/** How long a hovered link waits before its page is prefetched. */ export const ROUTER_HOVER_DELAY = 80; -// How long a fetched page is reused for, whether prefetched or visited. +/** How long a fetched page is reused for, whether prefetched or visited. */ export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; -// How many fetched pages are kept at once. +/** How many fetched pages are kept at once. */ export const ROUTER_MAX_PAGES = 10; -// The `` elements that belong to the page rather than to the site, and -// are replaced with it: `` tags (`og:title`) and the links that are not -// resources (`canonical`). Scripts and stylesheets run and apply once. +/** + * The attribute of the ``, - ], +export const buildAssetTags = ({ scripts, preloads, stylesheets }, root) => { + const routerData = JSON.stringify({ + root, + assets: [...scripts, ...stylesheets].map(file => `${root}${file}`), + }); + + return [ + ``, scripts.map( file => `` ), @@ -199,6 +200,7 @@ export const buildAssetTags = ({ scripts, preloads, stylesheets }, root) => ] .flat() .join('\n '); +}; /** * The output file of a page, relative to the output directory. From 85949b010686c657545ef6a07a18e7611d8784db Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Fri, 9 Oct 2026 13:42:43 +0200 Subject: [PATCH 12/14] docs: restore the vercel.json code block label doc-kit renders `displayName` as the code box title. Assisted-by: Claude Opus 5.5 --- docs/publishing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/publishing.md b/docs/publishing.md index a2481b1ba..5ab4f3233 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -37,7 +37,7 @@ everything else (the pages, the search index) revalidate: Most hosts default to revalidating every file on every load instead, which costs a request per asset each time a new tab opens the site. On Vercel: -```json +```json displayName="vercel.json" { "headers": [ { From 94183005a506313af00f7d5f52235dd33f444b25 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Fri, 9 Oct 2026 14:18:39 +0200 Subject: [PATCH 13/14] refactor(html): await an island's component on its own line Assisted-by: Claude Opus 5.5 --- packages/react/src/html/ui/islands/runtime.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/html/ui/islands/runtime.mjs b/packages/react/src/html/ui/islands/runtime.mjs index 91aacb663..ba432705c 100644 --- a/packages/react/src/html/ui/islands/runtime.mjs +++ b/packages/react/src/html/ui/islands/runtime.mjs @@ -89,7 +89,9 @@ Island.addInitType('preact', async island => { try { if (!loaded.has(name)) { - loaded.set(name, (await loader()).default); + const { default: component } = await loader(); + + loaded.set(name, component); } // A client-side navigation can replace the page while its component loads From 2da3404d9286f2e82f3798f51ac57bbcf9b85973 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Fri, 9 Oct 2026 14:39:17 +0200 Subject: [PATCH 14/14] fix(html): free fetched pages when they expire, and count the cache limit in pages Assisted-by: Claude Opus 5.5 --- packages/react/src/html/ui/constants.mjs | 2 +- packages/react/src/html/ui/router.mjs | 63 +++++++++++++++++------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/react/src/html/ui/constants.mjs b/packages/react/src/html/ui/constants.mjs index b41a39e23..5a42a377b 100644 --- a/packages/react/src/html/ui/constants.mjs +++ b/packages/react/src/html/ui/constants.mjs @@ -1,7 +1,7 @@ /** How long a hovered link waits before its page is prefetched. */ export const ROUTER_HOVER_DELAY = 80; -/** How long a fetched page is reused for, whether prefetched or visited. */ +/** How long a fetched page is kept for reuse, whether prefetched or visited. */ export const ROUTER_PAGE_LIFETIME = 5 * 60 * 1000; /** How many fetched pages are kept at once. */ diff --git a/packages/react/src/html/ui/router.mjs b/packages/react/src/html/ui/router.mjs index fe6b2b2ea..8acf5bba1 100644 --- a/packages/react/src/html/ui/router.mjs +++ b/packages/react/src/html/ui/router.mjs @@ -98,9 +98,37 @@ export const startRouter = ({ unmount, islands }) => { .map(({ href }) => href) ); - /** @type {Map, expires: number }>} */ + /** + * @typedef {object} Entry A fetched page, kept for reuse. + * @property {Promise} page + * @property {ReturnType} [expiry] - Drops the page once + * it is too old to reuse + */ + + /** + * The pages fetched lately, by URL, oldest first. A page the host redirected + * is kept under the URL it was fetched from as well. + * + * @type {Map} + */ const pages = new Map(); + /** + * Drops a page from memory, under every URL it is kept under. + * + * @param {Entry} entry + */ + const forget = entry => { + // A pending expiry would otherwise hold on to the page until it runs + clearTimeout(entry.expiry); + + for (const [href, kept] of pages) { + if (kept === entry) { + pages.delete(href); + } + } + }; + /** * Fetches a page, or reuses the copy fetched moments ago. * @@ -111,34 +139,35 @@ export const startRouter = ({ unmount, islands }) => { const loadPage = href => { const cached = pages.get(href); - if (cached && cached.expires > Date.now()) { + if (cached) { return cached.page; } - const page = fetchPage(href); - const entry = { page, expires: Date.now() + ROUTER_PAGE_LIFETIME }; + /** @type {Entry} */ + const entry = { page: fetchPage(href) }; - pages.delete(href); + entry.expiry = setTimeout(forget, ROUTER_PAGE_LIFETIME, entry); pages.set(href, entry); - // Redirected pages take two entries (below) - while (pages.size > ROUTER_MAX_PAGES) { - pages.delete(pages.keys().next().value); - } + // The limit counts pages, not the URLs they are kept under + const kept = new Set(pages.values()); - page.then(result => { - // A failure is not kept, so the next attempt fetches again - if (!result && pages.get(href)?.page === page) { - pages.delete(href); - } + if (kept.size > ROUTER_MAX_PAGES) { + forget(kept.values().next().value); + } - // Following the redirect (see the handler) then needs no second fetch - if (result && result.url !== href) { + entry.page.then(result => { + if (!result) { + // A failure is not kept, so the next attempt fetches again + forget(entry); + } else if (result.url !== href && pages.get(href) === entry) { + // Following the redirect (see the handler) then needs no second fetch + pages.delete(result.url); pages.set(result.url, entry); } }); - return page; + return entry.page; }; /**