@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.
Files changed (44) hide show
  1. package/AGENTS.md +327 -326
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +87 -78
  4. package/package.json +1 -1
  5. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  6. package/src/lib/ui/compile/compileShadow.ts +5 -0
  7. package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
  8. package/src/lib/ui/compile/generateBuild.ts +96 -12
  9. package/src/lib/ui/compile/generateSSR.ts +26 -9
  10. package/src/lib/ui/compile/lowerContext.ts +29 -9
  11. package/src/lib/ui/compile/parseTemplate.ts +2 -0
  12. package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
  13. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  14. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  15. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  16. package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
  17. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  18. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  19. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  20. package/src/lib/ui/dom/applyResolved.ts +3 -4
  21. package/src/lib/ui/dom/awaitBlock.ts +106 -46
  22. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  23. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  24. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  25. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  26. package/src/lib/ui/dom/each.ts +33 -17
  27. package/src/lib/ui/dom/eachAsync.ts +34 -26
  28. package/src/lib/ui/dom/isElement.ts +6 -0
  29. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  30. package/src/lib/ui/dom/mountRange.ts +4 -3
  31. package/src/lib/ui/dom/mountSlot.ts +4 -3
  32. package/src/lib/ui/dom/on.ts +6 -1
  33. package/src/lib/ui/dom/replaceRange.ts +24 -0
  34. package/src/lib/ui/dom/skeleton.ts +35 -92
  35. package/src/lib/ui/dom/switchBlock.ts +13 -10
  36. package/src/lib/ui/dom/types/EachRow.ts +9 -2
  37. package/src/lib/ui/dom/when.ts +13 -10
  38. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  39. package/src/lib/ui/runtime/batch.ts +22 -0
  40. package/src/lib/ui/runtime/clientPage.ts +3 -8
  41. package/src/lib/ui/runtime/createDoc.ts +9 -13
  42. package/src/lib/ui/seedResolved.ts +28 -0
  43. package/src/lib/ui/startClient.ts +6 -4
  44. 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 clearBetween. */
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, 'abide:snippet')
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, '/abide:snippet')
53
+ close = openMarker(parent, SNIPPET_CLOSE)
53
54
  } else {
54
- open = openMarker(parent, 'abide:snippet')
55
- close = openMarker(parent, '/abide:snippet')
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
- clearBetween(open, close, dispose)
72
- dispose = builder !== undefined ? group.track(fillBefore(close, builder)) : undefined
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 { RESUME } from '../runtime/RESUME.ts'
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
- seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
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
- RESUME[Number(id)] = payload.textContent ?? ''
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 { enterNamespace } from './enterNamespace.ts'
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 is a RANGE of element roots, tracked as a node array so a multi-root
16
- branch inserts/removes as a unit.
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: { nodes: Node[]; dispose: () => void } | undefined
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
- /* Remove via each node's LIVE parent, not the captured `parent` — when this
60
- await is a bare child of a control-flow branch, `parent` is the branch's
61
- build fragment, emptied into the document once the enclosing block placed
62
- it (`place` already inserts via `anchor.parentNode` for the same reason). */
63
- for (const node of active.nodes) {
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
- branch builds into a fragment (so any content — components, text, nested blocks
72
- — appends freely), whose top-level nodes are tracked for the next swap. */
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 fragment = document.createDocumentFragment()
76
- const dispose = group.track(
77
- enterNamespace(anchor?.parentNode ?? parent, () => scope(() => build(fragment))),
78
- )
79
- const nodes = [...fragment.childNodes]
80
- ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
81
- active = { nodes, dispose }
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
- place((host) => renderThen(host, result)) // warm-sync → resolved now, no flash
125
+ settleThen(result) // warm-sync → resolved now, no flash
89
126
  return
90
127
  }
91
- if (renderPending !== undefined) {
92
- place((host) => renderPending(host))
93
- } else {
94
- detach()
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
- place((host) => renderThen(host, value))
144
+ settleThen(value)
100
145
  }
101
146
  },
102
147
  (error) => {
103
- if (gen !== generation) {
104
- return
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
- then park an anchor just before the close marker for later swaps. The adopted
117
- content is everything the build claimed between the open and close markers. */
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
- cursor.next.set(parent, open?.nextSibling ?? null)
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
- const nodes: Node[] = []
125
- for (let node = open?.nextSibling ?? null; node !== null && node !== close; ) {
126
- nodes.push(node)
127
- node = node.nextSibling
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 = { nodes, dispose }
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
- build = (host) => renderThen(host, entry.value)
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, result))
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
+ }
@@ -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 { enterNamespace } from './enterNamespace.ts'
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
- render: (parent: Node, item: T) => void,
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, item)))
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
- const start = document.createComment('[')
63
- const end = document.createComment(']')
64
- const pending = document.createDocumentFragment()
65
- pending.appendChild(start)
66
- /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
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
- pending.appendChild(end)
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 { enterNamespace } from './enterNamespace.ts'
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
- render: (parent: Node, item: T) => void,
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 and insert it just before the anchor (arrival order). */
48
- const insertRange = (build: (into: Node) => void): EachRow => {
49
- const start = document.createComment('[')
50
- const end = document.createComment(']')
51
- const fragment = document.createDocumentFragment()
52
- fragment.appendChild(start)
53
- const dispose = group.track(
54
- enterNamespace(anchor.parentNode ?? parent, () => scope(() => build(fragment))),
55
- )
56
- fragment.appendChild(end)
57
- /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
58
- control-flow branch, the captured `parent` is the branch's build fragment,
59
- emptied into the document once the enclosing block placed it. */
60
- ;(anchor.parentNode ?? parent).insertBefore(fragment, anchor)
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: EachRow | undefined
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 the old
100
- range out (v1 has no in-place field patchrows bind plain snapshots). */
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 cellthe 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 item = result.value
103
- rows.set(
104
- key,
105
- insertRange((host) => render(host, item)),
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
+ }