@abide/abide 0.31.1 → 0.32.1

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 (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +26 -0
  3. package/package.json +1 -2
  4. package/src/checkAbide.ts +11 -6
  5. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
  6. package/src/lib/server/runtime/createServer.ts +28 -23
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
  8. package/src/lib/ui/compile/AbideCompileError.ts +16 -0
  9. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
  10. package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
  11. package/src/lib/ui/compile/bindListenEvent.ts +19 -0
  12. package/src/lib/ui/compile/compileShadow.ts +65 -52
  13. package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
  14. package/src/lib/ui/compile/generateBuild.ts +114 -212
  15. package/src/lib/ui/compile/generateSSR.ts +54 -88
  16. package/src/lib/ui/compile/lowerContext.ts +64 -0
  17. package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
  18. package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
  19. package/src/lib/ui/compile/scopeAttr.ts +9 -0
  20. package/src/lib/ui/compile/staticAttr.ts +11 -0
  21. package/src/lib/ui/compile/staticTextPart.ts +12 -0
  22. package/src/lib/ui/compile/unwrapParens.ts +10 -0
  23. package/src/lib/ui/dom/applyResolved.ts +9 -6
  24. package/src/lib/ui/dom/awaitBlock.ts +27 -21
  25. package/src/lib/ui/dom/clearBetween.ts +16 -0
  26. package/src/lib/ui/dom/each.ts +64 -38
  27. package/src/lib/ui/dom/eachAsync.ts +41 -54
  28. package/src/lib/ui/dom/fillBefore.ts +16 -0
  29. package/src/lib/ui/dom/moveRange.ts +19 -0
  30. package/src/lib/ui/dom/openMarker.ts +22 -0
  31. package/src/lib/ui/dom/removeRange.ts +18 -0
  32. package/src/lib/ui/dom/switchBlock.ts +32 -40
  33. package/src/lib/ui/dom/tryBlock.ts +31 -35
  34. package/src/lib/ui/dom/types/EachRow.ts +10 -3
  35. package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
  36. package/src/lib/ui/dom/when.ts +34 -43
  37. package/src/lib/ui/installHotBridge.ts +0 -2
  38. package/src/lib/ui/renderToStream.ts +14 -11
  39. package/src/lib/ui/state.ts +14 -5
  40. package/src/lib/ui/compile/branchElements.ts +0 -50
  41. package/src/lib/ui/dom/openRoot.ts +0 -20
@@ -3,50 +3,82 @@ 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 { moveRange } from './moveRange.ts'
7
+ import { removeRange } from './removeRange.ts'
6
8
  import type { EachRow } from './types/EachRow.ts'
7
9
 
8
10
  /*
9
- Keyed list binding — the runtime for `<template each key=>`. Rows live in their own
10
- region, bounded by a trailing anchor so positioning is relative to the each itself,
11
- never `parent.firstChild` a sibling before the each (e.g. a static nav link) must
12
- not be treated as the first row. An effect tracks `items()` and reconciles by key:
13
- a new key renders a row in its own ownership scope (so the row's bindings dispose
14
- when it leaves), and a departed key disposes and is removed. Keying by identity
15
- (not index) lets a row keep its node and inner effects across a reorder.
11
+ Keyed list binding — the runtime for `<template each key=>`. Each row is a content
12
+ RANGE bounded by two comment markers, so a row holds anything elements,
13
+ components, text, nested control-flow not just one node. Rows live before a
14
+ trailing anchor so positioning is relative to the each itself. An effect tracks
15
+ `items()` and reconciles by key: a new key builds a row in its own ownership scope,
16
+ a departed key disposes and its range is removed. Keying by identity lets a row
17
+ keep its range and inner effects across a reorder.
16
18
 
17
19
  Placement walks the desired list *backwards* from the trailing anchor, holding a
18
- cursor at the node each row should precede; a row already sitting there is left
19
- untouched, so a stable list does zero DOM moves and an append does exactly one —
20
- only out-of-place rows are re-inserted (`insertBefore` on an in-place node would
21
- otherwise remove-then-reinsert it, O(rows) moves per change). Departed rows are
22
- pruned *before* placement so their nodes can't shift the cursor's sibling checks.
20
+ cursor at the node each row should precede; a row already ending there is left
21
+ untouched, so a stable list does zero DOM moves and an append exactly one — only an
22
+ out-of-place row's range is moved. Departed rows are pruned *before* placement.
23
23
 
24
- On hydrate the SSR rows are already in place and in order: claim each one where it
25
- sits (no reordering), park the anchor after them, and skip the first reconcile.
24
+ On hydrate the SSR rows are already in place and in order: each row claims its
25
+ markers and content where they sit (no reordering), the anchor parks after them,
26
+ and the first reconcile is skipped.
26
27
  */
27
28
  // @readme plumbing
28
29
  export function each<T>(
29
30
  parent: Node,
30
31
  items: () => Iterable<T>,
31
32
  keyOf: (item: T) => string,
32
- render: (parent: Node, item: T) => Node,
33
+ render: (parent: Node, item: T) => void,
33
34
  ): void {
34
35
  const rows = new Map<string, EachRow>()
35
36
 
36
- /* Build one row in its own scope (render claims on hydrate, creates otherwise). */
37
+ /* Build a row's range. Hydrate mode (only while the claim cursor is active —
38
+ read fresh, since a row built by a post-hydration reconcile must create, not
39
+ claim): claim the start marker, build content in place, claim the end marker.
40
+ Create mode: markers + content in a fragment, held in `pending` until placement
41
+ inserts it. */
37
42
  const buildRow = (item: T): EachRow => {
38
- let node: Node | undefined
39
- const dispose = scope(() => {
40
- node = render(parent, item)
41
- })
42
- return { node: node as Node, dispose }
43
+ const hydration = RENDER.hydration
44
+ if (hydration !== undefined) {
45
+ const start = claimChild(hydration, parent) as Node
46
+ hydration.next.set(parent, start.nextSibling)
47
+ const dispose = scope(() => render(parent, item))
48
+ const end = claimChild(hydration, parent) as Node
49
+ hydration.next.set(parent, end.nextSibling)
50
+ return { start, end, dispose }
51
+ }
52
+ const start = document.createComment('[')
53
+ const end = document.createComment(']')
54
+ const pending = document.createDocumentFragment()
55
+ pending.appendChild(start)
56
+ const dispose = scope(() => render(pending, item))
57
+ pending.appendChild(end)
58
+ return { start, end, dispose, pending }
59
+ }
60
+
61
+ /* Place a row so its range ends just before `cursor`: insert a fresh row's
62
+ fragment, or move an existing range only when it isn't already there. Insert
63
+ via `cursor`'s LIVE parent, not the captured `parent` — when this `each` is a
64
+ bare child of a control-flow branch, `parent` is the branch's build fragment,
65
+ which the enclosing block has since emptied into the document. */
66
+ const placeBefore = (row: EachRow, cursor: Node): void => {
67
+ if (row.pending !== undefined) {
68
+ ;(cursor.parentNode ?? parent).insertBefore(row.pending, cursor)
69
+ row.pending = undefined
70
+ return
71
+ }
72
+ if (row.end.nextSibling !== cursor) {
73
+ moveRange(row.start, row.end, cursor)
74
+ }
43
75
  }
44
76
 
45
- const hydration = RENDER.hydration
46
77
  let anchor: Node
47
78
  /* When hydrating, the first effect run must NOT reconcile — the rows it would
48
79
  build are already adopted in place below. */
49
80
  let adopting = false
81
+ const hydration = RENDER.hydration
50
82
  if (hydration !== undefined) {
51
83
  for (const item of items()) {
52
84
  rows.set(keyOf(item), buildRow(item)) // claims the SSR row where it sits
@@ -61,8 +93,7 @@ export function each<T>(
61
93
 
62
94
  effect(() => {
63
95
  /* Read (subscribe) every run, including the adopting one. Materialize a
64
- non-array iterable to an array so a generator yields fresh each run and the
65
- list can be safely traversed once for placement, once for pruning. */
96
+ non-array iterable to an array so a generator yields fresh each run. */
66
97
  const source = items()
67
98
  if (adopting) {
68
99
  adopting = false // rows already adopted in document order; nothing to move
@@ -71,18 +102,18 @@ export function each<T>(
71
102
  const list = Array.isArray(source) ? source : [...source]
72
103
  const keys = list.map(keyOf)
73
104
  const present = new Set(keys)
74
- /* Prune departed rows first so their nodes don't sit between survivors and
105
+ /* Prune departed rows first so their ranges don't sit between survivors and
75
106
  throw off the in-place sibling checks below. */
76
107
  for (const [key, row] of rows) {
77
108
  if (!present.has(key)) {
78
109
  row.dispose()
79
- parent.removeChild(row.node)
110
+ removeRange(row.start, row.end)
80
111
  rows.delete(key)
81
112
  }
82
113
  }
83
114
  /* Walk backwards from the anchor: `cursor` is the node the current row must
84
- precede. A row already there keeps its place; only an out-of-order (or
85
- freshly built) row is moved. Placement never touches preceding siblings. */
115
+ precede. A row already ending there keeps its place; only out-of-order (or
116
+ freshly built) rows move. */
86
117
  let cursor: Node = anchor
87
118
  for (let index = list.length - 1; index >= 0; index -= 1) {
88
119
  const key = keys[index] as string
@@ -91,19 +122,14 @@ export function each<T>(
91
122
  row = buildRow(list[index] as T)
92
123
  rows.set(key, row)
93
124
  }
94
- if (row.node.nextSibling !== cursor) {
95
- parent.insertBefore(row.node, cursor)
96
- }
97
- cursor = row.node
125
+ placeBefore(row, cursor)
126
+ cursor = row.start
98
127
  }
99
128
  })
100
129
 
101
- /* Dispose every row still live when the enclosing scope tears down. The effect's
102
- own disposer only unsubscribes it from `items()`; it never reaches the per-row
103
- ownership scopes, which are pruned only on the departed-key path above. Without
104
- this, a row whose binding subscribes to a longer-lived signal (a module store,
105
- the cache, `page`) stays in that signal's observers after the list unmounts. The
106
- host's DOM is cleared by `mount`, so disposal need not remove the nodes. */
130
+ /* Dispose every row still live when the enclosing scope tears down (the effect's
131
+ own disposer only unsubscribes it from `items()`). The host's DOM is cleared by
132
+ `mount`, so disposal need not remove the nodes. */
107
133
  if (OWNER.current !== undefined) {
108
134
  OWNER.current.push(() => {
109
135
  for (const row of rows.values()) {
@@ -3,47 +3,33 @@ 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 { removeRange } from './removeRange.ts'
6
7
  import type { EachRow } from './types/EachRow.ts'
7
8
 
8
9
  /*
9
10
  Async keyed list — the runtime for `<template each await key=>`. Like `each`, but
10
- over an AsyncIterable: rows append and reconcile by key as the iterator yields, so
11
- a live stream (an `async function*` rpc, a socket feed) lands row by row. Keying by
12
- identity lets a re-yielded key reuse its node and inner effects rather than append a
13
- duplicate.
11
+ over an AsyncIterable: rows append and reconcile by key as the iterator yields. Each
12
+ row is a content RANGE bounded by comment markers, so a row holds any content.
14
13
 
15
- SSR renders no rows — async sources are client-time (an infinite stream would hang
16
- SSR) — so hydration just parks the anchor before the next sibling (like an empty
17
- sync each) and the client drains the iterable. A reactive re-run (a cache
18
- invalidate, or the iterable expression changing) bumps the generation so a prior
19
- in-flight drain stops appending; departed keys are pruned once a drain completes
20
- (an infinite stream never prunes — rows accumulate / update in place).
14
+ SSR renders no rows — async sources are client-time so hydration just parks the
15
+ anchor before the next sibling (like an empty sync each) and the client drains the
16
+ iterable. A reactive re-run bumps the generation so a prior in-flight drain stops
17
+ appending; departed keys are pruned once a drain completes.
21
18
 
22
19
  On a mid-stream rejection the already-streamed rows stay and the `<template catch>`
23
20
  branch (`renderCatch`) renders after them; absent a catch branch the rejection
24
- surfaces (re-throws) instead of being swallowed (mirrors `<template await>`).
21
+ surfaces (re-throws), mirroring `<template await>`.
25
22
  */
26
23
  // @readme plumbing
27
24
  export function eachAsync<T>(
28
25
  parent: Node,
29
26
  items: () => AsyncIterable<T>,
30
27
  keyOf: (item: T) => string,
31
- render: (parent: Node, item: T) => Node,
28
+ render: (parent: Node, item: T) => void,
32
29
  /* Absent → an iterator rejection surfaces instead of rendering a catch branch. */
33
- renderCatch: ((parent: Node, error: unknown) => Node[]) | undefined,
30
+ renderCatch: ((parent: Node, error: unknown) => void) | undefined,
34
31
  ): void {
35
32
  const rows = new Map<string, EachRow>()
36
-
37
- /* Build one row in its own scope so its bindings dispose when it leaves. Rows are
38
- always created (never on the server), so this runs after hydration has ended. */
39
- const buildRow = (item: T): EachRow => {
40
- let node: Node | undefined
41
- const dispose = scope(() => {
42
- node = render(parent, item)
43
- })
44
- return { node: node as Node, dispose }
45
- }
46
-
47
33
  const hydration = RENDER.hydration
48
34
  const anchor = document.createTextNode('')
49
35
  if (hydration !== undefined) {
@@ -52,30 +38,38 @@ export function eachAsync<T>(
52
38
  parent.appendChild(anchor)
53
39
  }
54
40
 
41
+ /* Build a content range and insert it just before the anchor (arrival order). */
42
+ const insertRange = (build: (into: Node) => void): EachRow => {
43
+ const start = document.createComment('[')
44
+ const end = document.createComment(']')
45
+ const fragment = document.createDocumentFragment()
46
+ fragment.appendChild(start)
47
+ const dispose = scope(() => build(fragment))
48
+ fragment.appendChild(end)
49
+ /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
50
+ control-flow branch, the captured `parent` is the branch's build fragment,
51
+ emptied into the document once the enclosing block placed it. */
52
+ ;(anchor.parentNode ?? parent).insertBefore(fragment, anchor)
53
+ return { start, end, dispose }
54
+ }
55
+
55
56
  /* The mounted `<template catch>` range, disposed when a fresh run re-streams. */
56
- let errorRange: { nodes: Node[]; dispose: () => void } | undefined
57
+ let errorRange: EachRow | undefined
57
58
  const clearError = (): void => {
58
59
  if (errorRange !== undefined) {
59
60
  errorRange.dispose()
60
- for (const node of errorRange.nodes) {
61
- parent.removeChild(node)
62
- }
61
+ removeRange(errorRange.start, errorRange.end)
63
62
  errorRange = undefined
64
63
  }
65
64
  }
66
65
 
67
66
  /* Bumped each run so a superseded drain stops appending and pruning. */
68
67
  let generation = 0
69
- /* The live iterator, held so a re-run or teardown can `return()` it — closing
70
- the source (a generator's `finally`, a socket) and resolving any pending
71
- `next()`. Bumping `generation` alone can't reach a drain parked on `await
72
- iterator.next()`: an idle infinite stream never yields again, so the loop
73
- never re-checks `generation` — it dangles on the microtask queue forever. */
74
68
  let iterator: AsyncIterator<T> | undefined
75
69
  effect(() => {
76
70
  generation += 1
77
71
  const generationAtStart = generation
78
- iterator?.return?.() // close the superseded run's iterator before re-streaming
72
+ iterator?.return?.(undefined)?.catch(() => undefined) // close the superseded run's iterator before re-streaming
79
73
  iterator = undefined
80
74
  clearError() // a fresh run drops a prior error branch
81
75
  const iterable = items() // read (subscribe) synchronously
@@ -95,20 +89,22 @@ export function eachAsync<T>(
95
89
  const key = keyOf(result.value)
96
90
  present.add(key)
97
91
  /* A re-yielded key rebuilds the row from the new value, swapping the old
98
- node out (v1 has no in-place field patch — rows bind plain snapshots). */
92
+ range out (v1 has no in-place field patch — rows bind plain snapshots). */
99
93
  const stale = rows.get(key)
100
- const row = buildRow(result.value)
101
- rows.set(key, row)
94
+ const item = result.value
95
+ rows.set(
96
+ key,
97
+ insertRange((host) => render(host, item)),
98
+ )
102
99
  if (stale !== undefined) {
103
100
  stale.dispose()
104
- parent.removeChild(stale.node)
101
+ removeRange(stale.start, stale.end)
105
102
  }
106
- parent.insertBefore(row.node, anchor) // arrival order, before the anchor
107
103
  }
108
104
  for (const [key, row] of rows) {
109
105
  if (!present.has(key)) {
110
106
  row.dispose()
111
- parent.removeChild(row.node)
107
+ removeRange(row.start, row.end)
112
108
  rows.delete(key)
113
109
  }
114
110
  }
@@ -122,26 +118,17 @@ export function eachAsync<T>(
122
118
  throw error
123
119
  }
124
120
  /* Keep the streamed rows; render the catch branch after them, at the anchor. */
125
- let nodes: Node[] = []
126
- const dispose = scope(() => {
127
- nodes = renderCatch(parent, error)
128
- })
129
- for (const node of nodes) {
130
- parent.insertBefore(node, anchor)
131
- }
132
- errorRange = { nodes, dispose }
121
+ errorRange = insertRange((host) => renderCatch(host, error))
133
122
  })
134
123
  })
135
124
 
136
- /* Stop the live stream when the enclosing scope tears down. The effect's own
137
- disposer only unsubscribes from `items()` it leaves the running `drain()`
138
- pulling rows into a now-detached parent forever. Bump the generation so the
139
- drain abandons its loop, `return()` the iterator to release the source and any
140
- pending `next()`, drop the error branch, and dispose every surviving row. */
125
+ /* Stop the live stream when the enclosing scope tears down: bump the generation so
126
+ the drain abandons its loop, `return()` the iterator to release the source, drop
127
+ the error branch, and dispose every surviving row. */
141
128
  if (OWNER.current !== undefined) {
142
129
  OWNER.current.push(() => {
143
130
  generation += 1
144
- iterator?.return?.()
131
+ iterator?.return?.(undefined)?.catch(() => undefined)
145
132
  iterator = undefined
146
133
  clearError()
147
134
  for (const row of rows.values()) {
@@ -0,0 +1,16 @@
1
+ import { scope } from '../runtime/scope.ts'
2
+
3
+ /*
4
+ Builds `content` into a fragment under a fresh reactive scope, then inserts it just
5
+ before the `end` marker; returns the scope disposer. Building into a fragment lets
6
+ the content append freely (elements, components, nested blocks all use the normal
7
+ append path) before it lands as a unit. Inserting via the marker's LIVE parent
8
+ (`end.parentNode`) keeps placement correct even after an enclosing block has moved
9
+ the markers from a build-time fragment into the document.
10
+ */
11
+ export function fillBefore(end: Node, content: (into: Node) => void): () => void {
12
+ const fragment = document.createDocumentFragment()
13
+ const dispose = scope(() => content(fragment))
14
+ ;(end.parentNode ?? end).insertBefore(fragment, end)
15
+ return dispose
16
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ Moves a contiguous node range — `start` through `end` inclusive — to sit just
3
+ before `ref`, preserving order. Each node's next sibling is captured before the
4
+ move (insertBefore relocates it, changing `nextSibling`). Used by the keyed `each`
5
+ to reposition a row whose content is a range rather than a single node.
6
+ */
7
+ export function moveRange(start: Node, end: Node, ref: Node | null): void {
8
+ const parent = ref?.parentNode ?? end.parentNode
9
+ if (parent === null) {
10
+ return
11
+ }
12
+ let node: Node | null = start
13
+ const stop: Node | null = end.nextSibling
14
+ while (node !== null && node !== stop) {
15
+ const next: Node | null = node.nextSibling
16
+ parent.insertBefore(node, ref)
17
+ node = next
18
+ }
19
+ }
@@ -0,0 +1,22 @@
1
+ import { claimChild } from '../runtime/claimChild.ts'
2
+ import { RENDER } from '../runtime/RENDER.ts'
3
+
4
+ /*
5
+ Opens a control-flow range boundary: a comment marker. Create mode appends a fresh
6
+ comment to `parent`; hydrate mode claims the server-rendered marker at the parent's
7
+ cursor and advances it. Markers are real comment nodes so they survive in the SSR
8
+ HTML and the block can claim them positionally on hydrate — the boundary that lets
9
+ a branch hold ANY content (components, text, nested blocks, snippets) as a range,
10
+ rather than a list of single nodes.
11
+ */
12
+ export function openMarker(parent: Node, data: string): Comment {
13
+ const hydration = RENDER.hydration
14
+ if (hydration !== undefined) {
15
+ const node = claimChild(hydration, parent) as unknown as Comment
16
+ hydration.next.set(parent, node === null ? null : node.nextSibling)
17
+ return node
18
+ }
19
+ const node = document.createComment(data)
20
+ parent.appendChild(node)
21
+ return node
22
+ }
@@ -0,0 +1,18 @@
1
+ /*
2
+ Removes a contiguous node range — `start` through `end` inclusive — from the DOM.
3
+ Used to evict a departed keyed-`each` row whose content is a range (markers plus
4
+ whatever they bound). Each next sibling is captured before removal.
5
+ */
6
+ export function removeRange(start: Node, end: Node): void {
7
+ const parent = end.parentNode
8
+ if (parent === null) {
9
+ return
10
+ }
11
+ let node: Node | null = start
12
+ const stop: Node | null = end.nextSibling
13
+ while (node !== null && node !== stop) {
14
+ const next: Node | null = node.nextSibling
15
+ parent.removeChild(node)
16
+ node = next
17
+ }
18
+ }
@@ -1,34 +1,29 @@
1
1
  import { effect } from '../effect.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
3
2
  import { RENDER } from '../runtime/RENDER.ts'
4
3
  import { scope } from '../runtime/scope.ts'
4
+ import { clearBetween } from './clearBetween.ts'
5
+ import { fillBefore } from './fillBefore.ts'
6
+ import { openMarker } from './openMarker.ts'
5
7
  import type { SwitchCase } from './types/SwitchCase.ts'
6
8
 
7
9
  /*
8
- Multi-branch binding — the runtime for `<template switch>`. An effect evaluates
9
- the subject, picks the first case whose `match` equals it (strict `===`), falling
10
- back to the default (`match` undefined); the chosen branch renders in its own
11
- scope, anchored for placement. A branch is a RANGE of element roots, tracked as a
12
- node array so a multi-root case inserts/removes as a unit. Staying on the same
13
- branch across a subject change leaves it mounted; switching disposes the old.
10
+ Multi-branch binding — the runtime for `<template switch>`. An effect evaluates the
11
+ subject, picks the first case whose `match` equals it (strict `===`), falling back
12
+ to the default (`match` undefined); the chosen case's content lives in a RANGE
13
+ bounded by two comment markers, so a case holds any content. Staying on the same
14
+ case across a subject change leaves it mounted; switching clears the range and
15
+ builds the new case fresh.
14
16
 
15
- On hydrate it adopts the case the server rendered (in place) and anchors after it;
16
- the effect's first run picks the same case and is a no-op, later changes swap fresh.
17
+ On hydrate it adopts the case the server rendered: claim the start marker, run the
18
+ matching case in place, claim the end marker. The effect's first run picks the same
19
+ case and is a no-op; later changes swap the range.
17
20
  */
18
21
  // @readme plumbing
19
22
  export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchCase[]): void {
20
23
  const hydration = RENDER.hydration
21
- let active: { nodes: Node[]; dispose: () => void } | undefined
22
- let activeIndex = -1
23
- let anchor: Node
24
-
25
- const build = (chosen: SwitchCase): { nodes: Node[]; dispose: () => void } => {
26
- let nodes: Node[] = []
27
- const dispose = scope(() => {
28
- nodes = chosen.render(parent)
29
- })
30
- return { nodes, dispose }
31
- }
24
+ let dispose: (() => void) | undefined
25
+ let activeIndex: number
26
+ let end: Comment
32
27
 
33
28
  const select = (value: unknown): number => {
34
29
  const matched = cases.findIndex(
@@ -36,18 +31,24 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
36
31
  )
37
32
  return matched === -1 ? cases.findIndex((entry) => entry.match === undefined) : matched
38
33
  }
34
+ const caseAt = (index: number): SwitchCase | undefined =>
35
+ index === -1 ? undefined : cases[index]
39
36
 
37
+ const start = openMarker(parent, '[')
40
38
  if (hydration !== undefined) {
41
39
  activeIndex = select(subject())
42
- const chosen = activeIndex === -1 ? undefined : cases[activeIndex]
40
+ const chosen = caseAt(activeIndex)
43
41
  if (chosen !== undefined) {
44
- active = build(chosen)
42
+ dispose = scope(() => chosen.render(parent)) // claim the SSR nodes in place
45
43
  }
46
- anchor = document.createTextNode('')
47
- parent.insertBefore(anchor, claimChild(hydration, parent))
44
+ end = openMarker(parent, ']')
48
45
  } else {
49
- anchor = document.createTextNode('')
50
- parent.appendChild(anchor)
46
+ end = openMarker(parent, ']')
47
+ activeIndex = select(subject())
48
+ const chosen = caseAt(activeIndex)
49
+ if (chosen !== undefined) {
50
+ dispose = fillBefore(end, (p) => chosen.render(p))
51
+ }
51
52
  }
52
53
 
53
54
  effect(() => {
@@ -55,21 +56,12 @@ export function switchBlock(parent: Node, subject: () => unknown, cases: SwitchC
55
56
  if (index === activeIndex) {
56
57
  return
57
58
  }
58
- if (active !== undefined) {
59
- active.dispose()
60
- for (const node of active.nodes) {
61
- parent.removeChild(node)
62
- }
63
- active = undefined
64
- }
59
+ clearBetween(start, end, dispose)
60
+ dispose = undefined
65
61
  activeIndex = index
66
- const chosen = index === -1 ? undefined : cases[index]
67
- if (chosen === undefined) {
68
- return
69
- }
70
- active = build(chosen)
71
- for (const node of active.nodes) {
72
- parent.insertBefore(node, anchor)
62
+ const chosen = caseAt(index)
63
+ if (chosen !== undefined) {
64
+ dispose = fillBefore(end, (p) => chosen.render(p))
73
65
  }
74
66
  })
75
67
  }
@@ -6,41 +6,35 @@ import { discardBoundary } from './discardBoundary.ts'
6
6
  /*
7
7
  Synchronous error boundary — the runtime for `<template try>`. Builds the guarded
8
8
  subtree (`renderTry`); if building it throws — including a throw from an initial
9
- reactive read, since effects run during build — it tears down the partial scope
10
- and builds `renderCatch(error)` instead. Both branches are a range of element
11
- roots tracked together. No `renderCatch` (no `<template catch>`) re-throws, so the
12
- error propagates to the nearest enclosing boundary (or the server 500 / stream).
9
+ reactive read, since effects run during build — it tears down the partial scope and
10
+ builds `renderCatch(error)` instead. Each branch builds its content (any content)
11
+ into the parent. No `renderCatch` (no `<template catch>`) re-throws, so the error
12
+ propagates to the nearest enclosing boundary. The block renders once and never
13
+ re-renders, so its content needs no range markers — an enclosing block's range
14
+ removes it on teardown.
13
15
 
14
- Catches throws during the BUILD of the subtree (mount, hydrate adoption, and the
15
- initial reactive reads). A throw in a later effect re-run is outside this lexical
16
- build and is not caught here.
17
-
18
- On hydrate it claims the SSR boundary (`<!--abide:try:N-->…<!--/abide:try:N-->`):
19
- the happy path adopts the guarded nodes in place; a throw discards the boundary's
20
- server nodes and builds the catch fresh.
16
+ On create the guarded content is built into a fragment first, so a throw mid-build
17
+ discards the partial nodes (they never entered the document) before the catch
18
+ builds. On hydrate it claims the SSR boundary
19
+ (`<!--abide:try:N-->…<!--/abide:try:N-->`): the happy path adopts the guarded nodes
20
+ in place; a throw discards the boundary's server nodes and builds the catch fresh.
21
21
  */
22
22
  // @readme plumbing
23
23
  export function tryBlock(
24
24
  parent: Node,
25
25
  id: number,
26
- renderTry: (parent: Node) => Node[],
27
- renderCatch?: (parent: Node, error: unknown) => Node[],
26
+ renderTry: (parent: Node) => void,
27
+ renderCatch?: (parent: Node, error: unknown) => void,
28
28
  ): void {
29
- /* Run a build under a fresh ownership scope; on throw, tear down the partial
30
- effects/listeners it registered and rethrow so the caller can fall back.
31
- Deliberately not `scope()`: that returns a deferred disposer and leaks the
32
- partial scope on throw (it only restores the owner), whereas an error boundary
33
- must dispose eagerly when the guarded build throws and hand back the built
34
- `Node[]` (not a disposer) on success — those nodes belong to the enclosing
35
- scope. Different return type, different throw semantics; merging would be wrong. */
36
- const buildScoped = (build: () => Node[]): Node[] => {
29
+ /* Run a void build under a fresh ownership scope; on throw, tear down the partial
30
+ effects/listeners it registered and rethrow so the caller can fall back. */
31
+ const guard = (build: () => void): void => {
37
32
  const previous = OWNER.current
38
33
  const disposers: Array<() => void> = []
39
34
  OWNER.current = disposers
40
35
  try {
41
- const nodes = build()
36
+ build()
42
37
  OWNER.current = previous
43
- return nodes
44
38
  } catch (error) {
45
39
  OWNER.current = previous
46
40
  for (let index = disposers.length - 1; index >= 0; index -= 1) {
@@ -55,12 +49,12 @@ export function tryBlock(
55
49
  const open = claimChild(hydration, parent)
56
50
  hydration.next.set(parent, open?.nextSibling ?? null) // advance past the open marker
57
51
  try {
58
- buildScoped(() => renderTry(parent)) // claims the guarded nodes in place
52
+ guard(() => renderTry(parent)) // claims the guarded nodes in place
59
53
  const close = claimChild(hydration, parent) // claim the close marker
60
54
  hydration.next.set(parent, close?.nextSibling ?? null)
61
55
  } catch (error) {
62
- /* The server rendered (or partially built) something that didn't adopt
63
- drop the whole boundary and build the catch fresh in its place. */
56
+ /* The server markup didn't adopt drop the whole boundary and build the
57
+ catch fresh in its place. */
64
58
  const after = discardBoundary(parent, open, `/abide:try:${id}`, hydration)
65
59
  if (renderCatch === undefined) {
66
60
  throw error
@@ -68,9 +62,9 @@ export function tryBlock(
68
62
  const previous = RENDER.hydration
69
63
  RENDER.hydration = undefined
70
64
  try {
71
- for (const node of buildScoped(() => renderCatch(parent, error))) {
72
- parent.insertBefore(node, after)
73
- }
65
+ const fragment = document.createDocumentFragment()
66
+ guard(() => renderCatch(fragment, error))
67
+ parent.insertBefore(fragment, after)
74
68
  } finally {
75
69
  RENDER.hydration = previous
76
70
  }
@@ -78,16 +72,18 @@ export function tryBlock(
78
72
  return
79
73
  }
80
74
 
81
- let nodes: Node[]
75
+ /* Create: build into a fragment so a throw mid-build discards the partial nodes
76
+ (they never entered the document) before the catch builds. */
82
77
  try {
83
- nodes = buildScoped(() => renderTry(parent))
78
+ const fragment = document.createDocumentFragment()
79
+ guard(() => renderTry(fragment))
80
+ parent.appendChild(fragment)
84
81
  } catch (error) {
85
82
  if (renderCatch === undefined) {
86
83
  throw error
87
84
  }
88
- nodes = buildScoped(() => renderCatch(parent, error))
89
- }
90
- for (const node of nodes) {
91
- parent.appendChild(node)
85
+ const fragment = document.createDocumentFragment()
86
+ guard(() => renderCatch(fragment, error))
87
+ parent.appendChild(fragment)
92
88
  }
93
89
  }