foldkit 0.147.0 → 0.148.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.
Files changed (61) hide show
  1. package/dist/buildToken.d.ts +3 -0
  2. package/dist/buildToken.d.ts.map +1 -0
  3. package/dist/buildToken.js +21 -0
  4. package/dist/controlledDomState.d.ts +25 -0
  5. package/dist/controlledDomState.d.ts.map +1 -0
  6. package/dist/controlledDomState.js +240 -0
  7. package/dist/cssStyleProperties.d.ts +6 -0
  8. package/dist/cssStyleProperties.d.ts.map +1 -0
  9. package/dist/cssStyleProperties.js +91 -0
  10. package/dist/domReflection.d.ts +73 -0
  11. package/dist/domReflection.d.ts.map +1 -0
  12. package/dist/domReflection.js +557 -0
  13. package/dist/experimental/server/host.d.ts +102 -8
  14. package/dist/experimental/server/host.d.ts.map +1 -1
  15. package/dist/experimental/server/host.js +203 -13
  16. package/dist/experimental/server/public.d.ts +2 -2
  17. package/dist/experimental/server/public.d.ts.map +1 -1
  18. package/dist/experimental/server/public.js +1 -1
  19. package/dist/experimental/server/serialize.d.ts +15 -5
  20. package/dist/experimental/server/serialize.d.ts.map +1 -1
  21. package/dist/experimental/server/serialize.js +367 -144
  22. package/dist/experimental/server/server.d.ts +55 -14
  23. package/dist/experimental/server/server.d.ts.map +1 -1
  24. package/dist/experimental/server/server.js +544 -21
  25. package/dist/experimental/server/template.d.ts +8 -0
  26. package/dist/experimental/server/template.d.ts.map +1 -1
  27. package/dist/experimental/server/template.js +437 -2
  28. package/dist/html/index.d.ts.map +1 -1
  29. package/dist/html/index.js +436 -29
  30. package/dist/hydrate.d.ts +1 -1
  31. package/dist/hydrate.d.ts.map +1 -1
  32. package/dist/hydrate.js +449 -121
  33. package/dist/hydrationMarkers.d.ts +15 -0
  34. package/dist/hydrationMarkers.d.ts.map +1 -0
  35. package/dist/hydrationMarkers.js +70 -0
  36. package/dist/nativeInnerHtml.d.ts +13 -0
  37. package/dist/nativeInnerHtml.d.ts.map +1 -0
  38. package/dist/nativeInnerHtml.js +30 -0
  39. package/dist/propertyProvenance.d.ts +33 -0
  40. package/dist/propertyProvenance.d.ts.map +1 -0
  41. package/dist/propertyProvenance.js +78 -0
  42. package/dist/propsModule.d.ts.map +1 -1
  43. package/dist/propsModule.js +149 -15
  44. package/dist/runtime/public.d.ts +1 -1
  45. package/dist/runtime/public.d.ts.map +1 -1
  46. package/dist/runtime/runtime.d.ts +30 -6
  47. package/dist/runtime/runtime.d.ts.map +1 -1
  48. package/dist/runtime/runtime.js +230 -27
  49. package/dist/snabbdom/attributes.d.ts.map +1 -1
  50. package/dist/snabbdom/attributes.js +65 -37
  51. package/dist/snabbdom/style.d.ts.map +1 -1
  52. package/dist/snabbdom/style.js +53 -34
  53. package/dist/test/apps/attributes.d.ts +1 -0
  54. package/dist/test/apps/attributes.d.ts.map +1 -1
  55. package/dist/test/apps/attributes.js +8 -1
  56. package/dist/test/apps/login.js +1 -1
  57. package/dist/test/matchers.d.ts.map +1 -1
  58. package/dist/test/matchers.js +2 -1
  59. package/dist/test/scene.d.ts.map +1 -1
  60. package/dist/test/scene.js +2 -1
  61. package/package.json +2 -2
package/dist/hydrate.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { Option } from 'effect';
2
+ import { HYDRATION_BUILD_ATTRIBUTE } from './buildToken.js';
3
+ import { controlledStatePropertyNames } from './controlledDomState.js';
4
+ import { BOOLEAN_PROPERTIES, htmlAttributeValue, parsedAttributeName, reflectedAttributeName, serializedHtmlPropertyValue, serializedStylePropertyName, } from './domReflection.js';
5
+ import { HYDRATION_IDENTITY_ATTRIBUTE, HYDRATION_KEY_ATTRIBUTE, hydrationIdentityMarker, hydrationKeyMarker, } from './hydrationMarkers.js';
6
+ import { readNativeInnerHtml, writeNativeInnerHtml } from './nativeInnerHtml.js';
7
+ import { hasTrustedInnerHtml, isClientOnlyProperty, markTrustedInnerHtml, } from './propertyProvenance.js';
2
8
  import { h, toVNode } from './snabbdom/index.js';
3
9
  import { tagNameFromSelector } from './tagName.js';
4
10
  import { dedupeSharedVNodes, patch } from './vdom.js';
@@ -24,19 +30,14 @@ import { dedupeSharedVNodes, patch } from './vdom.js';
24
30
  // untouched and never learns the nodes came from a server.
25
31
  //
26
32
  // Where the DOM disagrees with the vnode tree, the walk clears the nearest
27
- // parent's children and hands `patch` an empty child list, so the subtree is
28
- // rebuilt through `createElm`, which is exactly the pre-hydration replace
29
- // behavior scoped to the mismatching subtree. Trailing vnode children with
30
- // no DOM counterpart are simply absent from the clone; `updateChildren`
31
- // appends them.
33
+ // ordinary parent's children and hands `patch` an empty child list, so the
34
+ // subtree is rebuilt through `createElm`. A Custom Element whose light DOM is
35
+ // declared by the view is a replacement boundary instead: the host and its
36
+ // children are built while detached, exactly as they are during a fresh
37
+ // render. Trailing vnode children with no DOM counterpart are simply absent
38
+ // from the clone; `updateChildren` appends them.
32
39
  const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
33
40
  const HYDRATION_STAMP_ATTRIBUTE = 'data-foldkit-app';
34
- // The style module keys regular properties in camelCase (`backgroundColor`)
35
- // and custom properties as written (`--accent`), so a property read from the
36
- // DOM in kebab case is converted to the module's key form before seeding.
37
- const styleModuleKey = (property) => property.startsWith('--')
38
- ? property
39
- : property.replace(/-([a-z])/g, (_match, character) => character.toUpperCase());
40
41
  const classListOf = (element) => {
41
42
  const classes = {};
42
43
  for (const className of Array.from(element.classList)) {
@@ -46,11 +47,13 @@ const classListOf = (element) => {
46
47
  };
47
48
  const inlineStyleOf = (element) => {
48
49
  const style = {};
49
- if (element instanceof HTMLElement || element instanceof SVGElement) {
50
+ if ('style' in element && element.style instanceof CSSStyleDeclaration) {
50
51
  const inlineStyle = element.style;
51
52
  for (let index = 0; index < inlineStyle.length; index += 1) {
52
53
  const property = inlineStyle.item(index);
53
- style[styleModuleKey(property)] = inlineStyle.getPropertyValue(property);
54
+ const value = inlineStyle.getPropertyValue(property);
55
+ const priority = inlineStyle.getPropertyPriority(property);
56
+ style[property] = priority === '' ? value : `${value} !${priority}`;
54
57
  }
55
58
  }
56
59
  return style;
@@ -81,36 +84,108 @@ const seedStyle = (element, vnode, styleOwnedByModule, isCustomElement) => {
81
84
  if (!styleOwnedByModule) {
82
85
  return {};
83
86
  }
84
- if (!isCustomElement) {
85
- return inlineStyleOf(element);
87
+ const vnodeStyle = vnode.data?.style ?? {};
88
+ const declared = Object.fromEntries(Object.entries(vnodeStyle).flatMap(([key, value]) => typeof value === 'string'
89
+ ? [[serializedStylePropertyName(key), value]]
90
+ : []));
91
+ const style = Reflect.get(element, 'style');
92
+ if (!(style instanceof CSSStyleDeclaration)) {
93
+ return {};
86
94
  }
87
- const current = inlineStyleOf(element);
88
- const declared = {};
89
- for (const [key, value] of Object.entries(vnode.data?.style ?? {})) {
90
- if (typeof value === 'string') {
91
- declared[key] = current[key] ?? '';
95
+ const seeded = isCustomElement
96
+ ? {}
97
+ : inlineStyleOf(element);
98
+ const expected = element.ownerDocument.createElement('span').style;
99
+ const ownedProperties = new Set();
100
+ for (const [propertyName, value] of Object.entries(declared)) {
101
+ expected.setProperty(propertyName, value);
102
+ const ownershipProbe = element.ownerDocument.createElement('span').style;
103
+ ownershipProbe.setProperty(propertyName, value);
104
+ ownedProperties.add(propertyName);
105
+ for (let index = 0; index < ownershipProbe.length; index += 1) {
106
+ ownedProperties.add(ownershipProbe.item(index));
92
107
  }
93
108
  }
94
- return declared;
109
+ for (const propertyName of ownedProperties) {
110
+ delete seeded[propertyName];
111
+ }
112
+ const isEquivalent = Object.keys(declared).every(propertyName => style.getPropertyValue(propertyName) ===
113
+ expected.getPropertyValue(propertyName) &&
114
+ style.getPropertyPriority(propertyName) ===
115
+ expected.getPropertyPriority(propertyName));
116
+ if (isEquivalent) {
117
+ Object.assign(seeded, declared);
118
+ }
119
+ return seeded;
95
120
  };
96
- // Properties the serializer emits as attributes but the client sets as DOM
97
- // properties that do not reflect back to the attribute, so a correct hydration
98
- // drops the server attribute. When a vnode owns one of these through
99
- // `data.props`, the signature compares the live property value instead of the
100
- // attribute, so that expected drop is not read as a disagreement. A view that
101
- // instead sets the same name as a raw attribute keeps it in the attribute set.
102
- const NON_REFLECTING_PROPERTIES = new Set([
103
- 'value',
104
- 'checked',
105
- 'selected',
106
- 'muted',
107
- ]);
108
- const propertyManagedNames = (vnode) => {
109
- const props = vnode.data?.props;
110
- if (props === undefined) {
121
+ const createInertProbe = (element) => {
122
+ const inertDocument = element.ownerDocument.implementation.createHTMLDocument();
123
+ const base = inertDocument.createElement('base');
124
+ base.href = element.ownerDocument.baseURI;
125
+ inertDocument.head.appendChild(base);
126
+ if (element.namespaceURI === null ||
127
+ element.namespaceURI === HTML_NAMESPACE) {
128
+ return inertDocument.createElement(element.localName);
129
+ }
130
+ return inertDocument.createElementNS(element.namespaceURI, element.localName);
131
+ };
132
+ const reflectedPropertyNames = (vnode) => {
133
+ const properties = vnode.data?.props;
134
+ if (properties === undefined) {
111
135
  return [];
112
136
  }
113
- return Array.from(NON_REFLECTING_PROPERTIES).filter(name => name in props);
137
+ return Object.keys(properties).filter(name => !isClientOnlyProperty(properties, name) &&
138
+ reflectedAttributeName(name) !== undefined);
139
+ };
140
+ const reflectedAttributeValue = (tagName, propertyName, value) => {
141
+ if (BOOLEAN_PROPERTIES.has(propertyName)) {
142
+ return value === true ? '' : null;
143
+ }
144
+ if (value === false) {
145
+ return propertyName === 'draggable' ? 'false' : String(value);
146
+ }
147
+ if (propertyName === 'draggable') {
148
+ return value === true ? 'true' : 'false';
149
+ }
150
+ return serializedHtmlPropertyValue(tagName, propertyName, value);
151
+ };
152
+ const isControlledCurrentState = (element, propertyName) => {
153
+ const tagName = element.localName;
154
+ return ((propertyName === 'value' &&
155
+ (tagName === 'input' ||
156
+ tagName === 'textarea' ||
157
+ tagName === 'output' ||
158
+ tagName === 'select')) ||
159
+ (propertyName === 'checked' && tagName === 'input') ||
160
+ (propertyName === 'selected' && tagName === 'option') ||
161
+ (propertyName === 'muted' && (tagName === 'audio' || tagName === 'video')));
162
+ };
163
+ const STALE_REFLECTED_PROPERTY = Symbol('foldkit/stale-reflected-property');
164
+ const seedReflectedProperties = (element, vnode, propertyNames, isCustomElement, status) => {
165
+ const properties = vnode.data?.props;
166
+ if (properties === undefined || propertyNames.length === 0) {
167
+ return {};
168
+ }
169
+ const seeded = {};
170
+ for (const name of propertyNames) {
171
+ const authored = properties[name];
172
+ const attributeName = reflectedAttributeName(name);
173
+ if (attributeName === undefined) {
174
+ continue;
175
+ }
176
+ const isEquivalent = !isCustomElement && isControlledCurrentState(element, name)
177
+ ? Object.is(Reflect.get(element, name), authored)
178
+ : element.getAttribute(attributeName) ===
179
+ reflectedAttributeValue(element.localName, name, authored);
180
+ if (isEquivalent) {
181
+ seeded[name] = authored;
182
+ }
183
+ else {
184
+ seeded[name] = STALE_REFLECTED_PROPERTY;
185
+ detectMismatch(status);
186
+ }
187
+ }
188
+ return seeded;
114
189
  };
115
190
  const byName = ([leftName], [rightName]) => leftName.localeCompare(rightName);
116
191
  // A structured, order-independent snapshot of the state an element and its
@@ -122,14 +197,13 @@ const byName = ([leftName], [rightName]) => leftName.localeCompare(rightName);
122
197
  // APIs, so spelling differences never register, and the snapshot is JSON so no
123
198
  // value can collide with a delimiter. Values feed the comparison, never a log.
124
199
  export const __elementSignature = (element, vnode) => {
125
- const propertyNames = propertyManagedNames(vnode);
200
+ const propertyNames = controlledStatePropertyNames(element, vnode.data?.props);
126
201
  const attributes = [];
127
202
  for (const attribute of Array.from(element.attributes)) {
128
203
  const name = attribute.name;
129
204
  if (name === HYDRATION_STAMP_ATTRIBUTE ||
130
205
  name === 'class' ||
131
- name === 'style' ||
132
- propertyNames.includes(name)) {
206
+ name === 'style') {
133
207
  continue;
134
208
  }
135
209
  attributes.push([name, attribute.value]);
@@ -158,9 +232,20 @@ export const __elementSignature = (element, vnode) => {
158
232
  // The hydration stamp is never seeded, so it is never removed.
159
233
  const seedAdoptedState = (element, vnode, clone, status, isCustomElement) => {
160
234
  const classOwnedByModule = vnode.data?.class !== undefined &&
161
- vnode.data?.attrs?.['class'] === undefined;
235
+ htmlAttributeValue(vnode.data?.attrs, 'class') === undefined;
162
236
  const styleOwnedByModule = vnode.data?.style !== undefined &&
163
- vnode.data?.attrs?.['style'] === undefined;
237
+ htmlAttributeValue(vnode.data?.attrs, 'style') === undefined;
238
+ const propertyNames = reflectedPropertyNames(vnode);
239
+ const propertyAttributeNames = new Set(propertyNames.flatMap(name => {
240
+ const attributeName = reflectedAttributeName(name);
241
+ return attributeName === undefined
242
+ ? []
243
+ : [parsedAttributeName(element.namespaceURI, attributeName)];
244
+ }));
245
+ const datasetAttributeNames = new Map(Object.keys(vnode.data?.dataset ?? {}).map(name => [
246
+ `data-${name.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)}`,
247
+ name,
248
+ ]));
164
249
  // NOTE: a custom element that upgraded before hydration adds attributes of its
165
250
  // own in connectedCallback. Seeding only the attributes the vnode declares
166
251
  // leaves those component-owned attributes in place, while a vnode-declared
@@ -180,6 +265,12 @@ const seedAdoptedState = (element, vnode, clone, status, isCustomElement) => {
180
265
  if (name === 'style' && styleOwnedByModule) {
181
266
  continue;
182
267
  }
268
+ if (propertyAttributeNames.has(name)) {
269
+ continue;
270
+ }
271
+ if (datasetAttributeNames.has(name)) {
272
+ continue;
273
+ }
183
274
  if (declaredAttributes !== undefined &&
184
275
  !declaredAttributes.has(name.toLowerCase())) {
185
276
  continue;
@@ -188,16 +279,28 @@ const seedAdoptedState = (element, vnode, clone, status, isCustomElement) => {
188
279
  }
189
280
  const classes = seedClasses(element, vnode, classOwnedByModule, isCustomElement);
190
281
  const style = seedStyle(element, vnode, styleOwnedByModule, isCustomElement);
282
+ const props = seedReflectedProperties(element, vnode, propertyNames, isCustomElement, status);
283
+ const dataset = {};
284
+ for (const [attributeName, propertyName] of datasetAttributeNames) {
285
+ const value = element.getAttribute(attributeName);
286
+ if (value !== null) {
287
+ dataset[propertyName] = value;
288
+ }
289
+ }
191
290
  clone.data = {
192
291
  ...clone.data,
193
292
  ...(Object.keys(attrs).length > 0 ? { attrs } : {}),
194
293
  ...(Object.keys(classes).length > 0 ? { class: classes } : {}),
195
294
  ...(Object.keys(style).length > 0 ? { style } : {}),
295
+ ...(Object.keys(props).length > 0
296
+ ? { props: { ...clone.data?.props, ...props } }
297
+ : {}),
298
+ ...(Object.keys(dataset).length > 0 ? { dataset } : {}),
196
299
  };
197
300
  // In development, record the server DOM signature so the post-patch pass can
198
301
  // report an attribute-only mismatch the structural walk cannot see. Gated on
199
302
  // the dev flag so a production hydrate does no extra work.
200
- if (import.meta.hot) {
303
+ if (import.meta.hot && !status.adoptedSignatures.has(element)) {
201
304
  status.adoptedSignatures.set(element, {
202
305
  vnode,
203
306
  server: __elementSignature(element, vnode),
@@ -222,13 +325,49 @@ const hasOnlyTextContent = (element) => {
222
325
  return (firstChild === null ||
223
326
  (firstChild.nextSibling === null && isText(firstChild)));
224
327
  };
225
- const matchesTag = (element, selector) => element.tagName.toLowerCase() === tagNameFromSelector(selector).toLowerCase();
328
+ const matchesTag = (element, vnode) => {
329
+ const authored = tagNameFromSelector(vnode.sel ?? '');
330
+ const expected = vnode.data?.ns === undefined ? authored.toLowerCase() : authored;
331
+ return element.localName === expected;
332
+ };
226
333
  // The namespace a vnode expects is carried in `data.ns` for foreign content
227
334
  // (SVG, MathML) and is otherwise HTML. An element whose namespace disagrees
228
335
  // (an HTML element parsed inside an SVG integration point, say) must be
229
336
  // rebuilt rather than adopted, since the two are not interchangeable.
230
337
  const namespaceOf = (vnode) => typeof vnode.data?.ns === 'string' ? vnode.data.ns : HTML_NAMESPACE;
231
338
  const matchesNamespace = (element, vnode) => (element.namespaceURI ?? HTML_NAMESPACE) === namespaceOf(vnode);
339
+ // The server DOM does not encode a vnode's key or identity, so a positional
340
+ // match on tag and namespace alone could adopt a different logical entity: a
341
+ // reordered or stale keyed list would take over the wrong DOM node, and the user
342
+ // state sitting on it. The serializer stamps a digest of key and identity; here
343
+ // the same digest is computed for the vnode and compared, so a mismatch rebuilds
344
+ // instead of transferring state to the wrong row or branch. A key type the
345
+ // digest does not support (a symbol, which a hydratable render refuses) never
346
+ // matches, so it rebuilds rather than adopting on a guess.
347
+ const matchesAdoptionKey = (element, vnode) => {
348
+ const serverKey = element.getAttribute(HYDRATION_KEY_ATTRIBUTE);
349
+ if (vnode.key === undefined) {
350
+ return serverKey === null;
351
+ }
352
+ const clientKey = hydrationKeyMarker(vnode.key);
353
+ if (clientKey === undefined) {
354
+ // A key type the digest cannot represent (a symbol, which a hydratable
355
+ // render refuses) cannot be compared, so it never matches: rebuilding beats
356
+ // adopting on a guess.
357
+ return false;
358
+ }
359
+ return serverKey === clientKey;
360
+ };
361
+ const matchesAdoptionIdentity = (element, vnode) => {
362
+ if (!matchesAdoptionKey(element, vnode)) {
363
+ return false;
364
+ }
365
+ const serverIdentity = element.getAttribute(HYDRATION_IDENTITY_ATTRIBUTE);
366
+ const clientIdentity = vnode.identity === undefined
367
+ ? null
368
+ : hydrationIdentityMarker(vnode.identity);
369
+ return serverIdentity === clientIdentity;
370
+ };
232
371
  const cloneOf = (vnode, elm) => {
233
372
  const clone = {
234
373
  sel: vnode.sel,
@@ -243,6 +382,63 @@ const cloneOf = (vnode, elm) => {
243
382
  }
244
383
  return clone;
245
384
  };
385
+ const isAutonomousCustomElement = (element) => (element.namespaceURI === null || element.namespaceURI === HTML_NAMESPACE) &&
386
+ element.localName.includes('-');
387
+ const hasViewOwnedLightDom = (vnode) => vnode.text !== undefined ||
388
+ (vnode.children !== undefined && vnode.children.length > 0) ||
389
+ hasTrustedInnerHtml(vnode.data?.props);
390
+ const shouldRebuildCustomElement = (element, vnode) => isAutonomousCustomElement(element) && hasViewOwnedLightDom(vnode);
391
+ const collectInsertHooks = (vnode, collected) => {
392
+ const children = vnode.children;
393
+ if (children !== undefined) {
394
+ for (const child of children) {
395
+ if (typeof child !== 'string') {
396
+ collectInsertHooks(child, collected);
397
+ }
398
+ }
399
+ }
400
+ const insert = vnode.data?.hook?.insert;
401
+ if (insert !== undefined) {
402
+ collected.push({ vnode, insert });
403
+ }
404
+ };
405
+ const withoutInsertHooks = (vnode, body) => {
406
+ const deferred = [];
407
+ collectInsertHooks(vnode, deferred);
408
+ for (const { vnode: deferredVNode } of deferred) {
409
+ const hook = deferredVNode.data?.hook;
410
+ if (hook !== undefined) {
411
+ delete hook.insert;
412
+ }
413
+ }
414
+ try {
415
+ return body();
416
+ }
417
+ finally {
418
+ for (const { vnode: deferredVNode, insert } of deferred) {
419
+ const hook = deferredVNode.data?.hook;
420
+ if (hook !== undefined) {
421
+ hook.insert = insert;
422
+ }
423
+ }
424
+ }
425
+ };
426
+ // NOTE: removing children from an adopted Custom Element would run their
427
+ // disconnected callbacks while the host was still live. Those callbacks can
428
+ // synchronously mutate the host after hydration has sampled it. Replace the
429
+ // host with a comment first, then let the ordinary differ build the vnode while
430
+ // detached and insert it in the same position. Its insert hooks remain deferred
431
+ // until the whole hydration tree can fire them in render order.
432
+ const replaceHydrationElement = (element, vnode) => {
433
+ const ownerDocument = element.ownerDocument;
434
+ const parent = element.parentNode ?? ownerDocument.createDocumentFragment();
435
+ if (element.parentNode === null) {
436
+ parent.appendChild(element);
437
+ }
438
+ const placeholder = ownerDocument.createComment('');
439
+ parent.replaceChild(placeholder, element);
440
+ return withoutInsertHooks(vnode, () => patch(toVNode(placeholder), vnode));
441
+ };
246
442
  const asVNode = (child) => typeof child === 'string'
247
443
  ? {
248
444
  sel: undefined,
@@ -253,6 +449,34 @@ const asVNode = (child) => typeof child === 'string'
253
449
  key: undefined,
254
450
  }
255
451
  : child;
452
+ const clonePreparedTree = (vnode) => {
453
+ const elm = vnode.elm;
454
+ if (elm === undefined) {
455
+ throw new Error('[foldkit] A prepared hydration vnode has no DOM node.');
456
+ }
457
+ const clone = cloneOf(vnode, elm);
458
+ clone.data = {
459
+ ...clone.data,
460
+ ...(vnode.data?.on === undefined ? {} : { on: vnode.data.on }),
461
+ ...(vnode.data?.props === undefined ? {} : { props: vnode.data.props }),
462
+ };
463
+ clone.text = vnode.text;
464
+ const children = vnode.children;
465
+ if (children !== undefined) {
466
+ const cloneChildren = [];
467
+ let domChild = elm.firstChild;
468
+ for (const rawChild of children) {
469
+ const child = asVNode(rawChild);
470
+ if (child.elm === undefined) {
471
+ child.elm = domChild ?? undefined;
472
+ }
473
+ cloneChildren.push(clonePreparedTree(child));
474
+ domChild = child.elm?.nextSibling ?? null;
475
+ }
476
+ clone.children = cloneChildren;
477
+ }
478
+ return clone;
479
+ };
256
480
  const clearChildren = (element) => {
257
481
  element.textContent = '';
258
482
  };
@@ -281,43 +505,53 @@ const adoptText = (element, domChild, text) => {
281
505
  }
282
506
  return Option.none();
283
507
  };
284
- const adoptElement = (element, vnode, adopted, status) => {
508
+ const adoptElement = (element, vnode, status) => {
509
+ if (shouldRebuildCustomElement(element, vnode)) {
510
+ return clonePreparedTree(replaceHydrationElement(element, vnode));
511
+ }
285
512
  const clone = cloneOf(vnode, element);
286
- adopted.add(element);
513
+ // Strip the hydration markers the serializer stamped: they are internal to the
514
+ // handoff, already verified by the parent's positional walk before this call,
515
+ // and must not remain on the adopted element.
516
+ element.removeAttribute(HYDRATION_KEY_ATTRIBUTE);
517
+ element.removeAttribute(HYDRATION_IDENTITY_ATTRIBUTE);
287
518
  // NOTE: an autonomous custom element (an HTML-namespace element whose name
288
519
  // carries a hyphen) that upgraded before hydration adds attributes, classes,
289
520
  // styles, and light DOM of its own in connectedCallback. The attributes, class
290
521
  // tokens, and style properties the vnode does not declare are always preserved
291
- // (here and in seedAdoptedState). Light DOM ownership follows the vnode: a
292
- // childless vnode leaves the component's light DOM untouched, while a vnode
293
- // that declares children owns the light DOM and reconciles it like any
294
- // element. The two cannot share: once both write same-tag nodes a positional
295
- // walk cannot tell a component node from a view node, so declared children
296
- // take full ownership rather than interleave. The test is the name shape, not
522
+ // (here and in seedAdoptedState). One that declares no content leaves the
523
+ // component's light DOM untouched. A host with view-owned text, children, or
524
+ // trusted innerHTML is rebuilt before reaching this state-seeding path. The
525
+ // two cannot share because hydration cannot distinguish a component node from
526
+ // a matching view node. The test is the name shape, not
297
527
  // `customElements.get`: whether the element has upgraded is timing-dependent
298
528
  // at hydration (its definition may register after the server DOM parses), so a
299
529
  // name test is deterministic. A hyphenated element that never upgrades is
300
- // treated the same way, which is safe: with no component light DOM, a
301
- // childless vnode leaves an empty element and a vnode with children reconciles
302
- // normally.
303
- const isCustomElement = (element.namespaceURI === null ||
304
- element.namespaceURI === HTML_NAMESPACE) &&
305
- element.localName.includes('-');
306
- seedAdoptedState(element, vnode, clone, status, isCustomElement);
307
- // NOTE: a controlled textarea serializes its value as text content, which
308
- // sets the element's defaultValue. A fresh boot sets only the value
309
- // property and leaves defaultValue empty, so the server text is cleared
310
- // before the props module applies the value; otherwise the adopted
311
- // textarea's defaultValue and form.reset would differ from a fresh boot.
312
- // An uncontrolled textarea, whose content is its default, is left alone.
313
- if (element.tagName === 'TEXTAREA' &&
530
+ // treated the same way, which is safe: with no component light DOM,
531
+ // undeclared content leaves an empty element and declared content is rebuilt.
532
+ const isCustomElement = isAutonomousCustomElement(element);
533
+ const finishAdoption = () => {
534
+ if (!isCustomElement) {
535
+ return clone;
536
+ }
537
+ seedAdoptedState(element, vnode, clone, status, true);
538
+ withoutInsertHooks(vnode, () => patch(clone, vnode));
539
+ return clonePreparedTree(vnode);
540
+ };
541
+ // A controlled textarea or output serializes its value as text content.
542
+ // That text is its parsed default state, which the fresh client path now
543
+ // synchronizes as well. Keep the server text outside the vnode child walk;
544
+ // the controlled property owns it and its insert hook reasserts both current
545
+ // and default state after the patch.
546
+ if ((element.tagName === 'TEXTAREA' || element.tagName === 'OUTPUT') &&
314
547
  vnode.data?.props?.['value'] !== undefined) {
315
- clearChildren(element);
316
548
  clone.children = [];
317
- return clone;
549
+ return finishAdoption();
318
550
  }
319
551
  const authoredInnerHtml = vnode.data?.props?.['innerHTML'];
320
- if (authoredInnerHtml !== undefined) {
552
+ const hasAuthoredInnerHtml = authoredInnerHtml !== undefined && hasTrustedInnerHtml(vnode.data?.props);
553
+ const vnodeChildren = vnode.children;
554
+ if (hasAuthoredInnerHtml) {
321
555
  // NOTE: the browser normalizes markup as it parses (entity forms, tag
322
556
  // case, attribute order), so the served innerHTML string rarely equals
323
557
  // the authored one byte for byte. Parsing the authored string through a
@@ -329,28 +563,38 @@ const adoptElement = (element, vnode, adopted, status) => {
329
563
  // HTML-context parse would lowercase, false-mismatching every camelCase
330
564
  // attribute.
331
565
  if (typeof authoredInnerHtml === 'string') {
332
- const probe = element.namespaceURI === null || element.namespaceURI === HTML_NAMESPACE
333
- ? element.ownerDocument.createElement(element.tagName)
334
- : element.ownerDocument.createElementNS(element.namespaceURI, element.tagName);
335
- probe.innerHTML = authoredInnerHtml;
336
- const isEquivalentMarkup = probe.innerHTML === element.innerHTML;
566
+ const probe = createInertProbe(element);
567
+ writeNativeInnerHtml(probe, authoredInnerHtml);
568
+ const currentInnerHtml = readNativeInnerHtml(element);
569
+ const isEquivalentMarkup = readNativeInnerHtml(probe) === currentInnerHtml;
337
570
  if (!isEquivalentMarkup) {
338
571
  detectMismatch(status);
572
+ writeNativeInnerHtml(element, authoredInnerHtml);
339
573
  }
574
+ const props = {
575
+ ...clone.data?.props,
576
+ innerHTML: authoredInnerHtml,
577
+ };
578
+ markTrustedInnerHtml(props, props.innerHTML);
340
579
  clone.data = {
341
580
  ...clone.data,
342
- props: {
343
- innerHTML: isEquivalentMarkup ? authoredInnerHtml : element.innerHTML,
344
- },
581
+ props,
345
582
  };
346
583
  }
347
584
  else {
348
- clone.data = { ...clone.data, props: { innerHTML: element.innerHTML } };
585
+ const props = {
586
+ ...clone.data?.props,
587
+ innerHTML: readNativeInnerHtml(element),
588
+ };
589
+ markTrustedInnerHtml(props, props.innerHTML);
590
+ clone.data = {
591
+ ...clone.data,
592
+ props,
593
+ };
349
594
  }
350
595
  clone.children = [];
351
- return clone;
596
+ return finishAdoption();
352
597
  }
353
- const vnodeChildren = vnode.children;
354
598
  // NOTE: no children (undefined) and an empty child list both mean the view
355
599
  // declares no children, so they share the childless path. This is also where
356
600
  // a custom element's ownership splits: a childless vnode has no view child to
@@ -386,7 +630,7 @@ const adoptElement = (element, vnode, adopted, status) => {
386
630
  clearChildren(element);
387
631
  clone.children = [];
388
632
  }
389
- return clone;
633
+ return finishAdoption();
390
634
  }
391
635
  const cloneChildren = [];
392
636
  let domChild = element.firstChild;
@@ -402,7 +646,7 @@ const adoptElement = (element, vnode, adopted, status) => {
402
646
  }
403
647
  clearChildren(element);
404
648
  clone.children = [];
405
- return clone;
649
+ return finishAdoption();
406
650
  }
407
651
  const adoption = maybeAdoption.value;
408
652
  const textClone = cloneOf(child, adoption.adoptedNode);
@@ -420,7 +664,7 @@ const adoptElement = (element, vnode, adopted, status) => {
420
664
  detectMismatch(status);
421
665
  clearChildren(element);
422
666
  clone.children = [];
423
- return clone;
667
+ return finishAdoption();
424
668
  }
425
669
  const commentClone = cloneOf(child, domChild);
426
670
  commentClone.text = domChild.data;
@@ -429,15 +673,17 @@ const adoptElement = (element, vnode, adopted, status) => {
429
673
  continue;
430
674
  }
431
675
  if (!isElement(domChild) ||
432
- !matchesTag(domChild, child.sel) ||
433
- !matchesNamespace(domChild, child)) {
676
+ !matchesTag(domChild, child) ||
677
+ !matchesNamespace(domChild, child) ||
678
+ !matchesAdoptionIdentity(domChild, child)) {
434
679
  detectMismatch(status);
435
680
  clearChildren(element);
436
681
  clone.children = [];
437
- return clone;
682
+ return finishAdoption();
438
683
  }
439
- cloneChildren.push(adoptElement(domChild, child, adopted, status));
440
- domChild = domChild.nextSibling;
684
+ const nextDomChild = domChild.nextSibling;
685
+ cloneChildren.push(adoptElement(domChild, child, status));
686
+ domChild = nextDomChild;
441
687
  }
442
688
  while (domChild !== null) {
443
689
  detectMismatch(status);
@@ -446,68 +692,151 @@ const adoptElement = (element, vnode, adopted, status) => {
446
692
  domChild = nextDomChild;
447
693
  }
448
694
  clone.children = cloneChildren;
449
- return clone;
695
+ return finishAdoption();
450
696
  };
451
- const fireAdoptedInsertHooks = (vnode, adopted) => {
452
- const children = vnode.children;
453
- if (children !== undefined) {
454
- for (const child of children) {
455
- if (typeof child !== 'string') {
456
- fireAdoptedInsertHooks(child, adopted);
697
+ // NOTE: structural reconciliation can run Custom Element lifecycle callbacks.
698
+ // One replacement may mutate an ancestor or an earlier sibling after that node
699
+ // was first visited. Sample retained element state and text or comment data only
700
+ // after the entire walk has completed, so the ordinary patch compares the view
701
+ // against the final values those synchronous callbacks left behind. Custom
702
+ // Element code that changes another subtree's structure is outside this pass;
703
+ // components must keep structural DOM writes within their own host.
704
+ const resampleAdoptedTree = (clone, vnode, status) => {
705
+ const elm = clone.elm;
706
+ if (elm !== undefined && isElement(elm)) {
707
+ seedAdoptedState(elm, vnode, clone, status, isAutonomousCustomElement(elm));
708
+ if (vnode.text !== undefined) {
709
+ clone.text = hasOnlyTextContent(elm) ? (elm.textContent ?? '') : undefined;
710
+ }
711
+ }
712
+ else if (elm !== undefined && (isText(elm) || isComment(elm))) {
713
+ clone.text = elm.data;
714
+ }
715
+ const cloneChildren = clone.children;
716
+ const vnodeChildren = vnode.children;
717
+ if (cloneChildren === undefined || vnodeChildren === undefined) {
718
+ return;
719
+ }
720
+ const vnodeChildrenIterator = vnodeChildren.values();
721
+ for (const rawCloneChild of cloneChildren) {
722
+ const nextVnodeChild = vnodeChildrenIterator.next();
723
+ if (nextVnodeChild.done) {
724
+ return;
725
+ }
726
+ resampleAdoptedTree(asVNode(rawCloneChild), asVNode(nextVnodeChild.value), status);
727
+ }
728
+ };
729
+ // NOTE: hydration cannot let the patch fire its own `insert` hooks. The differ
730
+ // queues a hook when it creates a node and flushes the queue when the patch
731
+ // ends, which covers a fresh render but not a hydration, where some nodes are
732
+ // adopted and never created. Firing the created ones from the queue and the
733
+ // adopted ones afterward orders every created node before every adopted one:
734
+ // a `<main>` that adopts one child and creates its sibling ran the sibling's
735
+ // Mount first and the adopted child's second, the reverse of what a fresh
736
+ // render does. A Mount that depends on a sibling being initialized would work
737
+ // on a fresh boot and break on a hydrated one.
738
+ //
739
+ // So the hooks are detached before the patch, which leaves the differ's queue
740
+ // empty, and fired afterward in one pass over the whole tree, children first,
741
+ // the order the differ creates in. Adopted and created nodes are not
742
+ // distinguished, because every node in the new tree has a DOM node by then and
743
+ // a fresh render would have fired all of them.
744
+ const patchFiringInsertHooksInRenderOrder = (adoptedClone, nextVNode) => {
745
+ const deferred = [];
746
+ collectInsertHooks(nextVNode, deferred);
747
+ for (const { vnode } of deferred) {
748
+ const hook = vnode.data?.hook;
749
+ if (hook !== undefined) {
750
+ delete hook.insert;
751
+ }
752
+ }
753
+ let patchedVNode;
754
+ try {
755
+ patchedVNode = patch(adoptedClone, nextVNode);
756
+ }
757
+ finally {
758
+ for (const { vnode, insert } of deferred) {
759
+ const hook = vnode.data?.hook;
760
+ if (hook !== undefined) {
761
+ hook.insert = insert;
457
762
  }
458
763
  }
459
764
  }
460
- const insertHook = vnode.data?.hook?.insert;
461
- if (insertHook !== undefined &&
462
- vnode.elm !== undefined &&
463
- adopted.has(vnode.elm)) {
464
- insertHook(vnode);
765
+ for (const { vnode, insert } of deferred) {
766
+ insert(vnode);
465
767
  }
768
+ return patchedVNode;
466
769
  };
467
770
  /** Hydrates a server-rendered root element against the first render's vnode
468
- * tree. Matching DOM nodes are adopted in place, so pre-rendered content is
469
- * never torn down on boot: module hooks attach listeners and re-assert
470
- * attrs and props onto the existing elements, and `insert` hooks (Mounts)
471
- * fire for adopted nodes in the same children-first order the differ uses
472
- * for created ones. A mismatching subtree falls back to a rebuild through
473
- * `createElm` at the nearest parent, and a root-level mismatch falls back
474
- * to the pre-hydration replace boot. Development builds warn when
475
- * reconciliation is required. Returns the patched vnode to store as the
476
- * runtime's current tree. */
771
+ * tree. Matching DOM nodes are adopted in place: module hooks attach
772
+ * listeners and re-assert attrs and props onto the existing elements, and
773
+ * `insert` hooks (Mounts) fire for adopted nodes in the same children-first
774
+ * order the differ uses for created ones. A mismatching subtree falls back to
775
+ * a rebuild through `createElm` at the nearest parent. A Custom Element with
776
+ * view-owned light DOM is replaced so creation follows the fresh-render
777
+ * lifecycle. Any root-level mismatch also replaces the root. Development
778
+ * builds warn when reconciliation is required. Returns the patched vnode to
779
+ * store as the runtime's current tree. */
477
780
  // Replace the hydration root with a fresh render of the vnode. snabbdom's
478
781
  // sameVnode compares tag but not namespace, so patching the root directly
479
782
  // would reuse a same-tag element even across a namespace change. Patching
480
783
  // against a comment placed where the root was is never sameVnode with a new
481
784
  // element, so the differ builds a fresh node in the correct namespace and
482
785
  // swaps it in.
786
+ // A root with no parent gets one. Patching a detached root directly is the same
787
+ // reuse this function exists to avoid: snabbdom's sameVnode compares tag alone,
788
+ // so a same-tag root is kept along with the DOM state on it and its insert hooks
789
+ // never fire, which is how a rejected page could keep its own elements and their
790
+ // typed values. A fragment gives the placeholder a parent, so the replacement is
791
+ // built and swapped in exactly as it is for an attached root.
483
792
  const replaceHydrationRoot = (hydrationRoot, vnode) => {
484
- const parent = hydrationRoot.parentNode;
485
- if (parent === null) {
486
- return patch(toVNode(hydrationRoot), vnode);
793
+ const ownerDocument = hydrationRoot.ownerDocument;
794
+ const parent = hydrationRoot.parentNode ?? ownerDocument.createDocumentFragment();
795
+ if (hydrationRoot.parentNode === null) {
796
+ parent.appendChild(hydrationRoot);
487
797
  }
488
- const placeholder = hydrationRoot.ownerDocument.createComment('');
798
+ const placeholder = ownerDocument.createComment('');
489
799
  parent.replaceChild(placeholder, hydrationRoot);
490
800
  return patch(toVNode(placeholder), vnode);
491
801
  };
492
- export const __hydrateVNode = (hydrationRoot, nextVNode, seen) => {
802
+ export const __hydrateVNode = (hydrationRoot, nextVNode, seen, buildId) => {
493
803
  const dedupedVNode = nextVNode !== null ? dedupeSharedVNodes(nextVNode, seen) : h('!');
494
804
  const status = {
495
805
  isMismatchDetected: false,
496
806
  adoptedSignatures: new Map(),
497
807
  };
498
- if (dedupedVNode.sel === undefined ||
808
+ // The build token is checked again here, though the runtime has already
809
+ // refused a mismatch before reading the handoff. This is the last line of
810
+ // defense for a caller reaching the adoption step directly, and it never
811
+ // matches an absent marker: `buildId` is required and non-empty, so a page
812
+ // served before build ids existed cannot pass for one of this build's.
813
+ const servedBuild = hydrationRoot.getAttribute(HYDRATION_BUILD_ATTRIBUTE);
814
+ const isSameBuild = buildId !== '' && servedBuild === buildId;
815
+ hydrationRoot.removeAttribute(HYDRATION_BUILD_ATTRIBUTE);
816
+ // The root is checked for logical identity the same way every other adopted
817
+ // element is. A root whose key or view identity disagrees with the served one
818
+ // is a different logical root, so it is rebuilt rather than adopted: adopting
819
+ // it would carry the previous root's DOM state (a typed input's value) into a
820
+ // root the client never rendered there.
821
+ const isRootMismatch = !isSameBuild ||
822
+ dedupedVNode.sel === undefined ||
499
823
  dedupedVNode.sel === '' ||
500
824
  dedupedVNode.sel === '!' ||
501
- !matchesTag(hydrationRoot, dedupedVNode.sel) ||
502
- !matchesNamespace(hydrationRoot, dedupedVNode)) {
825
+ !matchesTag(hydrationRoot, dedupedVNode) ||
826
+ !matchesNamespace(hydrationRoot, dedupedVNode) ||
827
+ !matchesAdoptionIdentity(hydrationRoot, dedupedVNode);
828
+ if (isRootMismatch) {
503
829
  detectMismatch(status);
504
830
  const patchedVNode = replaceHydrationRoot(hydrationRoot, dedupedVNode);
505
831
  reportMismatch(status);
506
832
  return patchedVNode;
507
833
  }
508
- const adopted = new Set();
509
- const adoptedClone = adoptElement(hydrationRoot, dedupedVNode, adopted, status);
510
- const patchedVNode = patch(adoptedClone, dedupedVNode);
834
+ if (shouldRebuildCustomElement(hydrationRoot, dedupedVNode)) {
835
+ return replaceHydrationRoot(hydrationRoot, dedupedVNode);
836
+ }
837
+ const adoptedClone = adoptElement(hydrationRoot, dedupedVNode, status);
838
+ resampleAdoptedTree(adoptedClone, dedupedVNode, status);
839
+ const patchedVNode = patchFiringInsertHooksInRenderOrder(adoptedClone, dedupedVNode);
511
840
  if (import.meta.hot && !status.isMismatchDetected) {
512
841
  for (const [element, { vnode, server }] of status.adoptedSignatures) {
513
842
  if (__elementSignature(element, vnode) !== server) {
@@ -516,7 +845,6 @@ export const __hydrateVNode = (hydrationRoot, nextVNode, seen) => {
516
845
  }
517
846
  }
518
847
  }
519
- fireAdoptedInsertHooks(patchedVNode, adopted);
520
848
  reportMismatch(status);
521
849
  return patchedVNode;
522
850
  };