From 7ad849f91f5b7876ca3b55764846247ae45357d4 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Wed, 30 Sep 2026 10:49:33 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] =?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 5/6] 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 6/6] 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'; /**