Skip to content

Commit a411d86

Browse files
ovflowdclaude
andcommitted
refactor(html): embed router data in HTML; extract shouldIntercept; drop 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) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e7ed0e0 commit a411d86

4 files changed

Lines changed: 52 additions & 42 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,9 @@ describe('web generate', () => {
360360
assert.match(html, /<article data-custom-ssr>fs:1<\/article>/);
361361
assert.match(
362362
html,
363-
/<script type=module crossorigin src=\.\/custom\/index\.js data-root=\.\/>/
363+
/<script type=module crossorigin src=\.\/custom\/index\.js>/
364364
);
365+
assert.match(html, /data-router/);
365366
// The adapter reported no fonts, so there are none to preload
366367
assert.doesNotMatch(html, /as=font/);
367368
assert.match(

‎packages/react/src/html/ui/router.mjs‎

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,6 @@ export const isPage = (url, root) =>
5757
*/
5858
export const withoutFragment = href => href.split('#')[0];
5959

60-
/**
61-
* The absolute URLs of the scripts and stylesheets a document loads.
62-
*
63-
* @param {Document} doc
64-
* @param {string} base - The document's URL, for relative references
65-
* @returns {Array<URL>}
66-
*/
67-
const getAssets = (doc, base) =>
68-
[...doc.querySelectorAll('script[src], link[rel~="stylesheet"][href]')].map(
69-
element =>
70-
new URL(element.getAttribute('src') ?? element.getAttribute('href'), base)
71-
);
72-
7360
/**
7461
* Keys an iterable of islands by name and occurrence, so that the same island
7562
* is found again on the next page.
@@ -123,10 +110,25 @@ const updateHead = doc => {
123110
*/
124111
const transition = update => update();
125112

113+
/**
114+
* Whether a navigation event should be intercepted by the router.
115+
*
116+
* @param {NavigateEvent} event
117+
* @param {URL} url
118+
* @param {string} root
119+
*/
120+
const shouldIntercept = (event, url, root) =>
121+
event.canIntercept &&
122+
!event.hashChange &&
123+
event.downloadRequest === null &&
124+
!event.formData &&
125+
event.navigationType !== 'reload' &&
126+
isPage(url, root);
127+
126128
/**
127129
* Starts handling navigations between the pages of the site, unless the
128-
* browser lacks the Navigation API or the page does not say where the site
129-
* starts (see `buildAssetTags`).
130+
* browser lacks the Navigation API or the page does not carry router data
131+
* (see `buildAssetTags`).
130132
*
131133
* @param {object} options
132134
* @param {(root: Node) => void} options.unmount - Unmounts the components
@@ -135,18 +137,17 @@ const transition = update => update();
135137
* islands; used to save scroll positions before the body is replaced.
136138
*/
137139
export const startRouter = ({ unmount, islands }) => {
138-
const script = document.querySelector('script[data-root]');
140+
const tag = document.querySelector('script[data-router]');
139141

140-
if (!('navigation' in window) || !script) {
142+
if (!('navigation' in window) || !tag) {
141143
return;
142144
}
143145

144-
const root = new URL(script.dataset.root, location.href).href;
145-
146-
// The document's head is never replaced (see `PAGE_HEAD`), so the relative
147-
// URLs in it are resolved while they still point where they did at load
146+
/** @type {{ root: string, assets: Array<string> }} */
147+
const config = JSON.parse(tag.textContent);
148+
const root = new URL(config.root, location.href).href;
148149
const assets = new Set(
149-
getAssets(document, location.href).map(url => url.href)
150+
config.assets.map(href => new URL(href, location.href).href)
150151
);
151152

152153
/** @type {Map<string, { page: Promise<Page | null>, expires: number }>} */
@@ -202,8 +203,18 @@ export const startRouter = ({ unmount, islands }) => {
202203
*/
203204
const parsePage = ({ url, html }) => {
204205
const doc = new DOMParser().parseFromString(html, 'text/html');
206+
const tag = doc.querySelector('script[data-router]');
207+
208+
if (!tag) {
209+
return null;
210+
}
211+
212+
/** @type {{ root: string, assets: Array<string> }} */
213+
const pageConfig = JSON.parse(tag.textContent);
205214

206-
return getAssets(doc, url).every(asset => assets.has(asset.href))
215+
return pageConfig.assets
216+
.map(href => new URL(href, url).href)
217+
.every(href => assets.has(href))
207218
? doc
208219
: null;
209220
};
@@ -267,14 +278,7 @@ export const startRouter = ({ unmount, islands }) => {
267278
navigation.addEventListener('navigate', event => {
268279
const url = new URL(event.destination.url);
269280

270-
if (
271-
!event.canIntercept ||
272-
event.hashChange ||
273-
event.downloadRequest !== null ||
274-
event.formData ||
275-
event.navigationType === 'reload' ||
276-
!isPage(url, root)
277-
) {
281+
if (!shouldIntercept(event, url, root)) {
278282
return;
279283
}
280284

‎packages/react/src/html/utils/__tests__/processing.test.mjs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,14 +258,14 @@ describe('buildAssetTags', () => {
258258
stylesheets: ['assets/style-ghi.css'],
259259
};
260260

261-
it('resolves every asset against the page root, scripts first', () => {
261+
it('resolves every asset against the page root, router data tag first', () => {
262262
const tags = buildAssetTags(assets, '../').split('\n');
263263

264264
assert.deepStrictEqual(
265265
tags.map(tag => tag.trim()),
266266
[
267-
// The scripts also tell the client-side router where the site starts
268-
'<script type="module" crossorigin src="../assets/client-abc.js" data-root="../"></script>',
267+
`<script type="application/json" data-router>${JSON.stringify({ root: '../', assets: ['../assets/client-abc.js', '../assets/style-ghi.css'] })}</script>`,
268+
'<script type="module" crossorigin src="../assets/client-abc.js"></script>',
269269
'<link rel="modulepreload" crossorigin href="../assets/shared-def.js" />',
270270
'<link rel="stylesheet" crossorigin href="../assets/style-ghi.css" />',
271271
]
@@ -280,10 +280,10 @@ describe('buildAssetTags', () => {
280280
);
281281
});
282282

283-
it('renders nothing for an empty asset list', () => {
283+
it('renders only the router tag for an empty asset list', () => {
284284
assert.strictEqual(
285285
buildAssetTags({ scripts: [], preloads: [], stylesheets: [] }, './'),
286-
''
286+
'<script type="application/json" data-router>{"root":"./","assets":[]}</script>'
287287
);
288288
});
289289
});

‎packages/react/src/html/utils/processing.mjs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,24 @@ export const buildHead = ({ meta = [], links = [], html = [] }) =>
163163
* statically import as preload hints (as the bundler would inject them), and
164164
* the stylesheets as links.
165165
*
166-
* The entry scripts also carry the root itself, which tells the client-side
167-
* router (see `ui/router.mjs`) which links lead to pages of the site. It rides
168-
* along with the scripts because every template has to render them.
166+
* Also emits a `<script type="application/json" data-router>` tag carrying the
167+
* root and asset URLs so the client-side router can read them without querying
168+
* the DOM for script attributes or re-discovering loaded scripts and stylesheets.
169169
*
170170
* @param {import('../types').ClientAssets} assets - Output-relative asset paths
171171
* @param {string} root - The page's root (see {@link resolvePageRoot})
172172
* @returns {string}
173173
*/
174174
export const buildAssetTags = ({ scripts, preloads, stylesheets }, root) =>
175175
[
176+
[
177+
`<script type="application/json" data-router>${JSON.stringify({
178+
root,
179+
assets: [...scripts, ...stylesheets].map(file => `${root}${file}`),
180+
})}</script>`,
181+
],
176182
scripts.map(
177-
file =>
178-
`<script type="module" crossorigin src="${root}${file}" data-root="${root}"></script>`
183+
file => `<script type="module" crossorigin src="${root}${file}"></script>`
179184
),
180185
preloads.map(file =>
181186
renderTag('link', {

0 commit comments

Comments
 (0)