Skip to content

feat(html): client-side navigation with the Navigation API - #1117

Open
ovflowd wants to merge 6 commits into
mainfrom
feat/client-side-navigation
Open

ovflowd wants to merge 6 commits into
mainfrom
feat/client-side-navigation

Conversation

@ovflowd

@ovflowd ovflowd commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Description

This PR adds client-side navigation: clicking a link no longer loads a new page from scratch. Instead, the browser fetches just the HTML for the next page, swaps the <body> in, and keeps running — scripts, stylesheets, fonts, and in-memory data all stay loaded.

Without this, every click on a doc-kit site is expensive. With max-age=0, must-revalidate, the browser has to revalidate every asset on each page load: on doc-kit.nodejs.org that's a 308 redirect (.html → clean URL) plus 27 round-trips before anything renders.

How the intercept works. Modern browsers fire a navigate event before leaving a page. The router calls event.intercept() to handle the navigation itself — it fetches the target page's HTML, swaps the <body> and page-level <head> tags, and calls event.scroll() when done. The browser still manages history entries, back/forward, scroll restoration, focus, and the loading indicator. The router just handles the DOM swap.

What changed

Router (ui/router.mjs)

  • Intercepts same-site page navigations via the Navigation API (Baseline since January 2026)
  • Prefetches pages on hover (80 ms delay) and on pointer-down, so most clicks never wait on the network
  • Falls back to a full browser load for: links to other sites or builds, files that aren't pages (.json, .md), browsers without the Navigation API, and pages from a different deploy (mismatched asset hashes)
  • The router's scope and asset list are embedded by the generator as a <script type="application/json" data-router> tag — no DOM queries needed at startup

Island lifecycle

  • Islands are unmounted before the body swap so their effects clean up (the search box's ⌘K listener used to pile up across navigations)
  • Already-loaded components hydrate synchronously before the next paint — no flash of unhydrated content on navigated pages
  • The banner's slide-in animation only runs on the first page load; <html data-navigated> suppresses it on subsequent ones

Shared data

  • The Orama index (9.4 MB on the Node.js docs) is fetched and loaded once per visit, reused across all pages
  • The remote config is fetched once per visit and shared across all islands (previously fetched once per island — twice per page)

Speculation rules

Scope changed from "prefetch all same-origin links" to "prefetch same-origin links that leave the site". Speculative prefetch only benefits full-document navigations; for pages the router handles, it would just double-download them.

Asset caching

Fonts are now content-hashed like every other asset (preload URLs come from Vite's manifest). All of assets/ can be served immutable. vercel.json is updated; docs/publishing.md documents the pattern for other hosts.

Validation

Built the Node.js v26.10.0 docs (section-pages + orama-db) and clicked around:

Before After
Requests per click 1 redirect + 27 asset revalidations 1 HTML fetch, or none if the link was hovered
Orama index re-fetched on every page once per visit
  • Back/forward restores scroll position; #fragment links land on the right element
  • The sidebar keeps its scroll position and active item across page depths (fs.html → fs/promises-api.html → ../path.html)
  • Added e2e/client-side-navigation.spec.js: no asset refetches, scroll restoration, hover prefetch, shared remote config, and non-page links falling through to the browser
  • Unit tests for the router's URL rules and shouldIntercept, speculation rules scope, and hashed font preloads

cleanUrls on Vercel still turns every .html link into a 308 (~70 ms per page fetch) — worth a follow-up.


Check List

  • I have read the Contributing Guidelines and made commit messages that follow the guideline.
  • I have run node --run test and all tests passed.
  • I have check code formatting with node --run format:check & node --run lint.
  • I've covered new added functionality with unit tests if necessary.

Refs: #459

@ovflowd
ovflowd requested a review from a team as a code owner September 30, 2026 08:50
@vercel

vercel Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
api-docs-tooling Ready Ready Preview Sep 30, 2026 2:49pm UTC

Request Review

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Failed ❌

View logs ↗
6acb976 2026-09-30T15:42:37.571Z View logs ↗
  • Build: Failed ❌

View logs ↗
65e547d 2026-09-30T14:47:35.426Z View logs ↗
  • Build: Failed ❌

View logs ↗
faf7afb 2026-09-30T14:30:58.209Z View logs ↗
  • Build: Failed ❌

View logs ↗
da720ce 2026-09-30T14:11:10.862Z View logs ↗
  • Build: Failed ❌

View logs ↗
790f66b 2026-09-30T12:19:17.012Z View logs ↗
  • Build: Failed ❌

View logs ↗
a411d86 2026-09-30T10:07:48.352Z View logs ↗
  • Build: Failed ❌

View logs ↗
e7ed0e0 2026-09-30T09:55:48.642Z View logs ↗
  • Build: Failed ❌

View logs ↗
122ac33 2026-09-30T08:50:44.194Z View logs ↗

@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.64%. Comparing base (6c484cb) to head (6acb976).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1117      +/-   ##
==========================================
+ Coverage   91.38%   92.64%   +1.25%     
==========================================
  Files         249      244       -5     
  Lines       23253    23121     -132     
  Branches     2244     2260      +16     
==========================================
+ Hits        21250    21420     +170     
+ Misses       1994     1692     -302     
  Partials        9        9              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

api-links Generator

Performance estimate (single CI run)

  • Generation time: 58.9% slower (900.00 ms → 1.43 s)
  • Peak memory: 4.1% lower (438.55 MB → 420.37 MB)

json Generator

Performance estimate (single CI run)

  • Generation time: 33.1% slower (6.94 s → 9.24 s)
  • Peak memory: 0.1% lower (1.70 GB → 1.70 GB)

legacy-html Generator

Performance estimate (single CI run)

  • Generation time: 5.2% slower (31.31 s → 32.95 s)
  • Peak memory: 7.7% lower (2.55 GB → 2.36 GB)

legacy-json Generator

Performance estimate (single CI run)

  • Generation time: 6.4% faster (8.72 s → 8.16 s)
  • Peak memory: 10.0% higher (1.62 GB → 1.78 GB)

llms-txt Generator

Performance estimate (single CI run)

  • Generation time: 1.9% faster (7.82 s → 7.67 s)
  • Peak memory: 5.6% higher (1.58 GB → 1.67 GB)

orama-db Generator

Output size: 1 file changed · net +152.00 B

File size details
File Main PR Change
orama-db.json 9.55 MB 9.55 MB +152.00 B (+0.0%)

Performance estimate (single CI run)

  • Generation time: 10.9% slower (8.52 s → 9.45 s)
  • Peak memory: 7.6% lower (1.77 GB → 1.63 GB)

web Generator

Output size: 140 files changed · net -98.17 KB

File size details
File Main PR Change
assets/style-B-DVDVCK.css — 137.21 KB +137.21 KB
assets/style-CGabmPaa.css 137.13 KB — -137.13 KB (-100.0%)
assets/SearchBox-B7mJCRYv.js — 84.63 KB +84.63 KB
assets/SearchBox-CounBSE7.js 84.60 KB — -84.60 KB (-100.0%)
assets/fonts/open-sans-latin-wght-italic.woff2 49.04 KB — -49.04 KB (-100.0%)
assets/open-sans-latin-wght-italic-Bf1Hxpwk.woff2 — 49.04 KB +49.04 KB
assets/fonts/open-sans-latin-wght-normal.woff2 47.19 KB — -47.19 KB (-100.0%)
assets/open-sans-latin-wght-normal-CWNzRldh.woff2 — 47.19 KB +47.19 KB
assets/dist-CgTHRSfN.js 30.42 KB — -30.42 KB (-100.0%)
assets/dist-80sn2UOS.js — 30.42 KB +30.42 KB
assets/SideBar-BpTWaVhj.js 25.70 KB — -25.70 KB (-100.0%)
assets/SideBar-C2gtoqRI.js — 25.66 KB +25.66 KB
assets/client-DNiP1plf.js — 24.58 KB +24.58 KB
assets/client-BPjlLezu.js 21.63 KB — -21.63 KB (-100.0%)
assets/Combination-NqHZGC_F.js 16.12 KB — -16.12 KB (-100.0%)
assets/Combination-C5vih8q1.js — 16.12 KB +16.12 KB
assets/fonts/ibm-plex-mono-latin-400-normal.woff2 14.36 KB — -14.36 KB (-100.0%)
assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 — 14.36 KB +14.36 KB
assets/ThemeToggle-BtW1BkAT.js 13.64 KB — -13.64 KB (-100.0%)
assets/ThemeToggle-BUt6as8m.js — 13.64 KB +13.64 KB
assets/dist-Cgx7pOGB.js 10.20 KB — -10.20 KB (-100.0%)
assets/dist-DQ_mLtoP.js — 10.20 KB +10.20 KB
assets/Layout-BjN6hpWl.js 10.14 KB — -10.14 KB (-100.0%)
assets/Layout-CYO9ZVhQ.js — 10.14 KB +10.14 KB
assets/compat-CdgPiiLx.js 10.12 KB — -10.12 KB (-100.0%)
assets/compat-QvTwfud6.js — 10.12 KB +10.12 KB
assets/Tooltip-AKP8LdlL.js 7.93 KB — -7.93 KB (-100.0%)
assets/Tooltip-Cu1kacdD.js — 7.93 KB +7.93 KB
assets/dist-Bl9Qg5Ug.js 6.99 KB — -6.99 KB (-100.0%)
assets/dist-CVyv4uDV.js — 6.99 KB +6.99 KB
assets/jsx-runtime-6YqIbWBU.js 5.67 KB — -5.67 KB (-100.0%)
assets/jsx-runtime-B-mLLdNB.js — 5.67 KB +5.67 KB
assets/dist-BtoqWFFE.js 3.94 KB — -3.94 KB (-100.0%)
assets/dist-D5ETVkHU.js — 3.94 KB +3.94 KB
assets/CodeTabs-C102uqDz.js 3.89 KB — -3.89 KB (-100.0%)
assets/CodeTabs-DA6cN8bC.js — 3.89 KB +3.89 KB
assets/CodeBox-Da-adK82.js 3.44 KB — -3.44 KB (-100.0%)
assets/CodeBox-CJp81wk0.js — 3.44 KB +3.44 KB
assets/hooks.module-DS_fqR-L.js 3.40 KB — -3.40 KB (-100.0%)
assets/hooks.module-SxI3jBOe.js — 3.40 KB +3.40 KB
assets/FunctionSignature-C8z8Mgvr.js 2.28 KB — -2.28 KB (-100.0%)
assets/FunctionSignature-Dr9pMicI.js — 2.28 KB +2.28 KB
assets/Banner-Uit0PTEJ.js 2.13 KB — -2.13 KB (-100.0%)
assets/Banner-Dv5k_3R-.js — 2.13 KB +2.13 KB
assets/ChangeHistory-B_lqvQ2i.js 1.77 KB — -1.77 KB (-100.0%)
assets/ChangeHistory-CSKJNGRi.js — 1.77 KB +1.77 KB
404.html 21.90 KB 20.49 KB -1.41 KB (-6.4%)
all.html 33.01 MB 33.01 MB -1.41 KB (-0.0%)
addons.html 379.19 KB 377.78 KB -1.41 KB (-0.4%)
assert.html 650.82 KB 649.41 KB -1.41 KB (-0.2%)
async_context.html 318.34 KB 316.93 KB -1.41 KB (-0.4%)
async_hooks.html 283.61 KB 282.21 KB -1.41 KB (-0.5%)
bench.html 203.18 KB 201.77 KB -1.41 KB (-0.7%)
buffer.html 1.75 MB 1.75 MB -1.41 KB (-0.1%)
child_process.html 674.81 KB 673.40 KB -1.41 KB (-0.2%)
cli.html 631.57 KB 630.17 KB -1.41 KB (-0.2%)
cluster.html 302.74 KB 301.34 KB -1.41 KB (-0.5%)
console.html 185.16 KB 183.75 KB -1.41 KB (-0.8%)
crypto.html 1.96 MB 1.96 MB -1.41 KB (-0.1%)
debugger.html 143.46 KB 142.05 KB -1.41 KB (-1.0%)
deprecations.html 564.45 KB 563.05 KB -1.41 KB (-0.2%)
dgram.html 299.77 KB 298.37 KB -1.41 KB (-0.5%)
diagnostics_channel.html 529.81 KB 528.41 KB -1.41 KB (-0.3%)
dns.html 421.90 KB 420.49 KB -1.41 KB (-0.3%)
documentation.html 28.55 KB 27.14 KB -1.41 KB (-4.9%)
domain.html 126.91 KB 125.50 KB -1.41 KB (-1.1%)
dtls.html 300.38 KB 298.97 KB -1.41 KB (-0.5%)
embedding.html 70.60 KB 69.19 KB -1.41 KB (-2.0%)
environment_variables.html 37.69 KB 36.29 KB -1.41 KB (-3.7%)
errors.html 530.83 KB 529.42 KB -1.41 KB (-0.3%)
esm.html 187.11 KB 185.71 KB -1.41 KB (-0.8%)
events.html 834.21 KB 832.80 KB -1.41 KB (-0.2%)
ffi.html 192.51 KB 191.11 KB -1.41 KB (-0.7%)
fs.html 2.21 MB 2.21 MB -1.41 KB (-0.1%)
globals.html 281.61 KB 280.21 KB -1.41 KB (-0.5%)
http.html 1.14 MB 1.14 MB -1.41 KB (-0.1%)
http2.html 1.25 MB 1.25 MB -1.41 KB (-0.1%)
https.html 240.54 KB 239.13 KB -1.41 KB (-0.6%)
index.html 26.74 KB 25.34 KB -1.41 KB (-5.3%)
inspector.html 214.37 KB 212.96 KB -1.41 KB (-0.7%)
intl.html 56.42 KB 55.02 KB -1.41 KB (-2.5%)
module.html 510.36 KB 508.95 KB -1.41 KB (-0.3%)
modules.html 227.47 KB 226.06 KB -1.41 KB (-0.6%)
n-api.html 1.00 MB 1023.03 KB -1.41 KB (-0.1%)
net.html 633.42 KB 632.02 KB -1.41 KB (-0.2%)
os.html 164.88 KB 163.48 KB -1.41 KB (-0.9%)
packages.html 218.57 KB 217.16 KB -1.41 KB (-0.6%)
path.html 177.30 KB 175.89 KB -1.41 KB (-0.8%)
perf_hooks.html 866.72 KB 865.32 KB -1.41 KB (-0.2%)
permissions.html 90.04 KB 88.63 KB -1.41 KB (-1.6%)
process.html 1.14 MB 1.14 MB -1.41 KB (-0.1%)
punycode.html 64.84 KB 63.44 KB -1.41 KB (-2.2%)
querystring.html 66.98 KB 65.57 KB -1.41 KB (-2.1%)
quic.html 924.14 KB 922.73 KB -1.41 KB (-0.2%)
readline.html 363.68 KB 362.28 KB -1.41 KB (-0.4%)
repl.html 303.01 KB 301.60 KB -1.41 KB (-0.5%)
report.html 231.42 KB 230.01 KB -1.41 KB (-0.6%)
single-executable-applications.html 159.44 KB 158.04 KB -1.41 KB (-0.9%)
sqlite.html 520.05 KB 518.64 KB -1.41 KB (-0.3%)
stream.html 1.22 MB 1.22 MB -1.41 KB (-0.1%)
stream_iter.html 734.29 KB 732.88 KB -1.41 KB (-0.2%)
string_decoder.html 66.22 KB 64.82 KB -1.41 KB (-2.1%)
test.html 1.36 MB 1.35 MB -1.41 KB (-0.1%)
timers.html 182.85 KB 181.45 KB -1.41 KB (-0.8%)
tls.html 523.85 KB 522.44 KB -1.41 KB (-0.3%)
tracing.html 126.07 KB 124.66 KB -1.41 KB (-1.1%)
tty.html 107.25 KB 105.84 KB -1.41 KB (-1.3%)
typescript.html 50.26 KB 48.85 KB -1.41 KB (-2.8%)
url.html 512.50 KB 511.09 KB -1.41 KB (-0.3%)
util.html 1.21 MB 1.21 MB -1.41 KB (-0.1%)
v8.html 551.12 KB 549.71 KB -1.41 KB (-0.3%)
vfs.html 191.63 KB 190.23 KB -1.41 KB (-0.7%)
vm.html 633.52 KB 632.12 KB -1.41 KB (-0.2%)
wasi.html 80.33 KB 78.93 KB -1.41 KB (-1.7%)
webcrypto.html 640.93 KB 639.52 KB -1.41 KB (-0.2%)
webstreams.html 535.38 KB 533.97 KB -1.41 KB (-0.3%)
worker_threads.html 634.84 KB 633.43 KB -1.41 KB (-0.2%)
zlib.html 949.54 KB 948.13 KB -1.41 KB (-0.1%)
assets/DataTag-DhTo7f5P.js 844.00 B — -844.00 B (-100.0%)
assets/DataTag-Cq2e931H.js — 844.00 B +844.00 B
assets/DocumentationIndex-CvaGu8mz.js 833.00 B — -833.00 B (-100.0%)
assets/DocumentationIndex-DaXXQQzQ.js — 833.00 B +833.00 B
assets/ArrowUpRightIcon-BA1v6uIb.js 618.00 B — -618.00 B (-100.0%)
assets/ArrowUpRightIcon--u_0_Sjz.js — 618.00 B +618.00 B
assets/Badge-D17sBn5n.js 607.00 B — -607.00 B (-100.0%)
assets/Badge-BcT_1sS5.js — 607.00 B +607.00 B
assets/AlertBox-sBM0705a.js 591.00 B — -591.00 B (-100.0%)
assets/AlertBox-qp0WkRtE.js — 591.00 B +591.00 B
assets/CodeBracketIcon-BIjXAcj-.js 512.00 B — -512.00 B (-100.0%)
assets/CodeBracketIcon-CrAPuvsL.js — 512.00 B +512.00 B
assets/dist-Ceae2CTM.js 477.00 B — -477.00 B (-100.0%)
assets/dist-DaknNjgF.js — 477.00 B +477.00 B
assets/ChevronDownIcon-edP1bUMq.js 468.00 B — -468.00 B (-100.0%)
assets/ChevronDownIcon-D7_fPUo9.js — 468.00 B +468.00 B
assets/Blockquote-R3801o28.js 167.00 B — -167.00 B (-100.0%)
assets/Blockquote-Cd_aTmrU.js — 167.00 B +167.00 B
assets/useRemoteConfig-BmhtUfUU.js — 151.00 B +151.00 B
assets/useRemoteConfig-DFoVWnTz.js 149.00 B — -149.00 B (-100.0%)
assets/withIsland-BVqxZIfk.js 105.00 B — -105.00 B (-100.0%)
assets/withIsland-DqC1vNJ1.js — 105.00 B +105.00 B

Performance estimate (single CI run)

  • Generation time: 3.0% slower (62.70 s → 64.58 s)
  • Peak memory: 12.6% higher (3.06 GB → 3.44 GB)

@AugustinMauroy

This comment was marked as resolved.

@ovflowd

This comment was marked as resolved.

@AugustinMauroy

This comment was marked as resolved.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
…cePage and view transition

- 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 <noreply@anthropic.com>
…rop document queries

Replace the `data-root` attribute and runtime `document.querySelectorAll`
discovery with a `<script type="application/json" data-router>` tag emitted
by `buildAssetTags`. The tag carries `{ root, assets }` so `startRouter` and
`parsePage` read build-time data instead of querying the live document.

- `getAssets` helper removed; skew detection now reads `data-router` JSON
  from the fetched page
- `shouldIntercept(event, url, root)` extracted from the inline navigate guard
- `hydrated` exported from runtime so the router uses it directly for
  pre-swap island scroll saves (no `document.querySelectorAll` for that path)

Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/react/src/html/ui/hooks/useRemoteConfig.mjs Outdated
Comment thread packages/react/src/html/ui/hooks/useOrama.mjs Outdated
Comment thread packages/react/src/html/ui/router.mjs Outdated
Comment thread packages/react/src/html/ui/router.mjs
@avivkeller

Copy link
Copy Markdown
Member

This does a decent amount of things, and I don't fully understand it, so expect a few rounds of review as we try to grasp what's going on

@ovflowd

ovflowd commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

This does a decent amount of things, and I don't fully understand it, so expect a few rounds of review as we try to grasp what's going on

TL;DR: replaces the body with the next page... It is an attempt of soft navigation of sorts by intercepting Navigation calls and replacing the content with the loaded page. + Speculation APIs

…te config and Orama caches

- 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 <noreply@anthropic.com>
@ovflowd

ovflowd commented Sep 30, 2026

Copy link
Copy Markdown
Member Author

Claudio rebase to remove the co-author of Claude

Why? That is correct tho?

you cannot co-author an Coding agent openjsf.cdn.prismic.io/openjsf/acqiJpGXnQHGZGtq_OpenJSAICodingAssistantsPolicy.pdf

Thanks for the reminder, Augustin :)

…dFollowLink

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 <noreply@anthropic.com>
…rts 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 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – api-docs-tooling — 6acb9762 Deployed Sep 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants