@abide/abide 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +93 -63
  3. package/package.json +6 -2
  4. package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
  5. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  6. package/src/lib/shared/cache.ts +43 -29
  7. package/src/lib/shared/types/CacheEntry.ts +12 -12
  8. package/src/lib/shared/types/CacheOptions.ts +17 -13
  9. package/src/lib/ui/README.md +3 -3
  10. package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
  11. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
  12. package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
  13. package/src/lib/ui/compile/generateBuild.ts +265 -121
  14. package/src/lib/ui/compile/generateSSR.ts +78 -37
  15. package/src/lib/ui/compile/parseTemplate.ts +52 -0
  16. package/src/lib/ui/compile/skeletonable.ts +80 -0
  17. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
  18. package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
  19. package/src/lib/ui/dom/anchorCursor.ts +24 -0
  20. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  21. package/src/lib/ui/dom/appendTextAt.ts +70 -0
  22. package/src/lib/ui/dom/awaitBlock.ts +27 -7
  23. package/src/lib/ui/dom/cloneStatic.ts +15 -24
  24. package/src/lib/ui/dom/each.ts +44 -25
  25. package/src/lib/ui/dom/eachAsync.ts +6 -2
  26. package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
  27. package/src/lib/ui/dom/enterNamespace.ts +20 -0
  28. package/src/lib/ui/dom/fillBefore.ts +20 -3
  29. package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
  30. package/src/lib/ui/dom/hydrate.ts +1 -1
  31. package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
  32. package/src/lib/ui/dom/mountSlot.ts +32 -0
  33. package/src/lib/ui/dom/openMarker.ts +4 -2
  34. package/src/lib/ui/dom/skeleton.ts +202 -0
  35. package/src/lib/ui/dom/switchBlock.ts +10 -3
  36. package/src/lib/ui/dom/templateFor.ts +28 -0
  37. package/src/lib/ui/dom/tryBlock.ts +7 -5
  38. package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
  39. package/src/lib/ui/dom/when.ts +6 -2
  40. package/src/lib/ui/installHotBridge.ts +8 -2
  41. package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
  42. package/src/lib/ui/runtime/RENDER.ts +7 -0
  43. package/src/lib/ui/runtime/createDoc.ts +11 -8
  44. package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
  45. package/src/lib/ui/runtime/types/UiProps.ts +3 -4
  46. package/src/lib/ui/runtime/walkPath.ts +27 -0
  47. package/template/src/ui/pages/about/page.abide +4 -6
  48. package/template/src/ui/pages/layout.abide +21 -0
  49. package/template/src/ui/pages/page.abide +5 -8
  50. package/src/lib/ui/compile/partitionSlots.ts +0 -36
  51. package/src/lib/ui/dom/openChild.ts +0 -22
  52. package/src/lib/ui/runtime/pathExists.ts +0 -23
  53. package/src/lib/ui/runtime/valueAtPath.ts +0 -18
  54. package/template/src/ui/Layout.abide +0 -19
@@ -2,10 +2,9 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
2
  import { componentWrapperTag } from './componentWrapperTag.ts'
3
3
  import { groupBindParts } from './groupBindParts.ts'
4
4
  import { lowerContext } from './lowerContext.ts'
5
- import { partitionSlots } from './partitionSlots.ts'
6
5
  import { scopeAttr } from './scopeAttr.ts'
6
+ import { skeletonable } from './skeletonable.ts'
7
7
  import { staticAttr } from './staticAttr.ts'
8
- import { staticAttrValue } from './staticAttrValue.ts'
9
8
  import { staticTextPart } from './staticTextPart.ts'
10
9
  import { stripEffects } from './stripEffects.ts'
11
10
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -69,11 +68,35 @@ export function generateSSR(
69
68
  it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
70
69
  so an empty/false branch still emits the boundary the client claims). */
71
70
  function branchContent(children: TemplateNode[], target: string): string {
72
- return withNestedScripts(children, () => generateInto(children, target))
71
+ /* A control-flow branch is a fresh build context — the block runtime mounts it, not
72
+ the parent skeleton — so reset the skeleton/anchor tracking; the branch's own
73
+ skeletonable elements re-enter it. */
74
+ const previousSkeleton = inSkeleton
75
+ const previousMark = markText
76
+ inSkeleton = false
77
+ markText = false
78
+ const out = withNestedScripts(children, () => generateInto(children, target))
79
+ inSkeleton = previousSkeleton
80
+ markText = previousMark
81
+ return out
73
82
  }
74
83
  const openRange = (target: string): string => push(target, RANGE_OPEN)
75
84
  const closeRange = (target: string): string => push(target, RANGE_CLOSE)
76
85
 
86
+ /* True inside a skeletonable subtree; `markText` true when, additionally, the current
87
+ element is NOT a text-leaf — so its reactive text is interleaved and the client uses
88
+ an `<!--a-->` anchor. The marker is kept both sides (like control-flow ranges), so
89
+ SSR markup stays identical to the client DOM. */
90
+ let inSkeleton = false
91
+ let markText = false
92
+
93
+ /* In a skeleton, a control-flow block or slot is positioned by an `<!--a-->` anchor
94
+ (cloned into the located parent), so it can sit anywhere among static siblings.
95
+ Emitted both sides in document order — the client's anchor scan lines up with it.
96
+ Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
97
+ so no anchor. */
98
+ const anchorMark = (target: string): string => (inSkeleton ? push(target, '<!--a-->') : '')
99
+
77
100
  function generate(node: TemplateNode, target: string): string {
78
101
  if (node.kind === 'text') {
79
102
  return node.parts
@@ -82,7 +105,10 @@ export function generateSSR(
82
105
  const markup = staticTextPart(part.value)
83
106
  return markup === '' ? '' : push(target, markup)
84
107
  }
85
- return `${target}.push($text(${lowerExpression(part.code)}));\n`
108
+ const value = `$text(${lowerExpression(part.code)})`
109
+ return markText
110
+ ? `${target}.push('<!--a-->' + ${value});\n`
111
+ : `${target}.push(${value});\n`
86
112
  })
87
113
  .join('')
88
114
  }
@@ -93,7 +119,7 @@ export function generateSSR(
93
119
  if (elseBranch !== undefined && elseBranch.kind === 'case') {
94
120
  code += ` else {\n${branchContent(elseBranch.children, target)}}`
95
121
  }
96
- return `${openRange(target)}${code}\n${closeRange(target)}`
122
+ return `${anchorMark(target)}${openRange(target)}${code}\n${closeRange(target)}`
97
123
  }
98
124
  if (node.kind === 'switch') {
99
125
  const cases = node.children.filter(
@@ -111,7 +137,7 @@ export function generateSSR(
111
137
  if (fallback !== undefined) {
112
138
  code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
113
139
  }
114
- return `${openRange(target)}${code}}\n${closeRange(target)}`
140
+ return `${anchorMark(target)}${openRange(target)}${code}}\n${closeRange(target)}`
115
141
  }
116
142
  if (node.kind === 'case') {
117
143
  return ''
@@ -135,17 +161,19 @@ export function generateSSR(
135
161
  if (node.kind === 'each') {
136
162
  /* Async each (`await`) is drained on the client — render no rows on the
137
163
  server (an infinite stream would hang SSR); the client inserts its anchor
138
- before the next sibling during hydration, like an empty sync each. */
164
+ before the next sibling during hydration, like an empty sync each. In a
165
+ skeleton the `<!--a-->` anchor still marks its position (the client mounts
166
+ there); no range markers, since there are no server rows to claim. */
139
167
  if (node.async) {
140
- return ''
168
+ return anchorMark(target)
141
169
  }
142
- return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
170
+ return `${anchorMark(target)}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
143
171
  }
144
172
  if (node.kind === 'await') {
145
- return generateAwait(node, target)
173
+ return `${anchorMark(target)}${generateAwait(node, target)}`
146
174
  }
147
175
  if (node.kind === 'try') {
148
- return generateTry(node, target)
176
+ return `${anchorMark(target)}${generateTry(node, target)}`
149
177
  }
150
178
  if (node.kind === 'branch') {
151
179
  return ''
@@ -159,22 +187,12 @@ export function generateSSR(
159
187
  const parts = node.props.map(
160
188
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
161
189
  )
162
- const groups = partitionSlots(node.children)
163
- const slotCode = generateInto(groups.default, '$slot')
190
+ const slotCode = generateInto(node.children, '$slot')
164
191
  if (slotCode.trim() !== '') {
165
192
  parts.push(
166
193
  `"$children": () => { const $slot = []; ${slotCode}return $slot.join(''); }`,
167
194
  )
168
195
  }
169
- if (groups.named.length > 0) {
170
- const entries = groups.named
171
- .map((group) => {
172
- const code = generateInto(group.nodes, '$slot')
173
- return `${JSON.stringify(group.name)}: () => { const $slot = []; ${code}return $slot.join(''); }`
174
- })
175
- .join(', ')
176
- parts.push(`"$slots": { ${entries} }`)
177
- }
178
196
  /* Render the child and MERGE its await blocks into this page's `$awaits`
179
197
  so they join the page's SSR stream — their markers carry render-pass
180
198
  block ids (nextBlockId), unique across page + children, so the streamed
@@ -190,9 +208,9 @@ export function generateSSR(
190
208
  )
191
209
  }
192
210
  if (node.kind === 'element' && node.tag === 'slot') {
193
- /* A layout's unnamed `<slot/>` is the router's page outlet: emit an empty
211
+ /* A layout's `<slot/>` is the router's page outlet: emit an empty
194
212
  placeholder the chain composer folds the child layer's html into. */
195
- if (isLayout && staticAttrValue(node, 'name') === undefined) {
213
+ if (isLayout) {
196
214
  return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
197
215
  }
198
216
  return generateSlot(node, target)
@@ -228,32 +246,55 @@ export function generateSSR(
228
246
  }
229
247
  code += push(target, '>')
230
248
  if (!VOID_TAGS.has(node.tag)) {
249
+ /* Track skeleton context: reactive text gets an `<!--a-->` anchor only when
250
+ interleaved (this element has non-text children) inside a skeletonable
251
+ subtree — matching the client's anchor vs marker-free text-leaf choice. */
252
+ const entering = !inSkeleton && skeletonable(node)
253
+ if (entering) {
254
+ inSkeleton = true
255
+ }
256
+ const previousMark = markText
257
+ const isTextLeaf = node.children.every(
258
+ (child) => child.kind === 'text' || child.kind === 'style',
259
+ )
260
+ markText = inSkeleton && !isTextLeaf
231
261
  /* A `<script>` child scopes its bindings to this element's subtree. */
232
262
  code += withNestedScripts(node.children, () => generateInto(node.children, target))
263
+ markText = previousMark
264
+ if (entering) {
265
+ inSkeleton = false
266
+ }
233
267
  code += push(target, `</${node.tag}>`)
234
268
  }
235
269
  return code
236
270
  }
237
271
 
238
- /* A `<slot>` outlet: emit the parent-provided content for this slot (default
239
- via `$children`, named via `$slots[name]`), falling back to the slot's own
240
- children when none was supplied. */
272
+ /* A `<slot>` outlet: emit the parent-provided content (`$children`), falling back to the
273
+ slot's own children when none was supplied. Inside a skeleton the slot is positioned
274
+ by an `<!--a-->` anchor and its content bounded by a `[ … ]` range (matching the
275
+ client's `mountSlot`), so it can sit among static siblings. The fallback is a fresh,
276
+ non-skeleton build context — the client builds it via `mountSlot`/`fillBefore`, not the
277
+ skeleton clone — so its reactive text takes no anchor (reset like `branchContent`). */
241
278
  function generateSlot(
242
279
  node: Extract<TemplateNode, { kind: 'element' }>,
243
280
  target: string,
244
281
  ): string {
245
- const name = staticAttrValue(node, 'name')
246
- const guard =
247
- name === undefined
248
- ? '$props && $props.$children'
249
- : `$props && $props.$slots && $props.$slots[${JSON.stringify(name)}]`
250
- const provided =
251
- name === undefined ? '$props.$children' : `$props.$slots[${JSON.stringify(name)}]`
282
+ const wrap = inSkeleton
283
+ const previousSkeleton = inSkeleton
284
+ const previousMark = markText
285
+ inSkeleton = false
286
+ markText = false
252
287
  const fallback = generateInto(node.children, target)
253
- if (fallback.trim() === '') {
254
- return `if (${guard}) { ${target}.push(${provided}()); }\n`
288
+ inSkeleton = previousSkeleton
289
+ markText = previousMark
290
+ const body =
291
+ fallback.trim() === ''
292
+ ? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
293
+ : `if ($props && $props.$children) { ${target}.push($props.$children()); } else {\n${fallback}}\n`
294
+ if (!wrap) {
295
+ return body
255
296
  }
256
- return `if (${guard}) { ${target}.push(${provided}()); } else {\n${fallback}}\n`
297
+ return `${anchorMark(target)}${openRange(target)}${body}${closeRange(target)}`
257
298
  }
258
299
 
259
300
  /* Boundary markers + a `$awaits` registration carrying the promise and
@@ -20,6 +20,12 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
20
20
  scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
21
21
  */
22
22
 
23
+ /* A line-leading static `import` in a nested script body. The `(?=\s)` requires
24
+ whitespace after the keyword (sparing `import.meta` and no-space `import(...)`),
25
+ and `(?!\s*\()` spares a dynamic `import (...)` written with whitespace before the
26
+ paren — both legitimate lazy paths — so only a true static import statement matches. */
27
+ const NESTED_STATIC_IMPORT = /^[ \t]*import(?=\s)(?!\s*\()/m
28
+
23
29
  /* A braced template expression with the absolute source offset of its first
24
30
  (post-trim) character, so the type-checking shadow can map a diagnostic back. */
25
31
  type Braced = { code: string; loc: number }
@@ -179,6 +185,18 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
179
185
  const end = close === -1 ? source.length : close
180
186
  const code = source.slice(cursor, end)
181
187
  cursor = close === -1 ? source.length : end + '</script>'.length
188
+ /* A static `import` can't live here: a nested script compiles INTO the
189
+ branch's render-function body, where an import is illegal — and an
190
+ import nested in a branch falsely implies conditional/lazy loading ES
191
+ imports can't do (they hoist module-wide and load unconditionally). The
192
+ leading `<script>` hoists imports to module scope for the whole template,
193
+ so they belong there. The pattern spares dynamic `import(...)` (with or
194
+ without whitespace) and `import.meta` — the real lazy paths. */
195
+ if (NESTED_STATIC_IMPORT.test(code)) {
196
+ throw new Error(
197
+ "import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
198
+ )
199
+ }
182
200
  return { kind: 'script', code }
183
201
  }
184
202
  /* A capitalised tag is a child component; its attributes become props and
@@ -226,9 +244,43 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
226
244
  roots.push(readText())
227
245
  }
228
246
  }
247
+ rejectStrayBranches(roots, undefined)
229
248
  return { nodes: roots }
230
249
  }
231
250
 
251
+ /* A `case` node (`<template else>`/`<template case>`/`<template default>`) is valid
252
+ only as a direct child of its `<template if>`/`<template switch>`; a `branch`
253
+ (`then`/`catch`/`finally`) only inside its `<template await>`/`<template try>`.
254
+ A sibling `<template else>` — closed off from its `if` — parses to a stray `case`
255
+ sitting beside the `if`, which would otherwise be silently dropped. Reject it so
256
+ the mistake surfaces at compile time. Recurses so a stray branch nested anywhere
257
+ is caught, not just at the root. */
258
+ function rejectStrayBranches(
259
+ nodes: TemplateNode[],
260
+ parentKind: TemplateNode['kind'] | undefined,
261
+ ): void {
262
+ for (const node of nodes) {
263
+ if (node.kind === 'case' && parentKind !== 'if' && parentKind !== 'switch') {
264
+ throw new Error(
265
+ '[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
266
+ )
267
+ }
268
+ if (
269
+ node.kind === 'branch' &&
270
+ parentKind !== 'await' &&
271
+ parentKind !== 'try' &&
272
+ parentKind !== 'each'
273
+ ) {
274
+ throw new Error(
275
+ '[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
276
+ )
277
+ }
278
+ if ('children' in node) {
279
+ rejectStrayBranches(node.children, node.kind)
280
+ }
281
+ }
282
+ }
283
+
232
284
  /* Turns a component's attributes into props. A component has no directives —
233
285
  every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
234
286
  round-trip to their original names (the kinds the tag-blind attribute parser
@@ -0,0 +1,80 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /* A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton it mounts at an
4
+ `<!--a-->` anchor cloned into the located parent at the block's position (see
5
+ `anchorCursor`), so a block can sit ANYWHERE among static siblings — no contiguity or
6
+ prefix-shape constraint. */
7
+ function isControlFlow(node: TemplateNode): boolean {
8
+ return (
9
+ node.kind === 'if' ||
10
+ node.kind === 'each' ||
11
+ node.kind === 'await' ||
12
+ node.kind === 'switch' ||
13
+ node.kind === 'try'
14
+ )
15
+ }
16
+
17
+ /* Whether a subtree is skeleton STRUCTURE — anything the parser-backed clone can carry with
18
+ its holes mounted in place: elements, `<style>`, text (static OR reactive), child
19
+ components (their wrapper element is positioned in the skeleton), control-flow blocks and
20
+ `<slot>` outlets (each anchor-mounted at its position), and emit-nothing nodes — a nested
21
+ `<script>` (a scoped reactive block) and a `<template name>` snippet (a hoisted builder),
22
+ both of which the skeleton's bind list runs in document order, scoped like
23
+ `generateElement` does. Only a standalone `branch`/`case` (consumed by its block, never a
24
+ loose child) disqualifies. */
25
+ function skeletonStructure(node: TemplateNode): boolean {
26
+ if (isControlFlow(node) || node.kind === 'component') {
27
+ return true
28
+ }
29
+ if (node.kind === 'style' || node.kind === 'text') {
30
+ return true
31
+ }
32
+ if (node.kind === 'script' || node.kind === 'snippet') {
33
+ return true
34
+ }
35
+ if (node.kind !== 'element') {
36
+ return false // standalone branch|case
37
+ }
38
+ if (node.tag === 'slot') {
39
+ return true
40
+ }
41
+ return node.children.every(skeletonStructure)
42
+ }
43
+
44
+ /* Whether a subtree carries at least one hole — anything needing a runtime bind rather than
45
+ constant markup: a reactive attribute/listener/bind on an element, a reactive text part, a
46
+ control-flow block, a `<slot>` outlet, a child component (each needs a located node or an
47
+ anchor), or a nested `<script>`/snippet (emits no markup but must run). */
48
+ function hasHole(node: TemplateNode): boolean {
49
+ if (isControlFlow(node) || node.kind === 'component') {
50
+ return true
51
+ }
52
+ if (node.kind === 'script' || node.kind === 'snippet') {
53
+ return true
54
+ }
55
+ if (node.kind === 'text') {
56
+ return node.parts.some((part) => part.kind !== 'static')
57
+ }
58
+ if (node.kind === 'element') {
59
+ if (node.tag === 'slot') {
60
+ return true
61
+ }
62
+ return node.attrs.some((attr) => attr.kind !== 'static') || node.children.some(hasHole)
63
+ }
64
+ return false
65
+ }
66
+
67
+ /*
68
+ A subtree of skeleton structure carrying one or more holes. It builds through the
69
+ parser-backed `skeleton` (one clone, correct foreign namespaces) — element holes located by
70
+ element-only path, anchor holes (reactive text interleaved with elements, control flow,
71
+ slots) by document-order scan — instead of the imperative path. A bound element's reactive
72
+ attributes wire to the located node; a text-leaf element's reactive text binds via
73
+ `appendText` on it (marker-free, so SSR === client). Fully-static subtrees (no hole) stay on
74
+ `cloneStatic`.
75
+ */
76
+ export function skeletonable(node: TemplateNode): boolean {
77
+ return (
78
+ node.kind === 'element' && node.tag !== 'slot' && skeletonStructure(node) && hasHole(node)
79
+ )
80
+ }
@@ -0,0 +1,6 @@
1
+ /*
2
+ The MathML foreign-content namespace — the `<math>` counterpart to `SVG_NAMESPACE`.
3
+ The imperative build reads it to namespace MathML elements the static `skeleton` path
4
+ doesn't cover.
5
+ */
6
+ export const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'
@@ -0,0 +1,7 @@
1
+ /*
2
+ The SVG foreign-content namespace. An element created in it (or cloned from a
3
+ parser-built `<svg>` subtree) renders as SVG; the same tag in the HTML namespace
4
+ renders as nothing. The imperative build reads it to namespace foreign elements that
5
+ the static `skeleton` path doesn't cover (a foreign parent with dynamic children).
6
+ */
7
+ export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
@@ -0,0 +1,24 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+
3
+ /*
4
+ Positions a skeleton-anchored control-flow block or slot. The anchor (`<!--a-->`) sits
5
+ just before the block's range in BOTH the cloned skeleton and the server DOM, so it marks
6
+ the block's position independently of how wide the surrounding values or earlier blocks
7
+ render — the wall element-count positioning hits once block content varies in element
8
+ count.
9
+
10
+ Returns the CREATE insertion reference (the node right after the anchor), so the block's
11
+ range lands at the anchor's position rather than the parent's end. On hydrate it also parks
12
+ the parent's claim cursor there, so the block claims its own server range in place (the
13
+ block ignores the returned reference on hydrate). `parentNode` is the located element the
14
+ anchor was cloned into.
15
+ */
16
+ // @readme plumbing
17
+ export function anchorCursor(anchor: Node): Node | null {
18
+ const reference = anchor.nextSibling
19
+ const hydration = RENDER.hydration
20
+ if (hydration !== undefined) {
21
+ hydration.next.set(anchor.parentNode as Node, reference)
22
+ }
23
+ return reference
24
+ }
@@ -10,7 +10,7 @@ component scope (so they tear down with it). The body's reactive reads update
10
10
  fine-grained via those effects; an enclosing `each` re-mounts on list changes.
11
11
 
12
12
  On hydrate the builder runs against the server-rendered nodes between the
13
- `<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `openChild`/`appendText`
13
+ `<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `skeleton`/`appendText`
14
14
  claim them in place. The cursor is advanced past the open marker before, and past
15
15
  the close marker after, so the markers themselves are skipped.
16
16
  */
@@ -0,0 +1,70 @@
1
+ import { rawHtmlString } from '../../shared/html.ts'
2
+ import { snippetPayload } from '../../shared/snippet.ts'
3
+ import { effect } from '../effect.ts'
4
+ import { RENDER } from '../runtime/RENDER.ts'
5
+ import { appendSnippet } from './appendSnippet.ts'
6
+ import { appendText } from './appendText.ts'
7
+
8
+ /*
9
+ A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
10
+ for reactive text INTERLEAVED with element siblings (where element-only positioning can't
11
+ reach it). The anchor is located by `skeleton`'s scan, and content mounts immediately
12
+ after it. Handles the same three value kinds as `appendText` — escaped text, a
13
+ `{snippet(args)}` builder, and `html\`\``-branded raw markup — since the kind is only
14
+ known at runtime.
15
+
16
+ The anchor is KEPT (in both the SSR markup and the client DOM, like control flow's
17
+ `<!--[-->` range markers), so server and client render identical markup — `appendTextAt`
18
+ never strips it.
19
+
20
+ Hydrate delegates to `appendText` with the cursor temporarily pointed at the anchor's
21
+ content (the server rendered `<!--a-->value`, so the value is the anchor's next sibling),
22
+ reusing its text-split / snippet / raw-html claiming.
23
+ */
24
+ // @readme plumbing
25
+ export function appendTextAt(anchor: Node, read: () => unknown): void {
26
+ const parent = anchor.parentNode as Node
27
+ if (RENDER.hydration !== undefined) {
28
+ const hydration = RENDER.hydration
29
+ const had = hydration.next.has(parent)
30
+ const saved = hydration.next.get(parent)
31
+ hydration.next.set(parent, anchor.nextSibling)
32
+ appendText(parent, read)
33
+ if (had) {
34
+ hydration.next.set(parent, saved ?? null)
35
+ } else {
36
+ hydration.next.delete(parent)
37
+ }
38
+ return
39
+ }
40
+
41
+ const first = read()
42
+ if (typeof snippetPayload(first) === 'function') {
43
+ const fragment = document.createDocumentFragment()
44
+ appendSnippet(fragment, read)
45
+ parent.insertBefore(fragment, anchor.nextSibling)
46
+ return
47
+ }
48
+ if (rawHtmlString(first) !== undefined) {
49
+ let nodes: Node[] = []
50
+ effect(() => {
51
+ for (const node of nodes) {
52
+ parent.removeChild(node)
53
+ }
54
+ const holder = document.createElement('div')
55
+ holder.innerHTML = rawHtmlString(read()) ?? ''
56
+ nodes = [...holder.childNodes]
57
+ /* Insert the fresh markup just after the anchor (its live re-insertion point). */
58
+ const after = anchor.nextSibling
59
+ for (const node of nodes) {
60
+ parent.insertBefore(node, after)
61
+ }
62
+ })
63
+ return
64
+ }
65
+ const node = document.createTextNode('')
66
+ parent.insertBefore(node, anchor.nextSibling)
67
+ effect(() => {
68
+ node.data = String(read())
69
+ })
70
+ }
@@ -4,6 +4,7 @@ import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { RESUME } from '../runtime/RESUME.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
6
  import { discardBoundary } from './discardBoundary.ts'
7
+ import { enterNamespace } from './enterNamespace.ts'
7
8
 
8
9
  /*
9
10
  Async binding — the runtime for `<template await>`. Renders the pending branch,
@@ -34,6 +35,10 @@ export function awaitBlock(
34
35
  /* Absent when the block has no catch branch — a rejection then surfaces (re-throws
35
36
  to the unhandled-rejection path) instead of rendering an empty branch. */
36
37
  renderCatch: ((parent: Node, error: unknown) => void) | undefined,
38
+ /* A static node located by the skeleton: the block's anchor inserts before it on
39
+ create (block before a static suffix). Null appends (tail). insertBefore(x, null)
40
+ === appendChild, so the default is the prior behaviour. */
41
+ before: Node | null = null,
37
42
  ): void {
38
43
  const hydration = RENDER.hydration
39
44
  let active: { nodes: Node[]; dispose: () => void } | undefined
@@ -62,7 +67,9 @@ export function awaitBlock(
62
67
  const place = (build: (parent: Node) => void): void => {
63
68
  detach()
64
69
  const fragment = document.createDocumentFragment()
65
- const dispose = scope(() => build(fragment))
70
+ const dispose = enterNamespace(anchor?.parentNode ?? parent, () =>
71
+ scope(() => build(fragment)),
72
+ )
66
73
  const nodes = [...fragment.childNodes]
67
74
  ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
68
75
  active = { nodes, dispose }
@@ -122,14 +129,17 @@ export function awaitBlock(
122
129
  (hydration off) — the recovery path when adoption can't use the server markup. */
123
130
  const rebuildCold = (open: Node | null): void => {
124
131
  detach()
125
- discardBoundary(
132
+ /* Insert at the node AFTER the discarded boundary (its return) — NOT the captured
133
+ `before`, which for a skeleton-anchored block is the open boundary itself and is
134
+ removed here, so reusing it throws `NotFoundError` in a strict DOM. */
135
+ const after = discardBoundary(
126
136
  parent,
127
137
  open,
128
138
  `/abide:await:${id}`,
129
139
  hydration as NonNullable<typeof hydration>,
130
140
  )
131
141
  anchor = document.createTextNode('')
132
- parent.appendChild(anchor)
142
+ parent.insertBefore(anchor, after)
133
143
  const previous = RENDER.hydration
134
144
  RENDER.hydration = undefined
135
145
  try {
@@ -174,10 +184,20 @@ export function awaitBlock(
174
184
  }
175
185
  return
176
186
  }
177
- discardBoundary(parent, open, `/abide:await:${id}`, cursor)
187
+ /* Insert at the node after the discarded boundary (see `rebuildCold`). */
188
+ const after = discardBoundary(parent, open, `/abide:await:${id}`, cursor)
178
189
  anchor = document.createTextNode('')
179
- parent.appendChild(anchor)
180
- render(result)
190
+ parent.insertBefore(anchor, after)
191
+ /* The boundary's server nodes are gone, so the pending branch builds FRESH — clear
192
+ the claim cursor (mirrors `rebuildCold`) so its `cloneStatic`/text don't try to
193
+ claim discarded nodes and silently render nothing. */
194
+ const previous = RENDER.hydration
195
+ RENDER.hydration = undefined
196
+ try {
197
+ render(result)
198
+ } finally {
199
+ RENDER.hydration = previous
200
+ }
181
201
  }
182
202
 
183
203
  effect(() => {
@@ -195,7 +215,7 @@ export function awaitBlock(
195
215
  return
196
216
  }
197
217
  anchor = document.createTextNode('')
198
- parent.appendChild(anchor)
218
+ parent.insertBefore(anchor, before)
199
219
  }
200
220
  render(result)
201
221
  })
@@ -1,29 +1,14 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
-
4
- /*
5
- Parsed-once `<template>` per unique static-skeleton string, reused across every
6
- mount. A `<template>` (not a detached `<div>`) so table/select content parses by
7
- the real content model, exactly as the browser parsed the server markup.
8
- */
9
- const TEMPLATES = new Map<string, HTMLTemplateElement>()
10
-
11
- function templateFor(html: string): HTMLTemplateElement {
12
- let template = TEMPLATES.get(html)
13
- if (template === undefined) {
14
- template = document.createElement('template')
15
- template.innerHTML = html
16
- TEMPLATES.set(html, template)
17
- }
18
- return template
19
- }
3
+ import { foreignWrapperTag } from './foreignWrapperTag.ts'
4
+ import { templateFor } from './templateFor.ts'
20
5
 
21
6
  /*
22
7
  Appends a fully-static subtree (one or more top-level nodes) under `parent` — what
23
- the compiler emits in place of the imperative `openChild`/`setAttribute`/
24
- `appendStatic` chain when an element subtree carries no bindings, control flow, or
25
- listeners. The skeleton string is generated by the same rules as the SSR back-end,
26
- so it is byte-identical to the server markup.
8
+ the compiler emits for an element subtree that carries no bindings, control flow, or
9
+ listeners (its bound sibling, `skeleton`, carries the same clone plus located holes).
10
+ The skeleton string is generated by the same rules as the SSR back-end, so it is
11
+ byte-identical to the server markup.
27
12
 
28
13
  Create mode: clone the cached template once per mount — a single deep clone (native
29
14
  in the browser) instead of N create/append calls. Hydrate mode: nothing is built;
@@ -34,11 +19,17 @@ the same top-level node count as the server markup.
34
19
  */
35
20
  // @readme plumbing
36
21
  export function cloneStatic(parent: Node, html: string): void {
37
- const template = templateFor(html)
22
+ /* When `parent` is a foreign element built imperatively, the run's markup has no
23
+ foreign ancestor of its own, so a bare `<path>` would parse into the HTML
24
+ namespace. Parse it inside a matching `<svg>`/`<math>` wrapper and clone the
25
+ wrapper's children — the parser namespaces them correctly. */
26
+ const wrapper = foreignWrapperTag(parent)
27
+ const template = templateFor(wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`)
28
+ const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
38
29
  const hydration = RENDER.hydration
39
30
  if (hydration !== undefined) {
40
31
  let node = claimChild(hydration, parent)
41
- let remaining = template.content.childNodes.length
32
+ let remaining = source.childNodes.length
42
33
  while (remaining > 0 && node !== null) {
43
34
  node = node.nextSibling
44
35
  remaining -= 1
@@ -46,7 +37,7 @@ export function cloneStatic(parent: Node, html: string): void {
46
37
  hydration.next.set(parent, node)
47
38
  return
48
39
  }
49
- for (const child of template.content.childNodes) {
40
+ for (const child of source.childNodes) {
50
41
  parent.appendChild(child.cloneNode(true))
51
42
  }
52
43
  }