@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
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { enterNamespace } from './enterNamespace.ts'
6
7
  import { moveRange } from './moveRange.ts'
7
8
  import { removeRange } from './removeRange.ts'
8
9
  import type { EachRow } from './types/EachRow.ts'
@@ -31,6 +32,7 @@ export function each<T>(
31
32
  items: () => Iterable<T>,
32
33
  keyOf: (item: T) => string,
33
34
  render: (parent: Node, item: T) => void,
35
+ before: Node | null = null,
34
36
  ): void {
35
37
  const rows = new Map<string, EachRow>()
36
38
 
@@ -53,7 +55,9 @@ export function each<T>(
53
55
  const end = document.createComment(']')
54
56
  const pending = document.createDocumentFragment()
55
57
  pending.appendChild(start)
56
- const dispose = scope(() => render(pending, item))
58
+ /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
59
+ built into the detached fragment are namespaced, not built as HTML. */
60
+ const dispose = enterNamespace(parent, () => scope(() => render(pending, item)))
57
61
  pending.appendChild(end)
58
62
  return { start, end, dispose, pending }
59
63
  }
@@ -88,7 +92,9 @@ export function each<T>(
88
92
  adopting = true
89
93
  } else {
90
94
  anchor = document.createTextNode('')
91
- parent.appendChild(anchor)
95
+ /* `before` (a static node located by the skeleton) places the row anchor among
96
+ siblings on create, so rows land before a static suffix; null appends (tail). */
97
+ parent.insertBefore(anchor, before)
92
98
  }
93
99
 
94
100
  effect(() => {
@@ -99,31 +105,44 @@ export function each<T>(
99
105
  adopting = false // rows already adopted in document order; nothing to move
100
106
  return
101
107
  }
102
- const list = Array.isArray(source) ? source : [...source]
103
- const keys = list.map(keyOf)
104
- const present = new Set(keys)
105
- /* Prune departed rows first so their ranges don't sit between survivors and
106
- throw off the in-place sibling checks below. */
107
- for (const [key, row] of rows) {
108
- if (!present.has(key)) {
109
- row.dispose()
110
- removeRange(row.start, row.end)
111
- rows.delete(key)
108
+ /* All SSR rows were adopted in the pre-effect loop, so every reconcile build is
109
+ create mode. Clear the global claim cursor for the duration: a synchronous
110
+ write that reconciles *mid-hydrate* (RENDER.hydration still active — e.g. a
111
+ page setting shared state during the hydrate pass) would otherwise make
112
+ buildRow and its inner row render claim SSR nodes that don't exist for a
113
+ freshly keyed row. The same `next` Map is restored, so the outer hydration
114
+ cursor is untouched (mirrors awaitBlock/tryBlock). */
115
+ const previousHydration = RENDER.hydration
116
+ RENDER.hydration = undefined
117
+ try {
118
+ const list = Array.isArray(source) ? source : [...source]
119
+ const keys = list.map(keyOf)
120
+ const present = new Set(keys)
121
+ /* Prune departed rows first so their ranges don't sit between survivors and
122
+ throw off the in-place sibling checks below. */
123
+ for (const [key, row] of rows) {
124
+ if (!present.has(key)) {
125
+ row.dispose()
126
+ removeRange(row.start, row.end)
127
+ rows.delete(key)
128
+ }
112
129
  }
113
- }
114
- /* Walk backwards from the anchor: `cursor` is the node the current row must
115
- precede. A row already ending there keeps its place; only out-of-order (or
116
- freshly built) rows move. */
117
- let cursor: Node = anchor
118
- for (let index = list.length - 1; index >= 0; index -= 1) {
119
- const key = keys[index] as string
120
- let row = rows.get(key)
121
- if (row === undefined) {
122
- row = buildRow(list[index] as T)
123
- rows.set(key, row)
130
+ /* Walk backwards from the anchor: `cursor` is the node the current row must
131
+ precede. A row already ending there keeps its place; only out-of-order (or
132
+ freshly built) rows move. */
133
+ let cursor: Node = anchor
134
+ for (let index = list.length - 1; index >= 0; index -= 1) {
135
+ const key = keys[index] as string
136
+ let row = rows.get(key)
137
+ if (row === undefined) {
138
+ row = buildRow(list[index] as T)
139
+ rows.set(key, row)
140
+ }
141
+ placeBefore(row, cursor)
142
+ cursor = row.start
124
143
  }
125
- placeBefore(row, cursor)
126
- cursor = row.start
144
+ } finally {
145
+ RENDER.hydration = previousHydration
127
146
  }
128
147
  })
129
148
 
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { enterNamespace } from './enterNamespace.ts'
6
7
  import { removeRange } from './removeRange.ts'
7
8
  import type { EachRow } from './types/EachRow.ts'
8
9
 
@@ -28,6 +29,7 @@ export function eachAsync<T>(
28
29
  render: (parent: Node, item: T) => void,
29
30
  /* Absent → an iterator rejection surfaces instead of rendering a catch branch. */
30
31
  renderCatch: ((parent: Node, error: unknown) => void) | undefined,
32
+ before: Node | null = null,
31
33
  ): void {
32
34
  const rows = new Map<string, EachRow>()
33
35
  const hydration = RENDER.hydration
@@ -35,7 +37,7 @@ export function eachAsync<T>(
35
37
  if (hydration !== undefined) {
36
38
  parent.insertBefore(anchor, claimChild(hydration, parent)) // no server rows to claim
37
39
  } else {
38
- parent.appendChild(anchor)
40
+ parent.insertBefore(anchor, before) // `before` places rows before a static suffix
39
41
  }
40
42
 
41
43
  /* Build a content range and insert it just before the anchor (arrival order). */
@@ -44,7 +46,9 @@ export function eachAsync<T>(
44
46
  const end = document.createComment(']')
45
47
  const fragment = document.createDocumentFragment()
46
48
  fragment.appendChild(start)
47
- const dispose = scope(() => build(fragment))
49
+ const dispose = enterNamespace(anchor.parentNode ?? parent, () =>
50
+ scope(() => build(fragment)),
51
+ )
48
52
  fragment.appendChild(end)
49
53
  /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
50
54
  control-flow branch, the captured `parent` is the branch's build fragment,
@@ -0,0 +1,13 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+ import { inheritedNamespace } from './inheritedNamespace.ts'
3
+
4
+ /*
5
+ The foreign namespace a child built under `parent` belongs to. A real element dictates
6
+ it from its own namespace (`inheritedNamespace`); a detached `DocumentFragment` — a
7
+ control-flow block's build buffer — carries none, so it inherits the ambient context
8
+ the enclosing block set via `enterNamespace`. `foreignWrapperTag` reads this so a
9
+ `skeleton` parsed into a block's fragment wraps its markup in the right namespace.
10
+ */
11
+ export function effectiveChildNamespace(parent: Node): string | undefined {
12
+ return (parent as Element).namespaceURI == null ? RENDER.namespace : inheritedNamespace(parent)
13
+ }
@@ -0,0 +1,20 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+ import { effectiveChildNamespace } from './effectiveChildNamespace.ts'
3
+
4
+ /*
5
+ Runs a control-flow block's fragment build with the ambient foreign namespace set from
6
+ its insertion `parent`, then restores it. Foreign elements (svg/math children) the
7
+ block builds into its detached fragment read this context (the fragment carries no
8
+ namespace of its own). A foreign `parent` establishes the context; a fragment parent
9
+ keeps the current one, so a block nested inside foreign content stays foreign; a real
10
+ HTML parent (e.g. `<foreignObject>`'s content) resets it.
11
+ */
12
+ export function enterNamespace<T>(parent: Node, build: () => T): T {
13
+ const previous = RENDER.namespace
14
+ RENDER.namespace = effectiveChildNamespace(parent)
15
+ try {
16
+ return build()
17
+ } finally {
18
+ RENDER.namespace = previous
19
+ }
20
+ }
@@ -1,4 +1,6 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
1
2
  import { scope } from '../runtime/scope.ts'
3
+ import { enterNamespace } from './enterNamespace.ts'
2
4
 
3
5
  /*
4
6
  Builds `content` into a fragment under a fresh reactive scope, then inserts it just
@@ -7,10 +9,25 @@ the content append freely (elements, components, nested blocks all use the norma
7
9
  append path) before it lands as a unit. Inserting via the marker's LIVE parent
8
10
  (`end.parentNode`) keeps placement correct even after an enclosing block has moved
9
11
  the markers from a build-time fragment into the document.
12
+
13
+ `fillBefore` is exclusively the *create* path — control-flow blocks adopt SSR nodes
14
+ in place (a direct `scope(render)`) and route only fresh builds here. So neutralize
15
+ the global claim cursor for the build: a rebuild that runs while the hydrate pass is
16
+ still active (e.g. a synchronous write that flips a `when`/`switch` mid-hydrate)
17
+ would otherwise make the build helpers claim SSR nodes that don't exist for fresh
18
+ content. The same cursor is restored after (mirrors awaitBlock/tryBlock/each).
10
19
  */
11
20
  export function fillBefore(end: Node, content: (into: Node) => void): () => void {
12
21
  const fragment = document.createDocumentFragment()
13
- const dispose = scope(() => content(fragment))
14
- ;(end.parentNode ?? end).insertBefore(fragment, end)
15
- return dispose
22
+ const previousHydration = RENDER.hydration
23
+ RENDER.hydration = undefined
24
+ try {
25
+ /* Build under the insertion parent's foreign namespace (if any), so foreign
26
+ elements built into the fragment are namespaced off `end`'s live parent. */
27
+ const dispose = enterNamespace(end.parentNode ?? end, () => scope(() => content(fragment)))
28
+ ;(end.parentNode ?? end).insertBefore(fragment, end)
29
+ return dispose
30
+ } finally {
31
+ RENDER.hydration = previousHydration
32
+ }
16
33
  }
@@ -0,0 +1,22 @@
1
+ import { effectiveChildNamespace } from './effectiveChildNamespace.ts'
2
+ import { MATHML_NAMESPACE } from './MATHML_NAMESPACE.ts'
3
+ import { SVG_NAMESPACE } from './SVG_NAMESPACE.ts'
4
+
5
+ /*
6
+ The wrapper tag a static run must be parsed inside so its children land in `parent`'s
7
+ foreign namespace — `svg`/`math`, or undefined for HTML. A bare `<path>` fragment
8
+ parses into the HTML namespace; wrapping it in `<svg>` lets the parser namespace it.
9
+ `cloneStatic` uses this for a static run coalesced under a foreign parent that was
10
+ built imperatively, or under a control-flow block's fragment inside foreign content
11
+ (where `parent`'s effective namespace comes from the ambient context).
12
+ */
13
+ export function foreignWrapperTag(parent: Node): string | undefined {
14
+ const namespace = effectiveChildNamespace(parent)
15
+ if (namespace === SVG_NAMESPACE) {
16
+ return 'svg'
17
+ }
18
+ if (namespace === MATHML_NAMESPACE) {
19
+ return 'math'
20
+ }
21
+ return undefined
22
+ }
@@ -5,7 +5,7 @@ import { scope } from '../runtime/scope.ts'
5
5
 
6
6
  /*
7
7
  Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
8
- with a claim cursor active, so the dom helpers (openChild/appendText/appendStatic)
8
+ with a claim cursor active, so the dom helpers (skeleton/appendText/appendStatic)
9
9
  take the existing nodes rather than creating new ones — attaching event listeners
10
10
  and reactive effects to the server's markup in place (no re-render, preserved
11
11
  focus/scroll). Returns a disposer.
@@ -0,0 +1,19 @@
1
+ import { MATHML_NAMESPACE } from './MATHML_NAMESPACE.ts'
2
+ import { SVG_NAMESPACE } from './SVG_NAMESPACE.ts'
3
+
4
+ /*
5
+ The foreign namespace that element children of `node` inherit — `SVG_NAMESPACE` under
6
+ an svg, `MATHML_NAMESPACE` under math — or undefined for ordinary HTML. `<foreignObject>`
7
+ is SVG's re-entry point back into HTML, so its children are HTML despite its own SVG
8
+ namespace.
9
+ */
10
+ export function inheritedNamespace(node: Node): string | undefined {
11
+ const namespace = (node as Element).namespaceURI
12
+ if (namespace === SVG_NAMESPACE && (node as Element).localName !== 'foreignObject') {
13
+ return SVG_NAMESPACE
14
+ }
15
+ if (namespace === MATHML_NAMESPACE) {
16
+ return MATHML_NAMESPACE
17
+ }
18
+ return undefined
19
+ }
@@ -0,0 +1,32 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+ import { scope } from '../runtime/scope.ts'
3
+ import { fillBefore } from './fillBefore.ts'
4
+ import { openMarker } from './openMarker.ts'
5
+
6
+ /*
7
+ Mounts a component's `<slot>` content as a marker-bounded range, so a slot positions among
8
+ static siblings exactly like a control-flow block — by `before` (create) or the claim
9
+ cursor (hydrate). `render` appends the parent-supplied `$children`, or the slot's own
10
+ fallback when none was passed; it runs once (a slot never toggles), so there is no effect or
11
+ re-render — the markers exist only to delimit the range for create insertion and hydrate
12
+ claiming.
13
+
14
+ Create fills the range before the end marker; hydrate adopts the server range in place
15
+ (claiming from the parked cursor). Mirrors `when` without the conditional swap.
16
+ */
17
+ // @readme plumbing
18
+ export function mountSlot(
19
+ parent: Node,
20
+ render: (host: Node) => void,
21
+ before: Node | null = null,
22
+ ): void {
23
+ const hydration = RENDER.hydration
24
+ openMarker(parent, '[', before)
25
+ if (hydration !== undefined) {
26
+ scope(() => render(parent)) // content claims the SSR range in place
27
+ openMarker(parent, ']')
28
+ } else {
29
+ const end = openMarker(parent, ']', before)
30
+ fillBefore(end, render)
31
+ }
32
+ }
@@ -9,7 +9,7 @@ HTML and the block can claim them positionally on hydrate — the boundary that
9
9
  a branch hold ANY content (components, text, nested blocks, snippets) as a range,
10
10
  rather than a list of single nodes.
11
11
  */
12
- export function openMarker(parent: Node, data: string): Comment {
12
+ export function openMarker(parent: Node, data: string, before: Node | null = null): Comment {
13
13
  const hydration = RENDER.hydration
14
14
  if (hydration !== undefined) {
15
15
  const node = claimChild(hydration, parent) as unknown as Comment
@@ -17,6 +17,8 @@ export function openMarker(parent: Node, data: string): Comment {
17
17
  return node
18
18
  }
19
19
  const node = document.createComment(data)
20
- parent.appendChild(node)
20
+ /* `before` (a node already in `parent`) places the block among static siblings —
21
+ its content lands at that position; without it the marker appends (block at tail). */
22
+ parent.insertBefore(node, before)
21
23
  return node
22
24
  }
@@ -0,0 +1,202 @@
1
+ import { claimChild } from '../runtime/claimChild.ts'
2
+ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
+ import { RENDER } from '../runtime/RENDER.ts'
4
+ import { foreignWrapperTag } from './foreignWrapperTag.ts'
5
+ import type { SkeletonHoles } from './types/SkeletonHoles.ts'
6
+
7
+ type CompiledSkeleton = {
8
+ /* The node whose children are the skeleton's top-level run — the template content,
9
+ or a foreign wrapper element (`<svg>`/`<math>`) the run was parsed inside. */
10
+ source: Node
11
+ /* Element holes, in pre-order — each an element-only-index path from a top-level
12
+ node. Element-only indexing keeps a path stable: a reactive text value is a text
13
+ node, so its width never shifts an element hole between the empty client skeleton
14
+ and the value-filled server DOM. */
15
+ elementPaths: number[][]
16
+ topLevelCount: number
17
+ }
18
+
19
+ /* Parsed-once skeleton per unique string, keyed by the owning document (see
20
+ `templateFor` for the per-document rationale). */
21
+ const CACHES = new WeakMap<object, Map<string, CompiledSkeleton>>()
22
+
23
+ /* An element carries `hasAttribute`; text/comment nodes do not. Used instead of
24
+ `nodeType` so the walk runs under the test mini-dom too. */
25
+ function isElement(node: Node): node is Element {
26
+ return typeof (node as Element).hasAttribute === 'function'
27
+ }
28
+
29
+ /* A comment node's data, or undefined for elements/text. A comment is a node that is
30
+ neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
31
+ exposes no `nodeType`, so detect by method. */
32
+ function commentData(node: Node): string | undefined {
33
+ if (isElement(node) || typeof (node as Text).splitText === 'function') {
34
+ return undefined
35
+ }
36
+ return (node as Comment).data
37
+ }
38
+
39
+ /* Block-range boundary markers. A control-flow block's rendered content sits between an
40
+ OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
41
+ `abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
42
+ anchor (`a`) sits OUTSIDE any such range. */
43
+ function isOpenMarker(data: string): boolean {
44
+ return data === '[' || data.startsWith('abide:')
45
+ }
46
+ function isCloseMarker(data: string): boolean {
47
+ return data === ']' || data.startsWith('/abide:')
48
+ }
49
+
50
+ /* The `index`-th depth-0 ELEMENT among `children` — skipping text/comment nodes AND any
51
+ element nested inside a block's rendered range (between `[`…`]` / `abide:…` boundaries),
52
+ which belongs to that block's own skeleton. The compiler indexes element holes over the
53
+ SHALLOW template (block positions are `<!--a-->` anchors, no content), so on hydrate the
54
+ expanded tree must skip that inline content or a hole positioned after a block shifts. In
55
+ create mode the clone is shallow (no markers), so depth stays 0 — a plain element count. */
56
+ function elementChildAt(children: ArrayLike<Node>, index: number): Element {
57
+ let seen = 0
58
+ let depth = 0
59
+ for (let cursor = 0; cursor < children.length; cursor += 1) {
60
+ const child = children[cursor] as Node
61
+ const data = commentData(child)
62
+ if (data === undefined) {
63
+ if (isElement(child) && depth === 0) {
64
+ if (seen === index) {
65
+ return child
66
+ }
67
+ seen += 1
68
+ }
69
+ } else if (isCloseMarker(data)) {
70
+ depth -= 1
71
+ } else if (isOpenMarker(data)) {
72
+ depth += 1
73
+ }
74
+ }
75
+ return undefined as unknown as Element
76
+ }
77
+
78
+ /* Records each element hole's element-only path in PRE-ORDER (the `HOLE_ATTRIBUTE`
79
+ marks which) and strips the marker — the compiler assigns element-hole indices in the
80
+ same pre-order, so the arrays line up without numbering the markers. */
81
+ function indexElementHoles(container: Node, prefix: number[], paths: number[][]): void {
82
+ const children = container.childNodes
83
+ let elementIndex = 0
84
+ for (let cursor = 0; cursor < children.length; cursor += 1) {
85
+ const child = children[cursor] as Node
86
+ if (!isElement(child)) {
87
+ continue
88
+ }
89
+ const path = [...prefix, elementIndex]
90
+ elementIndex += 1
91
+ if (child.hasAttribute(HOLE_ATTRIBUTE)) {
92
+ paths.push(path)
93
+ child.removeAttribute(HOLE_ATTRIBUTE)
94
+ }
95
+ indexElementHoles(child, path, paths)
96
+ }
97
+ }
98
+
99
+ /* Collects THIS skeleton's own anchor holes (`a` comments) in document order, present in
100
+ both the cloned skeleton and the server DOM (text-width-independent). The compiler emits
101
+ anchors in the same order, so the arrays line up.
102
+
103
+ In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
104
+ (each rows, branches, await/try boundaries) sits inline — so a naive descent would also
105
+ collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
106
+ every index past the first block. Block content is bounded by range markers, so track
107
+ depth per sibling list and take an anchor (and recurse into an element) only at depth 0,
108
+ where the skeleton's own structure lives. In create mode the clone is shallow (the blocks
109
+ have not built yet — no markers), so depth stays 0 and this is a plain document scan. */
110
+ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
111
+ let depth = 0
112
+ for (let index = 0; index < nodes.length; index += 1) {
113
+ const node = nodes[index] as Node
114
+ const data = commentData(node)
115
+ if (data === undefined) {
116
+ if (isElement(node) && depth === 0) {
117
+ scanAnchors(node.childNodes, anchors)
118
+ }
119
+ } else if (isCloseMarker(data)) {
120
+ depth -= 1
121
+ } else if (isOpenMarker(data)) {
122
+ depth += 1
123
+ } else if (data === 'a' && depth === 0) {
124
+ anchors.push(node)
125
+ }
126
+ }
127
+ }
128
+
129
+ /* When `parent` is foreign (or a control-flow fragment inside foreign content), the
130
+ skeleton's own markup carries no foreign ancestor, so a bare `<circle>` would parse
131
+ into the HTML namespace. Parse it inside the matching wrapper so the parser
132
+ namespaces the run; key the cache by wrapper too, since one string can be realized
133
+ in either context. */
134
+ function compile(html: string, wrapper: string | undefined): CompiledSkeleton {
135
+ let cache = CACHES.get(document)
136
+ if (cache === undefined) {
137
+ cache = new Map()
138
+ CACHES.set(document, cache)
139
+ }
140
+ const key = wrapper === undefined ? html : `${wrapper} ${html}`
141
+ let compiled = cache.get(key)
142
+ if (compiled === undefined) {
143
+ const template = document.createElement('template')
144
+ template.innerHTML = wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`
145
+ const source =
146
+ wrapper === undefined ? template.content : (template.content.firstChild as Node)
147
+ const elementPaths: number[][] = []
148
+ indexElementHoles(source, [], elementPaths)
149
+ compiled = { source, elementPaths, topLevelCount: source.childNodes.length }
150
+ cache.set(key, compiled)
151
+ }
152
+ return compiled
153
+ }
154
+
155
+ /* Walks an element-only path from the top-level node list to the target element. */
156
+ function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
157
+ let node = elementChildAt(topLevel, path[0] as number)
158
+ for (let depth = 1; depth < path.length; depth += 1) {
159
+ node = elementChildAt(node.childNodes, path[depth] as number)
160
+ }
161
+ return node
162
+ }
163
+
164
+ /*
165
+ Realizes a compiled skeleton under `parent` and returns its holes: `el` the element
166
+ holes (attribute/listener/bind), in pre-order; `an` the anchor holes (reactive text,
167
+ control flow, components), in document order. The browser's parser is the sole
168
+ tree-builder here, so foreign content (SVG/MathML) lands in the correct namespace and
169
+ `cloneNode` preserves it — a hand-rolled `createElement` tree-builder could not.
170
+
171
+ Element holes resolve by element-only path (stable against text-value width, computed
172
+ client-side — no server marker). Anchor holes resolve by scanning for their `a` comment
173
+ markers (present in both clone and server DOM). Create mode clones the parsed top-level
174
+ nodes; hydrate mode claims the matching server run.
175
+ */
176
+ // @readme plumbing
177
+ export function skeleton(parent: Node, html: string): SkeletonHoles {
178
+ const { source, elementPaths, topLevelCount } = compile(html, foreignWrapperTag(parent))
179
+ const hydration = RENDER.hydration
180
+ const topLevel: Node[] = []
181
+ if (hydration !== undefined) {
182
+ let node = claimChild(hydration, parent)
183
+ for (let count = 0; count < topLevelCount && node !== null; count += 1) {
184
+ topLevel.push(node)
185
+ node = node.nextSibling
186
+ }
187
+ hydration.next.set(parent, node)
188
+ } else {
189
+ const children = source.childNodes
190
+ for (let index = 0; index < children.length; index += 1) {
191
+ const clone = (children[index] as Node).cloneNode(true)
192
+ topLevel.push(clone)
193
+ parent.appendChild(clone)
194
+ }
195
+ }
196
+ const an: Node[] = []
197
+ scanAnchors(topLevel, an)
198
+ return {
199
+ el: elementPaths.map((path) => resolveElementHole(topLevel, path)),
200
+ an,
201
+ }
202
+ }
@@ -19,7 +19,12 @@ matching case in place, claim the end marker. The effect's first run picks the s
19
19
  case and is a no-op; later changes swap the range.
20
20
  */
21
21
  // @readme plumbing
22
- export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchCase[]): void {
22
+ export function switchBlock(
23
+ parent: Node,
24
+ subject: () => unknown,
25
+ cases: SwitchCase[],
26
+ before: Node | null = null,
27
+ ): void {
23
28
  const hydration = RENDER.hydration
24
29
  let dispose: (() => void) | undefined
25
30
  let activeIndex: number
@@ -34,7 +39,9 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
34
39
  const caseAt = (index: number): SwitchCase | undefined =>
35
40
  index === -1 ? undefined : cases[index]
36
41
 
37
- const start = openMarker(parent, '[')
42
+ /* `before` places the range among static siblings on create (block before a suffix);
43
+ hydrate ignores it and uses the parked claim cursor. */
44
+ const start = openMarker(parent, '[', before)
38
45
  if (hydration !== undefined) {
39
46
  activeIndex = select(subject())
40
47
  const chosen = caseAt(activeIndex)
@@ -43,7 +50,7 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
43
50
  }
44
51
  end = openMarker(parent, ']')
45
52
  } else {
46
- end = openMarker(parent, ']')
53
+ end = openMarker(parent, ']', before)
47
54
  activeIndex = select(subject())
48
55
  const chosen = caseAt(activeIndex)
49
56
  if (chosen !== undefined) {
@@ -0,0 +1,28 @@
1
+ /*
2
+ Parsed-once `<template>` per unique static-skeleton string, reused across every
3
+ mount. A `<template>` (not a detached `<div>`) so table/select content parses by
4
+ the real content model, exactly as the browser parsed the server markup. Backs
5
+ `cloneStatic`'s skeleton-string→template cache.
6
+
7
+ The cache is keyed by the owning `document`: a template belongs to the document
8
+ that created it, and its clones must land in that same document. In production there
9
+ is one document, so this is one inner map; under the test harness, which installs a
10
+ fresh `document` per file, it keeps each file's templates (and their node class)
11
+ from leaking into the next.
12
+ */
13
+ const CACHES = new WeakMap<object, Map<string, HTMLTemplateElement>>()
14
+
15
+ export function templateFor(html: string): HTMLTemplateElement {
16
+ let cache = CACHES.get(document)
17
+ if (cache === undefined) {
18
+ cache = new Map()
19
+ CACHES.set(document, cache)
20
+ }
21
+ let template = cache.get(html)
22
+ if (template === undefined) {
23
+ template = document.createElement('template')
24
+ template.innerHTML = html
25
+ cache.set(html, template)
26
+ }
27
+ return template
28
+ }
@@ -2,6 +2,7 @@ import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { OWNER } from '../runtime/OWNER.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { discardBoundary } from './discardBoundary.ts'
5
+ import { enterNamespace } from './enterNamespace.ts'
5
6
 
6
7
  /*
7
8
  Synchronous error boundary — the runtime for `<template try>`. Builds the guarded
@@ -25,6 +26,7 @@ export function tryBlock(
25
26
  id: number,
26
27
  renderTry: (parent: Node) => void,
27
28
  renderCatch?: (parent: Node, error: unknown) => void,
29
+ before: Node | null = null,
28
30
  ): void {
29
31
  /* Run a void build under a fresh ownership scope; on throw, tear down the partial
30
32
  effects/listeners it registered and rethrow so the caller can fall back. */
@@ -63,7 +65,7 @@ export function tryBlock(
63
65
  RENDER.hydration = undefined
64
66
  try {
65
67
  const fragment = document.createDocumentFragment()
66
- guard(() => renderCatch(fragment, error))
68
+ enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
67
69
  parent.insertBefore(fragment, after)
68
70
  } finally {
69
71
  RENDER.hydration = previous
@@ -76,14 +78,14 @@ export function tryBlock(
76
78
  (they never entered the document) before the catch builds. */
77
79
  try {
78
80
  const fragment = document.createDocumentFragment()
79
- guard(() => renderTry(fragment))
80
- parent.appendChild(fragment)
81
+ enterNamespace(parent, () => guard(() => renderTry(fragment)))
82
+ parent.insertBefore(fragment, before)
81
83
  } catch (error) {
82
84
  if (renderCatch === undefined) {
83
85
  throw error
84
86
  }
85
87
  const fragment = document.createDocumentFragment()
86
- guard(() => renderCatch(fragment, error))
87
- parent.appendChild(fragment)
88
+ enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
89
+ parent.insertBefore(fragment, before)
88
90
  }
89
91
  }
@@ -0,0 +1,8 @@
1
+ /* The holes a realized `skeleton` exposes for the build's attach code to wire up:
2
+ `el` the element holes (attribute/listener/bind nodes) in pre-order, `an` the anchor
3
+ holes (reactive text, control flow, components) in document order. Two arrays so the
4
+ compiler and SSR only agree on traversal order, never on synchronized indices. */
5
+ export type SkeletonHoles = {
6
+ el: Element[]
7
+ an: Node[]
8
+ }