@vidact/runtime 0.2.0-beta.4 → 0.2.0-beta.5
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/CHANGELOG.md +15 -0
- package/dist/compiled/core.d.ts.map +1 -1
- package/dist/compiled/core.js +15 -12
- package/dist/compiled/core.js.map +1 -1
- package/dist/hydration-bridge.d.ts +10 -1
- package/dist/hydration-bridge.d.ts.map +1 -1
- package/dist/hydration-bridge.js +11 -1
- package/dist/hydration-bridge.js.map +1 -1
- package/dist/hydration.d.ts +24 -1
- package/dist/hydration.d.ts.map +1 -1
- package/dist/hydration.js +119 -46
- package/dist/hydration.js.map +1 -1
- package/dist/raw-html.js +1 -1
- package/dist/raw-html.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +24 -17
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
- package/src/compiled/core.ts +26 -11
- package/src/hydration-bridge.ts +13 -0
- package/src/hydration.ts +122 -36
- package/src/raw-html.ts +3 -3
- package/src/server.ts +43 -26
package/src/compiled/core.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isHydrating,
|
|
26
26
|
isHydrationMismatch,
|
|
27
27
|
noteHydrationStructuralParent,
|
|
28
|
+
skipHydrationRange,
|
|
28
29
|
withoutHydration,
|
|
29
30
|
withHydrationComponentRange,
|
|
30
31
|
withHydrationInsertion,
|
|
@@ -172,7 +173,6 @@ const narrowSourceOperations: SourceOperations = [
|
|
|
172
173
|
type RenderValue = CompiledRenderValue
|
|
173
174
|
|
|
174
175
|
export function deferred(render: () => CompiledRenderValue): StructuralBinding {
|
|
175
|
-
noteHydrationStructuralParent()
|
|
176
176
|
return [
|
|
177
177
|
STRUCTURAL,
|
|
178
178
|
(parent, before) => {
|
|
@@ -189,7 +189,6 @@ export function retainedActivity(
|
|
|
189
189
|
modeInput: 'visible' | 'hidden' | CompiledBinding<'visible' | 'hidden'>,
|
|
190
190
|
render: () => CompiledRenderValue,
|
|
191
191
|
): StructuralBinding {
|
|
192
|
-
noteHydrationStructuralParent()
|
|
193
192
|
const context = activeContextFrame ?? activeOwner?.[2] ?? null
|
|
194
193
|
const rootIdentity = activeOwner?.[3] ?? activeRootIdentity
|
|
195
194
|
if (rootIdentity === null) {
|
|
@@ -334,7 +333,6 @@ export function profiled(
|
|
|
334
333
|
render: () => CompiledRenderValue,
|
|
335
334
|
): StructuralBinding {
|
|
336
335
|
if (!DEV) return deferred(render)
|
|
337
|
-
noteHydrationStructuralParent()
|
|
338
336
|
const context = activeContextFrame ?? activeOwner?.[2] ?? null
|
|
339
337
|
const rootIdentity = activeOwner?.[3] ?? activeRootIdentity
|
|
340
338
|
if (rootIdentity === null) {
|
|
@@ -495,7 +493,6 @@ export function errorBoundary(
|
|
|
495
493
|
fallback: (error: unknown, reset: () => void) => CompiledRenderValue,
|
|
496
494
|
onError?: CompiledErrorHandler,
|
|
497
495
|
): StructuralBinding {
|
|
498
|
-
noteHydrationStructuralParent()
|
|
499
496
|
const context = activeContextFrame ?? activeOwner?.[2] ?? null
|
|
500
497
|
const rootIdentity = activeOwner?.[3] ?? activeRootIdentity
|
|
501
498
|
if (rootIdentity === null) {
|
|
@@ -582,7 +579,6 @@ export function suspense(
|
|
|
582
579
|
render: () => CompiledRenderValue,
|
|
583
580
|
fallback: () => CompiledRenderValue,
|
|
584
581
|
): StructuralBinding {
|
|
585
|
-
noteHydrationStructuralParent()
|
|
586
582
|
const context = activeContextFrame ?? activeOwner?.[2] ?? null
|
|
587
583
|
const rootIdentity = activeOwner?.[3] ?? activeRootIdentity
|
|
588
584
|
if (rootIdentity === null) {
|
|
@@ -614,11 +610,17 @@ export function suspense(
|
|
|
614
610
|
let removeResourceListener = noop
|
|
615
611
|
let generation = 0
|
|
616
612
|
let boundary: ErrorBoundaryHandler
|
|
613
|
+
// Server content is on screen but the client suspended while hydrating it (a lazy
|
|
614
|
+
// chunk still loading, say). The server nodes are kept until the next publish,
|
|
615
|
+
// which renders client-side and replaces them — the same shape as a claimed
|
|
616
|
+
// pending fallback, without ever showing the fallback over content the visitor
|
|
617
|
+
// can already see.
|
|
618
|
+
let dehydrated = false
|
|
617
619
|
|
|
618
620
|
const publish = (
|
|
619
621
|
read: () => CompiledRenderValue,
|
|
620
622
|
kind: 'content' | 'fallback',
|
|
621
|
-
|
|
623
|
+
replaceServerNodes = false,
|
|
622
624
|
): void => {
|
|
623
625
|
const currentParent = rangeParent(start, end, 'Suspense boundary')
|
|
624
626
|
const nextOwner = createOwner(
|
|
@@ -626,8 +628,8 @@ export function suspense(
|
|
|
626
628
|
rootIdentity,
|
|
627
629
|
kind === 'content' ? boundary : parentErrorBoundary,
|
|
628
630
|
)
|
|
629
|
-
const serverNodes =
|
|
630
|
-
const [fragment, nodes] =
|
|
631
|
+
const serverNodes = replaceServerNodes ? nodesBetween(start, end) : []
|
|
632
|
+
const [fragment, nodes] = replaceServerNodes
|
|
631
633
|
? withoutHydration(() => stageRender(read, nextOwner))
|
|
632
634
|
: hydratedRange === undefined || currentKind !== null
|
|
633
635
|
? stageRender(read, nextOwner)
|
|
@@ -662,9 +664,20 @@ export function suspense(
|
|
|
662
664
|
runOwnerTask(lifetimeOwner, () => attempt(false))
|
|
663
665
|
}
|
|
664
666
|
removeResourceListener = subscribeResource(failure.resource, retry)
|
|
667
|
+
if (
|
|
668
|
+
currentKind === null &&
|
|
669
|
+
hydratedRange !== undefined &&
|
|
670
|
+
pendingMarker === undefined &&
|
|
671
|
+
isHydrating()
|
|
672
|
+
) {
|
|
673
|
+
dehydrated = true
|
|
674
|
+
skipHydrationRange(start, end)
|
|
675
|
+
return
|
|
676
|
+
}
|
|
665
677
|
if (currentKind !== 'fallback') {
|
|
666
678
|
try {
|
|
667
|
-
publish(fallback, 'fallback')
|
|
679
|
+
publish(fallback, 'fallback', dehydrated)
|
|
680
|
+
dehydrated = false
|
|
668
681
|
if (pendingMarker !== undefined) {
|
|
669
682
|
currentNodes = [pendingMarker, ...currentNodes]
|
|
670
683
|
}
|
|
@@ -679,7 +692,8 @@ export function suspense(
|
|
|
679
692
|
const attempt = (initial: boolean): void => {
|
|
680
693
|
const attemptGeneration = beginAttempt()
|
|
681
694
|
try {
|
|
682
|
-
publish(render, 'content', initial && pendingMarker !== undefined)
|
|
695
|
+
publish(render, 'content', (initial && pendingMarker !== undefined) || dehydrated)
|
|
696
|
+
dehydrated = false
|
|
683
697
|
} catch (error) {
|
|
684
698
|
if (!isSuspension(error)) {
|
|
685
699
|
if (initial || !routeOwnerError(lifetimeOwner, error)) throw error
|
|
@@ -2904,7 +2918,8 @@ function structural(
|
|
|
2904
2918
|
mount: StructuralBinding[1],
|
|
2905
2919
|
hydrationKind?: StructuralBinding[2],
|
|
2906
2920
|
): StructuralBinding {
|
|
2907
|
-
|
|
2921
|
+
// Only lists carry an array marker; a slot-kind structural must not bias the claim.
|
|
2922
|
+
if (hydrationKind === 'array') noteHydrationStructuralParent()
|
|
2908
2923
|
let mounted = false
|
|
2909
2924
|
const owner = activeOwner ?? scopeOwners.get(scope)!
|
|
2910
2925
|
const context = activeContextFrame ?? owner[2]
|
package/src/hydration-bridge.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface HydrationOperations {
|
|
|
28
28
|
readonly claimSlotRange: (parent: Node) => HydrationRange | undefined
|
|
29
29
|
readonly borrowSlotRange: (parent: Node, current: boolean) => HydrationRange | undefined
|
|
30
30
|
readonly claimSuspenseFallback: (parent: Node) => Comment | undefined
|
|
31
|
+
readonly skipRange: (start: Comment, end: Comment) => void
|
|
31
32
|
readonly withoutHydration: <Result>(operation: () => Result) => Result
|
|
32
33
|
readonly withInsertion: <Result>(parent: Node, before: Node, operation: () => Result) => Result
|
|
33
34
|
readonly insertionPoint: () => readonly [parent: Node, before: Node] | undefined
|
|
@@ -39,6 +40,14 @@ export interface HydrationMismatchInfo {
|
|
|
39
40
|
readonly message: string
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Marker comment prefix of the server ↔ hydrate protocol (`<!--v2:b-->` … `<!--/v2:b-->`).
|
|
45
|
+
* Bumped whenever the set of markers or their meaning changes, so a stale server render
|
|
46
|
+
* fails hydration loudly instead of being claimed wrongly. `v2` dropped the per-element
|
|
47
|
+
* and per-text markers of `vidact:v1`; the hydrator infers those from the DOM.
|
|
48
|
+
*/
|
|
49
|
+
export const HYDRATION_PREFIX = 'v2'
|
|
50
|
+
|
|
42
51
|
export class HydrationMismatch extends Error {
|
|
43
52
|
constructor(message: string) {
|
|
44
53
|
super(message)
|
|
@@ -150,6 +159,10 @@ export function claimHydrationSuspenseFallback(parent: Node): Comment | undefine
|
|
|
150
159
|
return hydration?.claimSuspenseFallback(parent)
|
|
151
160
|
}
|
|
152
161
|
|
|
162
|
+
export function skipHydrationRange(start: Comment, end: Comment): void {
|
|
163
|
+
hydration?.skipRange(start, end)
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
export function withoutHydration<Result>(operation: () => Result): Result {
|
|
154
167
|
return hydration === undefined ? operation() : hydration.withoutHydration(operation)
|
|
155
168
|
}
|
package/src/hydration.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
+
HYDRATION_PREFIX,
|
|
2
3
|
HydrationMismatch,
|
|
3
4
|
installHydrationOperations,
|
|
4
5
|
type HydrationRange,
|
|
5
6
|
} from './hydration-bridge.ts'
|
|
6
7
|
|
|
7
|
-
const HYDRATION_PREFIX = 'vidact:v1'
|
|
8
|
-
|
|
9
8
|
interface HydrationState {
|
|
10
9
|
readonly host: ParentNode
|
|
11
10
|
readonly root: HydrationRange
|
|
@@ -32,7 +31,7 @@ export function beginHydration(host: ParentNode): () => void {
|
|
|
32
31
|
const ranges = scanRanges(host)
|
|
33
32
|
const root = ranges.roots[0]
|
|
34
33
|
if (root === undefined || ranges.roots.length !== 1) {
|
|
35
|
-
throw mismatch(
|
|
34
|
+
throw mismatch(`expected exactly one ${HYDRATION_PREFIX} root marker range`)
|
|
36
35
|
}
|
|
37
36
|
activeHydration = {
|
|
38
37
|
host,
|
|
@@ -129,10 +128,17 @@ export function claimHydrationElement(
|
|
|
129
128
|
if (state.pendingStructuralParents > 0) state.pendingStructuralParents = 0
|
|
130
129
|
state.claimedElements.add(element)
|
|
131
130
|
state.claimedElementCount += 1
|
|
132
|
-
state.cursors.set(element, element
|
|
131
|
+
state.cursors.set(element, initialCursor(element))
|
|
133
132
|
return element
|
|
134
133
|
}
|
|
135
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Called by a compiled list right before its container element is claimed. Lists are the
|
|
137
|
+
* only structural bindings whose server markup carries an `a` marker, so they are the
|
|
138
|
+
* only ones that may prefer a candidate containing one — for a conditional, Suspense or
|
|
139
|
+
* any other structural child that bias would pick an unrelated element that happens to
|
|
140
|
+
* hold a list (see `claimHydrationElement`).
|
|
141
|
+
*/
|
|
136
142
|
export function noteHydrationStructuralParent(): void {
|
|
137
143
|
if (activeHydration !== undefined) activeHydration.pendingStructuralParents += 1
|
|
138
144
|
}
|
|
@@ -188,14 +194,6 @@ export function claimHydrationNode(parent: Node, node: Node): boolean {
|
|
|
188
194
|
if (state === undefined) return false
|
|
189
195
|
enterHydrationSlot(state, parent)
|
|
190
196
|
const next = cursor(state, parent)
|
|
191
|
-
if (isMarker(next, `${HYDRATION_PREFIX}:s`)) {
|
|
192
|
-
const end = findClosingSibling(next, `${HYDRATION_PREFIX}:s`)
|
|
193
|
-
if (next.nextSibling !== node || node.nextSibling !== end) {
|
|
194
|
-
throw mismatch(`server node range does not contain exactly the expected ${node.nodeName}`)
|
|
195
|
-
}
|
|
196
|
-
setHydrationCursor(state, parent, end.nextSibling)
|
|
197
|
-
return true
|
|
198
|
-
}
|
|
199
197
|
if (next !== node) {
|
|
200
198
|
throw mismatch(`expected existing ${node.nodeName} at the current hydration position`)
|
|
201
199
|
}
|
|
@@ -220,9 +218,19 @@ export function claimHydrationComponentMount(parent: Node, start: Comment, end:
|
|
|
220
218
|
}
|
|
221
219
|
|
|
222
220
|
export function claimHydrationText(parent: Node, expected: string): Text | null | undefined {
|
|
223
|
-
|
|
221
|
+
const state = activeHydration
|
|
222
|
+
if (state === undefined) return undefined
|
|
223
|
+
enterHydrationSlot(state, parent)
|
|
224
|
+
return claimTextPrefix(state, parent, expected)
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Claims the text for a scalar binding together with a range it can later swap an
|
|
229
|
+
* element or collection into. The server emits no markers around text, so the binding
|
|
230
|
+
* borrows the enclosing child-slot markers when the slot holds exactly this text — the
|
|
231
|
+
* common case, and no DOM is touched. A text node shared with adjacent scalars (one
|
|
232
|
+
* parsed node for several array items) gets fresh anchor comments instead.
|
|
233
|
+
*/
|
|
226
234
|
export function claimHydrationTextRange(
|
|
227
235
|
parent: Node,
|
|
228
236
|
expected: string,
|
|
@@ -230,26 +238,54 @@ export function claimHydrationTextRange(
|
|
|
230
238
|
const state = activeHydration
|
|
231
239
|
if (state === undefined) return undefined
|
|
232
240
|
enterHydrationSlot(state, parent)
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
const at = cursor(state, parent)
|
|
242
|
+
const slotStart = at === null ? parent.lastChild : at.previousSibling
|
|
243
|
+
const text = claimTextPrefix(state, parent, expected)
|
|
244
|
+
const slotEnd = text === null ? at : text.nextSibling
|
|
245
|
+
if (
|
|
246
|
+
isMarker(slotStart, `${HYDRATION_PREFIX}:b`) &&
|
|
247
|
+
isMarker(slotEnd, `/${HYDRATION_PREFIX}:b`) &&
|
|
248
|
+
findClosingSibling(slotStart, `${HYDRATION_PREFIX}:b`) === slotEnd
|
|
249
|
+
) {
|
|
250
|
+
return [slotStart, slotEnd, text]
|
|
236
251
|
}
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
const start = document.createComment('')
|
|
253
|
+
const end = document.createComment('')
|
|
254
|
+
const reference = text ?? at
|
|
255
|
+
parent.insertBefore(start, reference)
|
|
256
|
+
parent.insertBefore(end, text === null ? reference : text.nextSibling)
|
|
257
|
+
return [start, end, text]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The server renders a scalar as bare text, and nothing at all for an empty one. Adjacent
|
|
262
|
+
* scalars in one collection parse into a single text node, so a claim takes only its own
|
|
263
|
+
* prefix and splits the rest off for the next claim.
|
|
264
|
+
*/
|
|
265
|
+
function claimTextPrefix(state: HydrationState, parent: Node, expected: string): Text | null {
|
|
266
|
+
const node = cursor(state, parent)
|
|
267
|
+
if (expected === '') {
|
|
268
|
+
if (node instanceof Text && node.data === '') {
|
|
269
|
+
setHydrationCursor(state, parent, node.nextSibling)
|
|
270
|
+
return node
|
|
271
|
+
}
|
|
272
|
+
// Nothing to consume, but closing slot markers at the cursor still need passing.
|
|
273
|
+
setHydrationCursor(state, parent, node)
|
|
274
|
+
return null
|
|
241
275
|
}
|
|
242
|
-
if (
|
|
243
|
-
throw mismatch(
|
|
276
|
+
if (!(node instanceof Text)) {
|
|
277
|
+
throw mismatch(
|
|
278
|
+
`expected server text ${JSON.stringify(expected)} in ${describeNode(parent)}; found ${describeNode(node)}`,
|
|
279
|
+
)
|
|
244
280
|
}
|
|
245
|
-
|
|
246
|
-
if ((text?.data ?? '') !== expected) {
|
|
281
|
+
if (!node.data.startsWith(expected)) {
|
|
247
282
|
throw mismatch(
|
|
248
|
-
`server text ${JSON.stringify(
|
|
283
|
+
`server text ${JSON.stringify(node.data)} does not match ${JSON.stringify(expected)}`,
|
|
249
284
|
)
|
|
250
285
|
}
|
|
251
|
-
|
|
252
|
-
|
|
286
|
+
if (node.data.length > expected.length) node.splitText(expected.length)
|
|
287
|
+
setHydrationCursor(state, parent, node.nextSibling)
|
|
288
|
+
return node
|
|
253
289
|
}
|
|
254
290
|
|
|
255
291
|
export function claimHydrationArrayRange(parent: Node): HydrationRange | undefined {
|
|
@@ -259,7 +295,7 @@ export function claimHydrationArrayRange(parent: Node): HydrationRange | undefin
|
|
|
259
295
|
const start = cursor(state, parent)
|
|
260
296
|
if (!isMarker(start, `${HYDRATION_PREFIX}:a`)) {
|
|
261
297
|
throw mismatch(
|
|
262
|
-
`expected a
|
|
298
|
+
`expected a ${HYDRATION_PREFIX} array marker in ${describeNode(parent)}; found ${JSON.stringify(markerValue(start as Node) ?? start?.nodeName ?? null)}`,
|
|
263
299
|
)
|
|
264
300
|
}
|
|
265
301
|
const end = findClosingSibling(start, `${HYDRATION_PREFIX}:a`)
|
|
@@ -281,7 +317,7 @@ export function claimHydrationSlotRange(parent: Node): HydrationRange | undefine
|
|
|
281
317
|
const start = cursor(state, parent)
|
|
282
318
|
if (!isMarker(start, `${HYDRATION_PREFIX}:b`)) {
|
|
283
319
|
throw mismatch(
|
|
284
|
-
`expected a
|
|
320
|
+
`expected a ${HYDRATION_PREFIX} child-slot marker in ${describeNode(parent)}; found ${JSON.stringify(markerValue(start as Node) ?? start?.nodeName ?? null)} after ${JSON.stringify(markerValue(start?.previousSibling as Node) ?? start?.previousSibling?.nodeName ?? null)}; insertion=${JSON.stringify(markerValue(activeInsertionPoint?.[1] as Node) ?? activeInsertionPoint?.[1]?.nodeName ?? null)}`,
|
|
285
321
|
)
|
|
286
322
|
}
|
|
287
323
|
const end = findClosingSibling(start, `${HYDRATION_PREFIX}:b`)
|
|
@@ -312,6 +348,37 @@ export function borrowHydrationSlotRange(
|
|
|
312
348
|
return [start, findClosingSibling(start, `${HYDRATION_PREFIX}:b`)]
|
|
313
349
|
}
|
|
314
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Claims everything between two markers as-is, without hydrating into it. A Suspense
|
|
353
|
+
* boundary uses this when the server rendered its content but the client cannot
|
|
354
|
+
* produce it yet (a lazy chunk still loading): the server DOM stays on screen as a
|
|
355
|
+
* dehydrated boundary and the client render replaces it once the resource resolves.
|
|
356
|
+
* The elements and component ranges inside are marked claimed so `finishHydration`'s
|
|
357
|
+
* accounting still balances.
|
|
358
|
+
*/
|
|
359
|
+
export function skipHydrationRange(start: Comment, end: Comment): void {
|
|
360
|
+
const state = requireHydration()
|
|
361
|
+
const parent = start.parentNode
|
|
362
|
+
if (parent === null || end.parentNode !== parent) throw mismatch('detached server marker range')
|
|
363
|
+
for (const element of collectPostorderElements([start, end])) {
|
|
364
|
+
if (state.claimedElements.has(element)) continue
|
|
365
|
+
state.claimedElements.add(element)
|
|
366
|
+
state.claimedElementCount += 1
|
|
367
|
+
}
|
|
368
|
+
// Component ranges are sorted by document position and every earlier one is already
|
|
369
|
+
// claimed, so the ones inside this range sit contiguously at the current index.
|
|
370
|
+
while (state.componentIndex < state.components.length) {
|
|
371
|
+
const componentStart = state.components[state.componentIndex]![0]
|
|
372
|
+
const inside =
|
|
373
|
+
(start.compareDocumentPosition(componentStart) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 &&
|
|
374
|
+
(componentStart.compareDocumentPosition(end) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
|
375
|
+
if (!inside) break
|
|
376
|
+
state.componentIndex += 1
|
|
377
|
+
}
|
|
378
|
+
state.pendingStructuralParents = 0
|
|
379
|
+
setHydrationCursor(state, parent, end)
|
|
380
|
+
}
|
|
381
|
+
|
|
315
382
|
export function claimHydrationSuspenseFallback(parent: Node): Comment | undefined {
|
|
316
383
|
const state = activeHydration
|
|
317
384
|
if (state === undefined) return undefined
|
|
@@ -368,10 +435,14 @@ function requireHydration(): HydrationState {
|
|
|
368
435
|
}
|
|
369
436
|
|
|
370
437
|
function cursor(state: HydrationState, parent: Node): Node | null {
|
|
371
|
-
if (!state.cursors.has(parent)) state.cursors.set(parent, parent
|
|
438
|
+
if (!state.cursors.has(parent)) state.cursors.set(parent, initialCursor(parent))
|
|
372
439
|
return state.cursors.get(parent) ?? null
|
|
373
440
|
}
|
|
374
441
|
|
|
442
|
+
function initialCursor(parent: Node): Node | null {
|
|
443
|
+
return parent.firstChild
|
|
444
|
+
}
|
|
445
|
+
|
|
375
446
|
function enterHydrationSlot(state: HydrationState, parent: Node): void {
|
|
376
447
|
const slots = state.slots.get(parent) ?? []
|
|
377
448
|
let start = cursor(state, parent)
|
|
@@ -401,6 +472,17 @@ function mismatch(message: string): HydrationMismatch {
|
|
|
401
472
|
return new HydrationMismatch(message)
|
|
402
473
|
}
|
|
403
474
|
|
|
475
|
+
/** `DIV.class-a[data-x]` — enough of an element to find it in the server markup. */
|
|
476
|
+
function describeNode(node: Node | null | undefined): string {
|
|
477
|
+
if (!(node instanceof Element)) return node?.nodeName ?? 'null'
|
|
478
|
+
const classes = node.classList.length === 0 ? '' : `.${[...node.classList].join('.')}`
|
|
479
|
+
const data = [...node.attributes]
|
|
480
|
+
.filter((attribute) => attribute.name.startsWith('data-') || attribute.name === 'id')
|
|
481
|
+
.map((attribute) => `[${attribute.name}]`)
|
|
482
|
+
.join('')
|
|
483
|
+
return `${node.nodeName}${classes}${data}`
|
|
484
|
+
}
|
|
485
|
+
|
|
404
486
|
function markerValue(node: Node): string | undefined {
|
|
405
487
|
return node instanceof Comment ? node.data : undefined
|
|
406
488
|
}
|
|
@@ -423,6 +505,9 @@ function findClosingSibling(start: Comment, kind: string): Comment {
|
|
|
423
505
|
throw mismatch(`missing closing ${kind} marker`)
|
|
424
506
|
}
|
|
425
507
|
|
|
508
|
+
/** Paired markers; `p` (pending fallback) is a lone marker and stays out of the stacks. */
|
|
509
|
+
const RANGE_MARKER = new RegExp(`^\\/?${HYDRATION_PREFIX}:([abchr])$`)
|
|
510
|
+
|
|
426
511
|
function scanRanges(host: ParentNode): {
|
|
427
512
|
roots: HydrationRange[]
|
|
428
513
|
components: HydrationRange[]
|
|
@@ -433,9 +518,9 @@ function scanRanges(host: ParentNode): {
|
|
|
433
518
|
const walker = document.createTreeWalker(host, NodeFilter.SHOW_COMMENT)
|
|
434
519
|
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
435
520
|
const comment = node as Comment
|
|
436
|
-
const match = comment.data
|
|
521
|
+
const match = RANGE_MARKER.exec(comment.data)
|
|
437
522
|
if (match === null) continue
|
|
438
|
-
const kind = match[1] as 'a' | 'b' | 'c' | 'h' | 'r'
|
|
523
|
+
const kind = match[1] as 'a' | 'b' | 'c' | 'h' | 'r'
|
|
439
524
|
if (!comment.data.startsWith('/')) {
|
|
440
525
|
const stack = stacks.get(kind) ?? []
|
|
441
526
|
stack.push(comment)
|
|
@@ -443,12 +528,12 @@ function scanRanges(host: ParentNode): {
|
|
|
443
528
|
continue
|
|
444
529
|
}
|
|
445
530
|
const start = stacks.get(kind)?.pop()
|
|
446
|
-
if (start === undefined) throw mismatch(`orphan closing
|
|
531
|
+
if (start === undefined) throw mismatch(`orphan closing ${HYDRATION_PREFIX}:${kind} marker`)
|
|
447
532
|
if (kind === 'r') roots.push([start, comment])
|
|
448
533
|
if (kind === 'c') components.push([start, comment])
|
|
449
534
|
}
|
|
450
535
|
if ([...stacks.values()].some((stack) => stack.length !== 0)) {
|
|
451
|
-
throw mismatch(
|
|
536
|
+
throw mismatch(`unclosed ${HYDRATION_PREFIX} hydration marker`)
|
|
452
537
|
}
|
|
453
538
|
components.sort(([left], [right]) => {
|
|
454
539
|
const position = left.compareDocumentPosition(right)
|
|
@@ -478,7 +563,7 @@ function collectPostorderElements(root: HydrationRange): Element[] {
|
|
|
478
563
|
}
|
|
479
564
|
if (opaqueDepth === 0 && child instanceof Element) visit(child)
|
|
480
565
|
}
|
|
481
|
-
if (opaqueDepth !== 0) throw mismatch(
|
|
566
|
+
if (opaqueDepth !== 0) throw mismatch(`unbalanced ${HYDRATION_PREFIX} raw HTML marker range`)
|
|
482
567
|
}
|
|
483
568
|
let opaqueDepth = 0
|
|
484
569
|
for (let node = root[0].nextSibling; node !== null && node !== root[1]; node = node.nextSibling) {
|
|
@@ -492,7 +577,7 @@ function collectPostorderElements(root: HydrationRange): Element[] {
|
|
|
492
577
|
}
|
|
493
578
|
if (opaqueDepth === 0 && node instanceof Element) visit(node)
|
|
494
579
|
}
|
|
495
|
-
if (opaqueDepth !== 0) throw mismatch(
|
|
580
|
+
if (opaqueDepth !== 0) throw mismatch(`unbalanced ${HYDRATION_PREFIX} raw HTML marker range`)
|
|
496
581
|
return elements
|
|
497
582
|
}
|
|
498
583
|
|
|
@@ -547,6 +632,7 @@ export function installHydration(): void {
|
|
|
547
632
|
claimSlotRange: claimHydrationSlotRange,
|
|
548
633
|
borrowSlotRange: borrowHydrationSlotRange,
|
|
549
634
|
claimSuspenseFallback: claimHydrationSuspenseFallback,
|
|
635
|
+
skipRange: skipHydrationRange,
|
|
550
636
|
withoutHydration,
|
|
551
637
|
withInsertion: withHydrationInsertion,
|
|
552
638
|
insertionPoint: hydrationInsertionPoint,
|
package/src/raw-html.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
mountCompiledPropTransition,
|
|
4
4
|
type CompiledPropTransition,
|
|
5
5
|
} from './compiled/core.ts'
|
|
6
|
-
import { HydrationMismatch, isHydrating } from './hydration-bridge.ts'
|
|
6
|
+
import { HYDRATION_PREFIX, HydrationMismatch, isHydrating } from './hydration-bridge.ts'
|
|
7
7
|
|
|
8
8
|
const RAW_HTML_PUBLICATION_PRIORITY = 100
|
|
9
9
|
const rawHtmlHosts = new WeakSet<HTMLElement>()
|
|
@@ -46,9 +46,9 @@ function hasEqualHydrationRawChildren(container: Node, expected: DocumentFragmen
|
|
|
46
46
|
const end = container.lastChild
|
|
47
47
|
if (
|
|
48
48
|
!(start instanceof Comment) ||
|
|
49
|
-
start.data !==
|
|
49
|
+
start.data !== `${HYDRATION_PREFIX}:h` ||
|
|
50
50
|
!(end instanceof Comment) ||
|
|
51
|
-
end.data !==
|
|
51
|
+
end.data !== `/${HYDRATION_PREFIX}:h`
|
|
52
52
|
) {
|
|
53
53
|
return false
|
|
54
54
|
}
|
package/src/server.ts
CHANGED
|
@@ -228,7 +228,8 @@ const VOID_ELEMENTS = new Set([
|
|
|
228
228
|
'wbr',
|
|
229
229
|
])
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
// Mirrors HYDRATION_PREFIX in hydration-bridge.ts; the server entry stays free of client modules.
|
|
232
|
+
const HYDRATION_PREFIX = 'v2'
|
|
232
233
|
const UNSAFE_HTML = typeof __VIDACT_UNSAFE_HTML__ !== 'undefined' && __VIDACT_UNSAFE_HTML__
|
|
233
234
|
|
|
234
235
|
let activeRender: RenderContext | undefined
|
|
@@ -411,7 +412,7 @@ function createServerElement(
|
|
|
411
412
|
(context) =>
|
|
412
413
|
Object.hasOwn(props ?? {}, 'children')
|
|
413
414
|
? multipleChildren
|
|
414
|
-
? serializeChildren(props?.children as ServerChild, context
|
|
415
|
+
? serializeChildren(props?.children as ServerChild, context)
|
|
415
416
|
: serializeSlot(props?.children as ServerChild, context)
|
|
416
417
|
: '',
|
|
417
418
|
'transparent',
|
|
@@ -487,9 +488,12 @@ function serializeIntrinsicContents(
|
|
|
487
488
|
const children =
|
|
488
489
|
rawHtml === undefined
|
|
489
490
|
? hasChildren
|
|
490
|
-
?
|
|
491
|
-
|
|
492
|
-
|
|
491
|
+
? serializeElementChildren(
|
|
492
|
+
type,
|
|
493
|
+
props?.children as ServerChild,
|
|
494
|
+
multipleChildren,
|
|
495
|
+
context,
|
|
496
|
+
)
|
|
493
497
|
: ''
|
|
494
498
|
: context.hydrationMarkers
|
|
495
499
|
? hydrationRange('h', assertSafeHydrationRawHtml(rawHtml))
|
|
@@ -830,19 +834,14 @@ function isServerSuspension(value: unknown): value is ServerSuspension {
|
|
|
830
834
|
return typeof value === 'object' && value !== null && SERVER_SUSPENSION in value
|
|
831
835
|
}
|
|
832
836
|
|
|
833
|
-
function serializeChild(value: ServerChild, context: RenderContext
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
if (isServerNode(value))
|
|
838
|
-
|
|
839
|
-
return context.hydrationMarkers && value[SERVER_NODE_KIND] === 'intrinsic'
|
|
840
|
-
? hydrationRange('s', content)
|
|
841
|
-
: content
|
|
842
|
-
}
|
|
843
|
-
if (isRenderable(value)) return serializeChild(cloneRenderable(value), context, markScalar)
|
|
837
|
+
function serializeChild(value: ServerChild, context: RenderContext): string {
|
|
838
|
+
// Empty scalars render nothing and intrinsic elements and text render bare: the
|
|
839
|
+
// hydrator infers both from the DOM inside the enclosing child slot.
|
|
840
|
+
if (value === null || value === undefined || typeof value === 'boolean') return ''
|
|
841
|
+
if (isServerNode(value)) return value[SERVER_NODE](context)
|
|
842
|
+
if (isRenderable(value)) return serializeChild(cloneRenderable(value), context)
|
|
844
843
|
if (Array.isArray(value)) {
|
|
845
|
-
const content = value.map((child) => serializeChild(child, context
|
|
844
|
+
const content = value.map((child) => serializeChild(child, context)).join('')
|
|
846
845
|
return context.hydrationMarkers ? hydrationRange('a', content) : content
|
|
847
846
|
}
|
|
848
847
|
if (typeof value === 'object' || typeof value === 'function' || typeof value === 'symbol') {
|
|
@@ -852,7 +851,7 @@ function serializeChild(value: ServerChild, context: RenderContext, markScalar =
|
|
|
852
851
|
if (context.activityHidden && content !== '') {
|
|
853
852
|
throw new TypeError('initially hidden Activity server children require a host element root')
|
|
854
853
|
}
|
|
855
|
-
return
|
|
854
|
+
return content
|
|
856
855
|
}
|
|
857
856
|
|
|
858
857
|
function hasRenderableChild(value: ServerChild): boolean {
|
|
@@ -1108,27 +1107,45 @@ function escapeAttribute(value: string): string {
|
|
|
1108
1107
|
return escapeText(value).replaceAll('"', '"').replaceAll("'", ''')
|
|
1109
1108
|
}
|
|
1110
1109
|
|
|
1111
|
-
function
|
|
1110
|
+
function serializeElementChildren(
|
|
1111
|
+
type: string,
|
|
1112
1112
|
value: ServerChild,
|
|
1113
|
+
multipleChildren: boolean,
|
|
1113
1114
|
context: RenderContext,
|
|
1114
|
-
markScalar: boolean,
|
|
1115
1115
|
): string {
|
|
1116
|
+
if (!isRawTextElement(type)) {
|
|
1117
|
+
return multipleChildren ? serializeChildren(value, context) : serializeSlot(value, context)
|
|
1118
|
+
}
|
|
1119
|
+
// The HTML parser treats the contents of <script>, <style>, <textarea> and <title> as
|
|
1120
|
+
// text, so a hydration marker here would surface as literal content — a visible
|
|
1121
|
+
// "<!--v2:b-->" inside the textarea until hydration replaced it. The client claims the
|
|
1122
|
+
// parsed text node directly instead.
|
|
1123
|
+
const hydrationMarkers = context.hydrationMarkers
|
|
1124
|
+
context.hydrationMarkers = false
|
|
1125
|
+
try {
|
|
1126
|
+
return multipleChildren ? serializeChildren(value, context) : serializeSlot(value, context)
|
|
1127
|
+
} finally {
|
|
1128
|
+
context.hydrationMarkers = hydrationMarkers
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function serializeChildren(value: ServerChild, context: RenderContext): string {
|
|
1116
1133
|
return Array.isArray(value)
|
|
1117
|
-
? value.map((child) => serializeSlot(child, context
|
|
1118
|
-
: serializeSlot(value, context
|
|
1134
|
+
? value.map((child) => serializeSlot(child, context)).join('')
|
|
1135
|
+
: serializeSlot(value, context)
|
|
1119
1136
|
}
|
|
1120
1137
|
|
|
1121
|
-
function serializeSlot(value: ServerChild, context: RenderContext
|
|
1122
|
-
const content = serializeChild(value, context
|
|
1138
|
+
function serializeSlot(value: ServerChild, context: RenderContext): string {
|
|
1139
|
+
const content = serializeChild(value, context)
|
|
1123
1140
|
return context.hydrationMarkers ? hydrationRange('b', content) : content
|
|
1124
1141
|
}
|
|
1125
1142
|
|
|
1126
|
-
function hydrationRange(kind: 'a' | 'b' | 'c' | 'h' | 'r'
|
|
1143
|
+
function hydrationRange(kind: 'a' | 'b' | 'c' | 'h' | 'r', content: string): string {
|
|
1127
1144
|
return `<!--${HYDRATION_PREFIX}:${kind}-->${content}<!--/${HYDRATION_PREFIX}:${kind}-->`
|
|
1128
1145
|
}
|
|
1129
1146
|
|
|
1130
1147
|
function assertSafeHydrationRawHtml(value: string): string {
|
|
1131
|
-
if (/<!--\/?vidact:v\d
|
|
1148
|
+
if (/<!--\/?(?:vidact:)?v\d+:[a-z]-->/i.test(value)) {
|
|
1132
1149
|
throw new Error('raw HTML cannot contain Vidact hydration marker syntax')
|
|
1133
1150
|
}
|
|
1134
1151
|
return value
|