@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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +26 -0
- package/package.json +1 -2
- package/src/checkAbide.ts +11 -6
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
- package/src/lib/server/runtime/createServer.ts +28 -23
- package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
- package/src/lib/ui/compile/AbideCompileError.ts +16 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
- package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
- package/src/lib/ui/compile/bindListenEvent.ts +19 -0
- package/src/lib/ui/compile/compileShadow.ts +65 -52
- package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
- package/src/lib/ui/compile/generateBuild.ts +114 -212
- package/src/lib/ui/compile/generateSSR.ts +54 -88
- package/src/lib/ui/compile/lowerContext.ts +64 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
- package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
- package/src/lib/ui/compile/scopeAttr.ts +9 -0
- package/src/lib/ui/compile/staticAttr.ts +11 -0
- package/src/lib/ui/compile/staticTextPart.ts +12 -0
- package/src/lib/ui/compile/unwrapParens.ts +10 -0
- package/src/lib/ui/dom/applyResolved.ts +9 -6
- package/src/lib/ui/dom/awaitBlock.ts +27 -21
- package/src/lib/ui/dom/clearBetween.ts +16 -0
- package/src/lib/ui/dom/each.ts +64 -38
- package/src/lib/ui/dom/eachAsync.ts +41 -54
- package/src/lib/ui/dom/fillBefore.ts +16 -0
- package/src/lib/ui/dom/moveRange.ts +19 -0
- package/src/lib/ui/dom/openMarker.ts +22 -0
- package/src/lib/ui/dom/removeRange.ts +18 -0
- package/src/lib/ui/dom/switchBlock.ts +32 -40
- package/src/lib/ui/dom/tryBlock.ts +31 -35
- package/src/lib/ui/dom/types/EachRow.ts +10 -3
- package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
- package/src/lib/ui/dom/when.ts +34 -43
- package/src/lib/ui/installHotBridge.ts +0 -2
- package/src/lib/ui/renderToStream.ts +14 -11
- package/src/lib/ui/state.ts +14 -5
- package/src/lib/ui/compile/branchElements.ts +0 -50
- package/src/lib/ui/dom/openRoot.ts +0 -20
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -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=>`.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
a new key
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
untouched, so a stable list does zero DOM moves and an append
|
|
20
|
-
|
|
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:
|
|
25
|
-
|
|
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) =>
|
|
33
|
+
render: (parent: Node, item: T) => void,
|
|
33
34
|
): void {
|
|
34
35
|
const rows = new Map<string, EachRow>()
|
|
35
36
|
|
|
36
|
-
/* Build
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
85
|
-
freshly built)
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
102
|
-
own disposer only unsubscribes it from `items()
|
|
103
|
-
|
|
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
|
|
11
|
-
a
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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)
|
|
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) =>
|
|
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) =>
|
|
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:
|
|
57
|
+
let errorRange: EachRow | undefined
|
|
57
58
|
const clearError = (): void => {
|
|
58
59
|
if (errorRange !== undefined) {
|
|
59
60
|
errorRange.dispose()
|
|
60
|
-
|
|
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
|
-
|
|
92
|
+
range out (v1 has no in-place field patch — rows bind plain snapshots). */
|
|
99
93
|
const stale = rows.get(key)
|
|
100
|
-
const
|
|
101
|
-
rows.set(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
the effect's first run picks the same
|
|
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
|
|
22
|
-
let activeIndex
|
|
23
|
-
let
|
|
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
|
|
40
|
+
const chosen = caseAt(activeIndex)
|
|
43
41
|
if (chosen !== undefined) {
|
|
44
|
-
|
|
42
|
+
dispose = scope(() => chosen.render(parent)) // claim the SSR nodes in place
|
|
45
43
|
}
|
|
46
|
-
|
|
47
|
-
parent.insertBefore(anchor, claimChild(hydration, parent))
|
|
44
|
+
end = openMarker(parent, ']')
|
|
48
45
|
} else {
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
67
|
-
if (chosen
|
|
68
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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) =>
|
|
27
|
-
renderCatch?: (parent: Node, error: unknown) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
parent.appendChild(node)
|
|
85
|
+
const fragment = document.createDocumentFragment()
|
|
86
|
+
guard(() => renderCatch(fragment, error))
|
|
87
|
+
parent.appendChild(fragment)
|
|
92
88
|
}
|
|
93
89
|
}
|