@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
@@ -0,0 +1,19 @@
1
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
2
+
3
+ /* The block-range nesting rule, the single source both marker-depth scans share
4
+ (`depthZeroNodes`'s anchor filter and `skeleton`'s `elementChildAt` element walk):
5
+ `+1` opening a range, `-1` closing one, `0` for any other comment. A control-flow
6
+ block's rendered content sits between an OPEN and CLOSE comment — `[`…`]` for each
7
+ rows / if / switch / slot ranges, named `abide:…`…`/abide:…` for await / try /
8
+ snippet / html; the skeleton's own anchor (`a`) sits OUTSIDE any range, so it scores
9
+ `0`. Keeping the rule here means a new marker family updates one place, so the two
10
+ scans can never disagree on what nests. */
11
+ export function markerDepthDelta(data: string): number {
12
+ if (data === RANGE_CLOSE || data.startsWith('/abide:')) {
13
+ return -1
14
+ }
15
+ if (data === RANGE_OPEN || data.startsWith('abide:')) {
16
+ return 1
17
+ }
18
+ return 0
19
+ }
@@ -1,3 +1,4 @@
1
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
1
2
  import { RENDER } from '../runtime/RENDER.ts'
2
3
  import { scope } from '../runtime/scope.ts'
3
4
  import type { UiProps } from '../runtime/types/UiProps.ts'
@@ -31,15 +32,15 @@ export function mountRange(
31
32
  label: string | undefined = undefined,
32
33
  ): { start: Comment; end: Comment; dispose: () => void } {
33
34
  const hydration = RENDER.hydration
34
- const start = openMarker(parent, '[', before)
35
+ const start = openMarker(parent, RANGE_OPEN, before)
35
36
  if (hydration === undefined) {
36
- const end = openMarker(parent, ']', before)
37
+ const end = openMarker(parent, RANGE_CLOSE, before)
37
38
  return fillRange(start, end, build, props, label)
38
39
  }
39
40
  /* Hydrate: adopt the server range in place. Establish the child's lexical scope
40
41
  and render pass (same as `fillRange`), build claiming the existing nodes, then
41
42
  claim the end marker the build's content stops before. */
42
43
  const scoped = withScope(label, () => scope(() => build(parent, props)))
43
- const end = openMarker(parent, ']')
44
+ const end = openMarker(parent, RANGE_CLOSE)
44
45
  return { start, end, dispose: disposeRange(scoped, start, end) }
45
46
  }
@@ -1,3 +1,4 @@
1
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
1
2
  import { RENDER } from '../runtime/RENDER.ts'
2
3
  import { scope } from '../runtime/scope.ts'
3
4
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -26,12 +27,12 @@ export function mountSlot(
26
27
  dispose on owner teardown (a navigation) — the slot never toggles, so the
27
28
  group only ever tracks this one child. */
28
29
  const group = scopeGroup()
29
- openMarker(parent, '[', before)
30
+ openMarker(parent, RANGE_OPEN, before)
30
31
  if (hydration !== undefined) {
31
32
  group.track(scope(() => render(parent))) // content claims the SSR range in place
32
- openMarker(parent, ']')
33
+ openMarker(parent, RANGE_CLOSE)
33
34
  } else {
34
- const end = openMarker(parent, ']', before)
35
+ const end = openMarker(parent, RANGE_CLOSE, before)
35
36
  group.track(fillBefore(end, render))
36
37
  }
37
38
  }
@@ -1,3 +1,4 @@
1
+ import { batch } from '../runtime/batch.ts'
1
2
  import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
2
3
  import { inScope } from '../runtime/inScope.ts'
3
4
  import { OWNER } from '../runtime/OWNER.ts'
@@ -20,7 +21,11 @@ export function on(element: Element, type: string, handler: EventListener): void
20
21
  return
21
22
  }
22
23
  const captured = CURRENT_SCOPE.current
23
- const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
24
+ /* Coalesce the handler's writes: a handler setting several signals re-runs each
25
+ dependent effect once on batch exit, not once per write (the unbatched default
26
+ flushed eagerly per `trigger`). Stays synchronous — flush runs at handler-end
27
+ before it returns, so the causal stack (dispatch → handler → effects) is intact. */
28
+ const wrapped: EventListener = (event) => inScope(captured, () => batch(() => handler(event)))
24
29
  element.addEventListener(type, wrapped)
25
30
  if (OWNER.current !== undefined) {
26
31
  OWNER.current.push(() => element.removeEventListener(type, wrapped))
@@ -0,0 +1,24 @@
1
+ import { clearBetween } from './clearBetween.ts'
2
+ import { fillBefore } from './fillBefore.ts'
3
+
4
+ /*
5
+ The one "replace a marker-bounded region's contents" operation. A control-flow block
6
+ that swaps a branch as a unit (if/else, switch, snippet) clears the live range
7
+ (disposing its scope and removing its nodes via `clearBetween`), then — if the new
8
+ branch has content — builds it fresh into the range before the close marker
9
+ (`fillBefore`). Returns the new content's disposer, or `undefined` for an empty
10
+ branch.
11
+
12
+ This is the single seam those blocks shared by copy-paste: same `clearBetween` +
13
+ conditional `fillBefore` shape, hand-rolled three times. Routing them through one
14
+ helper makes the region-update strategy a named, testable primitive.
15
+ */
16
+ export function replaceRange(
17
+ start: Node,
18
+ end: Node,
19
+ dispose: (() => void) | undefined,
20
+ content: ((into: Node) => void) | undefined,
21
+ ): (() => void) | undefined {
22
+ clearBetween(start, end, dispose)
23
+ return content !== undefined ? fillBefore(end, content) : undefined
24
+ }
@@ -1,8 +1,15 @@
1
+ import { walkAnchorOrder } from '../compile/walkAnchorOrder.ts'
2
+ import { walkElementOrder } from '../compile/walkElementOrder.ts'
1
3
  import { claimChild } from '../runtime/claimChild.ts'
2
4
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
5
  import { RENDER } from '../runtime/RENDER.ts'
4
6
  import { commentData } from './commentData.ts'
7
+ import { depthZeroNodes } from './depthZeroNodes.ts'
8
+ import { domAnchorAdapter } from './domAnchorAdapter.ts'
9
+ import { domElementAdapter } from './domElementAdapter.ts'
5
10
  import { foreignWrapperTag } from './foreignWrapperTag.ts'
11
+ import { isElement } from './isElement.ts'
12
+ import { markerDepthDelta } from './markerDepthDelta.ts'
6
13
  import type { SkeletonHoles } from './types/SkeletonHoles.ts'
7
14
 
8
15
  type CompiledSkeleton = {
@@ -21,23 +28,6 @@ type CompiledSkeleton = {
21
28
  `templateFor` for the per-document rationale). */
22
29
  const CACHES = new WeakMap<object, Map<string, CompiledSkeleton>>()
23
30
 
24
- /* An element carries `hasAttribute`; text/comment nodes do not. Used instead of
25
- `nodeType` so the walk runs under the test mini-dom too. */
26
- function isElement(node: Node): node is Element {
27
- return typeof (node as Element).hasAttribute === 'function'
28
- }
29
-
30
- /* Block-range boundary markers. A control-flow block's rendered content sits between an
31
- OPEN and CLOSE comment: `[`…`]` for each rows / if / switch / slot ranges, and named
32
- `abide:…`…`/abide:…` boundaries for await / try / snippet / html. The skeleton's own
33
- anchor (`a`) sits OUTSIDE any such range. */
34
- function isOpenMarker(data: string): boolean {
35
- return data === '[' || data.startsWith('abide:')
36
- }
37
- function isCloseMarker(data: string): boolean {
38
- return data === ']' || data.startsWith('/abide:')
39
- }
40
-
41
31
  /* The `index`-th depth-0 ELEMENT among `children` — skipping text/comment nodes AND any
42
32
  element nested inside a block's rendered range (between `[`…`]` / `abide:…` boundaries),
43
33
  which belongs to that block's own skeleton. The compiler indexes element holes over the
@@ -57,69 +47,28 @@ function elementChildAt(children: ArrayLike<Node>, index: number): Element | und
57
47
  }
58
48
  seen += 1
59
49
  }
60
- } else if (isCloseMarker(data)) {
61
- depth -= 1
62
- } else if (isOpenMarker(data)) {
63
- depth += 1
50
+ continue
64
51
  }
52
+ depth += markerDepthDelta(data)
65
53
  }
66
54
  return undefined
67
55
  }
68
56
 
69
- /* Records each element hole's element-only path in PRE-ORDER (the `HOLE_ATTRIBUTE`
70
- marks which) and strips the marker the compiler assigns element-hole indices in the
71
- same pre-order, so the arrays line up without numbering the markers. */
72
- function indexElementHoles(container: Node, prefix: number[], paths: number[][]): void {
73
- const children = container.childNodes
74
- let elementIndex = 0
75
- for (let cursor = 0; cursor < children.length; cursor += 1) {
76
- const child = children[cursor] as Node
77
- if (!isElement(child)) {
78
- continue
79
- }
80
- const path = [...prefix, elementIndex]
81
- elementIndex += 1
82
- if (child.hasAttribute(HOLE_ATTRIBUTE)) {
83
- paths.push(path)
84
- child.removeAttribute(HOLE_ATTRIBUTE)
85
- }
86
- indexElementHoles(child, path, paths)
57
+ /* Walks an element-only path from the top-level node list to the target element. A step
58
+ that resolves to nothing means the claimed server run is missing an element the skeleton
59
+ expects here a hydration desync; throw AT it (naming the path) rather than returning
60
+ the undefined that derefs in the downstream `mountChild`/`attr`, far from the cause. */
61
+ function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
62
+ let node = elementChildAt(topLevel, path[0] as number)
63
+ for (let depth = 1; depth < path.length && node !== undefined; depth += 1) {
64
+ node = elementChildAt(node.childNodes, path[depth] as number)
87
65
  }
88
- }
89
-
90
- /* Collects THIS skeleton's own anchor holes (`a` comments) in document order, present in
91
- both the cloned skeleton and the server DOM (text-width-independent). The compiler emits
92
- anchors in the same order, so the arrays line up.
93
-
94
- In hydrate mode the claimed tree is FULLY EXPANDED — a nested block's rendered content
95
- (each rows, branches, await/try boundaries) sits inline — so a naive descent would also
96
- collect the inner block's anchors, which belong to that block's OWN skeleton, shifting
97
- every index past the first block. Block AND child-component content is bounded by range
98
- markers (a component mounts as a `[`…`]` range at its anchor, like a block — see
99
- `mountRange`), so track depth per sibling list and take an anchor (and recurse into an
100
- element) only at depth 0, where the skeleton's own structure lives. In create mode the
101
- clone is shallow (the ranges have not built yet — no markers), so depth stays 0 and this
102
- is a plain document scan. */
103
- function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
104
- let depth = 0
105
- for (let index = 0; index < nodes.length; index += 1) {
106
- const node = nodes[index] as Node
107
- const data = commentData(node)
108
- if (data === undefined) {
109
- /* Recurse into this skeleton's own elements at depth 0. A child component's
110
- content sits inside its `[`…`]` range (depth > 0), so it is skipped like any
111
- block range — its anchors belong to the child's own skeleton. */
112
- if (isElement(node) && depth === 0) {
113
- scanAnchors(node.childNodes, anchors)
114
- }
115
- } else if (isCloseMarker(data)) {
116
- depth -= 1
117
- } else if (isOpenMarker(data)) {
118
- depth += 1
119
- } else if (data === 'a' && depth === 0) {
120
- anchors.push(node)
121
- }
66
+ if (node === undefined) {
67
+ throw new Error(
68
+ `[abide] hydration desync: skeleton element hole [${path.join(',')}] resolved to no node the server DOM is missing an element the client build expects here.`,
69
+ )
122
70
  }
71
+ return node
123
72
  }
124
73
 
125
74
  /* When `parent` is foreign (or a control-flow fragment inside foreign content), the
@@ -140,31 +89,20 @@ function compile(html: string, wrapper: string | undefined): CompiledSkeleton {
140
89
  template.innerHTML = wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`
141
90
  const source =
142
91
  wrapper === undefined ? template.content : (template.content.firstChild as Node)
92
+ /* Element holes via the ONE shared rule (`walkElementOrder`) — the same element-only
93
+ pre-order the compiler numbers `elIndex` with. Record each `HOLE_ATTRIBUTE`
94
+ element's path and strip the marker so a clone never carries it into the live DOM. */
143
95
  const elementPaths: number[][] = []
144
- indexElementHoles(source, [], elementPaths)
96
+ walkElementOrder(Array.from(source.childNodes), domElementAdapter, (node, path) => {
97
+ elementPaths.push(path)
98
+ ;(node as Element).removeAttribute(HOLE_ATTRIBUTE)
99
+ })
145
100
  compiled = { source, elementPaths, topLevelCount: source.childNodes.length }
146
101
  cache.set(key, compiled)
147
102
  }
148
103
  return compiled
149
104
  }
150
105
 
151
- /* Walks an element-only path from the top-level node list to the target element. A step
152
- that resolves to nothing means the claimed server run is missing an element the skeleton
153
- expects here — a hydration desync; throw AT it (naming the path) rather than returning
154
- the undefined that derefs in the downstream `mountChild`/`attr`, far from the cause. */
155
- function resolveElementHole(topLevel: ArrayLike<Node>, path: number[]): Element {
156
- let node = elementChildAt(topLevel, path[0] as number)
157
- for (let depth = 1; depth < path.length && node !== undefined; depth += 1) {
158
- node = elementChildAt(node.childNodes, path[depth] as number)
159
- }
160
- if (node === undefined) {
161
- throw new Error(
162
- `[abide] hydration desync: skeleton element hole [${path.join(',')}] resolved to no node — the server DOM is missing an element the client build expects here.`,
163
- )
164
- }
165
- return node
166
- }
167
-
168
106
  /*
169
107
  Realizes a compiled skeleton under `parent` and returns its holes: `el` the element
170
108
  holes (attribute/listener/bind), in pre-order; `an` the anchor holes (reactive text,
@@ -197,8 +135,13 @@ export function skeleton(parent: Node, html: string): SkeletonHoles {
197
135
  parent.appendChild(clone)
198
136
  }
199
137
  }
138
+ /* Anchor holes via the ONE shared ordering rule (`walkAnchorOrder`) — the same traversal
139
+ the compiler numbers `anIndex` with. The top-level list is depth-0-filtered up front (a
140
+ nested range can sit among the top-level run); the adapter filters each deeper level. */
200
141
  const an: Node[] = []
201
- scanAnchors(topLevel, an)
142
+ walkAnchorOrder(depthZeroNodes(topLevel), domAnchorAdapter, (anchor) => {
143
+ an.push(anchor as Node)
144
+ })
202
145
  return {
203
146
  el: elementPaths.map((path) => resolveElementHole(topLevel, path)),
204
147
  an,
@@ -1,10 +1,11 @@
1
1
  import { effect } from '../effect.ts'
2
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
3
4
  import { scope } from '../runtime/scope.ts'
4
5
  import { scopeGroup } from '../runtime/scopeGroup.ts'
5
- import { clearBetween } from './clearBetween.ts'
6
6
  import { fillBefore } from './fillBefore.ts'
7
7
  import { openMarker } from './openMarker.ts'
8
+ import { replaceRange } from './replaceRange.ts'
8
9
  import type { SwitchCase } from './types/SwitchCase.ts'
9
10
 
10
11
  /*
@@ -28,7 +29,7 @@ export function switchBlock(
28
29
  ): void {
29
30
  const hydration = RENDER.hydration
30
31
  /* The live case's scope, registered with the owner so it disposes on owner
31
- teardown — not only when the subject switches cases via clearBetween. */
32
+ teardown — not only when the subject switches cases via replaceRange. */
32
33
  const group = scopeGroup()
33
34
  let dispose: (() => void) | undefined
34
35
  let activeIndex: number
@@ -49,16 +50,16 @@ export function switchBlock(
49
50
 
50
51
  /* `before` places the range among static siblings on create (block before a suffix);
51
52
  hydrate ignores it and uses the parked claim cursor. */
52
- const start = openMarker(parent, '[', before)
53
+ const start = openMarker(parent, RANGE_OPEN, before)
53
54
  if (hydration !== undefined) {
54
55
  activeIndex = select(subject())
55
56
  const chosen = caseAt(activeIndex)
56
57
  if (chosen !== undefined) {
57
58
  dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
58
59
  }
59
- end = openMarker(parent, ']')
60
+ end = openMarker(parent, RANGE_CLOSE)
60
61
  } else {
61
- end = openMarker(parent, ']', before)
62
+ end = openMarker(parent, RANGE_CLOSE, before)
62
63
  activeIndex = select(subject())
63
64
  const chosen = caseAt(activeIndex)
64
65
  if (chosen !== undefined) {
@@ -71,12 +72,14 @@ export function switchBlock(
71
72
  if (index === activeIndex) {
72
73
  return
73
74
  }
74
- clearBetween(start, end, dispose)
75
- dispose = undefined
76
75
  activeIndex = index
77
76
  const chosen = caseAt(index)
78
- if (chosen !== undefined) {
79
- dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
80
- }
77
+ /* Null `dispose` before `replaceRange` builds the new case: a reentrant switch
78
+ during that build (an effect in the new content writing the subject) would
79
+ otherwise re-enter with the already-disposed disposer and clear it twice. */
80
+ const prior = dispose
81
+ dispose = undefined
82
+ const next = replaceRange(start, end, prior, chosen && ((p) => chosen.render(p)))
83
+ dispose = next !== undefined ? group.track(next) : undefined
81
84
  })
82
85
  }
@@ -1,10 +1,17 @@
1
+ import type { State } from '../../runtime/types/State.ts'
2
+
1
3
  /* A live row in a keyed list: a content RANGE bounded by two comment markers (so a
2
4
  row holds any content, not just one node), plus the disposer for the bindings
3
- created in its ownership scope. `pending` holds a freshly built row's nodes in a
4
- fragment until first placement inserts them. */
5
+ created in its ownership scope. `cell` holds the row's item as a reactive value, so a
6
+ re-key with a changed value (same key, new object) updates the row in place instead of
7
+ leaving it frozen. `indexCell` holds the row's reactive position, so a reorder repaints
8
+ its `index` binding in place without a rebuild. `pending` holds a freshly built row's
9
+ nodes in a fragment until first placement inserts them. */
5
10
  export type EachRow = {
6
11
  start: Node
7
12
  end: Node
8
13
  dispose: () => void
14
+ cell: State<unknown>
15
+ indexCell: State<number>
9
16
  pending?: DocumentFragment
10
17
  }
@@ -1,10 +1,11 @@
1
1
  import { effect } from '../effect.ts'
2
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
3
4
  import { scope } from '../runtime/scope.ts'
4
5
  import { scopeGroup } from '../runtime/scopeGroup.ts'
5
- import { clearBetween } from './clearBetween.ts'
6
6
  import { fillBefore } from './fillBefore.ts'
7
7
  import { openMarker } from './openMarker.ts'
8
+ import { replaceRange } from './replaceRange.ts'
8
9
 
9
10
  /*
10
11
  Conditional binding — the runtime for `<template if>` (with optional `else`). The
@@ -34,7 +35,7 @@ export function when(
34
35
  branch's own interpolations still track, each through its own effect. */
35
36
  const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
36
37
  /* The live branch's scope, registered with the owner so it disposes on owner
37
- teardown — not only on a branch flip via clearBetween. */
38
+ teardown — not only on a branch flip via replaceRange. */
38
39
  const group = scopeGroup()
39
40
  let dispose: (() => void) | undefined
40
41
  let activeBranch: 'then' | 'else'
@@ -43,16 +44,16 @@ export function when(
43
44
  /* `before` (a static node located by the skeleton) places the range among siblings on
44
45
  create, so the block sits before a static suffix rather than at the parent's end.
45
46
  Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement. */
46
- const start = openMarker(parent, '[', before)
47
+ const start = openMarker(parent, RANGE_OPEN, before)
47
48
  if (hydration !== undefined) {
48
49
  activeBranch = condition() ? 'then' : 'else'
49
50
  const chosen = chosenFor(activeBranch)
50
51
  if (chosen !== undefined) {
51
52
  dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
52
53
  }
53
- end = openMarker(parent, ']')
54
+ end = openMarker(parent, RANGE_CLOSE)
54
55
  } else {
55
- end = openMarker(parent, ']', before)
56
+ end = openMarker(parent, RANGE_CLOSE, before)
56
57
  activeBranch = condition() ? 'then' : 'else'
57
58
  const chosen = chosenFor(activeBranch)
58
59
  if (chosen !== undefined) {
@@ -65,12 +66,14 @@ export function when(
65
66
  if (branch === activeBranch) {
66
67
  return
67
68
  }
68
- clearBetween(start, end, dispose)
69
- dispose = undefined
70
69
  activeBranch = branch
71
70
  const chosen = chosenFor(branch)
72
- if (chosen !== undefined) {
73
- dispose = group.track(fillBefore(end, chosen))
74
- }
71
+ /* Null `dispose` before `replaceRange` builds the new branch: a reentrant flip
72
+ during that build (an effect in the new content writing the condition) would
73
+ otherwise re-enter with the already-disposed disposer and clear it twice. */
74
+ const prior = dispose
75
+ dispose = undefined
76
+ const next = replaceRange(start, end, prior, chosen)
77
+ dispose = next !== undefined ? group.track(next) : undefined
75
78
  })
76
79
  }
@@ -0,0 +1,16 @@
1
+ /* The comment-marker "wire alphabet" — the single source of truth for the sentinel
2
+ strings the SSR emit (`generateSSR`) writes into HTML comments and the hydrate scan
3
+ (`skeleton`) + every range-mount runtime (`when`/`switch`/`each`/`mountRange`/
4
+ `mountSlot`/`appendSnippet`) creates as `document.createComment` nodes. Both sides
5
+ reference THESE constants, so a marker the server writes and the marker the client
6
+ looks for can never drift on a literal.
7
+
8
+ A control-flow block's rendered content sits between an OPEN (`[`) and CLOSE (`]`)
9
+ comment; a snippet interpolation between `abide:snippet` / `/abide:snippet` (matching
10
+ `skeleton`'s `abide:` / `/abide:` named-boundary convention, like `OUTLET_MARKER`).
11
+ The skeleton's own positioning anchor (`a`) sits OUTSIDE any such range. */
12
+ export const RANGE_OPEN = '['
13
+ export const RANGE_CLOSE = ']'
14
+ export const ANCHOR = 'a'
15
+ export const SNIPPET_OPEN = 'abide:snippet'
16
+ export const SNIPPET_CLOSE = '/abide:snippet'
@@ -0,0 +1,22 @@
1
+ import { flushEffects } from './flushEffects.ts'
2
+ import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
3
+
4
+ /*
5
+ Runs `fn` with reactive writes coalesced: effects dirtied inside queue once and
6
+ flush a single time when the outermost batch exits, so a burst of writes (e.g. an
7
+ event handler setting several signals) re-runs each dependent effect once instead
8
+ of once per write. Nests safely — only the depth-0 exit flushes — so a batched
9
+ write that calls into another batched write (a handler invoking a doc patch) still
10
+ flushes once, at the top. Same idiom `createDoc`/`clientPage` inline, factored out.
11
+ */
12
+ export function batch<T>(fn: () => T): T {
13
+ REACTIVE_CONTEXT.batchDepth += 1
14
+ try {
15
+ return fn()
16
+ } finally {
17
+ REACTIVE_CONTEXT.batchDepth -= 1
18
+ if (REACTIVE_CONTEXT.batchDepth === 0) {
19
+ flushEffects()
20
+ }
21
+ }
22
+ }
@@ -1,7 +1,6 @@
1
1
  import type { PageSnapshot } from '../../shared/types/PageSnapshot.ts'
2
2
  import { state } from '../state.ts'
3
- import { flushEffects } from './flushEffects.ts'
4
- import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
3
+ import { batch } from './batch.ts'
5
4
  import type { State } from './types/State.ts'
6
5
 
7
6
  /*
@@ -107,15 +106,11 @@ export const clientPage: { value: PageSnapshot } = {
107
106
  (e.g. `page.url` + `page.params.id`) re-runs once per field and transiently
108
107
  observes a half-updated snapshot (new url, stale id). Same batch idiom as
109
108
  `createDoc` — flush once, after every cell is reconciled. */
110
- REACTIVE_CONTEXT.batchDepth += 1
111
- try {
109
+ batch(() => {
112
110
  routeCell.value = next.route
113
111
  urlCell.value = next.url
114
112
  navigatingCell.value = next.navigating
115
113
  reconcileParams(next.params)
116
- } finally {
117
- REACTIVE_CONTEXT.batchDepth -= 1
118
- }
119
- flushEffects()
114
+ })
120
115
  },
121
116
  }
@@ -1,9 +1,8 @@
1
1
  import { applyPatchToTree } from './applyPatchToTree.ts'
2
+ import { batch } from './batch.ts'
2
3
  import { createComputedNode } from './createComputedNode.ts'
3
4
  import { createSignalNode } from './createSignalNode.ts'
4
- import { flushEffects } from './flushEffects.ts'
5
5
  import { PATCH_BUS } from './PATCH_BUS.ts'
6
- import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
7
6
  import { readNode } from './readNode.ts'
8
7
  import { trigger } from './trigger.ts'
9
8
  import type { Cell } from './types/Cell.ts'
@@ -157,8 +156,7 @@ export function createDoc(initial: unknown): Doc {
157
156
  const nonShiftingAdd =
158
157
  patch.op === 'add' &&
159
158
  (!parentIsArray || leafKey === '-' || Number(leafKey) === arrayLength - 1)
160
- REACTIVE_CONTEXT.batchDepth += 1
161
- try {
159
+ batch(() => {
162
160
  if (segments.length === 0) {
163
161
  wakeSubtree('', true, true)
164
162
  } else if (!structural) {
@@ -178,15 +176,13 @@ export function createDoc(initial: unknown): Doc {
178
176
  } else {
179
177
  wakeSubtree(parentPath, true, true)
180
178
  }
181
- } finally {
182
- REACTIVE_CONTEXT.batchDepth -= 1
183
- }
184
- /* Announce the change before flushing effects, so a patch an effect emits in
185
- reaction lands AFTER this one on the bus — the journal stays chronological. */
186
- if (PATCH_BUS.active) {
187
- PATCH_BUS.emit({ doc: self, patch, inverse: inverseOf(patch, before) })
188
- }
189
- flushEffects()
179
+ /* Announce the change before effects flush, so a patch an effect emits in
180
+ reaction lands AFTER this one on the bus — the journal stays chronological.
181
+ Emitting inside the batch keeps it ahead of the depth-0 flush on batch exit. */
182
+ if (PATCH_BUS.active) {
183
+ PATCH_BUS.emit({ doc: self, patch, inverse: inverseOf(patch, before) })
184
+ }
185
+ })
190
186
  }
191
187
 
192
188
  /* The patch that undoes `patch`, from the pre-image `before` (a value the change
@@ -0,0 +1,28 @@
1
+ import { RESUME } from './runtime/RESUME.ts'
2
+ import { seedStreamedResolution } from './seedStreamedResolution.ts'
3
+ import type { ResolvedFrame } from './types/ResolvedFrame.ts'
4
+
5
+ /*
6
+ The single client intake seam for SSR warm-state seeding. Both warm-seed channels —
7
+ the cache-snapshot channel (a settled `cache()` value, keyed by cache key) and the
8
+ await-resume channel (an `await`-block resolved value, keyed by boundary id) — answer
9
+ the same "ship a server-settled value so hydration doesn't re-fetch" question, but land
10
+ in two distinct stores: the cache STORE (read by `cache()`) and the RESUME MANIFEST
11
+ (read by `awaitBlock` on adopt). This routes a discriminated `ResolvedFrame` to the
12
+ matching store so every consumer — startClient's boot drain, the live `__abideResolve`,
13
+ applyResolved's stream swap — registers through ONE call instead of poking each store
14
+ inline. The codecs stay split by source: the cache value is an HTTP body capped at plain
15
+ JSON (it must agree with the live `decodeResponse` read), the resume value is an in-process
16
+ graph carried as ref-json text and decoded lazily at the read site.
17
+ */
18
+ // @documentation plumbing
19
+ export function seedResolved(frame: ResolvedFrame): void {
20
+ if (frame.kind === 'cache') {
21
+ seedStreamedResolution(frame.resolution)
22
+ return
23
+ }
24
+ /* The resume value rides as raw ref-json text; store it unparsed so the inline
25
+ stream-swap script (vanilla, runs before the bundle's codec loads) can register
26
+ through this same seam. `awaitBlock` decodes it at the read. */
27
+ RESUME[frame.id] = frame.resume
28
+ }
@@ -11,7 +11,7 @@ import { probeNavigation } from './probeNavigation.ts'
11
11
  import { router } from './router.ts'
12
12
  import { clientPage } from './runtime/clientPage.ts'
13
13
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
14
- import { seedStreamedResolution } from './seedStreamedResolution.ts'
14
+ import { seedResolved } from './seedResolved.ts'
15
15
 
16
16
  /* The server's __SSR__ payload this entry consumes. */
17
17
  type SsrPayload = { cache?: CacheSnapshotEntry[]; base?: string }
@@ -65,14 +65,16 @@ export function startClient(
65
65
  const streamed =
66
66
  (globalThis as { __abideResumeCache?: StreamedResolution[] }).__abideResumeCache ?? []
67
67
  for (const resolution of [...(ssr.cache ?? []), ...streamed]) {
68
- seedStreamedResolution(resolution)
68
+ seedResolved({ kind: 'cache', resolution })
69
69
  }
70
70
  /* Keep the cache channel live past boot: replace the head's buffering collector with
71
71
  the store-connected sink so a post-load resolution — streaming SPA navigation or a
72
72
  socket-delivered SSR frame, both routed through applyResolved — seeds the store
73
- directly instead of pushing to a buffer nothing drains again. */
73
+ directly instead of pushing to a buffer nothing drains again. The inline doc-stream
74
+ script only ever hands this a cache `StreamedResolution`, so wrap it as a cache frame
75
+ through the one intake seam. */
74
76
  ;(globalThis as { __abideResolve?: (resolution: StreamedResolution) => void }).__abideResolve =
75
- seedStreamedResolution
77
+ (resolution) => seedResolved({ kind: 'cache', resolution })
76
78
 
77
79
  return router(target, routes, layoutRoutes, probeNavigation)
78
80
  }
@@ -0,0 +1,15 @@
1
+ import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
2
+
3
+ /*
4
+ A warm-state-seed frame for the single client intake (`seedResolved`), discriminated by
5
+ `kind`. Both kinds ship a server-settled value so hydration adopts without a re-fetch,
6
+ but route to distinct stores:
7
+ - `cache` — a `StreamedResolution` for the cache store, read by a warm `cache()` call.
8
+ - `resume` — an `await`-block boundary id plus its ref-json-encoded value STRING for the
9
+ RESUME manifest, decoded lazily by `awaitBlock` when it adopts the branch.
10
+ The payloads stay distinct (cache snapshot vs boundary-keyed value); the unified thing is
11
+ the intake seam, not the payload.
12
+ */
13
+ export type ResolvedFrame =
14
+ | { kind: 'cache'; resolution: StreamedResolution }
15
+ | { kind: 'resume'; id: number; resume: string }