Skip to content

Commit 122ac33

Browse files
ovflowdclaude
andcommitted
feat(html): client-side navigation with the Navigation API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6c484cb commit 122ac33

20 files changed

Lines changed: 987 additions & 123 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@doc-kit/generator-react': minor
3+
---
4+
5+
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

‎docs/publishing.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,39 @@ convention). Two things to know about the result:
2323
index alongside the pages by targeting both generators — `target: ['html',
2424
'orama-db']`, so the search box has data to query.
2525

26+
## Cache the assets
27+
28+
Every file the `html` generator writes to `assets/` (scripts, stylesheets,
29+
fonts) is named after a hash of its content, so a changed file always gets a
30+
new name. Serve that directory with a long-lived, immutable cache, and let
31+
everything else (the pages, the search index) revalidate:
32+
33+
```
34+
/assets/* Cache-Control: public, max-age=31536000, immutable
35+
```
36+
37+
Most hosts default to revalidating every file on every load instead, which
38+
costs a request per asset each time a new tab opens the site. On Vercel:
39+
40+
```json displayName="vercel.json"
41+
{
42+
"headers": [
43+
{
44+
"source": "/assets/(.*)",
45+
"headers": [
46+
{
47+
"key": "Cache-Control",
48+
"value": "public, max-age=31536000, immutable"
49+
}
50+
]
51+
}
52+
]
53+
}
54+
```
55+
56+
Within a visit, moving between pages loads no assets at all: the site
57+
navigates client-side, swapping in the next page's content.
58+
2659
## Tell doc-kit its public URL
2760

2861
Set `baseURL` to where the site will live. Generators that emit absolute

‎e2e/client-side-navigation.spec.js‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json';
4+
5+
/**
6+
* Navigates the way following a link does, and waits for the navigation to
7+
* finish.
8+
*/
9+
const navigate = (page, url) =>
10+
page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url);
11+
12+
test.describe('Client-side navigation', () => {
13+
test.beforeEach(async ({ page }) => {
14+
await page.route(REMOTE_CONFIG_URL, route =>
15+
route.fulfill({
16+
contentType: 'application/json',
17+
body: JSON.stringify({
18+
websiteBanners: { index: { text: 'Important announcement' } },
19+
}),
20+
})
21+
);
22+
23+
await page.goto('/assert.html');
24+
25+
// A full load would start a new document, and lose this
26+
await page.evaluate(() => (window.__document = 'first'));
27+
});
28+
29+
test('swaps the next page in without loading assets again', async ({
30+
page,
31+
}) => {
32+
const loaded = await page.evaluate(() =>
33+
[
34+
...document.querySelectorAll(
35+
'script[src], link[rel="stylesheet"], link[as="font"]'
36+
),
37+
].map(element => element.src || element.href)
38+
);
39+
40+
const requests = [];
41+
page.on('request', request => requests.push(request.url()));
42+
43+
await navigate(page, 'all.html');
44+
45+
await expect(page).toHaveURL(/\/all\.html$/);
46+
await expect(page).toHaveTitle(/^All \|/);
47+
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
48+
'content',
49+
/^All \|/
50+
);
51+
expect(await page.evaluate(() => window.__document)).toBe('first');
52+
53+
// The scripts, stylesheets and fonts are still loaded
54+
expect(requests.filter(url => loaded.includes(url))).toEqual([]);
55+
});
56+
57+
test('goes back to the previous page, where it was scrolled to', async ({
58+
page,
59+
}) => {
60+
await page.evaluate(() => scrollTo(0, 2000));
61+
await navigate(page, 'all.html');
62+
await page.evaluate(() => navigation.back().finished.then(() => {}));
63+
64+
// Hosts with clean URLs redirect the first page to one without `.html`
65+
await expect(page).toHaveURL(/\/assert(\.html)?$/);
66+
await expect(page).toHaveTitle(/^Assert \|/);
67+
expect(await page.evaluate(() => scrollY)).toBe(2000);
68+
expect(await page.evaluate(() => window.__document)).toBe('first');
69+
});
70+
71+
test('prefetches a page as its link is hovered', async ({ page }) => {
72+
await page.evaluate(() =>
73+
document
74+
.querySelector('main')
75+
.insertAdjacentHTML('beforeend', '<a id="all" href="all.html">All</a>')
76+
);
77+
78+
// Hosts with clean URLs answer the prefetch with a redirect first
79+
const prefetched = page.waitForResponse(
80+
response => /\/all(\.html)?$/.test(response.url()) && response.ok()
81+
);
82+
83+
await page.hover('#all');
84+
await prefetched;
85+
86+
const requests = [];
87+
page.on('request', request => requests.push(request.url()));
88+
89+
await page.click('#all');
90+
91+
await expect(page).toHaveTitle(/^All \|/);
92+
expect(requests).toEqual([]);
93+
});
94+
95+
test('keeps the remote config, and shows its banner at once', async ({
96+
page,
97+
}) => {
98+
const banner = page.getByRole('region', { name: 'Announcement' });
99+
await expect(banner).toBeVisible();
100+
101+
let fetched = 0;
102+
103+
await page.route(REMOTE_CONFIG_URL, route => {
104+
fetched++;
105+
return route.fallback();
106+
});
107+
108+
await navigate(page, 'all.html');
109+
110+
await expect(banner).toBeVisible();
111+
// It animates in on the first page only
112+
await expect(banner).toHaveCSS('animation-name', 'none');
113+
expect(fetched).toBe(0);
114+
});
115+
116+
test('leaves links to files that are not pages to the browser', async ({
117+
page,
118+
}) => {
119+
await page.getByRole('link', { name: 'JSON' }).click();
120+
121+
await expect(page).toHaveURL(/\/assert\.json$/);
122+
expect(await page.evaluate(() => window.__document)).toBeUndefined();
123+
});
124+
});

‎eslint.config.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export default defineConfig([
9898
files: [
9999
'packages/node-legacy/src/legacy-html/assets/*.js',
100100
'packages/react/src/html/ui/**/*',
101+
// End-to-end specs run code in the page through `page.evaluate`
102+
'e2e/**/*.spec.js',
101103
],
102104
languageOptions: {
103105
globals: {

‎packages/react/src/html/README.md‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,12 @@ generator's `constants.mjs`), so that the page and the library share one Preact.
215215
`buildClient` receives `{ entry, virtualImports, config }`. The client `entry`
216216
is a single program shared by every page. It must be bundled into
217217
`config.output` and the call must return
218-
`{ scripts, preloads, stylesheets }`: paths relative to the output root of the
219-
module scripts to load, the chunks they statically import (rendered as
220-
`modulepreload` hints), and the stylesheets. The generator renders those into
221-
every page, resolved against the page's location.
218+
`{ scripts, preloads, stylesheets, fonts }`: paths relative to the output root
219+
of the module scripts to load, the chunks they statically import (rendered as
220+
`modulepreload` hints), the stylesheets, and optionally the fonts to preload.
221+
The generator renders those into every page, resolved against the page's
222+
location. Name every file after its content (a hash), so hosts can cache them
223+
indefinitely (see [Publishing](../../../docs/publishing.md#cache-the-assets)).
222224
223225
`config` is the resolved `html` configuration. The adapter must compile the
224226
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
309311
never the HTML pages. Customize the pages through the
310312
[HTML template](#html-template) instead.
311313
312-
The adapter reads the client asset names from Vite's manifest. A manifest is
313-
written either way; pass `build: { manifest: true }` (or a file name) to
314-
`createViteBundler` to keep it in the output for another tool.
314+
The adapter reads the client asset names from Vite's manifest, including the
315+
hashed names of the fonts to preload. A manifest is written either way; pass
316+
`build: { manifest: true }` (or a file name) to `createViteBundler` to keep it
317+
in the output for another tool.
315318
316319
The adapter is only ever used on the main thread, so function-valued plugins
317320
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
488491
- `dehydrated` {string} Server-rendered HTML for the page content.
489492
- `assets` {string} The `<script>` and `<link>` tags loading the client
490493
assets, resolved against this page's location.
491-
- `speculationRules` {string} Speculation rules JSON for prefetching.
494+
- `preloads` {string} The preload hints for the fonts the bundler reported.
495+
- `speculationRules` {string} Speculation rules JSON that prefetches the
496+
same-origin links leaving the site when they are hovered or pressed. Links
497+
within the site navigate client-side instead (see
498+
[Client-side navigation](#client-side-navigation)).
492499
- `themeScript` {string} Inline script that applies the saved theme before paint.
493500
- `root` {string} Relative or absolute path to the site root.
494501
- `metadata` {Object} Full page metadata (frontmatter, path, heading, etc.).
@@ -505,3 +512,38 @@ Since the template supports arbitrary JS expressions, you can use conditionals a
505512
The populated page is the final HTML: it is minified when `minify` is set and
506513
written as is. Put `${assets}` in the `<head>`, or the page loads no script and
507514
no stylesheet.
515+
516+
## Client-side navigation
517+
518+
The site moves between its own pages client-side, through the
519+
[Navigation API](https://developer.mozilla.org/docs/Web/API/Navigation_API):
520+
following a link to another page fetches that page and swaps it into the
521+
current document instead of loading a new one. Scripts, stylesheets and fonts
522+
stay loaded, the search index and the remote config are fetched once per visit,
523+
and the sidebar keeps its scroll position. Back and forward, scroll restoration
524+
and focus behave as they do for full loads, and the old page cross-fades into
525+
the new one where view transitions are supported.
526+
527+
Pages are prefetched into memory when a link is hovered (unless the browser
528+
asks to save data) or pressed, so most navigations do not wait on the network.
529+
530+
Everything else is a regular navigation: links outside the site (including
531+
other versions of the docs), files that are not pages (such as the JSON and
532+
Markdown renderings), and every navigation in a browser without the Navigation
533+
API. So is a page that loads scripts or stylesheets the current one did not,
534+
such as one from a newer deployment.
535+
536+
When customizing the site, keep in mind that:
537+
538+
- The whole `<body>` is replaced, and scripts in it do not run. Of the
539+
`<head>`, only the `<title>` and the page-specific tags follow the page:
540+
`<meta>` tags, and `<link>` tags other than stylesheets and preloads (such as
541+
`canonical`).
542+
- Islands are unmounted and hydrated again on every page. Anything shared
543+
across pages belongs in module scope, which lasts for the whole visit. An
544+
island that scrolls keeps its position when the next page renders it too.
545+
- Once the document has navigated client-side, the `<html>` element has a
546+
`data-navigated` attribute, for styles that only belong on the first page,
547+
such as entry animations.
548+
- Analytics can count client-side page views through the Navigation API's
549+
`navigatesuccess` event.

‎packages/react/src/html/__tests__/generate.test.mjs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import buildContent from '../../jsx-ast/utils/buildContent.mjs';
1212
import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs';
1313
import { generate as chunk } from '../../section-pages/generate.mjs';
1414
import { compile, createViteBundler } from '../bundlers/vite.mjs';
15+
import { FONTS } from '../constants.mjs';
1516
import { generate } from '../generate.mjs';
1617

1718
/**
@@ -105,6 +106,18 @@ describe('web generate', () => {
105106
assert.match(fsHTML, /on:idle[^>]*data-island-name=SearchBox/);
106107
// The manifest the asset tags were read from does not ship
107108
assert.equal((await readdir(output)).includes('.vite'), false);
109+
110+
// Fonts are hashed like every other asset, and preloaded from where the
111+
// bundler actually wrote them
112+
const fonts = [...fsHTML.matchAll(/href=\.\.\/(assets\/[^ ]+\.woff2)/g)];
113+
const written = await readdir(join(output, 'assets'));
114+
115+
assert.strictEqual(fonts.length, FONTS.length);
116+
117+
for (const [, font] of fonts) {
118+
assert.match(font, /-[\w-]{8}\.woff2$/);
119+
assert.ok(written.includes(font.slice('assets/'.length)), font);
120+
}
108121
});
109122

110123
it('assembles all.html from the module pages, in sidebar order', async context => {
@@ -347,8 +360,10 @@ describe('web generate', () => {
347360
assert.match(html, /<article data-custom-ssr>fs:1<\/article>/);
348361
assert.match(
349362
html,
350-
/<script type=module crossorigin src=\.\/custom\/index\.js>/
363+
/<script type=module crossorigin src=\.\/custom\/index\.js data-root=\.\/>/
351364
);
365+
// The adapter reported no fonts, so there are none to preload
366+
assert.doesNotMatch(html, /as=font/);
352367
assert.match(
353368
html,
354369
/<link rel=modulepreload crossorigin href=\.\/custom\/shared\.js>/

‎packages/react/src/html/bundlers/vite.mjs‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFile, rm, rmdir } from 'node:fs/promises';
2-
import { dirname, join, resolve } from 'node:path';
2+
import { dirname, join, posix, resolve } from 'node:path';
33
import { fileURLToPath, pathToFileURL } from 'node:url';
44

55
import {
@@ -10,7 +10,7 @@ import {
1010
transformWithOxc,
1111
} from 'vite';
1212

13-
import { FONT_DIRECTORY, JSX_PRAGMA, JSX_PRAGMA_FRAG } from '../constants.mjs';
13+
import { FONTS, JSX_PRAGMA, JSX_PRAGMA_FRAG } from '../constants.mjs';
1414

1515
const VIRTUAL_PREFIX = 'virtual:doc-kit/';
1616
const RESOLVED_VIRTUAL_PREFIX = '\0doc-kit:';
@@ -234,16 +234,10 @@ export const createViteConfig = ({
234234
...vite.build?.rolldownOptions?.output,
235235
format: 'es',
236236

237-
/**
238-
* Determine the asset names for different files
239-
*/
240-
assetFileNames: asset =>
241-
asset.names.some(name => name.endsWith('.woff2'))
242-
? // We need to know where the fonts are to preload
243-
// them. Using a dynamic hash would make this
244-
// difficult.
245-
`${FONT_DIRECTORY}/[name][extname]`
246-
: 'assets/[name]-[hash][extname]',
237+
// Every emitted file, fonts included, is named after its content, so
238+
// hosts can cache `assets/` indefinitely. The fonts to preload are
239+
// found through the manifest (see `buildClient`).
240+
assetFileNames: 'assets/[name]-[hash][extname]',
247241

248242
...(server
249243
? {
@@ -351,6 +345,15 @@ const collectImports = (manifest, chunk, seen = new Set()) => {
351345
return [...seen].map(key => manifest[key].file);
352346
};
353347

348+
/**
349+
* Whether a manifest entry is one of the fonts to preload. Fonts get a manifest
350+
* entry of their own, under the source file they were emitted from.
351+
*
352+
* @param {{ src: string }} entry
353+
* @returns {boolean}
354+
*/
355+
const isPreloadedFont = ({ src }) => FONTS.includes(posix.basename(src));
356+
354357
/**
355358
* Bundles the client entry into the site and reads back, from Vite's
356359
* manifest, the assets every page has to load.
@@ -411,10 +414,16 @@ export const buildClient = async ({
411414
.map(({ file }) => file)
412415
.filter(file => file.endsWith('.css'));
413416

417+
const fonts = entries
418+
.filter(({ src }) => src)
419+
.filter(isPreloadedFont)
420+
.map(({ file }) => file);
421+
414422
return {
415423
scripts: [chunk.file],
416424
preloads: collectImports(manifest, chunk),
417425
stylesheets: [...new Set(stylesheets)],
426+
fonts,
418427
};
419428
};
420429

0 commit comments

Comments
 (0)