foldkit 0.147.0 → 0.148.1

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 (63) 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 +373 -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 +546 -22
  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 +72 -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 +33 -6
  47. package/dist/runtime/runtime.d.ts.map +1 -1
  48. package/dist/runtime/runtime.js +240 -27
  49. package/dist/snabbdom/attributes.d.ts.map +1 -1
  50. package/dist/snabbdom/attributes.js +65 -37
  51. package/dist/snabbdom/class.d.ts.map +1 -1
  52. package/dist/snabbdom/class.js +61 -5
  53. package/dist/snabbdom/style.d.ts.map +1 -1
  54. package/dist/snabbdom/style.js +57 -34
  55. package/dist/test/apps/attributes.d.ts +1 -0
  56. package/dist/test/apps/attributes.d.ts.map +1 -1
  57. package/dist/test/apps/attributes.js +8 -1
  58. package/dist/test/apps/login.js +1 -1
  59. package/dist/test/matchers.d.ts.map +1 -1
  60. package/dist/test/matchers.js +2 -1
  61. package/dist/test/scene.d.ts.map +1 -1
  62. package/dist/test/scene.js +2 -1
  63. package/package.json +2 -2
@@ -1,9 +1,13 @@
1
- import { Array as Array_, Context, Data, Effect, Option, Predicate, Schema, pipe, } from 'effect';
2
- import { parseFragment } from 'parse5';
1
+ import { Array as Array_, Context, Data, Effect, Option, Predicate, Schema, String, pipe, } from 'effect';
2
+ import { html as Parse5Html, defaultTreeAdapter, parse, parseFragment, } from 'parse5';
3
+ import { HYDRATION_BUILD_ATTRIBUTE } from '../../buildToken.js';
4
+ import { MATHML_NAMESPACE, SVG_NAMESPACE, parsedAttributeName, } from '../../domReflection.js';
3
5
  import { beginRender, createBoundaryRegistry } from '../../html/boundary.js';
4
6
  import { __htmlBuilder as htmlBuilderFor, textDirectionToAttribute, } from '../../html/index.js';
5
7
  import { clearRuntime, setRuntime, } from '../../html/runtimeSingleton.js';
6
8
  import { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE, } from '../../hydrationMarker.js';
9
+ import { HYDRATION_IDENTITY_ATTRIBUTE, HYDRATION_KEY_ATTRIBUTE, } from '../../hydrationMarkers.js';
10
+ import { hasTrustedInnerHtml } from '../../propertyProvenance.js';
7
11
  import { tagNameFromSelector } from '../../tagName.js';
8
12
  import { fromString } from '../../url/index.js';
9
13
  import { controlledValueContent, escapeAttributeValue, serializeHtml, } from './serialize.js';
@@ -123,6 +127,14 @@ const structureMismatch = (parsed) => {
123
127
  'mismatch. Write the structure HTML parsing produces, such as explicit ' +
124
128
  'table sections.');
125
129
  };
130
+ const expectedParsedTagName = (vnode) => {
131
+ const tagName = tagNameFromSelector(vnode.sel ?? '');
132
+ return vnode.data?.ns === undefined ? tagName.toLowerCase() : tagName;
133
+ };
134
+ const foreignTagNameMismatch = (parsedTagName, authoredTagName) => new Error(`[foldkit] HTML parsing changed the foreign-content tag ` +
135
+ `<${authoredTagName}> to <${parsedTagName}>. SVG and MathML tag names ` +
136
+ 'are case-sensitive when the client creates them. Use the canonical ' +
137
+ 'tag spelling so the served and fresh client DOM agree.');
126
138
  // Compare the child structure the browser parsed against the structure the
127
139
  // view declared, recursively. The top-level check rejects a root that splits
128
140
  // into siblings; this rejects a parser correction inside the root, whether an
@@ -130,9 +142,12 @@ const structureMismatch = (parsed) => {
130
142
  // foster-parented text, which hydration would otherwise see as a whole-subtree
131
143
  // mismatch.
132
144
  const assertStructureMatches = (parsed, vnode) => {
133
- // InnerHTML owns an opaque, parser-produced subtree that the vnode does not
134
- // model as children, so it is left unwalked.
135
- if (vnode.data?.props?.['innerHTML'] !== undefined) {
145
+ // `h.InnerHTML` owns an opaque, parser-produced subtree that the vnode does
146
+ // not model as children, so it is left unwalked. A property that merely
147
+ // carries the name is not that: the serializer emits the vnode's own children
148
+ // for it, so they are walked like any others.
149
+ if (hasTrustedInnerHtml(vnode.data?.props)) {
150
+ assertInnerHtmlContextMatches(parsed, vnode);
136
151
  return;
137
152
  }
138
153
  const parsedChildren = normalizeParsedChildren(parsed);
@@ -148,11 +163,16 @@ const assertStructureMatches = (parsed, vnode) => {
148
163
  throw structureMismatch(parsed);
149
164
  }
150
165
  if (parsedChild.kind === 'Element' && vnodeChild.kind === 'Element') {
151
- const expectedTag = tagNameFromSelector(vnodeChild.vnode.sel ?? '').toLowerCase();
166
+ const expectedTag = expectedParsedTagName(vnodeChild.vnode);
152
167
  const expectedNamespace = typeof vnodeChild.vnode.data?.ns === 'string'
153
168
  ? vnodeChild.vnode.data.ns
154
169
  : HTML_NAMESPACE;
155
- if (parsedChild.element.tagName.toLowerCase() !== expectedTag ||
170
+ if (expectedNamespace !== HTML_NAMESPACE &&
171
+ parsedChild.element.namespaceURI === expectedNamespace &&
172
+ parsedChild.element.tagName !== expectedTag) {
173
+ throw foreignTagNameMismatch(parsedChild.element.tagName, expectedTag);
174
+ }
175
+ if (parsedChild.element.tagName !== expectedTag ||
156
176
  parsedChild.element.namespaceURI !== expectedNamespace) {
157
177
  throw new Error(`[foldkit] HTML parsing produced <${parsedChild.element.tagName}> ` +
158
178
  `where the view declared <${expectedTag}> inside ` +
@@ -190,6 +210,419 @@ const buildDivFragmentContext = () => {
190
210
  return onlyChild;
191
211
  };
192
212
  const DIV_FRAGMENT_CONTEXT = buildDivFragmentContext();
213
+ // After serialization, confirm the root closes cleanly. An unterminated element
214
+ // inside the root (an unclosed `<textarea>`, `<script>`, `<style>`, comment, or
215
+ // `<plaintext>`, typically from an incomplete `InnerHTML` fragment) does not end
216
+ // at the root's own close tag, so it would swallow the Flags payload, the client
217
+ // entry, and everything after the root when the served document is parsed. The
218
+ // per-element structure walk skips `InnerHTML` subtrees, so this parses the root
219
+ // markup with a trailing sentinel comment and requires the sentinel to survive
220
+ // as a top-level sibling: if the root consumed it, so would the rest of the page.
221
+ //
222
+ // NOTE: the parse runs twice, because `<noscript>` has two parser modes and the
223
+ // document has to survive both. With scripting enabled, the state a hydrating
224
+ // visitor's browser is in, noscript content is raw text. With scripting
225
+ // disabled, the state noscript exists to serve, the same content parses as HTML,
226
+ // so an unterminated element inside a noscript fallback swallows the rest of the
227
+ // page for exactly the visitors the fallback was written for. Both modes are
228
+ // checked, so a fallback that frames cleanly for one visitor cannot break the
229
+ // document for the other.
230
+ const TRAILING_SENTINEL_DATA = 'foldkit-trailing-boundary';
231
+ const TRAILING_SENTINEL = `<!--${TRAILING_SENTINEL_DATA}-->`;
232
+ const closesCleanly = (html, isScriptingEnabled) => {
233
+ const fragment = parseFragment(DIV_FRAGMENT_CONTEXT, html + TRAILING_SENTINEL, {
234
+ scriptingEnabled: isScriptingEnabled,
235
+ });
236
+ const significant = fragment.childNodes.filter(node => !isIgnorableText(node));
237
+ const last = significant[significant.length - 1];
238
+ return (last !== undefined &&
239
+ last.nodeName === '#comment' &&
240
+ 'data' in last &&
241
+ last.data === TRAILING_SENTINEL_DATA);
242
+ };
243
+ // Framing is not the only thing `<noscript>` can break. An element the fallback
244
+ // leaves open does not have to reach the end of the document to do damage: with
245
+ // scripting disabled an unclosed `<form>` or `<table>` stops at the root's close
246
+ // tag, so the document still frames cleanly, while everything between it and
247
+ // that close tag is pulled inside the fallback and disappears from the page a
248
+ // visitor without JavaScript sees.
249
+ //
250
+ // The check is a comparison rather than a scan, because the two parser modes
251
+ // have to agree on everything except the one place they are meant to differ.
252
+ // The serialized root is parsed both ways and the trees compared, descending
253
+ // into every element except `<noscript>`, whose content is raw text in one mode
254
+ // and markup in the other by design. What must match is where each `<noscript>`
255
+ // sits and what follows it. Comparing the parsed output rather than the declared
256
+ // vnodes also covers a `<noscript>` that arrives inside an `h.InnerHTML`
257
+ // fragment, which no walk over the view's own children can see.
258
+ // `<noscript>` means something to the parser only in the HTML namespace. Inside
259
+ // SVG or MathML an element of that name is an ordinary foreign element whose
260
+ // content parses the same way in both modes, so skipping it there would leave a
261
+ // real HTML `<noscript>` nested below it unchecked. `<template>` holds its
262
+ // children in a separate content fragment rather than in `childNodes`, so its
263
+ // content is traversed explicitly; a fallback inside one is invisible otherwise,
264
+ // and declarative shadow DOM makes that content live in the page.
265
+ const isHtmlNoscript = (element) => element.tagName.toLowerCase() === 'noscript' &&
266
+ element.namespaceURI === HTML_NAMESPACE;
267
+ const traversableContent = (element) => {
268
+ if (element.tagName.toLowerCase() === 'template' &&
269
+ element.namespaceURI === HTML_NAMESPACE &&
270
+ 'content' in element) {
271
+ const content = element.content;
272
+ if (Predicate.isObject(content) &&
273
+ Predicate.hasProperty(content, 'childNodes')) {
274
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
275
+ return content;
276
+ }
277
+ }
278
+ return element;
279
+ };
280
+ const parsedChildrenAgree = (scripted, unscripted) => {
281
+ const scriptedChildren = normalizeParsedChildren(scripted);
282
+ const unscriptedChildren = normalizeParsedChildren(unscripted);
283
+ if (scriptedChildren.length !== unscriptedChildren.length) {
284
+ return false;
285
+ }
286
+ for (const [scriptedChild, unscriptedChild] of Array_.zip(scriptedChildren, unscriptedChildren)) {
287
+ if (scriptedChild.kind !== unscriptedChild.kind) {
288
+ return false;
289
+ }
290
+ if (scriptedChild.kind === 'Element' &&
291
+ unscriptedChild.kind === 'Element') {
292
+ const tagName = scriptedChild.element.tagName.toLowerCase();
293
+ if (tagName !== unscriptedChild.element.tagName.toLowerCase() ||
294
+ scriptedChild.element.namespaceURI !==
295
+ unscriptedChild.element.namespaceURI ||
296
+ !parsedAttributesAgree(scriptedChild.element, unscriptedChild.element)) {
297
+ return false;
298
+ }
299
+ if (isHtmlNoscript(scriptedChild.element)) {
300
+ continue;
301
+ }
302
+ if (!parsedChildrenAgree(traversableContent(scriptedChild.element), traversableContent(unscriptedChild.element))) {
303
+ return false;
304
+ }
305
+ }
306
+ else if (scriptedChild.kind === 'Text' &&
307
+ unscriptedChild.kind === 'Text') {
308
+ if (scriptedChild.text !== unscriptedChild.text) {
309
+ return false;
310
+ }
311
+ }
312
+ else if (scriptedChild.kind === 'Comment' &&
313
+ unscriptedChild.kind === 'Comment') {
314
+ if (scriptedChild.text !== unscriptedChild.text) {
315
+ return false;
316
+ }
317
+ }
318
+ }
319
+ return true;
320
+ };
321
+ const parsedAttributesAgree = (left, right) => {
322
+ const signature = (element) => element.attrs
323
+ .map(attribute => JSON.stringify([
324
+ attribute.namespace ?? '',
325
+ attribute.prefix ?? '',
326
+ attribute.name,
327
+ attribute.value,
328
+ ]))
329
+ .sort();
330
+ const leftAttributes = signature(left);
331
+ const rightAttributes = signature(right);
332
+ return (leftAttributes.length === rightAttributes.length &&
333
+ leftAttributes.every((attribute, index) => attribute === rightAttributes[index]));
334
+ };
335
+ const assertInnerHtmlContextMatches = (parsed, vnode) => {
336
+ const innerHtml = vnode.data?.props?.['innerHTML'];
337
+ if (typeof innerHtml !== 'string') {
338
+ return;
339
+ }
340
+ const context = defaultTreeAdapter.createElement(parsed.tagName, parsed.namespaceURI, parsed.attrs.map(attribute => ({ ...attribute })));
341
+ const freshChildren = parseFragment(context, innerHtml, {
342
+ scriptingEnabled: true,
343
+ });
344
+ if (parsedChildrenAgree(traversableContent(parsed), freshChildren)) {
345
+ return;
346
+ }
347
+ throw new Error(`[foldkit] h.InnerHTML on <${parsed.tagName}> parses differently when ` +
348
+ 'assigned to a fresh element than when the serialized page is parsed ' +
349
+ 'under its ancestors. Ancestor parser state can insert, move, or drop ' +
350
+ 'nodes from the server DOM (for example, a nested <form> is dropped ' +
351
+ 'under an existing form), so the server and client cannot describe one ' +
352
+ 'tree. Move the fragment outside that context or build the content with ' +
353
+ 'ordinary view children.');
354
+ };
355
+ const assertNoscriptModesAgree = (html) => {
356
+ const scripted = parseFragment(DIV_FRAGMENT_CONTEXT, html, {
357
+ scriptingEnabled: true,
358
+ });
359
+ const unscripted = parseFragment(DIV_FRAGMENT_CONTEXT, html, {
360
+ scriptingEnabled: false,
361
+ });
362
+ if (parsedChildrenAgree(scripted, unscripted)) {
363
+ return;
364
+ }
365
+ throw new Error('[foldkit] <noscript> content changes the rest of the page when a browser ' +
366
+ 'parses it with scripting disabled. Its content is raw text while ' +
367
+ 'scripting is enabled and ordinary HTML when it is not, so fallback ' +
368
+ 'markup that leaves an element open (a <form>, <table>, <textarea>, ' +
369
+ '<style>, or comment) pulls the markup that follows the <noscript> ' +
370
+ 'inside it, and the visitors the fallback was written for never see it. ' +
371
+ 'Ensure the fallback is complete and balanced.');
372
+ };
373
+ // Declarative shadow DOM is refused rather than modeled. A browser turns a
374
+ // `<template shadowrootmode>` into a shadow root as it parses, moving its
375
+ // content out of the light DOM entirely, which neither the parser these checks
376
+ // use nor hydration's own probe reproduces. The served page and the tree
377
+ // hydration reconciles would stop describing the same ownership structure.
378
+ //
379
+ // The scan runs on the rendered markup, so it covers a fragment that arrived
380
+ // through `h.InnerHTML` as well as elements the view declared. It descends into
381
+ // template content, where a nested declaration would otherwise sit unseen, and
382
+ // it parses both scripting modes, because a `<noscript>` holds raw text in one
383
+ // and live markup in the other.
384
+ const DECLARATIVE_SHADOW_ROOT_ATTRIBUTES = [
385
+ 'shadowrootmode',
386
+ 'shadowroot',
387
+ ];
388
+ const declaresShadowRoot = (element) => element.tagName.toLowerCase() === 'template' &&
389
+ element.namespaceURI === HTML_NAMESPACE &&
390
+ DECLARATIVE_SHADOW_ROOT_ATTRIBUTES.some(name => element.attrs.some(attribute => attribute.name === name));
391
+ // NOTE: one recursion per element. `traversableContent` returns a template's
392
+ // content fragment and the element itself for everything else, so descending
393
+ // into both it and the element visits an ordinary subtree twice, which doubles
394
+ // the work at every level. A tree the serializer accepts (depth up to 1000) took
395
+ // most of a second at depth 24 and would have blocked the event loop well before
396
+ // the depth limit.
397
+ const hasDeclarativeShadowRoot = (node) => node.childNodes.some(child => isParse5Element(child) &&
398
+ (declaresShadowRoot(child) ||
399
+ hasDeclarativeShadowRoot(traversableContent(child))));
400
+ export const __assertNoDeclarativeShadowRoot = (html) => {
401
+ const declaresInEitherMode = [true, false].some(scriptingEnabled => hasDeclarativeShadowRoot(parseFragment(DIV_FRAGMENT_CONTEXT, html, { scriptingEnabled })));
402
+ if (!declaresInEitherMode) {
403
+ return;
404
+ }
405
+ throw new Error('[foldkit] A rendered <template> declares a shadow root, which server ' +
406
+ 'rendering does not support. A browser turns it into a shadow root ' +
407
+ 'while parsing, moving its content out of the light DOM, so the served ' +
408
+ 'page and the tree hydration reconciles no longer describe the same ' +
409
+ 'thing. Attach shadow roots from a custom element instead.');
410
+ };
411
+ // A <base> parsed anywhere in the live HTML tree changes how every relative URL
412
+ // after it resolves, including the client entry a host writes after the rendered
413
+ // application. The browser applies a base element in body before hydration can
414
+ // remove it, so a view could redirect its own bootstrap module to another
415
+ // origin. Template content is inert and does not participate in URL resolution;
416
+ // declarative shadow templates are refused separately.
417
+ const hasLiveBaseElement = (node) => {
418
+ for (const child of node.childNodes) {
419
+ if (!isParse5Element(child)) {
420
+ continue;
421
+ }
422
+ const tagName = child.tagName.toLowerCase();
423
+ if (child.namespaceURI === HTML_NAMESPACE && tagName === 'base') {
424
+ return true;
425
+ }
426
+ if (child.namespaceURI === HTML_NAMESPACE && tagName === 'template') {
427
+ continue;
428
+ }
429
+ if (hasLiveBaseElement(child)) {
430
+ return true;
431
+ }
432
+ }
433
+ return false;
434
+ };
435
+ export const __assertNoLiveBaseElement = (html) => {
436
+ const hasBaseInEitherMode = [true, false].some(scriptingEnabled => hasLiveBaseElement(parseFragment(DIV_FRAGMENT_CONTEXT, html, { scriptingEnabled })));
437
+ if (!hasBaseInEitherMode) {
438
+ return;
439
+ }
440
+ throw new Error('[foldkit] Rendered application markup contains a live HTML <base> ' +
441
+ 'element. A browser applies it before hydration, so it changes every ' +
442
+ 'relative URL that follows the application, including the client entry ' +
443
+ 'module, and can redirect startup to another origin. Put <base> in the ' +
444
+ 'HTML template head under host control instead.');
445
+ };
446
+ // A page the rendered markup is placed into, with a probe element standing
447
+ // where the application root goes. Parsing the whole document is what makes a
448
+ // structural escape visible: `<html>` and `<body>` start tags inside the markup
449
+ // are dropped by a fragment parse, so a check that only ever sees a fragment
450
+ // reads them as harmless. A browser instead merges their attributes onto the
451
+ // document's own elements and hoists their content, which puts them outside the
452
+ // application's ownership entirely.
453
+ const DOCUMENT_PROBE_ID = 'foldkit-document-probe';
454
+ const DOCUMENT_PROBE_PREFIX = '<!doctype html><html><head><title>probe</title></head><body><div id="' +
455
+ DOCUMENT_PROBE_ID +
456
+ '">';
457
+ const DOCUMENT_PROBE_SUFFIX = '</div></body></html>';
458
+ const childElementsOf = (node) => node.childNodes.filter(isParse5Element);
459
+ const findByTag = (node, tagName) => childElementsOf(node).find(element => element.tagName.toLowerCase() === tagName);
460
+ const escapesDocumentStructure = (html, scriptingEnabled) => {
461
+ const document = parse(`${DOCUMENT_PROBE_PREFIX}${html}${DOCUMENT_PROBE_SUFFIX}`, { scriptingEnabled });
462
+ const documentElement = findByTag(document, 'html');
463
+ if (documentElement === undefined) {
464
+ return true;
465
+ }
466
+ const head = findByTag(documentElement, 'head');
467
+ const body = findByTag(documentElement, 'body');
468
+ if (head === undefined || body === undefined) {
469
+ return true;
470
+ }
471
+ const probe = childElementsOf(body)[0];
472
+ return (documentElement.attrs.length > 0 ||
473
+ body.attrs.length > 0 ||
474
+ head.attrs.length > 0 ||
475
+ childElementsOf(head).length !== 1 ||
476
+ childElementsOf(body).length !== 1 ||
477
+ probe === undefined ||
478
+ probe.attrs.find(attribute => attribute.name === 'id')?.value !==
479
+ DOCUMENT_PROBE_ID);
480
+ };
481
+ export const __assertNoDocumentStructureEscape = (html) => {
482
+ if (![true, false].some(mode => escapesDocumentStructure(html, mode))) {
483
+ return;
484
+ }
485
+ throw new Error('[foldkit] The rendered markup changes the document it is placed into. ' +
486
+ 'An <html>, <head>, <body>, or <frameset> tag inside it, typically from ' +
487
+ 'an InnerHTML fragment, is not rendered where it is written: a browser ' +
488
+ "merges its attributes onto the page's own elements and hoists its " +
489
+ 'content out of the application root, so the result is neither the ' +
490
+ 'markup the view wrote nor anything the application owns. Remove those ' +
491
+ 'tags from the fragment.');
492
+ };
493
+ const assertRootClosesCleanly = (html) => {
494
+ if (closesCleanly(html, true) && closesCleanly(html, false)) {
495
+ return;
496
+ }
497
+ throw new Error('[foldkit] The rendered root does not close cleanly. An unterminated ' +
498
+ 'element (an unclosed <textarea>, <script>, <style>, comment, or ' +
499
+ '<plaintext>, typically inside an InnerHTML fragment) would swallow the ' +
500
+ 'Flags payload, the client entry, and the rest of the document when the ' +
501
+ 'page is parsed. The check covers <noscript> fallback markup with ' +
502
+ 'scripting disabled too, where the content parses as HTML rather than as ' +
503
+ 'raw text. Ensure InnerHTML fragments are complete and balanced.');
504
+ };
505
+ // The tags that name a document's own structure. A browser builds these from
506
+ // the served document, not from markup spliced into it: a `<body>` or `<head>`
507
+ // start tag encountered inside the document body is dropped and its children
508
+ // are hoisted, an `<html>` start tag only merges its attributes onto the
509
+ // document element, and `<frameset>` replaces the body outright. A view rooted
510
+ // at one of them therefore serializes to markup no template can hold, so it is
511
+ // refused by name rather than surfacing later as a parser-rearranged root.
512
+ const DOCUMENT_STRUCTURE_TAGS = new Set([
513
+ 'body',
514
+ 'frameset',
515
+ 'head',
516
+ 'html',
517
+ ]);
518
+ const assertRootIsNotDocumentStructure = (root) => {
519
+ const tagName = tagNameFromSelector(root.sel ?? '').toLowerCase();
520
+ if (!DOCUMENT_STRUCTURE_TAGS.has(tagName)) {
521
+ return;
522
+ }
523
+ throw new Error(`[foldkit] The view root is <${tagName}>, which names the structure of a ` +
524
+ 'document rather than content placed into one. A browser builds those ' +
525
+ 'elements from the document it parses, so the tag is dropped, merged, ' +
526
+ 'or replaces the body when the rendered markup is spliced into a ' +
527
+ 'template, and the served root is not the element the view wrote. This ' +
528
+ 'holds for static output too. Root the view at an ordinary element ' +
529
+ 'such as a <div> or <main>, and set the document title, lang, and dir ' +
530
+ 'through the Document the view returns.');
531
+ };
532
+ const RESERVED_HANDOFF_ATTRIBUTES = new Set([
533
+ FOLDKIT_APP_ATTRIBUTE,
534
+ HYDRATION_BUILD_ATTRIBUTE,
535
+ FOLDKIT_FLAGS_ATTRIBUTE,
536
+ HYDRATION_KEY_ATTRIBUTE,
537
+ HYDRATION_IDENTITY_ATTRIBUTE,
538
+ ]);
539
+ const reservedHandoffAttributeIn = (node) => {
540
+ for (const child of node.childNodes) {
541
+ if (!isParse5Element(child)) {
542
+ continue;
543
+ }
544
+ const marker = child.attrs.find(attribute => RESERVED_HANDOFF_ATTRIBUTES.has(attribute.name));
545
+ if (marker !== undefined) {
546
+ return marker.name;
547
+ }
548
+ const nested = reservedHandoffAttributeIn(traversableContent(child));
549
+ if (nested !== undefined) {
550
+ return nested;
551
+ }
552
+ }
553
+ return undefined;
554
+ };
555
+ const hasScriptElement = (node) => node.childNodes.some(child => isParse5Element(child) &&
556
+ (child.tagName.toLowerCase() === 'script' ||
557
+ hasScriptElement(traversableContent(child))));
558
+ const assertViewDoesNotAuthorReservedContent = (node) => {
559
+ const attrs = node.data?.attrs;
560
+ if (attrs !== undefined) {
561
+ for (const name of Object.keys(attrs)) {
562
+ const parsedName = parsedAttributeName(node.data?.ns, name);
563
+ if (RESERVED_HANDOFF_ATTRIBUTES.has(parsedName)) {
564
+ throw new Error(`[foldkit] The view authored ${parsedName}, which is reserved for ` +
565
+ 'Foldkit\u2019s server-to-client hydration handoff. Application ' +
566
+ 'markup cannot own root, build, Flags, key, or identity markers. ' +
567
+ 'Remove the attribute.');
568
+ }
569
+ }
570
+ }
571
+ const innerHtml = node.data?.props?.['innerHTML'];
572
+ if (typeof innerHtml === 'string' && hasTrustedInnerHtml(node.data?.props)) {
573
+ const tagName = expectedParsedTagName(node);
574
+ const namespace = node.data?.ns === SVG_NAMESPACE
575
+ ? Parse5Html.NS.SVG
576
+ : node.data?.ns === MATHML_NAMESPACE
577
+ ? Parse5Html.NS.MATHML
578
+ : Parse5Html.NS.HTML;
579
+ const context = defaultTreeAdapter.createElement(tagName, namespace, []);
580
+ const parsedFragments = [true, false].map(scriptingEnabled => parseFragment(context, innerHtml, { scriptingEnabled }));
581
+ const marker = parsedFragments
582
+ .map(reservedHandoffAttributeIn)
583
+ .find(candidate => candidate !== undefined);
584
+ if (marker !== undefined) {
585
+ throw new Error(`[foldkit] h.InnerHTML on <${tagName}> authored ${marker}, which is ` +
586
+ 'reserved for Foldkit\u2019s server-to-client hydration handoff. ' +
587
+ 'Application markup cannot own root, build, Flags, key, or identity ' +
588
+ 'markers. Remove the attribute.');
589
+ }
590
+ if (parsedFragments.some(hasScriptElement)) {
591
+ throw new Error(`[foldkit] h.InnerHTML on <${tagName}> contains a <script> element. ` +
592
+ 'A script element created while parsing served HTML is not ' +
593
+ 'equivalent to one created by assigning innerHTML. Executable forms ' +
594
+ 'can run only on the server path, and other script types have ' +
595
+ 'type-specific processing that Foldkit does not model. Foldkit ' +
596
+ 'therefore refuses classic, module, import-map, speculation-rules, ' +
597
+ 'and data-block scripts at this boundary. Build the script as an ' +
598
+ 'ordinary view element or move it to the HTML template instead.');
599
+ }
600
+ }
601
+ for (const child of node.children ?? []) {
602
+ if (typeof child !== 'string') {
603
+ assertViewDoesNotAuthorReservedContent(child);
604
+ }
605
+ }
606
+ };
607
+ const elementsAtAndBelow = (element) => {
608
+ const elements = [element];
609
+ for (const child of traversableContent(element).childNodes) {
610
+ if (isParse5Element(child)) {
611
+ elements.push(...elementsAtAndBelow(child));
612
+ }
613
+ }
614
+ return elements;
615
+ };
616
+ const elementsCarrying = (root, attributeName) => elementsAtAndBelow(root).filter(element => element.attrs.some(attribute => attribute.name === attributeName));
617
+ const assertNoReservedHandoffMarkers = (root) => {
618
+ const maybeMarker = Array_.findFirst(Array_.fromIterable(RESERVED_HANDOFF_ATTRIBUTES), attributeName => elementsCarrying(root, attributeName).length > 0);
619
+ if (Option.isNone(maybeMarker)) {
620
+ return;
621
+ }
622
+ throw new Error(`[foldkit] Static output contains ${maybeMarker.value}, which is reserved for ` +
623
+ 'Foldkit\u2019s server-to-client hydration handoff. A render that no ' +
624
+ 'client will hydrate must not carry hydration markers.');
625
+ };
193
626
  // After serialization, parse the stamped root markup with the same HTML parser
194
627
  // a browser uses and require it to describe exactly one element: the stamped
195
628
  // root, with the tag and namespace the view produced. A view can serialize a
@@ -198,7 +631,9 @@ const DIV_FRAGMENT_CONTEXT = buildDivFragmentContext();
198
631
  // moving nodes outside the element the client hydrates. Hydration owns only
199
632
  // that root, so it cannot remove escaped siblings; rejecting here keeps the
200
633
  // invariant that the served root parses back to the single intended element.
201
- const assertSingleStampedRoot = (html, runtimeId, root) => {
634
+ // The single element the served markup parses back to, or a refusal naming what
635
+ // the parser did to it instead.
636
+ const parsedRootOf = (html, root) => {
202
637
  const fragment = parseFragment(DIV_FRAGMENT_CONTEXT, html, {});
203
638
  const significant = fragment.childNodes.filter(node => !isIgnorableText(node));
204
639
  const only = significant[0];
@@ -209,23 +644,58 @@ const assertSingleStampedRoot = (html, runtimeId, root) => {
209
644
  'splits into more than one top-level node. An element the parser ' +
210
645
  'moves out of its parent (a block element inside a <p>, a stray ' +
211
646
  'element inside a <table>, or an HTML element inside an <svg>) ' +
212
- 'leaves content outside the application root that hydration cannot ' +
213
- 'own. Keep the view root a single, structurally valid element tree.');
214
- }
215
- const stamp = only.attrs.find(attribute => attribute.name === FOLDKIT_APP_ATTRIBUTE);
216
- if (stamp?.value !== runtimeId) {
217
- throw new Error('[foldkit] HTML parsing moved the hydration marker off the rendered ' +
218
- 'root, so the served DOM would not carry the stamp the client ' +
219
- 'adopts. Keep the view root a single, structurally valid element.');
647
+ 'leaves content outside the application root. Keep the view root a ' +
648
+ 'single, structurally valid element tree.');
220
649
  }
221
- const expectedTag = tagNameFromSelector(root.sel ?? '').toLowerCase();
650
+ const expectedTag = expectedParsedTagName(root);
222
651
  const expectedNamespace = typeof root.data?.ns === 'string' ? root.data.ns : HTML_NAMESPACE;
223
- if (only.tagName.toLowerCase() !== expectedTag ||
224
- only.namespaceURI !== expectedNamespace) {
652
+ if (expectedNamespace !== HTML_NAMESPACE &&
653
+ only.namespaceURI === expectedNamespace &&
654
+ only.tagName !== expectedTag) {
655
+ throw foreignTagNameMismatch(only.tagName, expectedTag);
656
+ }
657
+ if (only.tagName !== expectedTag || only.namespaceURI !== expectedNamespace) {
225
658
  throw new Error('[foldkit] HTML parsing reinterpreted the rendered root as a ' +
226
659
  `<${only.tagName}>, not the view's <${expectedTag}>. Keep the view ` +
227
660
  'root a single, structurally valid element.');
228
661
  }
662
+ return only;
663
+ };
664
+ // After serialization, parse the root markup with the same HTML parser a browser
665
+ // uses and require it to describe the tree the view wrote. A view can serialize
666
+ // a structurally invalid shape the parser rearranges (a block element inside a
667
+ // `<p>`, a bare `<tr>` in a `<table>`, text foster-parented out of a table),
668
+ // moving or dropping nodes the view declared.
669
+ //
670
+ // This runs for a static render too. Hydration is what would otherwise rebuild a
671
+ // reshaped subtree, so without it a dropped subtree is simply lost, with nothing
672
+ // left to notice. What stays conditional is the marker check below: only a
673
+ // hydratable render emits a stamp for the parser to move.
674
+ const assertParsedRootMatchesView = (html, root) => {
675
+ const parsed = parsedRootOf(html, root);
676
+ assertNoReservedHandoffMarkers(parsed);
677
+ assertStructureMatches(parsed, root);
678
+ };
679
+ const assertSingleStampedRoot = (html, runtimeId, buildId, root) => {
680
+ const only = parsedRootOf(html, root);
681
+ const applicationMarkers = elementsCarrying(only, FOLDKIT_APP_ATTRIBUTE);
682
+ const buildMarkers = elementsCarrying(only, HYDRATION_BUILD_ATTRIBUTE);
683
+ const flagsMarkers = elementsCarrying(only, FOLDKIT_FLAGS_ATTRIBUTE);
684
+ const stamp = only.attrs.find(attribute => attribute.name === FOLDKIT_APP_ATTRIBUTE);
685
+ const buildStamp = only.attrs.find(attribute => attribute.name === HYDRATION_BUILD_ATTRIBUTE);
686
+ if (applicationMarkers.length !== 1 ||
687
+ applicationMarkers[0] !== only ||
688
+ stamp?.value !== runtimeId ||
689
+ buildMarkers.length !== 1 ||
690
+ buildMarkers[0] !== only ||
691
+ buildStamp?.value !== buildId ||
692
+ flagsMarkers.length !== 0) {
693
+ throw new Error('[foldkit] The rendered root does not carry exactly one Foldkit root ' +
694
+ 'and build marker on the root itself, or application markup contains ' +
695
+ 'a reserved root, build, or Flags marker below it. The client could ' +
696
+ 'resolve an ambiguous root or payload and refuse the page. Remove ' +
697
+ 'application-authored data-foldkit handoff markers.');
698
+ }
229
699
  assertStructureMatches(only, root);
230
700
  };
231
701
  export { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE };
@@ -265,6 +735,19 @@ export class InvalidRuntimeId extends Data.TaggedError('InvalidRuntimeId') {
265
735
  */
266
736
  export class InvalidHydrationRoot extends Data.TaggedError('InvalidHydrationRoot') {
267
737
  }
738
+ /** Failure of a hydratable render that was given no build id. Hydration
739
+ * compares the id on the served root with the client's own to refuse a page
740
+ * from another deployment, so a render that carries none has no such
741
+ * protection. Supply one from a value the deployment already has, such as a
742
+ * commit or a release tag, through `@foldkit/vite-plugin`'s `buildId` option or
743
+ * the `FOLDKIT_BUILD_ID` environment variable, and pass
744
+ * `import.meta.env.FOLDKIT_BUILD_ID` to `renderToString` and `Runtime.hydrate`.
745
+ * A render that nothing will hydrate (`isHydratable: false`) needs none.
746
+ *
747
+ * @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
748
+ */
749
+ export class MissingBuildId extends Data.TaggedError('MissingBuildId') {
750
+ }
268
751
  const noOpDispatch = () => { };
269
752
  // NOTE: the html builder reads its dispatch context from a process-wide
270
753
  // frame stack (`setRuntime` / `clearRuntime`) rather than an argument, so
@@ -367,6 +850,19 @@ export function renderToString(config, options) {
367
850
  const hasRouting = config.routing !== undefined;
368
851
  const FlagsCodec = config.Flags;
369
852
  const isHydratable = options?.isHydratable ?? true;
853
+ // A hydratable render must say which deployment it came from. Deriving it
854
+ // here is not possible: the render cannot see the sources, the
855
+ // configuration, or the dependencies that decide what the view produced, so
856
+ // an id it invented would either differ between the client and server
857
+ // builds of one deployment or be shared by two that render differently.
858
+ // Refusing is the only honest answer, and it names what to supply.
859
+ const configuredBuildId = options?.buildId;
860
+ const isConfiguredBuildId = Predicate.isString(configuredBuildId) &&
861
+ !String.isEmpty(configuredBuildId);
862
+ if (isHydratable && !isConfiguredBuildId) {
863
+ return yield* Effect.fail(new MissingBuildId());
864
+ }
865
+ const buildId = isConfiguredBuildId ? configuredBuildId : '';
370
866
  const url = hasRouting ? yield* parseUrl(options?.url ?? '') : undefined;
371
867
  const flagsHandoff = isHydratable && FlagsCodec !== undefined
372
868
  ? yield* encodeFlagsHandoff(FlagsCodec, options?.flags, runtimeId)
@@ -387,11 +883,39 @@ export function renderToString(config, options) {
387
883
  }
388
884
  const rootHtml = yield* Effect.try({
389
885
  try: () => {
886
+ // Checked for every render, hydratable or not: static markup is placed
887
+ // into a document the same way, so a document-structure root is
888
+ // rearranged by the parser either way.
889
+ if (nextDocument.body !== null) {
890
+ assertRootIsNotDocumentStructure(nextDocument.body);
891
+ assertViewDoesNotAuthorReservedContent(nextDocument.body);
892
+ }
893
+ // The build id rides on the root so hydration can refuse a page from
894
+ // another deployment before it adopts any of its DOM.
390
895
  const html = serializeHtml(nextDocument.body, isHydratable
391
- ? { rootAttributes: { [FOLDKIT_APP_ATTRIBUTE]: runtimeId } }
896
+ ? {
897
+ rootAttributes: {
898
+ [FOLDKIT_APP_ATTRIBUTE]: runtimeId,
899
+ [HYDRATION_BUILD_ATTRIBUTE]: buildId,
900
+ },
901
+ emitHydrationMarkers: true,
902
+ }
392
903
  : {});
393
- if (isHydratable && nextDocument.body !== null) {
394
- assertSingleStampedRoot(html, runtimeId, nextDocument.body);
904
+ // Framing is checked for every render, hydratable or not. Static markup
905
+ // is placed into a document the same way, so a root that does not close
906
+ // cleanly swallows whatever the host writes after it either way.
907
+ assertRootClosesCleanly(html);
908
+ assertNoscriptModesAgree(html);
909
+ __assertNoDeclarativeShadowRoot(html);
910
+ __assertNoDocumentStructureEscape(html);
911
+ __assertNoLiveBaseElement(html);
912
+ if (nextDocument.body !== null) {
913
+ if (isHydratable) {
914
+ assertSingleStampedRoot(html, runtimeId, buildId, nextDocument.body);
915
+ }
916
+ else {
917
+ assertParsedRootMatchesView(html, nextDocument.body);
918
+ }
395
919
  }
396
920
  return html;
397
921
  },
@@ -30,6 +30,14 @@ export type InjectIntoTemplateOptions = Readonly<{
30
30
  * element in its head. Throws when either required location is missing or
31
31
  * appears more than once.
32
32
  *
33
+ * Pass the `RenderedApplication` returned by `renderToString` unchanged. A
34
+ * hydratable value must parse as exactly one top-level element carrying one
35
+ * nonempty root stamp and build stamp, optionally followed by one matching
36
+ * top-level JSON Flags script. Static output may contain one element, text, or
37
+ * comment root, or no body output. The helper rejects additional top-level
38
+ * content, ambiguous handoff markers, and source that the HTML parser drops,
39
+ * splits, moves, or reconstructs before insertion.
40
+ *
33
41
  * This helper is pure with no module state, so a host process may import it
34
42
  * directly even when the render itself must stay inside the server entry's
35
43
  * module graph.