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.
- package/dist/buildToken.d.ts +3 -0
- package/dist/buildToken.d.ts.map +1 -0
- package/dist/buildToken.js +21 -0
- package/dist/controlledDomState.d.ts +25 -0
- package/dist/controlledDomState.d.ts.map +1 -0
- package/dist/controlledDomState.js +240 -0
- package/dist/cssStyleProperties.d.ts +6 -0
- package/dist/cssStyleProperties.d.ts.map +1 -0
- package/dist/cssStyleProperties.js +91 -0
- package/dist/domReflection.d.ts +73 -0
- package/dist/domReflection.d.ts.map +1 -0
- package/dist/domReflection.js +557 -0
- package/dist/experimental/server/host.d.ts +102 -8
- package/dist/experimental/server/host.d.ts.map +1 -1
- package/dist/experimental/server/host.js +203 -13
- package/dist/experimental/server/public.d.ts +2 -2
- package/dist/experimental/server/public.d.ts.map +1 -1
- package/dist/experimental/server/public.js +1 -1
- package/dist/experimental/server/serialize.d.ts +15 -5
- package/dist/experimental/server/serialize.d.ts.map +1 -1
- package/dist/experimental/server/serialize.js +367 -144
- package/dist/experimental/server/server.d.ts +55 -14
- package/dist/experimental/server/server.d.ts.map +1 -1
- package/dist/experimental/server/server.js +544 -21
- package/dist/experimental/server/template.d.ts +8 -0
- package/dist/experimental/server/template.d.ts.map +1 -1
- package/dist/experimental/server/template.js +437 -2
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +436 -29
- package/dist/hydrate.d.ts +1 -1
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +449 -121
- package/dist/hydrationMarkers.d.ts +15 -0
- package/dist/hydrationMarkers.d.ts.map +1 -0
- package/dist/hydrationMarkers.js +70 -0
- package/dist/nativeInnerHtml.d.ts +13 -0
- package/dist/nativeInnerHtml.d.ts.map +1 -0
- package/dist/nativeInnerHtml.js +30 -0
- package/dist/propertyProvenance.d.ts +33 -0
- package/dist/propertyProvenance.d.ts.map +1 -0
- package/dist/propertyProvenance.js +78 -0
- package/dist/propsModule.d.ts.map +1 -1
- package/dist/propsModule.js +149 -15
- package/dist/runtime/public.d.ts +1 -1
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/runtime.d.ts +30 -6
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +230 -27
- package/dist/snabbdom/attributes.d.ts.map +1 -1
- package/dist/snabbdom/attributes.js +65 -37
- package/dist/snabbdom/style.d.ts.map +1 -1
- package/dist/snabbdom/style.js +53 -34
- package/dist/test/apps/attributes.d.ts +1 -0
- package/dist/test/apps/attributes.d.ts.map +1 -1
- package/dist/test/apps/attributes.js +8 -1
- package/dist/test/apps/login.js +1 -1
- package/dist/test/matchers.d.ts.map +1 -1
- package/dist/test/matchers.js +2 -1
- package/dist/test/scene.d.ts.map +1 -1
- package/dist/test/scene.js +2 -1
- package/package.json +2 -2
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Array as Array_, Context, Data, Effect, Option, Predicate, Schema, pipe, } from 'effect';
|
|
2
|
-
import { parseFragment } from 'parse5';
|
|
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
|
|
134
|
-
// model as children, so it is left unwalked.
|
|
135
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
|
213
|
-
'
|
|
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 =
|
|
650
|
+
const expectedTag = expectedParsedTagName(root);
|
|
222
651
|
const expectedNamespace = typeof root.data?.ns === 'string' ? root.data.ns : HTML_NAMESPACE;
|
|
223
|
-
if (
|
|
224
|
-
only.namespaceURI
|
|
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,18 @@ 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
|
+
if (isHydratable &&
|
|
861
|
+
(configuredBuildId === undefined || configuredBuildId === '')) {
|
|
862
|
+
return yield* Effect.fail(new MissingBuildId());
|
|
863
|
+
}
|
|
864
|
+
const buildId = configuredBuildId ?? '';
|
|
370
865
|
const url = hasRouting ? yield* parseUrl(options?.url ?? '') : undefined;
|
|
371
866
|
const flagsHandoff = isHydratable && FlagsCodec !== undefined
|
|
372
867
|
? yield* encodeFlagsHandoff(FlagsCodec, options?.flags, runtimeId)
|
|
@@ -387,11 +882,39 @@ export function renderToString(config, options) {
|
|
|
387
882
|
}
|
|
388
883
|
const rootHtml = yield* Effect.try({
|
|
389
884
|
try: () => {
|
|
885
|
+
// Checked for every render, hydratable or not: static markup is placed
|
|
886
|
+
// into a document the same way, so a document-structure root is
|
|
887
|
+
// rearranged by the parser either way.
|
|
888
|
+
if (nextDocument.body !== null) {
|
|
889
|
+
assertRootIsNotDocumentStructure(nextDocument.body);
|
|
890
|
+
assertViewDoesNotAuthorReservedContent(nextDocument.body);
|
|
891
|
+
}
|
|
892
|
+
// The build id rides on the root so hydration can refuse a page from
|
|
893
|
+
// another deployment before it adopts any of its DOM.
|
|
390
894
|
const html = serializeHtml(nextDocument.body, isHydratable
|
|
391
|
-
? {
|
|
895
|
+
? {
|
|
896
|
+
rootAttributes: {
|
|
897
|
+
[FOLDKIT_APP_ATTRIBUTE]: runtimeId,
|
|
898
|
+
[HYDRATION_BUILD_ATTRIBUTE]: buildId,
|
|
899
|
+
},
|
|
900
|
+
emitHydrationMarkers: true,
|
|
901
|
+
}
|
|
392
902
|
: {});
|
|
393
|
-
|
|
394
|
-
|
|
903
|
+
// Framing is checked for every render, hydratable or not. Static markup
|
|
904
|
+
// is placed into a document the same way, so a root that does not close
|
|
905
|
+
// cleanly swallows whatever the host writes after it either way.
|
|
906
|
+
assertRootClosesCleanly(html);
|
|
907
|
+
assertNoscriptModesAgree(html);
|
|
908
|
+
__assertNoDeclarativeShadowRoot(html);
|
|
909
|
+
__assertNoDocumentStructureEscape(html);
|
|
910
|
+
__assertNoLiveBaseElement(html);
|
|
911
|
+
if (nextDocument.body !== null) {
|
|
912
|
+
if (isHydratable) {
|
|
913
|
+
assertSingleStampedRoot(html, runtimeId, buildId, nextDocument.body);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
assertParsedRootMatchesView(html, nextDocument.body);
|
|
917
|
+
}
|
|
395
918
|
}
|
|
396
919
|
return html;
|
|
397
920
|
},
|
|
@@ -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.
|