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.
@@ -2,11 +2,24 @@ import { headKey, mergeHeadEntries } from './tags.js';
2
2
 
3
3
  const OWNER_ATTRIBUTE = 'data-snappy-head';
4
4
  const CREATED_ATTRIBUTE = 'data-snappy-head-created';
5
+ const ATTRIBUTE_KINDS = new Set(['html', 'body', 'titleAttribute']);
6
+ const ORIGINAL_HTML = '__html';
7
+ const ORIGINAL_BODY = '__body';
8
+ const ORIGINAL_TITLE = '__title';
5
9
  const stack = [];
6
10
  const snapshots = new WeakMap();
7
11
  const createdElements = new WeakSet();
8
- const htmlOriginals = new Map();
9
- const originals = readRecordedOriginals();
12
+ const adoptedElements = new WeakSet();
13
+ const recorded = readRecordedOriginals();
14
+ const originals = { ...recorded };
15
+ delete originals[ORIGINAL_HTML];
16
+ delete originals[ORIGINAL_BODY];
17
+ delete originals[ORIGINAL_TITLE];
18
+ const htmlOriginals = new Map(Object.entries(recorded[ORIGINAL_HTML] ?? {}));
19
+ const bodyOriginals = new Map(Object.entries(recorded[ORIGINAL_BODY] ?? {}));
20
+ const titleOriginals = new Map(Object.entries(recorded[ORIGINAL_TITLE] ?? {}));
21
+ let previousState = null;
22
+ let externalTitleElement = null;
10
23
  publishOriginals();
11
24
 
12
25
  /**
@@ -29,11 +42,11 @@ export function readMetadataDefaults() {
29
42
  /**
30
43
  * Registers one mounted Head's tag list and reconciles the document head with the merged
31
44
  * result. Called on every render, so a client-side route change updates the head without a
32
- * page load.
45
+ * page load. The optional callback observes real DOM changes only.
33
46
  */
34
- export function registerHead(id, sequence, entries) {
47
+ export function registerHead(id, sequence, entries, onChangeClientState = null) {
35
48
  const index = stack.findIndex((item) => item.id === id);
36
- const item = { id, sequence, entries };
49
+ const item = { id, sequence, entries, onChangeClientState };
37
50
  if (index === -1) stack.push(item);
38
51
  else stack[index] = item;
39
52
  applyMerged();
@@ -51,23 +64,26 @@ export function unregisterHead(id) {
51
64
  }
52
65
 
53
66
  // Brings the head in line with the merged entries: create, adopt or update each tag, drop
54
- // duplicates of the same identity, release tags that are no longer wanted, then restore a
55
- // deterministic order.
67
+ // duplicates of the same identity, release tags that are no longer wanted, apply element
68
+ // attributes, then restore a deterministic order.
56
69
  function applyMerged() {
57
70
  if (typeof document === 'undefined' || !document.head) return;
58
71
  const head = document.head;
59
72
  const merged = mergeHeadEntries(stack);
60
- const headEntries = merged.filter((entry) => entry.kind !== 'html');
73
+ const headEntries = merged.filter((entry) => !ATTRIBUTE_KINDS.has(entry.kind));
61
74
  const htmlEntries = merged.filter((entry) => entry.kind === 'html');
75
+ const bodyEntries = merged.filter((entry) => entry.kind === 'body');
76
+ const titleEntries = merged.filter((entry) => entry.kind === 'titleAttribute');
62
77
  const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
78
+ const skipTitle = adoptExternalTitle(head, desired.get('title'));
63
79
 
64
80
  for (const [key, entry] of desired) {
65
- const matches = findTags(head, key);
81
+ if (key === 'title' && skipTitle) continue;
82
+ const matches = findTags(head, key, entry);
66
83
  const element = matches[0] ?? createTag(head, entry);
67
84
  writeTag(element, entry, key);
68
- // A second tag with the same identity is one the page should not ship twice. Titles are
69
- // exempt: React 19 native metadata and the head layer can each write one.
70
- if (key !== 'title') {
85
+ // A second tag with the same identity is one the page should not ship twice.
86
+ if (matches.length > 1) {
71
87
  for (const duplicate of matches.slice(1)) removeDuplicate(duplicate, key);
72
88
  }
73
89
  }
@@ -77,78 +93,141 @@ function applyMerged() {
77
93
  if (!desired.has(key)) releaseTag(element, key);
78
94
  }
79
95
 
80
- applyHtmlAttributes(htmlEntries);
96
+ applyAttributes(document.documentElement, htmlEntries, htmlOriginals);
97
+ applyAttributes(document.body, bodyEntries, bodyOriginals);
98
+ applyTitleAttributes(head, titleEntries);
81
99
  orderTags(head, headEntries);
100
+ notifyState(merged);
82
101
  }
83
102
 
84
- // Applies lang/dir to the document element and restores the original value when no Head
85
- // wants the attribute any more.
86
- function applyHtmlAttributes(entries) {
87
- if (typeof document === 'undefined' || !document.documentElement) return;
88
- const root = document.documentElement;
89
- const desired = new Map(entries.map((entry) => [entry.key, entry.value]));
103
+ // Leaves an already-correct, app-owned title untouched. React 19 hoists native titles and
104
+ // removes them on navigation itself; adopting that node would make React's own removal fail.
105
+ // A title whose text already matches is therefore treated as externally owned.
106
+ function adoptExternalTitle(head, entry) {
107
+ if (!entry) {
108
+ externalTitleElement = null;
109
+ return false;
110
+ }
111
+ const titles = [...head.querySelectorAll('title')];
112
+ const external = titles.find(
113
+ (title) =>
114
+ !title.hasAttribute(OWNER_ATTRIBUTE) &&
115
+ !title.hasAttribute(CREATED_ATTRIBUTE) &&
116
+ !snapshots.has(title) &&
117
+ title.textContent === entry.text,
118
+ );
119
+ if (!external) {
120
+ externalTitleElement = null;
121
+ return false;
122
+ }
123
+ externalTitleElement = external;
124
+ for (const title of titles) {
125
+ if (title !== external) title.remove();
126
+ }
127
+ return true;
128
+ }
129
+
130
+ // Applies title attributes, leaving them alone when the document has no title to decorate.
131
+ function applyTitleAttributes(head, entries) {
132
+ const title = head.querySelector('title');
133
+ if (!title || title === externalTitleElement) return;
134
+ applyAttributes(title, entries, titleOriginals);
135
+ }
136
+
137
+ // Applies one attribute family to an element and restores the original value once no Head
138
+ // wants the attribute any more. Recorded values survive a prerender so client navigation
139
+ // can restore what the document shipped with.
140
+ function applyAttributes(element, entries, originals) {
141
+ if (!element) return;
142
+ const desired = new Map(entries.map((entry) => [entry.key, String(entry.value)]));
90
143
  for (const [key, value] of desired) {
91
- if (!htmlOriginals.has(key)) htmlOriginals.set(key, root.getAttribute(key));
92
- root.setAttribute(key, value);
144
+ const current = element.getAttribute(key);
145
+ // Record only a real change. When the Head writes the value the document already had,
146
+ // leaving it in place after unmount is the correct restoration.
147
+ if (!originals.has(key) && current !== value) {
148
+ originals.set(key, current);
149
+ publishOriginals();
150
+ }
151
+ element.setAttribute(key, value);
93
152
  }
94
- for (const key of [...htmlOriginals.keys()]) {
153
+ for (const key of [...originals.keys()]) {
95
154
  if (desired.has(key)) continue;
96
- const previous = htmlOriginals.get(key);
97
- if (previous === null) root.removeAttribute(key);
98
- else root.setAttribute(key, previous);
99
- htmlOriginals.delete(key);
155
+ const previous = originals.get(key);
156
+ if (previous === null) element.removeAttribute(key);
157
+ else element.setAttribute(key, previous);
158
+ originals.delete(key);
159
+ publishOriginals();
100
160
  }
101
161
  }
102
162
 
103
163
  /**
104
164
  * Finds every tag a key refers to, in document order. Matching on the attribute a crawler
105
- * reads means a tag already in the document — including one in prerendered HTML — is
106
- * updated in place instead of duplicated.
165
+ * reads means a tag already in the document — including one in prerendered HTML or hoisted
166
+ * by React 19 — is updated in place instead of duplicated.
107
167
  */
108
- function findTags(head, key) {
168
+ function findTags(head, key, entry) {
109
169
  if (key === 'title') {
110
- const title = head.querySelector('title');
111
- return title ? [title] : [];
170
+ // Prefer the last title so a React 19 hoisted title keeps its DOM node and the older
171
+ // template title is the one removed as a duplicate.
172
+ return [...head.querySelectorAll('title')].reverse();
112
173
  }
113
174
  const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
114
175
  if (owned) return [owned];
115
- if (key.startsWith('link:')) return findLinkTags(head, key.slice(5));
116
- const [kind, attribute, ...rest] = key.split(':');
117
- const value = rest.join(':');
118
- if (
119
- kind === 'meta' &&
120
- (attribute === 'name' || attribute === 'property' || attribute === 'http-equiv')
121
- ) {
122
- return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
176
+ if (entry?.match) {
177
+ const matches = [...head.querySelectorAll(entry.kind)].filter((element) =>
178
+ entry.match.every(([name, value]) => element.getAttribute(name) === value),
179
+ );
180
+ if (matches.length > 0) return matches;
181
+ if (entry.adopt) {
182
+ return [...head.querySelectorAll(entry.kind)]
183
+ .filter(
184
+ (element) =>
185
+ element.getAttribute(OWNER_ATTRIBUTE) === null &&
186
+ entry.adopt.every(([name, value]) => element.getAttribute(name) === value),
187
+ )
188
+ .slice(0, 1);
189
+ }
190
+ return [];
123
191
  }
124
- return [];
192
+ return findUntrackedTag(head, entry);
125
193
  }
126
194
 
127
- // Matches links that a previous run or the app itself wrote. Head-owned links carry the
128
- // owner attribute and are found above; this handles adoption on the first run.
129
- function findLinkTags(head, spec) {
130
- const links = [...head.querySelectorAll('link')];
131
- if (spec.startsWith('rel:')) {
132
- const [rel, hreflang, href] = spec.slice(4).split('|');
133
- return links.filter(
134
- (link) =>
135
- link.getAttribute('rel') === rel &&
136
- (hreflang === undefined || link.getAttribute('hreflang') === hreflang) &&
137
- (href === undefined || link.getAttribute('href') === href),
195
+ // Finds an app-written script, style, noscript or base tag by the content it carries, so
196
+ // repeated renders adopt the same element instead of appending a copy.
197
+ function findUntrackedTag(head, entry) {
198
+ if (entry.kind === 'base') {
199
+ const base = head.querySelector('base');
200
+ return base ? [base] : [];
201
+ }
202
+ if (entry.kind === 'script') {
203
+ const src = entry.attrs?.src;
204
+ const scripts = [...head.querySelectorAll('script')];
205
+ if (src) {
206
+ const match = scripts.find((element) => element.getAttribute('src') === src);
207
+ return match ? [match] : [];
208
+ }
209
+ const text = unescapeClosingTag(entry.text ?? '', 'script');
210
+ const match = scripts.find((element) => element.textContent === text);
211
+ return match ? [match] : [];
212
+ }
213
+ if (entry.kind === 'style') {
214
+ const text = unescapeClosingTag(entry.text ?? '', 'style');
215
+ const match = [...head.querySelectorAll('style')].find(
216
+ (element) => element.textContent === text,
138
217
  );
218
+ return match ? [match] : [];
139
219
  }
140
- if (!spec.includes('=')) return [];
141
- const attrs = Object.fromEntries(
142
- spec.split('|').map((pair) => {
143
- const index = pair.indexOf('=');
144
- return [pair.slice(0, index), pair.slice(index + 1)];
145
- }),
146
- );
147
- return links.filter((link) =>
148
- Object.entries(attrs).every(([name, value]) => link.getAttribute(name) === value),
149
- );
220
+ if (entry.kind === 'noscript') {
221
+ const html = entry.html ?? entry.text ?? '';
222
+ const match = [...head.querySelectorAll('noscript')].find(
223
+ (element) => element.innerHTML === html,
224
+ );
225
+ return match ? [match] : [];
226
+ }
227
+ return [];
150
228
  }
151
229
 
230
+ // Creates the element for an entry and records that the head layer owns it.
152
231
  function createTag(head, entry) {
153
232
  const element = document.createElement(entry.kind);
154
233
  createdElements.add(element);
@@ -169,18 +248,34 @@ function removeDuplicate(element, key) {
169
248
  // page load can still tell it apart from one the app wrote and remove rather than restore it.
170
249
  function writeTag(element, entry, key) {
171
250
  snapshotTag(element, key);
251
+ if (!createdElements.has(element) && !element.hasAttribute(CREATED_ATTRIBUTE)) {
252
+ adoptedElements.add(element);
253
+ }
172
254
  element.setAttribute(OWNER_ATTRIBUTE, key);
173
255
  if (createdElements.has(element)) element.setAttribute(CREATED_ATTRIBUTE, '');
174
256
  if (entry.kind === 'title') {
175
- element.textContent = entry.text;
257
+ if (element.textContent !== entry.text) element.textContent = entry.text;
176
258
  return;
177
259
  }
178
260
  for (const attribute of [...element.attributes]) {
179
261
  if (attribute.name === OWNER_ATTRIBUTE || attribute.name === CREATED_ATTRIBUTE) continue;
180
- if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
262
+ if (!Object.hasOwn(entry.attrs ?? {}, attribute.name)) element.removeAttribute(attribute.name);
263
+ }
264
+ for (const [name, value] of Object.entries(entry.attrs ?? {})) {
265
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
266
+ }
267
+ if (entry.kind === 'script' || entry.kind === 'style') {
268
+ const text = entry.text ?? '';
269
+ if (element.textContent !== text) element.textContent = text;
270
+ return;
271
+ }
272
+ if (entry.kind === 'noscript') {
273
+ if (entry.html !== undefined) {
274
+ if (element.innerHTML !== entry.html) element.innerHTML = entry.html;
275
+ } else if (element.textContent !== (entry.text ?? '')) {
276
+ element.textContent = entry.text ?? '';
277
+ }
181
278
  }
182
- for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
183
- if (entry.kind === 'script') element.textContent = entry.text;
184
279
  }
185
280
 
186
281
  // Remembers what a tag looked like before the head layer first touched it, so an app-owned
@@ -196,8 +291,9 @@ function snapshotTag(element, key) {
196
291
  }
197
292
  const snapshot = {
198
293
  attrs: Object.fromEntries(attrs),
199
- text: element.tagName === 'TITLE' || element.tagName === 'SCRIPT' ? element.textContent : null,
294
+ text: ['TITLE', 'SCRIPT', 'STYLE'].includes(element.tagName) ? element.textContent : null,
200
295
  };
296
+ if (element.tagName === 'NOSCRIPT') snapshot.html = element.innerHTML;
201
297
  snapshots.set(element, snapshot);
202
298
  if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
203
299
  // A record from an earlier prerender stays as it is, so a rerun over already generated
@@ -207,11 +303,17 @@ function snapshotTag(element, key) {
207
303
  publishOriginals();
208
304
  }
209
305
 
210
- // Mirrors the recorded originals into a global the prerenderer picks up, so the generated HTML
211
- // can carry them and a later page load can hand the app's own tags back. Published once at
212
- // module load as well, because a rerun must not lose records it did not add itself.
306
+ // Mirrors the recorded originals into a global the prerenderer picks up, so the generated
307
+ // HTML can carry them and a later page load can hand the app's own tags back. Attribute
308
+ // families use reserved keys so they travel beside the tag records.
213
309
  function publishOriginals() {
214
- if (typeof window !== 'undefined') window.__snappyHeadOriginals = originals;
310
+ if (typeof window === 'undefined') return;
311
+ window.__snappyHeadOriginals = {
312
+ ...originals,
313
+ ...(htmlOriginals.size > 0 ? { [ORIGINAL_HTML]: Object.fromEntries(htmlOriginals) } : {}),
314
+ ...(bodyOriginals.size > 0 ? { [ORIGINAL_BODY]: Object.fromEntries(bodyOriginals) } : {}),
315
+ ...(titleOriginals.size > 0 ? { [ORIGINAL_TITLE]: Object.fromEntries(titleOriginals) } : {}),
316
+ };
215
317
  }
216
318
 
217
319
  // Reads the originals an earlier prerender baked into the page.
@@ -244,7 +346,7 @@ function readRecordedOriginal(key) {
244
346
  return key in originals ? originals[key] : null;
245
347
  }
246
348
 
247
- // Replaces every attribute and the text of a tag with a recorded snapshot.
349
+ // Replaces every attribute, the text and the inner markup of a tag with a recorded snapshot.
248
350
  function applySnapshot(element, snapshot) {
249
351
  for (const name of [...element.attributes].map((attribute) => attribute.name)) {
250
352
  element.removeAttribute(name);
@@ -252,20 +354,133 @@ function applySnapshot(element, snapshot) {
252
354
  for (const [name, value] of Object.entries(snapshot.attrs ?? {})) {
253
355
  element.setAttribute(name, value);
254
356
  }
255
- if (snapshot.text !== null && snapshot.text !== undefined) element.textContent = snapshot.text;
357
+ if (snapshot.html !== null && snapshot.html !== undefined) {
358
+ element.innerHTML = snapshot.html;
359
+ } else if (snapshot.text !== null && snapshot.text !== undefined) {
360
+ element.textContent = snapshot.text;
361
+ }
256
362
  }
257
363
 
258
364
  // Re-appends owned tags in merged order, so the emitted head is identical between runs
259
365
  // regardless of the order in which components first rendered their tags.
260
366
  function orderTags(head, merged) {
261
- const order = merged.flatMap((entry) => findTags(head, headKey(entry)));
262
- const owned = [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)];
367
+ const order = merged
368
+ .flatMap((entry) => findTags(head, headKey(entry), entry))
369
+ .filter((element) => element !== externalTitleElement && !adoptedElements.has(element));
370
+ const owned = [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)].filter(
371
+ (element) => !adoptedElements.has(element),
372
+ );
263
373
  const sameOrder =
264
374
  owned.length === order.length && owned.every((element, index) => element === order[index]);
265
375
  if (sameOrder) return;
266
376
  for (const element of order) head.appendChild(element);
267
377
  }
268
378
 
379
+ // Compares the merged entries with the previous pass and reports real additions, removals
380
+ // and value changes. A render that produces the same head never calls a callback.
381
+ function notifyState(merged) {
382
+ const current = new Map(
383
+ merged.map((entry) => [headKey(entry), { signature: entrySignature(entry), entry }]),
384
+ );
385
+ const previous = previousState;
386
+ previousState = current;
387
+ if (!previous) {
388
+ if (current.size === 0) return;
389
+ runCallbacks(
390
+ buildState(merged),
391
+ [...current.values()].map((item) => describeEntry(item.entry)),
392
+ [],
393
+ );
394
+ return;
395
+ }
396
+ const added = [];
397
+ const removed = [];
398
+ for (const [key, item] of current) {
399
+ const before = previous.get(key);
400
+ if (!before || before.signature !== item.signature) added.push(describeEntry(item.entry));
401
+ }
402
+ for (const [key, item] of previous) {
403
+ const after = current.get(key);
404
+ if (!after || after.signature !== item.signature) removed.push(describeEntry(item.entry));
405
+ }
406
+ if (added.length === 0 && removed.length === 0) return;
407
+ runCallbacks(buildState(merged), added, removed);
408
+ }
409
+
410
+ // Groups the merged entries by the shape documented for onChangeClientState.
411
+ function buildState(merged) {
412
+ const state = {
413
+ title: null,
414
+ meta: [],
415
+ link: [],
416
+ script: [],
417
+ style: [],
418
+ noscript: [],
419
+ base: [],
420
+ htmlAttributes: [],
421
+ bodyAttributes: [],
422
+ titleAttributes: [],
423
+ };
424
+ for (const entry of merged) {
425
+ const descriptor = describeEntry(entry);
426
+ if (entry.kind === 'title') state.title = descriptor;
427
+ else if (entry.kind === 'html') state.htmlAttributes.push(descriptor);
428
+ else if (entry.kind === 'body') state.bodyAttributes.push(descriptor);
429
+ else if (entry.kind === 'titleAttribute') state.titleAttributes.push(descriptor);
430
+ else state[entry.kind]?.push(descriptor);
431
+ }
432
+ return state;
433
+ }
434
+
435
+ // Converts one entry into the public callback descriptor, without internal match data.
436
+ function describeEntry(entry) {
437
+ const descriptor = { kind: entry.kind, key: headKey(entry) };
438
+ if (entry.attrs) descriptor.attrs = { ...entry.attrs };
439
+ if (entry.text !== undefined) descriptor.text = entry.text;
440
+ if (entry.html !== undefined) descriptor.html = entry.html;
441
+ if (entry.value !== undefined) descriptor.value = entry.value;
442
+ return descriptor;
443
+ }
444
+
445
+ // Captures everything that affects the rendered DOM, ignoring internal matching helpers.
446
+ function entrySignature(entry) {
447
+ return JSON.stringify([
448
+ headKey(entry),
449
+ entry.kind,
450
+ entry.key ?? null,
451
+ entry.attrs ?? null,
452
+ entry.text ?? null,
453
+ entry.html ?? null,
454
+ entry.value ?? null,
455
+ ]);
456
+ }
457
+
458
+ // Calls each mounted Head's callback once per change. A callback that throws is rethrown
459
+ // asynchronously so one observer cannot stop the head from reconciling.
460
+ function runCallbacks(state, added, removed) {
461
+ const callbacks = [...new Set(stack.map((item) => item.onChangeClientState).filter(Boolean))];
462
+ for (const callback of callbacks) {
463
+ try {
464
+ callback(state, added, removed);
465
+ } catch (error) {
466
+ queueMicrotask(() => {
467
+ throw error;
468
+ });
469
+ }
470
+ }
471
+ }
472
+
473
+ // Reverses escapeClosingTag so an app-written element can be recognised by its real text.
474
+ function unescapeClosingTag(text, tag) {
475
+ return text.replace(new RegExp(`<\\\\/${tag}`, 'gi'), (match) => `</${match.slice(3)}`);
476
+ }
477
+
478
+ // Escapes a value for use inside an attribute selector, including control characters.
269
479
  function cssEscape(value) {
270
- return String(value).replace(/["\\]/g, '\\$&');
480
+ return String(value).replace(/["\\\n\r\f]/g, (char) => {
481
+ if (char === '\n') return '\\a ';
482
+ if (char === '\r') return '\\d ';
483
+ if (char === '\f') return '\\c ';
484
+ return `\\${char}`;
485
+ });
271
486
  }