@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.
@@ -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
- detachedHydrationProbe = false,
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 = detachedHydrationProbe ? nodesBetween(start, end) : []
630
- const [fragment, nodes] = detachedHydrationProbe
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
- if (hydrationKind !== undefined) noteHydrationStructuralParent()
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]
@@ -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('expected exactly one vidact:v1 root marker range')
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.firstChild)
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
- return claimHydrationTextRange(parent, expected)?.[2]
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 start = cursor(state, parent)
234
- if (!isMarker(start, `${HYDRATION_PREFIX}:t`)) {
235
- throw mismatch('expected a vidact:v1 text marker')
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 end = findClosingSibling(start, `${HYDRATION_PREFIX}:t`)
238
- const nodes: Node[] = []
239
- for (let node = start.nextSibling; node !== null && node !== end; node = node.nextSibling) {
240
- nodes.push(node)
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 (nodes.length > 1 || (nodes.length === 1 && !(nodes[0] instanceof Text))) {
243
- throw mismatch('server text marker must contain at most one text node')
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
- const text = (nodes[0] as Text | undefined) ?? null
246
- if ((text?.data ?? '') !== expected) {
281
+ if (!node.data.startsWith(expected)) {
247
282
  throw mismatch(
248
- `server text ${JSON.stringify(text?.data ?? '')} does not match ${JSON.stringify(expected)}`,
283
+ `server text ${JSON.stringify(node.data)} does not match ${JSON.stringify(expected)}`,
249
284
  )
250
285
  }
251
- setHydrationCursor(state, parent, end.nextSibling)
252
- return [start, end, text]
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 vidact:v1 array marker in ${parent.nodeName}; found ${JSON.stringify(markerValue(start as Node) ?? start?.nodeName ?? null)}`,
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 vidact:v1 child-slot marker in ${parent.nodeName}; 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)}`,
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.firstChild)
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.match(/^\/?vidact:v1:([abchrst])$/)
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' | 's' | 't'
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 vidact:v1:${kind} marker`)
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('unclosed vidact:v1 hydration marker')
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('unbalanced vidact:v1 raw HTML marker range')
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('unbalanced vidact:v1 raw HTML marker range')
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 !== 'vidact:v1:h' ||
49
+ start.data !== `${HYDRATION_PREFIX}:h` ||
50
50
  !(end instanceof Comment) ||
51
- end.data !== '/vidact:v1:h'
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
- const HYDRATION_PREFIX = 'vidact:v1'
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, true)
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
- ? multipleChildren
491
- ? serializeChildren(props?.children as ServerChild, context, !isRawTextElement(type))
492
- : serializeSlot(props?.children as ServerChild, context, !isRawTextElement(type))
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, markScalar = true): string {
834
- if (value === null || value === undefined || typeof value === 'boolean') {
835
- return context.hydrationMarkers && markScalar ? hydrationRange('t', '') : ''
836
- }
837
- if (isServerNode(value)) {
838
- const content = value[SERVER_NODE](context)
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, markScalar)).join('')
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 context.hydrationMarkers && markScalar ? hydrationRange('t', content) : content
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('"', '&quot;').replaceAll("'", '&#x27;')
1109
1108
  }
1110
1109
 
1111
- function serializeChildren(
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, markScalar)).join('')
1118
- : serializeSlot(value, context, markScalar)
1134
+ ? value.map((child) => serializeSlot(child, context)).join('')
1135
+ : serializeSlot(value, context)
1119
1136
  }
1120
1137
 
1121
- function serializeSlot(value: ServerChild, context: RenderContext, markScalar = true): string {
1122
- const content = serializeChild(value, context, markScalar)
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' | 's' | 't', content: string): string {
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+:/i.test(value)) {
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