@abide/abide 0.40.2 → 0.41.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 +327 -326
- package/CHANGELOG.md +62 -0
- package/README.md +87 -78
- package/package.json +1 -1
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/compileShadow.ts +5 -0
- package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
- package/src/lib/ui/compile/generateBuild.ts +96 -12
- package/src/lib/ui/compile/generateSSR.ts +26 -9
- package/src/lib/ui/compile/lowerContext.ts +29 -9
- package/src/lib/ui/compile/parseTemplate.ts +2 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
- package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
- package/src/lib/ui/compile/walkElementOrder.ts +60 -0
- package/src/lib/ui/dom/appendSnippet.ts +9 -8
- package/src/lib/ui/dom/applyResolved.ts +3 -4
- package/src/lib/ui/dom/awaitBlock.ts +106 -46
- package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
- package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
- package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
- package/src/lib/ui/dom/domElementAdapter.ts +20 -0
- package/src/lib/ui/dom/each.ts +33 -17
- package/src/lib/ui/dom/eachAsync.ts +34 -26
- package/src/lib/ui/dom/isElement.ts +6 -0
- package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
- package/src/lib/ui/dom/mountRange.ts +4 -3
- package/src/lib/ui/dom/mountSlot.ts +4 -3
- package/src/lib/ui/dom/on.ts +6 -1
- package/src/lib/ui/dom/replaceRange.ts +24 -0
- package/src/lib/ui/dom/skeleton.ts +35 -92
- package/src/lib/ui/dom/switchBlock.ts +13 -10
- package/src/lib/ui/dom/types/EachRow.ts +9 -2
- package/src/lib/ui/dom/when.ts +13 -10
- package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
- package/src/lib/ui/runtime/batch.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +3 -8
- package/src/lib/ui/runtime/createDoc.ts +9 -13
- package/src/lib/ui/seedResolved.ts +28 -0
- package/src/lib/ui/startClient.ts +6 -4
- package/src/lib/ui/types/ResolvedFrame.ts +15 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { snippetPayload } from '../../shared/snippet.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
|
+
import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
5
|
import { scope } from '../runtime/scope.ts'
|
|
5
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
|
-
import { clearBetween } from './clearBetween.ts'
|
|
7
7
|
import { fillBefore } from './fillBefore.ts'
|
|
8
8
|
import { openMarker } from './openMarker.ts'
|
|
9
|
+
import { replaceRange } from './replaceRange.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
|
|
@@ -30,7 +31,7 @@ the args; a later argument change rebuilds fresh (the SSR markers stay as the ra
|
|
|
30
31
|
export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
31
32
|
const hydration = RENDER.hydration
|
|
32
33
|
/* Mount scopes register with the owner so they dispose on owner teardown, not
|
|
33
|
-
only on an argument-driven rebuild via
|
|
34
|
+
only on an argument-driven rebuild via replaceRange. */
|
|
34
35
|
const group = scopeGroup()
|
|
35
36
|
let dispose: (() => void) | undefined
|
|
36
37
|
|
|
@@ -44,15 +45,15 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
44
45
|
let open: Comment
|
|
45
46
|
let close: Comment
|
|
46
47
|
if (hydration !== undefined) {
|
|
47
|
-
open = openMarker(parent,
|
|
48
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
48
49
|
const builder = builderOf()
|
|
49
50
|
if (builder !== undefined) {
|
|
50
51
|
dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
|
|
51
52
|
}
|
|
52
|
-
close = openMarker(parent,
|
|
53
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
53
54
|
} else {
|
|
54
|
-
open = openMarker(parent,
|
|
55
|
-
close = openMarker(parent,
|
|
55
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
56
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
56
57
|
const builder = builderOf()
|
|
57
58
|
if (builder !== undefined) {
|
|
58
59
|
dispose = group.track(fillBefore(close, builder))
|
|
@@ -68,7 +69,7 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
68
69
|
first = false
|
|
69
70
|
return
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
dispose =
|
|
72
|
+
const next = replaceRange(open, close, dispose, builder)
|
|
73
|
+
dispose = next !== undefined ? group.track(next) : undefined
|
|
73
74
|
})
|
|
74
75
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { seedStreamedResolution } from '../seedStreamedResolution.ts'
|
|
1
|
+
import { seedResolved } from '../seedResolved.ts'
|
|
3
2
|
|
|
4
3
|
/*
|
|
5
4
|
Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
|
|
@@ -31,7 +30,7 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
31
30
|
bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
|
|
32
31
|
if (resolved.nodeName === 'ABIDE-CACHE') {
|
|
33
32
|
try {
|
|
34
|
-
|
|
33
|
+
seedResolved({ kind: 'cache', resolution: JSON.parse(resolved.textContent ?? 'null') })
|
|
35
34
|
} catch {
|
|
36
35
|
/* malformed payload — leave unseeded; the read falls back to a live fetch */
|
|
37
36
|
}
|
|
@@ -47,7 +46,7 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
47
46
|
recording it lets a later hydrate adopt this branch (no re-fetch). */
|
|
48
47
|
const payload = resolved.firstChild as Element | null
|
|
49
48
|
if (payload !== null && payload.nodeName === 'SCRIPT') {
|
|
50
|
-
|
|
49
|
+
seedResolved({ kind: 'resume', id: Number(id), resume: payload.textContent ?? '' })
|
|
51
50
|
payload.remove()
|
|
52
51
|
}
|
|
53
52
|
const open = `abide:await:${id}`
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
3
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
4
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
4
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
6
|
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
6
7
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
7
8
|
import { scope } from '../runtime/scope.ts'
|
|
8
9
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
10
|
+
import type { State } from '../runtime/types/State.ts'
|
|
11
|
+
import { state } from '../state.ts'
|
|
12
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
9
13
|
import { discardBoundary } from './discardBoundary.ts'
|
|
10
|
-
import {
|
|
14
|
+
import { removeRange } from './removeRange.ts'
|
|
11
15
|
|
|
12
16
|
/*
|
|
13
17
|
Async binding — the runtime for `<template await>`. Renders the pending branch,
|
|
14
18
|
then swaps to the resolved branch (with the value) or the error branch on settle.
|
|
15
|
-
Each branch
|
|
16
|
-
branch inserts
|
|
19
|
+
Each branch's content lives in a RANGE bounded by two comment markers (`[`…`]`), the
|
|
20
|
+
same model `when`/`switch`/`each` use — so a multi-root branch inserts as a unit
|
|
21
|
+
(`buildDetachedRange`) and detaches as a unit (`removeRange`), rather than tracking and
|
|
22
|
+
removing a node array by hand.
|
|
17
23
|
|
|
18
24
|
The read runs inside a abide-ui `effect`, so it's reactive: `abide/shared/cache`'s
|
|
19
25
|
store subscribes the key it reads to this effect (createSubscriber is abide-ui-
|
|
@@ -47,88 +53,127 @@ export function awaitBlock(
|
|
|
47
53
|
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
48
54
|
teardown — not only when a settle/re-run swaps branches via detach. */
|
|
49
55
|
const group = scopeGroup()
|
|
50
|
-
let active: {
|
|
56
|
+
let active: { start: Comment; end: Comment; dispose: () => void } | undefined
|
|
51
57
|
let anchor: Node | undefined
|
|
52
58
|
let first = true
|
|
53
59
|
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
|
|
54
60
|
let generation = 0
|
|
61
|
+
/* The resolved value, held as a reactive cell so the then-branch reads it through its
|
|
62
|
+
own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
|
|
63
|
+
the branch — the branch (and any keyed `each` inside it) survives and updates in place,
|
|
64
|
+
so a live cache patch no longer flashes the whole subtree. The branch is rebuilt only
|
|
65
|
+
across a kind change (pending/catch ↔ then), where it has to be. */
|
|
66
|
+
let valueCell: State<unknown> | undefined
|
|
67
|
+
/* Which branch is currently mounted, so a settle knows whether it can update the cell in
|
|
68
|
+
place (then→then) or must build a fresh branch. */
|
|
69
|
+
let activeKind: 'pending' | 'then' | 'catch' | undefined
|
|
55
70
|
|
|
56
71
|
const detach = (): void => {
|
|
57
72
|
if (active !== undefined) {
|
|
58
73
|
active.dispose()
|
|
59
|
-
/*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
node.parentNode?.removeChild(node)
|
|
65
|
-
}
|
|
74
|
+
/* `removeRange` evicts the markers AND everything between them via the end
|
|
75
|
+
marker's LIVE parent — not the captured `parent`, which (when this await is a
|
|
76
|
+
bare child of a control-flow branch) is the branch's build fragment, emptied
|
|
77
|
+
into the document once the enclosing block placed it. */
|
|
78
|
+
removeRange(active.start, active.end)
|
|
66
79
|
active = undefined
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
|
|
70
|
-
/* Replace the current content with a freshly-built branch, before the anchor. The
|
|
71
|
-
|
|
72
|
-
— appends freely),
|
|
83
|
+
/* Replace the current content with a freshly-built branch, before the anchor. The branch
|
|
84
|
+
builds into a detached `[`…`]`-bracketed fragment (so any content — components, text,
|
|
85
|
+
nested blocks — appends freely), the same create primitive the keyed-list runtimes use,
|
|
86
|
+
which lands as a marker-bounded range the next swap detaches with `removeRange`. */
|
|
73
87
|
const place = (build: (parent: Node) => void): void => {
|
|
74
88
|
detach()
|
|
75
|
-
const
|
|
76
|
-
const dispose =
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
const namespaceParent = anchor?.parentNode ?? parent
|
|
90
|
+
const { start, end, fragment, dispose } = buildDetachedRange(namespaceParent, build)
|
|
91
|
+
const tracked = group.track(dispose)
|
|
92
|
+
namespaceParent.insertBefore(fragment, anchor ?? null)
|
|
93
|
+
active = { start, end, dispose: tracked }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Settle to a resolved value. then→then updates the cell in place — the branch and its
|
|
97
|
+
inner each survive (no flash); any other prior kind builds a fresh then-branch around
|
|
98
|
+
a new cell. renderThen receives the CELL (not the raw value), so the branch reads it
|
|
99
|
+
reactively and re-runs its own effects when a later settle sets it. */
|
|
100
|
+
const settleThen = (value: unknown): void => {
|
|
101
|
+
if (activeKind === 'then' && valueCell !== undefined) {
|
|
102
|
+
valueCell.value = value
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const cell = state(value)
|
|
106
|
+
valueCell = cell
|
|
107
|
+
place((host) => renderThen(host, cell))
|
|
108
|
+
activeKind = 'then'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Settle to a rejection: surface it with no catch branch, else swap to the catch branch. */
|
|
112
|
+
const settleError = (error: unknown): void => {
|
|
113
|
+
if (renderCatch === undefined) {
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
valueCell = undefined
|
|
117
|
+
place((host) => renderCatch(host, error))
|
|
118
|
+
activeKind = 'catch'
|
|
82
119
|
}
|
|
83
120
|
|
|
84
121
|
/* Render a settled-or-pending result into the current generation. */
|
|
85
122
|
const render = (result: unknown): void => {
|
|
86
123
|
const gen = generation
|
|
87
124
|
if (!isThenable(result)) {
|
|
88
|
-
|
|
125
|
+
settleThen(result) // warm-sync → resolved now, no flash
|
|
89
126
|
return
|
|
90
127
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
128
|
+
/* A then-branch is already mounted (a revalidation): keep it visible and update in
|
|
129
|
+
place when the new value settles, instead of blanking to pending and rebuilding —
|
|
130
|
+
this is the no-flash live-update path. A first load (or a prior pending/catch)
|
|
131
|
+
shows the pending branch (or detaches) while the promise is in flight. */
|
|
132
|
+
if (activeKind !== 'then') {
|
|
133
|
+
if (renderPending !== undefined) {
|
|
134
|
+
place((host) => renderPending(host))
|
|
135
|
+
activeKind = 'pending'
|
|
136
|
+
} else {
|
|
137
|
+
detach()
|
|
138
|
+
activeKind = undefined
|
|
139
|
+
}
|
|
95
140
|
}
|
|
96
141
|
result.then(
|
|
97
142
|
(value) => {
|
|
98
143
|
if (gen === generation) {
|
|
99
|
-
|
|
144
|
+
settleThen(value)
|
|
100
145
|
}
|
|
101
146
|
},
|
|
102
147
|
(error) => {
|
|
103
|
-
if (gen
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
/* No catch branch → surface the rejection instead of an empty branch. */
|
|
107
|
-
if (renderCatch === undefined) {
|
|
108
|
-
throw error
|
|
148
|
+
if (gen === generation) {
|
|
149
|
+
settleError(error)
|
|
109
150
|
}
|
|
110
|
-
place((host) => renderCatch(host, error))
|
|
111
151
|
},
|
|
112
152
|
)
|
|
113
153
|
}
|
|
114
154
|
|
|
115
|
-
/* Adopt an SSR-resolved branch in place (its content claims the existing nodes),
|
|
116
|
-
|
|
117
|
-
content is everything the build claimed between
|
|
155
|
+
/* Adopt an SSR-resolved branch in place (its content claims the existing nodes), then
|
|
156
|
+
wrap the adopted region in `[`…`]` markers and park an anchor just before the close
|
|
157
|
+
marker for later swaps. The adopted content is everything the build claimed between
|
|
158
|
+
the open and close markers; bracketing it makes the adopted branch a marker-bounded
|
|
159
|
+
range identical to a freshly-`place`d one, so the FIRST swap detaches it with
|
|
160
|
+
`removeRange` like every later swap (no node-array special case). */
|
|
118
161
|
const adopt = (open: Node | null, build: (parent: Node) => void): void => {
|
|
119
162
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
120
|
-
|
|
163
|
+
const firstAdopted = open?.nextSibling ?? null
|
|
164
|
+
cursor.next.set(parent, firstAdopted)
|
|
121
165
|
const dispose = group.track(scope(() => build(parent)))
|
|
122
166
|
const close = claimChild(cursor, parent)
|
|
123
167
|
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
168
|
+
/* Bracket the adopted nodes: `[` before the first claimed node (or before `close`
|
|
169
|
+
for an empty branch), `]` then the anchor just before `close`. */
|
|
170
|
+
const start = document.createComment(RANGE_OPEN)
|
|
171
|
+
parent.insertBefore(start, firstAdopted ?? close)
|
|
172
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
173
|
+
parent.insertBefore(end, close)
|
|
129
174
|
anchor = document.createTextNode('')
|
|
130
175
|
parent.insertBefore(anchor, close)
|
|
131
|
-
active = {
|
|
176
|
+
active = { start, end, dispose }
|
|
132
177
|
}
|
|
133
178
|
|
|
134
179
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|
|
@@ -176,11 +221,21 @@ export function awaitBlock(
|
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
if (entry !== undefined) {
|
|
224
|
+
/* Build the adopted branch around a value CELL (then) so a later re-run updates
|
|
225
|
+
it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
|
|
226
|
+
stays OUTSIDE the adopt try/catch so it surfaces rather than triggering the
|
|
227
|
+
cold-rebuild fallback. */
|
|
179
228
|
let build: (host: Node) => void
|
|
229
|
+
let cell: State<unknown> | undefined
|
|
230
|
+
let kind: 'then' | 'catch'
|
|
180
231
|
if (entry.ok) {
|
|
181
|
-
|
|
232
|
+
cell = state(entry.value)
|
|
233
|
+
const resolved = cell
|
|
234
|
+
build = (host) => renderThen(host, resolved)
|
|
235
|
+
kind = 'then'
|
|
182
236
|
} else if (renderCatch !== undefined) {
|
|
183
237
|
build = (host) => renderCatch(host, entry.error)
|
|
238
|
+
kind = 'catch'
|
|
184
239
|
} else {
|
|
185
240
|
/* A resumed rejection with no catch branch surfaces (mirrors the cold
|
|
186
241
|
path); in practice the server 500s such a block, so none resumes. */
|
|
@@ -188,14 +243,19 @@ export function awaitBlock(
|
|
|
188
243
|
}
|
|
189
244
|
try {
|
|
190
245
|
adopt(open, build)
|
|
246
|
+
valueCell = cell
|
|
247
|
+
activeKind = kind
|
|
191
248
|
} catch {
|
|
192
249
|
rebuildCold(open)
|
|
193
250
|
}
|
|
194
251
|
return
|
|
195
252
|
}
|
|
196
253
|
if (!isThenable(result)) {
|
|
254
|
+
const cell = state(result)
|
|
197
255
|
try {
|
|
198
|
-
adopt(open, (host) => renderThen(host,
|
|
256
|
+
adopt(open, (host) => renderThen(host, cell))
|
|
257
|
+
valueCell = cell
|
|
258
|
+
activeKind = 'then'
|
|
199
259
|
} catch {
|
|
200
260
|
rebuildCold(open)
|
|
201
261
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
|
+
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Builds a `[ … ]`-bounded content range into a DETACHED fragment under a fresh reactive
|
|
7
|
+
scope, then returns the markers, the fragment, and the scope disposer — leaving
|
|
8
|
+
INSERTION to the caller. The create-path range builder for the keyed list runtimes
|
|
9
|
+
(`each` / `eachAsync`): `each` holds the fragment in `pending` for deferred placement
|
|
10
|
+
(its reconcile reorders rows), `eachAsync` inserts it immediately at the stream anchor
|
|
11
|
+
(arrival order). Both need the same bracketed-fragment build, so it lives here once.
|
|
12
|
+
|
|
13
|
+
Unlike `openMarker`, this never touches a live parent or the hydrate claim cursor — the
|
|
14
|
+
markers are created fresh and parked in a fragment — so it is a pure CREATE primitive;
|
|
15
|
+
the hydrate paths claim their server markers directly. `namespaceParent` sets the
|
|
16
|
+
ambient foreign namespace for the build (a row's svg/math children read it off the
|
|
17
|
+
fragment's eventual parent), matching `fillBefore`/`enterNamespace`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildDetachedRange(
|
|
20
|
+
namespaceParent: Node,
|
|
21
|
+
build: (into: Node) => void,
|
|
22
|
+
): { start: Comment; end: Comment; fragment: DocumentFragment; dispose: () => void } {
|
|
23
|
+
const start = document.createComment(RANGE_OPEN)
|
|
24
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
25
|
+
const fragment = document.createDocumentFragment()
|
|
26
|
+
fragment.appendChild(start)
|
|
27
|
+
const dispose = enterNamespace(namespaceParent, () => scope(() => build(fragment)))
|
|
28
|
+
fragment.appendChild(end)
|
|
29
|
+
return { start, end, fragment, dispose }
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { commentData } from './commentData.ts'
|
|
2
|
+
import { markerDepthDelta } from './markerDepthDelta.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
A sibling list's DEPTH-0 nodes — those belonging to THIS skeleton's own structure, excluding
|
|
6
|
+
any node inside a block/component's `[`…`]` / `abide:` range (depth > 0), whose anchors belong
|
|
7
|
+
to that range's OWN skeleton. The realized-DOM analogue of the compiler's fresh-context
|
|
8
|
+
boundary: the shared anchor walk (`walkAnchorOrder`) classifies one node at a time and so can't
|
|
9
|
+
skip a sibling RUN, so the marker-depth skip lives here, feeding the walk only own-skeleton
|
|
10
|
+
nodes. In create mode the clone is shallow (no markers built yet), so depth stays 0 and every
|
|
11
|
+
node is returned — a plain document scan.
|
|
12
|
+
*/
|
|
13
|
+
export function depthZeroNodes(nodes: ArrayLike<Node>): Node[] {
|
|
14
|
+
const own: Node[] = []
|
|
15
|
+
let depth = 0
|
|
16
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
17
|
+
const node = nodes[index] as Node
|
|
18
|
+
const data = commentData(node)
|
|
19
|
+
if (data === undefined) {
|
|
20
|
+
if (depth === 0) {
|
|
21
|
+
own.push(node)
|
|
22
|
+
}
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
const delta = markerDepthDelta(data)
|
|
26
|
+
if (delta !== 0) {
|
|
27
|
+
depth += delta
|
|
28
|
+
} else if (depth === 0) {
|
|
29
|
+
/* A non-marker comment (this skeleton's own `a` anchor) at depth 0. */
|
|
30
|
+
own.push(node)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return own
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AnchorRole, AnchorWalkAdapter } from '../compile/walkAnchorOrder.ts'
|
|
2
|
+
import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
|
|
3
|
+
import { commentData } from './commentData.ts'
|
|
4
|
+
import { depthZeroNodes } from './depthZeroNodes.ts'
|
|
5
|
+
import { isElement } from './isElement.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The realized-DOM side of the shared anchor-ordering rule (`walkAnchorOrder`). Classifies each
|
|
9
|
+
recovered node by the SAME positions the template-AST side numbered (`templateAnchorAdapter`),
|
|
10
|
+
so `scanAnchors`'s collection lines up with the compiler's `anIndex`.
|
|
11
|
+
|
|
12
|
+
An `a` comment is this skeleton's own anchor (one position); an element is an own-skeleton
|
|
13
|
+
container to descend; anything else contributes nothing. `childrenOf` returns only depth-0
|
|
14
|
+
nodes — a nested block/component's marker-bracketed content is skipped exactly as the compiler
|
|
15
|
+
stops at a fresh-context boundary.
|
|
16
|
+
*/
|
|
17
|
+
export const domAnchorAdapter: AnchorWalkAdapter<Node> = {
|
|
18
|
+
classify: (node: Node): AnchorRole => {
|
|
19
|
+
const data = commentData(node)
|
|
20
|
+
if (data === ANCHOR) {
|
|
21
|
+
return { kind: 'anchor', positions: [node] }
|
|
22
|
+
}
|
|
23
|
+
if (data === undefined && isElement(node)) {
|
|
24
|
+
return { kind: 'recurse' }
|
|
25
|
+
}
|
|
26
|
+
return { kind: 'skip' }
|
|
27
|
+
},
|
|
28
|
+
childrenOf: (node: Node): readonly Node[] => depthZeroNodes(node.childNodes),
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ElementRole, ElementWalkAdapter } from '../compile/walkElementOrder.ts'
|
|
2
|
+
import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
3
|
+
import { isElement } from './isElement.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The parsed-DOM side of the shared element-hole numbering rule (`walkElementOrder`). Classifies
|
|
7
|
+
each node of the parsed skeleton by the SAME holes the template-AST side numbered
|
|
8
|
+
(`templateElementAdapter`), so the runtime's collected element paths line up with the compiler's
|
|
9
|
+
`elIndex`. Runs over the SHALLOW parsed skeleton (blocks/components/slots are already `<!--a-->`
|
|
10
|
+
anchors, not elements), so a `HOLE_ATTRIBUTE` element is a hole and every element is descended;
|
|
11
|
+
everything else skips. The collected paths resolve against the expanded server DOM separately
|
|
12
|
+
(`resolveElementHole`, which skips nested ranges there).
|
|
13
|
+
*/
|
|
14
|
+
export const domElementAdapter: ElementWalkAdapter<Node> = {
|
|
15
|
+
classify: (node: Node): ElementRole =>
|
|
16
|
+
isElement(node)
|
|
17
|
+
? { kind: 'element', isHole: node.hasAttribute(HOLE_ATTRIBUTE) }
|
|
18
|
+
: { kind: 'skip' },
|
|
19
|
+
childrenOf: (node: Node): readonly Node[] => Array.from(node.childNodes),
|
|
20
|
+
}
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { claimExpected } from '../runtime/claimExpected.ts'
|
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
|
-
import {
|
|
7
|
+
import type { State } from '../runtime/types/State.ts'
|
|
8
|
+
import { state } from '../state.ts'
|
|
9
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
8
10
|
import { moveRange } from './moveRange.ts'
|
|
9
11
|
import { removeRange } from './removeRange.ts'
|
|
10
12
|
import type { EachRow } from './types/EachRow.ts'
|
|
@@ -32,7 +34,11 @@ export function each<T>(
|
|
|
32
34
|
parent: Node,
|
|
33
35
|
items: () => Iterable<T>,
|
|
34
36
|
keyOf: (item: T) => string,
|
|
35
|
-
|
|
37
|
+
/* The row receives its item and its position as reactive cells (not raw snapshots): a
|
|
38
|
+
re-key with a changed value, or a reorder that shifts the row, sets the matching cell
|
|
39
|
+
and re-runs the row's effects in place. The compiler binds the `as`/`index` names to
|
|
40
|
+
read them; a direct caller reads `item.value` / `index.value`. */
|
|
41
|
+
render: (parent: Node, item: State<T>, index: State<number>) => void,
|
|
36
42
|
before: Node | null = null,
|
|
37
43
|
): void {
|
|
38
44
|
const rows = new Map<string, EachRow>()
|
|
@@ -49,27 +55,28 @@ export function each<T>(
|
|
|
49
55
|
(`scope` builds untracked), so a raw reactive read in the row content — e.g. a
|
|
50
56
|
nested `<script>` body — can't re-reconcile the whole list. Only `items()` drives
|
|
51
57
|
the each; each row's own interpolations track through their own effects. */
|
|
52
|
-
const buildRow = (item: T): EachRow => {
|
|
58
|
+
const buildRow = (item: T, position: number): EachRow => {
|
|
59
|
+
/* Item and position are held in reactive cells the row reads, so a later re-key with
|
|
60
|
+
a changed value, or a reorder that shifts the row, updates it in place (see the
|
|
61
|
+
reconcile below) instead of rebuilding it. */
|
|
62
|
+
const cell = state(item) as State<unknown>
|
|
63
|
+
const indexCell = state(position)
|
|
53
64
|
const hydration = RENDER.hydration
|
|
54
65
|
if (hydration !== undefined) {
|
|
55
66
|
const start = claimExpected(hydration, parent, 'each row start marker')
|
|
56
67
|
hydration.next.set(parent, start.nextSibling)
|
|
57
|
-
const dispose = group.track(scope(() => render(parent,
|
|
68
|
+
const dispose = group.track(scope(() => render(parent, cell as State<T>, indexCell)))
|
|
58
69
|
const end = claimExpected(hydration, parent, 'each row end marker')
|
|
59
70
|
hydration.next.set(parent, end.nextSibling)
|
|
60
|
-
return { start, end, dispose }
|
|
71
|
+
return { start, end, dispose, cell, indexCell }
|
|
61
72
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
built into the detached fragment are namespaced, not built as HTML. */
|
|
68
|
-
const dispose = group.track(
|
|
69
|
-
enterNamespace(parent, () => scope(() => render(pending, item))),
|
|
73
|
+
/* Shared detached-range create primitive: a `[ … ]` fragment built under `parent`'s
|
|
74
|
+
foreign namespace (so svg/math row children are namespaced, not built as HTML),
|
|
75
|
+
held in `pending` until placement inserts it. */
|
|
76
|
+
const { start, end, fragment, dispose } = buildDetachedRange(parent, (host) =>
|
|
77
|
+
render(host, cell as State<T>, indexCell),
|
|
70
78
|
)
|
|
71
|
-
|
|
72
|
-
return { start, end, dispose, pending }
|
|
79
|
+
return { start, end, dispose: group.track(dispose), cell, indexCell, pending: fragment }
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/* Place a row so its range ends just before `cursor`: insert a fresh row's
|
|
@@ -94,8 +101,10 @@ export function each<T>(
|
|
|
94
101
|
let adopting = false
|
|
95
102
|
const hydration = RENDER.hydration
|
|
96
103
|
if (hydration !== undefined) {
|
|
104
|
+
let position = 0
|
|
97
105
|
for (const item of items()) {
|
|
98
|
-
rows.set(keyOf(item), buildRow(item)) // claims the SSR row where it sits
|
|
106
|
+
rows.set(keyOf(item), buildRow(item, position)) // claims the SSR row where it sits
|
|
107
|
+
position += 1
|
|
99
108
|
}
|
|
100
109
|
anchor = document.createTextNode('')
|
|
101
110
|
parent.insertBefore(anchor, claimChild(hydration, parent))
|
|
@@ -145,8 +154,15 @@ export function each<T>(
|
|
|
145
154
|
const key = keys[index] as string
|
|
146
155
|
let row = rows.get(key)
|
|
147
156
|
if (row === undefined) {
|
|
148
|
-
row = buildRow(list[index] as T)
|
|
157
|
+
row = buildRow(list[index] as T, index)
|
|
149
158
|
rows.set(key, row)
|
|
159
|
+
} else {
|
|
160
|
+
/* Surviving key: push the (possibly new) item and position into the row's
|
|
161
|
+
cells. Both write through `Object.is`, so an unchanged item/position is a
|
|
162
|
+
no-op and a changed one re-runs only the row's own effects — the row's DOM
|
|
163
|
+
is never rebuilt. A reorder thus repaints only the moved rows' `index`. */
|
|
164
|
+
row.cell.value = list[index]
|
|
165
|
+
row.indexCell.value = index
|
|
150
166
|
}
|
|
151
167
|
placeBefore(row, cursor)
|
|
152
168
|
cursor = row.start
|
|
@@ -2,9 +2,10 @@ import { effect } from '../effect.ts'
|
|
|
2
2
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
-
import { scope } from '../runtime/scope.ts'
|
|
6
5
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
|
-
import {
|
|
6
|
+
import type { State } from '../runtime/types/State.ts'
|
|
7
|
+
import { state } from '../state.ts'
|
|
8
|
+
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
8
9
|
import { removeRange } from './removeRange.ts'
|
|
9
10
|
import type { EachRow } from './types/EachRow.ts'
|
|
10
11
|
|
|
@@ -27,7 +28,10 @@ export function eachAsync<T>(
|
|
|
27
28
|
parent: Node,
|
|
28
29
|
items: () => AsyncIterable<T>,
|
|
29
30
|
keyOf: (item: T) => string,
|
|
30
|
-
|
|
31
|
+
/* The row receives its item and position as reactive cells — same contract as sync
|
|
32
|
+
`each`; the streaming runtime rebuilds the row on a re-yield rather than patching, and
|
|
33
|
+
the position is the stream arrival ordinal (a stream only appends, never reorders). */
|
|
34
|
+
render: (parent: Node, item: State<T>, index: State<number>) => void,
|
|
31
35
|
/* Absent → an iterator rejection surfaces instead of rendering a catch branch. */
|
|
32
36
|
renderCatch: ((parent: Node, error: unknown) => void) | undefined,
|
|
33
37
|
before: Node | null = null,
|
|
@@ -44,25 +48,24 @@ export function eachAsync<T>(
|
|
|
44
48
|
parent.insertBefore(anchor, before) // `before` places rows before a static suffix
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
/* Build a content range
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return { start, end, dispose }
|
|
51
|
+
/* Build a content range (the shared detached-range create primitive) and insert it
|
|
52
|
+
just before the anchor (arrival order). A bare range (no item cell) — the data-row
|
|
53
|
+
site adds the cell, the error branch needs none. */
|
|
54
|
+
const insertRange = (
|
|
55
|
+
build: (into: Node) => void,
|
|
56
|
+
): { start: Node; end: Node; dispose: () => void } => {
|
|
57
|
+
/* Namespace the build off the anchor's LIVE parent, and insert there too: when this
|
|
58
|
+
`each` is a bare child of a control-flow branch, the captured `parent` is the
|
|
59
|
+
branch's build fragment, emptied into the document once the enclosing block
|
|
60
|
+
placed it. */
|
|
61
|
+
const host = anchor.parentNode ?? parent
|
|
62
|
+
const { start, end, fragment, dispose } = buildDetachedRange(host, build)
|
|
63
|
+
host.insertBefore(fragment, anchor)
|
|
64
|
+
return { start, end, dispose: group.track(dispose) }
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/* The mounted `<template catch>` range, disposed when a fresh run re-streams. */
|
|
65
|
-
let errorRange:
|
|
68
|
+
let errorRange: { start: Node; end: Node; dispose: () => void } | undefined
|
|
66
69
|
const clearError = (): void => {
|
|
67
70
|
if (errorRange !== undefined) {
|
|
68
71
|
errorRange.dispose()
|
|
@@ -82,6 +85,7 @@ export function eachAsync<T>(
|
|
|
82
85
|
clearError() // a fresh run drops a prior error branch
|
|
83
86
|
const iterable = items() // read (subscribe) synchronously
|
|
84
87
|
const present = new Set<string>()
|
|
88
|
+
let arrivals = 0 // stream arrival ordinal → each row's index
|
|
85
89
|
const drain = async (): Promise<void> => {
|
|
86
90
|
const active = iterable[Symbol.asyncIterator]()
|
|
87
91
|
iterator = active
|
|
@@ -96,14 +100,18 @@ export function eachAsync<T>(
|
|
|
96
100
|
}
|
|
97
101
|
const key = keyOf(result.value)
|
|
98
102
|
present.add(key)
|
|
99
|
-
/* A re-yielded key rebuilds the row from the new value, swapping
|
|
100
|
-
range out
|
|
103
|
+
/* A re-yielded key rebuilds the row from the new value (arrival order), swapping
|
|
104
|
+
the old range out. The item rides in a cell — the row reads it reactively, same
|
|
105
|
+
contract as sync `each` — but the streaming runtime rebuilds rather than patches. */
|
|
101
106
|
const stale = rows.get(key)
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
const cell = state(result.value) as State<unknown>
|
|
108
|
+
const indexCell = state(arrivals)
|
|
109
|
+
arrivals += 1
|
|
110
|
+
rows.set(key, {
|
|
111
|
+
...insertRange((host) => render(host, cell as State<T>, indexCell)),
|
|
112
|
+
cell,
|
|
113
|
+
indexCell,
|
|
114
|
+
})
|
|
107
115
|
if (stale !== undefined) {
|
|
108
116
|
stale.dispose()
|
|
109
117
|
removeRange(stale.start, stale.end)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* An element carries `hasAttribute`; comment/text nodes do not. Detected by method (not
|
|
2
|
+
`nodeType`) so every skeleton walk runs under the test mini-dom too. Shared by the
|
|
3
|
+
element-hole path resolver (`skeleton`) and both realized-DOM walk adapters. */
|
|
4
|
+
export function isElement(node: Node): node is Element {
|
|
5
|
+
return typeof (node as Element).hasAttribute === 'function'
|
|
6
|
+
}
|