snappy-prerender 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/write.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { parse, serialize } from 'parse5';
2
3
  import { findBuildOriginUrls } from './build-origin.js';
3
- import { applyCspHashes } from './csp.js';
4
+ import { applyCspHashesToDocument } from './csp.js';
5
+ import { resolveHydrationPolicy } from './hydration-policy.js';
4
6
  import { inlineCriticalCss } from './inline-css.js';
7
+ import { applyLocaleMetadata, buildLocaleLinks, localePageUrl } from './locales.js';
5
8
  import { minifyHtml } from './minify.js';
6
9
  import { normaliseHtml } from './normalize.js';
7
10
  import {
@@ -12,11 +15,20 @@ import {
12
15
  writeFileIfChanged,
13
16
  writeRouteHtml,
14
17
  } from './output.js';
15
- import { collectPageMeta } from './page-meta.js';
16
- import { collectScriptSources, injectPreloadLinks } from './preload-links.js';
17
- import { collectPageResources } from './preload-manifest.js';
18
+ import { collectPageMetaFromDocument } from './page-meta.js';
19
+ import { collectScriptSourcesFromDocument, injectPreloadLinksInto } from './preload-links.js';
20
+ import { collectPageResourcesFromDocument } from './preload-manifest.js';
18
21
  import { resolveRouteConfig } from './route-config.js';
19
22
  import { normaliseRoute, toPublicPath } from './routes.js';
23
+ import { mapLimit } from './scheduler.js';
24
+ import {
25
+ buildSeoLocaleScript,
26
+ injectSeoLocaleScript,
27
+ resolveSeoLocale,
28
+ seoLocaleMetadataOptions,
29
+ } from './seo-locale.js';
30
+
31
+ const WRITE_CONCURRENCY = 4;
20
32
 
21
33
  /**
22
34
  * Writes every rendered route and returns the routes that can be verified. A write
@@ -24,12 +36,15 @@ import { normaliseRoute, toPublicPath } from './routes.js';
24
36
  */
25
37
  export async function writeAllRoutes({
26
38
  rendered,
39
+ routeLocales = new Map(),
40
+ localePlan = null,
27
41
  outputDir,
28
42
  config,
29
43
  log,
30
44
  report,
31
45
  state,
32
46
  manifestEntries,
47
+ caches = null,
33
48
  }) {
34
49
  const sorted = [...report.routes].sort();
35
50
  const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
@@ -38,57 +53,92 @@ export async function writeAllRoutes({
38
53
 
39
54
  const verifiable = [];
40
55
  const hashes = new Map();
41
- for (const route of sorted) {
42
- if (conflicted.has(route)) continue;
43
- try {
44
- const routeConfig = resolveRouteConfig(config, route);
45
- const result = rendered.get(route);
46
- const { file, resources } = await writeRouteOutput({
47
- route,
48
- result,
49
- outputDir,
50
- config: routeConfig,
51
- log,
52
- state,
53
- report,
54
- });
55
- report.files.push(file);
56
- report.routeDetails.push({
57
- route,
58
- order: result.order ?? null,
59
- file: file.file,
60
- status: file.status,
61
- bytes: file.bytes,
62
- depth: result.depth ?? null,
63
- attempts: result.attempts ?? 1,
64
- httpStatus: result.httpStatus ?? null,
65
- finalUrl: result.finalUrl ?? null,
66
- empty: result.empty === true,
67
- notFound: file.notFound === true,
68
- noindex: file.noindex === true,
69
- meta: file.meta ?? null,
70
- images: file.images ?? [],
71
- alternates: file.alternates ?? [],
72
- mode: null,
73
- framework: null,
74
- hydrationErrors: [],
75
- pageErrors: result.pageErrors ?? [],
76
- consoleErrors: result.consoleErrors ?? [],
77
- failedRequests: result.failedRequests ?? [],
78
- firstMismatch: null,
79
- buildOriginUrls: file.buildOriginUrls ?? [],
80
- });
81
- if (resources) manifestEntries.push({ route, ...resources });
82
- if (file.hash) {
83
- const group = hashes.get(file.hash) ?? [];
84
- group.push(route);
85
- hashes.set(file.hash, group);
56
+ const localeScripts = new Map();
57
+ const { results } = await mapLimit(
58
+ sorted,
59
+ Math.min(WRITE_CONCURRENCY, config.concurrency),
60
+ async (route) => {
61
+ if (conflicted.has(route)) return null;
62
+ const routeReport = { errors: [], duplicateTitles: [], cspSkipped: [], files: [] };
63
+ try {
64
+ const routeConfig = resolveRouteConfig(config, route);
65
+ const result = rendered.get(route);
66
+ const { file, resources } = await writeRouteOutput({
67
+ route,
68
+ result,
69
+ outputDir,
70
+ config: routeConfig,
71
+ log,
72
+ state,
73
+ report: routeReport,
74
+ caches,
75
+ locale: routeLocales.get(route)?.locale ?? null,
76
+ baseRoute: routeLocales.get(route)?.baseRoute ?? route,
77
+ localePlan,
78
+ localeScripts,
79
+ });
80
+ return {
81
+ route,
82
+ result,
83
+ file,
84
+ resources,
85
+ hash: file.hash,
86
+ report: routeReport,
87
+ };
88
+ } catch (error) {
89
+ routeReport.errors.push({ route, message: `write failed: ${error.message}` });
90
+ log.error(` write failed for ${route}: ${error.message}`);
91
+ return { route, report: routeReport };
86
92
  }
87
- verifiable.push(route);
88
- } catch (error) {
89
- report.errors.push({ route, message: `write failed: ${error.message}` });
90
- log.error(` write failed for ${route}: ${error.message}`);
93
+ },
94
+ );
95
+
96
+ for (const entry of results) {
97
+ if (!entry) continue;
98
+ for (const error of entry.report.errors) report.errors.push(error);
99
+ for (const route of entry.report.duplicateTitles) report.duplicateTitles.push(route);
100
+ for (const route of entry.report.cspSkipped) report.cspSkipped.push(route);
101
+ for (const file of entry.report.files ?? []) report.files.push(file);
102
+ if (!entry.file) continue;
103
+ const { route, result, file, resources } = entry;
104
+ report.files.push(file);
105
+ report.routeDetails.push({
106
+ route,
107
+ locale: routeLocales.get(route)?.locale?.code ?? null,
108
+ order: result.order ?? null,
109
+ file: file.file,
110
+ status: file.status,
111
+ bytes: file.bytes,
112
+ depth: result.depth ?? null,
113
+ attempts: result.attempts ?? 1,
114
+ httpStatus: result.httpStatus ?? null,
115
+ finalUrl: result.finalUrl ?? null,
116
+ empty: result.empty === true,
117
+ notFound: file.notFound === true,
118
+ noindex: file.noindex === true,
119
+ meta: file.meta ?? null,
120
+ images: file.images ?? [],
121
+ alternates: file.alternates ?? [],
122
+ localeUrl: file.localeUrl ?? null,
123
+ mode: null,
124
+ framework: null,
125
+ hydrationErrors: [],
126
+ ignoredHydrationErrors: [],
127
+ hydrationPolicy: resolveHydrationPolicy(config, route),
128
+ hydrationAllowed: false,
129
+ pageErrors: result.pageErrors ?? [],
130
+ consoleErrors: result.consoleErrors ?? [],
131
+ failedRequests: result.failedRequests ?? [],
132
+ firstMismatch: null,
133
+ buildOriginUrls: file.buildOriginUrls ?? [],
134
+ });
135
+ if (resources) manifestEntries.push({ route, ...resources });
136
+ if (entry.hash) {
137
+ const group = hashes.get(entry.hash) ?? [];
138
+ group.push(route);
139
+ hashes.set(entry.hash, group);
91
140
  }
141
+ verifiable.push(route);
92
142
  }
93
143
  reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
94
144
  return verifiable;
@@ -176,7 +226,20 @@ function reportOutputCollisions({ routes, config, log, report }) {
176
226
  * Applies the configured post-processing to one rendered route and writes it: DOM
177
227
  * cleanups and link hints, critical CSS extraction, then optional minification.
178
228
  */
179
- async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
229
+ async function writeRouteOutput({
230
+ route,
231
+ result,
232
+ outputDir,
233
+ config,
234
+ log,
235
+ state,
236
+ report,
237
+ caches,
238
+ locale = null,
239
+ baseRoute = route,
240
+ localePlan = null,
241
+ localeScripts = new Map(),
242
+ }) {
180
243
  if (result.screenshot) {
181
244
  const file = routeToScreenshotFile(route, config);
182
245
  if (config.dryRun) {
@@ -198,23 +261,89 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
198
261
  return { file: { route, ...written }, resources: null };
199
262
  }
200
263
 
201
- const { html, stats } = normaliseHtml(result.html, {
202
- removeStyleTags: config.removeStyleTags,
203
- removeScriptTags: config.removeScriptTags,
204
- asyncScriptTags: config.asyncScriptTags,
205
- removeBlobs: config.removeBlobs,
206
- removeAttributes: config.removeAttributes,
207
- stripBuildOriginMetadata: config.stripBuildOriginMetadata,
208
- noindex:
209
- config.notFoundRoute !== null &&
210
- normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
211
- preconnectOrigins: config.preconnectThirdParty
212
- ? [...result.collector.thirdPartyOrigins].sort()
213
- : [],
214
- preloadImages: config.preloadImages
215
- ? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
216
- : [],
217
- });
264
+ const needsString =
265
+ config.inlineCss === 'critical' || Boolean(config.minifyHtml) || Boolean(config.transformHtml);
266
+ const normalised = normaliseHtml(
267
+ result.html,
268
+ {
269
+ removeStyleTags: config.removeStyleTags,
270
+ removeScriptTags: config.removeScriptTags,
271
+ asyncScriptTags: config.asyncScriptTags,
272
+ removeBlobs: config.removeBlobs,
273
+ removeAttributes: config.removeAttributes,
274
+ stripBuildOriginMetadata: config.stripBuildOriginMetadata,
275
+ noindex:
276
+ config.notFoundRoute !== null &&
277
+ normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
278
+ preconnectOrigins: config.preconnectThirdParty
279
+ ? [...result.collector.thirdPartyOrigins].sort()
280
+ : [],
281
+ preloadImages: config.preloadImages
282
+ ? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
283
+ : [],
284
+ },
285
+ { serialize: needsString },
286
+ );
287
+ const { stats } = normalised;
288
+ let document = normalised.document;
289
+ let preTransformScripts = new Set();
290
+ let transformedHtml = null;
291
+ let localeChanged = false;
292
+ if (needsString) {
293
+ let output = normalised.html;
294
+ if (config.inlineCss === 'critical') {
295
+ output = await applyCriticalCss({ output, outputDir, config, log, state, caches });
296
+ }
297
+ if (config.minifyHtml) {
298
+ output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
299
+ }
300
+ if (config.transformHtml) {
301
+ preTransformScripts = collectScriptSourcesFromDocument(document);
302
+ const transformed = await config.transformHtml(output, {
303
+ route,
304
+ file: routeToFile(route, config),
305
+ stats,
306
+ config,
307
+ });
308
+ if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
309
+ throw new TypeError('transformHtml must return a string, null or undefined');
310
+ }
311
+ output = transformed ?? output;
312
+ }
313
+ transformedHtml = output;
314
+ document = parse(output);
315
+ }
316
+ const noindex =
317
+ documentHasNoindex(document) ||
318
+ (config.notFoundRoute !== null &&
319
+ normaliseRoute(baseRoute) === normaliseRoute(config.notFoundRoute));
320
+ const localeLinks =
321
+ locale && localePlan ? buildLocaleLinks({ locale, baseRoute, plan: localePlan, config }) : null;
322
+ if (localeLinks) {
323
+ localeChanged = true;
324
+ const seo = resolveSeoLocale(config, locale, route);
325
+ applyLocaleMetadata(document, localeLinks, {
326
+ skipAlternates: noindex,
327
+ ...seoLocaleMetadataOptions(seo),
328
+ });
329
+ if (seo?.inject) {
330
+ const script = buildSeoLocaleScript(seo);
331
+ const external = config.csp === 'strict' || (config.externalScripts && config.csp !== 'hash');
332
+ if (external) {
333
+ const externalHref = await ensureLocaleScript({
334
+ script,
335
+ outputDir,
336
+ config,
337
+ report,
338
+ log,
339
+ cache: localeScripts,
340
+ });
341
+ if (externalHref) injectSeoLocaleScript(document, script, externalHref);
342
+ } else {
343
+ injectSeoLocaleScript(document, script, null);
344
+ }
345
+ }
346
+ }
218
347
  if (
219
348
  stats.removedElements > 0 ||
220
349
  stats.removedAttributes > 0 ||
@@ -230,26 +359,7 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
230
359
  log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
231
360
  }
232
361
 
233
- let output = html;
234
- if (config.inlineCss === 'critical') {
235
- output = await applyCriticalCss({ output, outputDir, config, log, state });
236
- }
237
- if (config.minifyHtml) {
238
- output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
239
- }
240
- const preTransformScripts = config.transformHtml ? collectScriptSources(output) : new Set();
241
- if (config.transformHtml) {
242
- const transformed = await config.transformHtml(output, {
243
- route,
244
- file: routeToFile(route, config),
245
- stats,
246
- config,
247
- });
248
- if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
249
- throw new TypeError('transformHtml must return a string, null or undefined');
250
- }
251
- output = transformed ?? output;
252
- }
362
+ let preloadAdded = 0;
253
363
  if (config.preloadLinks) {
254
364
  const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
255
365
  const skipScripts = new Set(
@@ -262,28 +372,38 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
262
372
  return toPublicPath(appRelative, config.base);
263
373
  }),
264
374
  );
265
- output = injectPreloadLinks(output, {
266
- scripts: config.removeScriptTags ? [] : [...result.collector.scripts],
267
- fonts: result.collector.fonts,
375
+ preloadAdded = injectPreloadLinksInto(document, {
376
+ scripts: config.removeScriptTags ? [] : [...result.collector.scripts].sort(),
377
+ fonts: new Map(
378
+ [...result.collector.fonts].sort(([left], [right]) => left.localeCompare(right)),
379
+ ),
268
380
  lcpImage: result.lcpImage,
269
381
  base: config.base,
270
382
  ignoreForPreload: config.ignoreForPreload,
271
383
  skipScripts,
272
384
  });
273
385
  }
386
+ let cspChanged = false;
274
387
  if (config.csp === 'hash') {
275
- const policy = applyCspHashes(output);
388
+ const policy = applyCspHashesToDocument(document);
389
+ cspChanged = !policy.existingPolicy;
276
390
  if (policy.existingPolicy) {
277
391
  log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
278
392
  report.cspSkipped.push(route);
279
393
  } else {
280
- output = policy.html;
281
394
  log.debug(
282
395
  ` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
283
396
  );
284
397
  }
285
398
  }
286
- const meta = collectPageMeta(output);
399
+ const meta = collectPageMetaFromDocument(document);
400
+ let output =
401
+ transformedHtml !== null && preloadAdded === 0 && !cspChanged && !localeChanged
402
+ ? transformedHtml
403
+ : serialize(document);
404
+ if (localeChanged && config.minifyHtml) {
405
+ output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
406
+ }
287
407
  warnOnBuildOriginMetadata({
288
408
  origins: stats.metadataOrigins,
289
409
  removed: stats.removedMetadata,
@@ -302,7 +422,7 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
302
422
  }
303
423
  const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
304
424
  const resources = config.preloadManifest
305
- ? collectPageResources(output, { base: config.base, route })
425
+ ? collectPageResourcesFromDocument(document, { base: config.base, route })
306
426
  : null;
307
427
  return {
308
428
  file: {
@@ -317,6 +437,9 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
317
437
  meta,
318
438
  images: meta.images,
319
439
  alternates: meta.alternates,
440
+ localeUrl: locale
441
+ ? localePageUrl(locale, baseRoute, config, localePlan?.mode ?? 'path')
442
+ : null,
320
443
  inlineStyleAttributes: meta.inlineStyleAttributes,
321
444
  buildOriginUrls: buildOrigins,
322
445
  },
@@ -328,13 +451,14 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
328
451
  * Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
329
452
  * the output directory, which a command server does not have to serve.
330
453
  */
331
- async function applyCriticalCss({ output, outputDir, config, log, state }) {
454
+ async function applyCriticalCss({ output, outputDir, config, log, state, caches }) {
332
455
  if (!config.serveCmd) {
333
456
  return inlineCriticalCss({
334
457
  html: output,
335
458
  outputDir,
336
459
  base: config.base,
337
460
  preload: config.criticalCssPreload,
461
+ cache: caches?.criticalCss ?? null,
338
462
  });
339
463
  }
340
464
  if (!state.warnedCriticalCssSkipped) {
@@ -346,6 +470,59 @@ async function applyCriticalCss({ output, outputDir, config, log, state }) {
346
470
  return output;
347
471
  }
348
472
 
473
+ /**
474
+ * Reports whether the document already declares a noindex policy. Locale alternates are
475
+ * skipped on those pages, while language attributes are still applied.
476
+ * @param {object} document
477
+ * @returns {boolean}
478
+ */
479
+ function documentHasNoindex(document) {
480
+ let noindex = false;
481
+ const walk = (node) => {
482
+ if (noindex) return;
483
+ if (
484
+ node.tagName === 'meta' &&
485
+ node.attrs?.some((entry) => entry.name === 'name' && entry.value.toLowerCase() === 'robots')
486
+ ) {
487
+ const content = node.attrs.find((entry) => entry.name === 'content')?.value ?? '';
488
+ if (/\b(noindex|none)\b/i.test(content)) noindex = true;
489
+ }
490
+ for (const child of node.childNodes ?? []) walk(child);
491
+ };
492
+ walk(document);
493
+ return noindex;
494
+ }
495
+
496
+ /**
497
+ * Writes the external SEO locale bootstrap once per unique script. The file is
498
+ * content-addressed, so reruns and locales that produce the same script share it.
499
+ * @returns {Promise<string | null>} public href, or null when the write failed
500
+ */
501
+ async function ensureLocaleScript({ script, outputDir, config, report, log, cache }) {
502
+ const hash = createHash('sha256').update(script).digest('hex').slice(0, 16);
503
+ const file = `snappy-seo-locale-${hash}.js`;
504
+ if (cache.has(file)) return cache.get(file);
505
+ const pending = (async () => {
506
+ const bytes = Buffer.byteLength(script);
507
+ if (config.dryRun) {
508
+ report.files.push({ route: null, file, status: 'dry-run', bytes });
509
+ return toPublicPath(`/${file}`, config.base);
510
+ }
511
+ try {
512
+ const written = await writeFileIfChanged({ dir: outputDir, file, contents: script });
513
+ report.files.push({ route: null, ...written });
514
+ log.debug(` wrote ${file}: ${written.status}`);
515
+ return toPublicPath(`/${file}`, config.base);
516
+ } catch (error) {
517
+ report.errors.push({ route: null, message: `locale script write failed: ${error.message}` });
518
+ log.error(` write failed for ${file}: ${error.message}`);
519
+ return null;
520
+ }
521
+ })();
522
+ cache.set(file, pending);
523
+ return pending;
524
+ }
525
+
349
526
  /**
350
527
  * Warns once when metadata contains the build server's origin, which means the page was
351
528
  * composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
package/src/head/index.js CHANGED
@@ -6,13 +6,103 @@ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : us
6
6
 
7
7
  let sequence = 0;
8
8
 
9
+ /**
10
+ * Attribute names and values accepted by html, body, title and arbitrary head tags.
11
+ * @typedef {Object<string, string | number | boolean | null | undefined>} HeadAttributes
12
+ */
13
+
14
+ /**
15
+ * One arbitrary head element.
16
+ * @typedef {Object} HeadTag
17
+ * @property {'script' | 'style' | 'noscript' | 'base'} tag
18
+ * @property {HeadAttributes} [attrs]
19
+ * @property {string | number} [text]
20
+ * @property {string} [html] Trusted inner HTML for noscript only.
21
+ * @property {string} [key]
22
+ */
23
+
24
+ /**
25
+ * One hreflang alternate.
26
+ * @typedef {Object} HeadAlternate
27
+ * @property {string} href
28
+ * @property {string} [hreflang]
29
+ * @property {string} [lang]
30
+ * @property {string} [type]
31
+ */
32
+
33
+ /**
34
+ * Extra meta or link attributes. keys and duplicates are consumed by the head layer.
35
+ * @typedef {Object<string, string | number | boolean | null | undefined>} HeadExtra
36
+ */
37
+
38
+ /**
39
+ * One merged head entry reported to onChangeClientState.
40
+ * @typedef {Object} HeadEntry
41
+ * @property {string} kind
42
+ * @property {string} key
43
+ * @property {Record<string, string>} [attrs]
44
+ * @property {string} [text]
45
+ * @property {string} [html]
46
+ * @property {string} [value]
47
+ */
48
+
49
+ /**
50
+ * Entries grouped by kind for onChangeClientState.
51
+ * @typedef {Object} HeadChangeState
52
+ * @property {HeadEntry | null} title
53
+ * @property {HeadEntry[]} meta
54
+ * @property {HeadEntry[]} link
55
+ * @property {HeadEntry[]} script
56
+ * @property {HeadEntry[]} style
57
+ * @property {HeadEntry[]} noscript
58
+ * @property {HeadEntry[]} base
59
+ * @property {HeadEntry[]} htmlAttributes
60
+ * @property {HeadEntry[]} bodyAttributes
61
+ * @property {HeadEntry[]} titleAttributes
62
+ */
63
+
64
+ /**
65
+ * @typedef {(state: HeadChangeState, added: HeadEntry[], removed: HeadEntry[]) => void} HeadChange
66
+ */
67
+
68
+ /**
69
+ * Props accepted by Head and setHead.
70
+ * @typedef {Object} HeadProps
71
+ * @property {string} [title]
72
+ * @property {string} [defaultTitle]
73
+ * @property {string} [titleTemplate]
74
+ * @property {string} [siteName]
75
+ * @property {string} [description]
76
+ * @property {string} [robots]
77
+ * @property {string} [type]
78
+ * @property {string} [locale]
79
+ * @property {string | false} [canonical]
80
+ * @property {string} [image]
81
+ * @property {string[]} [images]
82
+ * @property {string[]} [alternateLocales]
83
+ * @property {string} [lang]
84
+ * @property {'ltr' | 'rtl' | 'auto'} [dir]
85
+ * @property {boolean} [openGraph]
86
+ * @property {boolean} [twitter]
87
+ * @property {HeadAlternate[]} [alternates]
88
+ * @property {unknown} [structuredData]
89
+ * @property {HeadExtra[]} [extra]
90
+ * @property {HeadTag[]} [tags]
91
+ * @property {HeadAttributes} [htmlAttributes]
92
+ * @property {HeadAttributes} [bodyAttributes]
93
+ * @property {HeadAttributes} [titleAttributes]
94
+ * @property {HeadChange} [onChangeClientState]
95
+ */
96
+
9
97
  /**
10
98
  * Declares document metadata for the route being rendered: title, description, robots,
11
- * canonical, Open Graph and Twitter card tags. The head is reconciled on every render while
12
- * the component is mounted, so client-side navigation gives each route its own head exactly
13
- * like a site that renders a page per request.
99
+ * canonical, Open Graph and Twitter card tags, plus arbitrary head tags and html, body and
100
+ * title attributes. The head is reconciled on every render while the component is mounted,
101
+ * so client-side navigation gives each route its own head exactly like a site that renders a
102
+ * page per request.
14
103
  *
15
104
  * Renders nothing, so it never affects hydration of the page content.
105
+ * @param {HeadProps} props
16
106
  */
17
107
  export function Head(props) {
18
108
  const idRef = useRef(null);
@@ -31,7 +121,7 @@ export function Head(props) {
31
121
  // A layout effect so a route change updates the head before the browser paints, and a
32
122
  // plain effect on the server, where layout effects never run.
33
123
  useIsomorphicLayoutEffect(() => {
34
- registerHead(idRef.current, sequenceRef.current, entries);
124
+ registerHead(idRef.current, sequenceRef.current, entries, props.onChangeClientState ?? null);
35
125
  });
36
126
 
37
127
  useEffect(() => () => unregisterHead(idRef.current), []);
@@ -42,14 +132,22 @@ export function Head(props) {
42
132
  /**
43
133
  * Imperative equivalent for code that is not a React component, for example a vanilla
44
134
  * router or an analytics callback.
135
+ * @param {string} id
136
+ * @param {HeadProps} props
45
137
  */
46
138
  export function setHead(id, props) {
47
139
  sequence += 1;
48
- registerHead(id, sequence, buildHeadEntries(props, readMetadataDefaults(), id));
140
+ registerHead(
141
+ id,
142
+ sequence,
143
+ buildHeadEntries(props, readMetadataDefaults(), id),
144
+ props.onChangeClientState ?? null,
145
+ );
49
146
  }
50
147
 
51
148
  /**
52
149
  * Removes metadata previously set with setHead.
150
+ * @param {string} id
53
151
  */
54
152
  export function clearHead(id) {
55
153
  unregisterHead(id);