@abide/abide 0.37.0 → 0.38.0

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 (36) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/abideResolverPlugin.ts +3 -17
  4. package/src/appEntry.ts +18 -8
  5. package/src/controlServerWorker.ts +8 -1
  6. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  7. package/src/lib/mcp/createMcpServer.ts +8 -0
  8. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  9. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  10. package/src/lib/server/rpc/parseArgs.ts +12 -1
  11. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  12. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  13. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  14. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  15. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  16. package/src/lib/server/sockets/defineSocket.ts +6 -1
  17. package/src/lib/shared/cache.ts +6 -0
  18. package/src/lib/shared/createPushIterator.ts +7 -2
  19. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
  20. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  21. package/src/lib/ui/compile/asOutlet.ts +29 -0
  22. package/src/lib/ui/compile/componentWrapperTag.ts +3 -1
  23. package/src/lib/ui/compile/generateBuild.ts +31 -31
  24. package/src/lib/ui/compile/generateSSR.ts +29 -19
  25. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  26. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  27. package/src/lib/ui/compile/skeletonContext.ts +97 -34
  28. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  29. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  30. package/src/lib/ui/dom/fillBefore.ts +10 -0
  31. package/src/lib/ui/dom/mountSlot.ts +7 -2
  32. package/src/lib/ui/dom/scopeLabel.ts +5 -1
  33. package/src/lib/ui/dom/skeleton.ts +4 -1
  34. package/src/lib/ui/persist.ts +4 -1
  35. package/src/lib/ui/runtime/createDoc.ts +20 -7
  36. package/src/lib/ui/seedStreamedResolution.ts +10 -1
@@ -1,4 +1,5 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { asOutlet } from './asOutlet.ts'
2
3
  import { componentWrapperTag } from './componentWrapperTag.ts'
3
4
  import { groupBindParts } from './groupBindParts.ts'
4
5
  import { isControlFlow } from './isControlFlow.ts'
@@ -18,6 +19,17 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
18
19
  const RANGE_OPEN = '<!--[-->'
19
20
  const RANGE_CLOSE = '<!--]-->'
20
21
 
22
+ /* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
23
+ function branchNamed(
24
+ children: TemplateNode[],
25
+ which: 'then' | 'catch' | 'finally',
26
+ ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
27
+ return children.find(
28
+ (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
29
+ child.kind === 'branch' && child.branch === which,
30
+ )
31
+ }
32
+
21
33
  /*
22
34
  Server code generator: turns the parsed template into statements that push HTML
23
35
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -64,11 +76,17 @@ export function generateSSR(
64
76
  return children.map((child) => generate(child, target)).join('')
65
77
  }
66
78
 
79
+ /* In a layout, rewrite `<slot/>` outlets to `OUTLET_TAG` elements up front (the same shared
80
+ `asOutlet` the client back-end runs), then drive both the skeleton context and the
81
+ traversal from this tree — one decision site for the outlet, and the outlet emitted bare
82
+ through the generic element path exactly as the client clones it. */
83
+ const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
84
+
67
85
  /* Per-node skeleton position, computed once. Both back-ends read this single source of
68
86
  truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
69
87
  (control-flow branches, component/slot/snippet content) are enumerated there, not
70
88
  re-tracked here as mutable state that a forgotten reset could leak past. */
71
- const { inSkeleton, markText } = skeletonContext(nodes)
89
+ const { inSkeleton, markText } = skeletonContext(rootNodes)
72
90
 
73
91
  /* A control-flow branch's content, generated exactly like a normal child list so
74
92
  a branch holds ANY content (components, text, nested blocks). `generate` emits
@@ -214,8 +232,10 @@ export function generateSSR(
214
232
  )
215
233
  }
216
234
  if (node.kind === 'element' && node.tag === 'slot') {
217
- /* A layout's `<slot/>` is the router's page outlet: emit an empty
218
- placeholder the chain composer folds the child layer's html into. */
235
+ /* `asOutlet` already rewrote a layout's top-level/element-nested `<slot/>` to an
236
+ `OUTLET_TAG` element (handled by the generic path below), so a `slot` node reaching
237
+ here in a layout is control-flow-nested — emit the same bare outlet placeholder the
238
+ client's nested-slot path clones, which the chain composer folds the child into. */
219
239
  if (isLayout) {
220
240
  return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
221
241
  }
@@ -291,17 +311,12 @@ export function generateSSR(
291
311
  an empty boundary — its resolved branch is the children bound to `node.as` — and
292
312
  flags the entry so `renderToStream` settles it before the first flush. */
293
313
  function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
294
- const branchOf = (which: 'then' | 'catch' | 'finally') =>
295
- node.children.find(
296
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
297
- child.kind === 'branch' && child.branch === which,
298
- )
299
- const catchBranch = branchOf('catch')
300
- const finallyChildren = branchOf('finally')?.children ?? []
314
+ const catchBranch = branchNamed(node.children, 'catch')
315
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
301
316
  /* Resolved branch + its bound value: the children directly when blocking, the
302
317
  `then` child when streaming. Pending (streaming only) is the non-branch
303
318
  children. */
304
- const thenBranch = branchOf('then')
319
+ const thenBranch = branchNamed(node.children, 'then')
305
320
  const resolvedChildren = node.blocking
306
321
  ? node.children.filter((child) => child.kind !== 'branch')
307
322
  : (thenBranch?.children ?? [])
@@ -342,13 +357,8 @@ export function generateSSR(
342
357
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
343
358
  discard the server content if the client adoption fails. */
344
359
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
345
- const branchOf = (which: 'catch' | 'finally') =>
346
- node.children.find(
347
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
348
- child.kind === 'branch' && child.branch === which,
349
- )
350
- const catchBranch = branchOf('catch')
351
- const finallyChildren = branchOf('finally')?.children ?? []
360
+ const catchBranch = branchNamed(node.children, 'catch')
361
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
352
362
  const guarded = node.children.filter((child) => child.kind !== 'branch')
353
363
  const errName = catchBranch?.as ?? '_error'
354
364
  const id = nextVar('$tid')
@@ -371,5 +381,5 @@ export function generateSSR(
371
381
  return code
372
382
  }
373
383
 
374
- return generateInto(nodes, '$out')
384
+ return generateInto(rootNodes, '$out')
375
385
  }
@@ -148,7 +148,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
148
148
  } else {
149
149
  attrs.push({ kind: 'expression', name, code, loc })
150
150
  }
151
- } else {
151
+ } else if (source.charAt(cursor) === '"' || source.charAt(cursor) === "'") {
152
152
  const quote = source.charAt(cursor)
153
153
  cursor += 1
154
154
  let value = ''
@@ -158,6 +158,15 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
158
158
  }
159
159
  cursor += 1 // past closing quote
160
160
  attrs.push({ kind: 'static', name, value })
161
+ } else {
162
+ /* Unquoted value (`<input type=text>`): runs to the next whitespace or
163
+ `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
164
+ let value = ''
165
+ while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
166
+ value += source.charAt(cursor)
167
+ cursor += 1
168
+ }
169
+ attrs.push({ kind: 'static', name, value })
161
170
  }
162
171
  }
163
172
  return attrs
@@ -35,8 +35,15 @@ export function renameSignalRefs(
35
35
  if (ts.isPropertyAccessExpression(node)) {
36
36
  skip.add(node.name)
37
37
  }
38
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
39
- skip.add(node.name)
38
+ /* A declaration name is a binding, not a value read. A plain identifier is
39
+ skipped directly; a destructuring pattern's leaf names are collected so a
40
+ bound name shadowing a signal (`const { count } = …`) is never rewritten. */
41
+ if (ts.isVariableDeclaration(node)) {
42
+ if (ts.isIdentifier(node.name)) {
43
+ skip.add(node.name)
44
+ } else {
45
+ collectBindingIdentifiers(node.name, skip)
46
+ }
40
47
  }
41
48
  if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
42
49
  skip.add(node.name)
@@ -180,6 +187,22 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
180
187
  }
181
188
  }
182
189
 
190
+ /* The identifier NODES a binding name binds — a plain identifier or the leaves of a
191
+ destructuring pattern (object/array, including nested patterns and rest elements).
192
+ For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
193
+ Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
194
+ function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
195
+ if (ts.isIdentifier(name)) {
196
+ into.add(name)
197
+ return
198
+ }
199
+ for (const element of name.elements) {
200
+ if (ts.isBindingElement(element)) {
201
+ collectBindingIdentifiers(element.name, into)
202
+ }
203
+ }
204
+ }
205
+
183
206
  /* Every identifier bound by a binding name — a plain identifier or the leaves of a
184
207
  destructuring pattern (object/array, including rest elements). */
185
208
  function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
@@ -5,74 +5,137 @@ import type { SkeletonContext } from './types/SkeletonContext.ts'
5
5
  import type { TemplateNode } from './types/TemplateNode.ts'
6
6
 
7
7
  /*
8
- The single source of truth for where skeleton markers go. One top-down walk records,
9
- per node, whether it sits inside a parser-backed skeleton clone (`inSkeleton`) and
10
- whether its reactive text is interleaved (`markText`) — the two facts that decide
11
- `<!--a-->` anchor placement. Both back-ends read this instead of re-deriving the
12
- position (the client structurally, the server as mutable traversal state), which is
13
- what let them drift: a fresh-context boundary the server forgot to reset leaked an
14
- anchor the client never emitted, desyncing hydration.
8
+ The single source of truth for where skeleton markers go AND what their hole indices are.
9
+ One top-down walk records, per node, whether it sits inside a parser-backed skeleton clone
10
+ (`inSkeleton`) and whether its reactive text is interleaved (`markText`) — the two facts
11
+ that decide `<!--a-->` anchor placement and assigns each hole its `el`/`an` index.
12
+ `generateBuild` reads these indices instead of threading its own counter through a second
13
+ document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
15
14
 
16
- A fresh-context boundary resets to NOT-in-skeleton, because the content there is
17
- built by its own runtime (a control-flow block's branch, a component's slot content,
18
- a `<slot>`'s fallback, a snippet's body) never cloned by the enclosing skeleton
19
- so an enclosing skeletonable subtree must not stamp markers onto it. Enumerating
20
- every such boundary HERE, once, makes "forgetting to reset one" impossible.
15
+ The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
16
+ skeleton the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`; a standalone
17
+ component roots its own skeleton too). `el` numbers element holes in pre-order; `an` numbers
18
+ anchor holes (interleaved reactive text PARTS, control-flow blocks, `<slot>` outlets) in
19
+ document order the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
20
+ the realized DOM, so the compile-time numbers and the runtime positions line up.
21
+
22
+ A fresh-context boundary resets to NOT-in-skeleton (and to no active counter), because the
23
+ content there is built by its own runtime (a control-flow block's branch, a component's slot
24
+ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton.
21
25
  */
22
26
  export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
23
27
  const inSkeleton = new WeakMap<TemplateNode, boolean>()
24
28
  const markText = new WeakMap<TemplateNode, boolean>()
29
+ /* Element holes keyed by node; anchor holes keyed by node (control-flow/slot) OR by the
30
+ reactive text PART object (a text node carries one anchor per reactive part). */
31
+ const elIndex = new WeakMap<TemplateNode, number>()
32
+ const anIndex = new WeakMap<object, number>()
33
+
34
+ type Counter = { el: number; an: number }
25
35
 
26
- /* Walk `node` carrying the context that applies AT it; recurse into children with the
27
- context that applies to THEM. */
28
- function visit(node: TemplateNode, nodeInSkeleton: boolean, nodeMarkText: boolean): void {
36
+ /* Walk `node` carrying the context AND the active skeleton counter that apply AT it
37
+ (`counter === undefined` outside any skeleton — no holes are numbered there). */
38
+ function visit(
39
+ node: TemplateNode,
40
+ nodeInSkeleton: boolean,
41
+ nodeMarkText: boolean,
42
+ counter: Counter | undefined,
43
+ ): void {
29
44
  inSkeleton.set(node, nodeInSkeleton)
30
45
  markText.set(node, nodeMarkText)
31
46
 
32
- /* Control-flow branches, component slot content, and snippet bodies are fresh build
33
- contexts their children re-enter the skeleton only via their own skeletonable
34
- elements, so reset both flags. (A standalone branch/case is reached as a child of
35
- its control-flow node and likewise resets.) */
47
+ /* Control-flow blocks, components, and snippets are fresh build contexts. The node
48
+ ITSELF is a hole in the enclosing skeleton (an anchor for a block, an element hole
49
+ for a component); its children re-enter the skeleton only via their own roots. */
36
50
  if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
51
+ /* A control-flow block takes an anchor only inside an enclosing skeleton (a
52
+ standalone block routes through `generateIf`/etc, not the skeleton path). */
53
+ if (counter !== undefined && isControlFlow(node)) {
54
+ anIndex.set(node, counter.an++)
55
+ }
56
+ /* A component is its OWN element hole either way: in the enclosing skeleton's
57
+ counter when nested, else as the root (index 0) of its own standalone skeleton
58
+ (`generateChild` routes a lone component through `generateSkeleton` too). */
59
+ if (node.kind === 'component') {
60
+ const componentCounter = counter ?? { el: 0, an: 0 }
61
+ elIndex.set(node, componentCounter.el++)
62
+ }
37
63
  for (const child of childrenOf(node)) {
38
- visit(child, false, false)
64
+ visit(child, false, false, undefined)
39
65
  }
40
66
  return
41
67
  }
42
- /* A `branch`/`case` is a transparent grouping inside its control-flow block (its
43
- children are generated directly, the wrapper never emits markup) pass the
44
- already-reset context through so a skeletonable element inside it re-enters. */
68
+ /* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
69
+ already-reset context (and absent counter) through so a skeletonable element inside
70
+ it opens its own skeleton. */
45
71
  if (node.kind === 'branch' || node.kind === 'case') {
46
72
  for (const child of node.children) {
47
- visit(child, nodeInSkeleton, nodeMarkText)
73
+ visit(child, nodeInSkeleton, nodeMarkText, counter)
74
+ }
75
+ return
76
+ }
77
+ if (node.kind === 'text') {
78
+ /* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
79
+ anchor, numbered in document order. A text-leaf's text (markText false) binds
80
+ marker-free via the element, so its parts take no anchor. */
81
+ if (counter !== undefined && nodeMarkText) {
82
+ for (const part of node.parts) {
83
+ if (part.kind !== 'static') {
84
+ anIndex.set(part, counter.an++)
85
+ }
86
+ }
48
87
  }
49
88
  return
50
89
  }
51
90
  if (node.kind !== 'element') {
52
- return // text / script / style carry no skeleton children
91
+ return // script / style carry no skeleton children and no hole
53
92
  }
54
93
  if (node.tag === 'slot') {
55
- /* The slot's own children are its fallback a fresh context built by `mountSlot`,
56
- not the enclosing clone. */
94
+ /* The slot outlet is an anchor hole in the enclosing skeleton; its children are
95
+ the fallback a fresh context built by `mountSlot`. */
96
+ if (counter !== undefined) {
97
+ anIndex.set(node, counter.an++)
98
+ }
57
99
  for (const child of node.children) {
58
- visit(child, false, false)
100
+ visit(child, false, false, undefined)
59
101
  }
60
102
  return
61
103
  }
62
- /* A skeletonable element not already in a skeleton opens one; its descendants are in
63
- skeleton. Reactive text interleaves (takes an anchor) unless this element is a
64
- text-leaf (only text/style children), which binds its text marker-free. */
104
+ /* A skeletonable element not already in a skeleton OPENS one (a fresh counter — the
105
+ `generateSkeleton` unit). An element already in a skeleton uses the enclosing
106
+ counter. A static element outside any skeleton numbers nothing. */
107
+ const opensSkeleton = !nodeInSkeleton && skeletonable(node)
65
108
  const childInSkeleton = nodeInSkeleton || skeletonable(node)
109
+ const effectiveCounter = nodeInSkeleton
110
+ ? counter
111
+ : opensSkeleton
112
+ ? { el: 0, an: 0 }
113
+ : undefined
66
114
  const childMarkText = childInSkeleton && !isTextLeaf(node)
115
+
116
+ if (effectiveCounter !== undefined) {
117
+ /* The element is a located hole when it carries a reactive attr/listener/bind, or
118
+ binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
119
+ so holes number in pre-order — the order the runtime's path walk produces them. */
120
+ const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
121
+ const reactiveTextChild = node.children.find(
122
+ (child) =>
123
+ child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
124
+ )
125
+ const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
126
+ if (hasReactiveAttr || textLeafBind) {
127
+ elIndex.set(node, effectiveCounter.el++)
128
+ }
129
+ }
67
130
  for (const child of node.children) {
68
- visit(child, childInSkeleton, childMarkText)
131
+ visit(child, childInSkeleton, childMarkText, effectiveCounter)
69
132
  }
70
133
  }
71
134
 
72
135
  for (const node of nodes) {
73
- visit(node, false, false)
136
+ visit(node, false, false, undefined)
74
137
  }
75
- return { inSkeleton, markText }
138
+ return { inSkeleton, markText, elIndex, anIndex }
76
139
  }
77
140
 
78
141
  /* The child list of any node that has one (control-flow, component, snippet, element);
@@ -12,4 +12,10 @@ filled by one shared `skeletonContext` pass over the parsed tree.
12
12
  export type SkeletonContext = {
13
13
  inSkeleton: WeakMap<TemplateNode, boolean>
14
14
  markText: WeakMap<TemplateNode, boolean>
15
+ /* Per-hole indices assigned in the same walk, so `generateBuild` reads its `sk.el`/`sk.an`
16
+ numbering rather than re-deriving it. `elIndex` keyed by element/component node;
17
+ `anIndex` keyed by control-flow/slot node OR by a reactive text PART object (a text node
18
+ carries one anchor per reactive part). */
19
+ elIndex: WeakMap<TemplateNode, number>
20
+ anIndex: WeakMap<object, number>
15
21
  }
@@ -1,34 +1,74 @@
1
1
  import { snippetPayload } from '../../shared/snippet.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
2
+ import { effect } from '../effect.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scope } from '../runtime/scope.ts'
5
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
+ import { clearBetween } from './clearBetween.ts'
7
+ import { fillBefore } from './fillBefore.ts'
8
+ import { openMarker } from './openMarker.ts'
4
9
 
5
10
  /*
6
- A `{snippet(args)}` interpolation: mount the branded builder's nodes at this
7
- position. The builder builds straight into `parent` — sequential build order
8
- places it correctly among siblings, and its effects join the surrounding
9
- component scope (so they tear down with it). The body's reactive reads update
10
- fine-grained via those effects; an enclosing `each` re-mounts on list changes.
11
+ A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
12
+ bounded by two comment markers. The content builds straight into `parent` —
13
+ sequential build order places it correctly among siblings and its effects join
14
+ the surrounding component scope, so the body's reactive reads update fine-grained.
15
+
16
+ The CALL is reactive in its arguments: an effect re-reads `read()` so a change in
17
+ the argument expression (e.g. `{row(items())}`) tears the range down and rebuilds
18
+ with fresh args. `read()` returns a fresh builder closing over freshly-evaluated
19
+ args each call, so there is nothing to diff — the snippet re-mounts as a unit, the
20
+ same coarse model as `when`/`each` (args behave like props). The body's own reads
21
+ stay fine-grained within a single mount; the outer effect tracks only the args,
22
+ since `fillBefore` wraps the body in its own scope.
11
23
 
12
24
  On hydrate the builder runs against the server-rendered nodes between the
13
25
  `<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `skeleton`/`appendText`
14
- claim them in place. The cursor is advanced past the open marker before, and past
15
- the close marker after, so the markers themselves are skipped.
26
+ claim them in place and the effect's first run is a no-op that only subscribes to
27
+ the args; a later argument change rebuilds fresh (the SSR markers stay as the range).
16
28
  */
17
29
  // @documentation plumbing
18
30
  export function appendSnippet(parent: Node, read: () => unknown): void {
19
- const builder = snippetPayload(read())
20
- if (typeof builder !== 'function') {
21
- return
22
- }
23
- const mount = builder as (host: Node) => void
24
31
  const hydration = RENDER.hydration
32
+ /* Mount scopes register with the owner so they dispose on owner teardown, not
33
+ only on an argument-driven rebuild via clearBetween. */
34
+ const group = scopeGroup()
35
+ let dispose: (() => void) | undefined
36
+
37
+ /* The branded builder, or undefined for anything else (a non-snippet value
38
+ mounts nothing — the range stays empty until the args yield a builder). */
39
+ const builderOf = (): ((host: Node) => void) | undefined => {
40
+ const payload = snippetPayload(read())
41
+ return typeof payload === 'function' ? (payload as (host: Node) => void) : undefined
42
+ }
43
+
44
+ let open: Comment
45
+ let close: Comment
25
46
  if (hydration !== undefined) {
26
- const open = claimChild(hydration, parent)
27
- hydration.next.set(parent, open === null ? null : open.nextSibling)
28
- mount(parent)
29
- const close = claimChild(hydration, parent)
30
- hydration.next.set(parent, close === null ? null : close.nextSibling)
31
- return
47
+ open = openMarker(parent, 'abide:snippet')
48
+ const builder = builderOf()
49
+ if (builder !== undefined) {
50
+ dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
51
+ }
52
+ close = openMarker(parent, '/abide:snippet')
53
+ } else {
54
+ open = openMarker(parent, 'abide:snippet')
55
+ close = openMarker(parent, '/abide:snippet')
56
+ const builder = builderOf()
57
+ if (builder !== undefined) {
58
+ dispose = group.track(fillBefore(close, builder))
59
+ }
32
60
  }
33
- mount(parent)
61
+
62
+ /* The initial mount is built above (create or hydrate); the first effect run only
63
+ subscribes to the args via `builderOf`, then later argument changes rebuild. */
64
+ let first = true
65
+ effect(() => {
66
+ const builder = builderOf()
67
+ if (first) {
68
+ first = false
69
+ return
70
+ }
71
+ clearBetween(open, close, dispose)
72
+ dispose = builder !== undefined ? group.track(fillBefore(close, builder)) : undefined
73
+ })
34
74
  }
@@ -18,6 +18,16 @@ would otherwise make the build helpers claim SSR nodes that don't exist for fres
18
18
  content. The same cursor is restored after (mirrors awaitBlock/tryBlock/each).
19
19
  */
20
20
  export function fillBefore(end: Node, content: (into: Node) => void): () => void {
21
+ /* A control-flow effect can fire one final time after its block was already
22
+ detached — e.g. an enclosing await/each block tears the branch down in the same
23
+ microtask flush that re-ran this effect, before the owner scope disposes it. The
24
+ end marker then has no live parent, so there is nowhere to build into; inserting
25
+ a fragment before a parentless comment throws HierarchyRequestError ("would yield
26
+ an incorrect node tree"). Skip the rebuild — owner teardown disposes this dead
27
+ block anyway. */
28
+ if (!end.parentNode) {
29
+ return () => {}
30
+ }
21
31
  const fragment = document.createDocumentFragment()
22
32
  const previousHydration = RENDER.hydration
23
33
  RENDER.hydration = undefined
@@ -1,5 +1,6 @@
1
1
  import { RENDER } from '../runtime/RENDER.ts'
2
2
  import { scope } from '../runtime/scope.ts'
3
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
3
4
  import { fillBefore } from './fillBefore.ts'
4
5
  import { openMarker } from './openMarker.ts'
5
6
 
@@ -21,12 +22,16 @@ export function mountSlot(
21
22
  before: Node | null = null,
22
23
  ): void {
23
24
  const hydration = RENDER.hydration
25
+ /* The slot content's scope, registered with the owner so its effects/listeners
26
+ dispose on owner teardown (a navigation) — the slot never toggles, so the
27
+ group only ever tracks this one child. */
28
+ const group = scopeGroup()
24
29
  openMarker(parent, '[', before)
25
30
  if (hydration !== undefined) {
26
- scope(() => render(parent)) // content claims the SSR range in place
31
+ group.track(scope(() => render(parent))) // content claims the SSR range in place
27
32
  openMarker(parent, ']')
28
33
  } else {
29
34
  const end = openMarker(parent, ']', before)
30
- fillBefore(end, render)
35
+ group.track(fillBefore(end, render))
31
36
  }
32
37
  }
@@ -6,10 +6,14 @@ component name; any other host (a page/layout outlet) yields its lowercased tag.
6
6
  Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
7
7
  than an opaque counter id. Returns undefined when there's no element to name from.
8
8
  */
9
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
10
+
9
11
  export function scopeLabel(host: Element): string | undefined {
10
12
  const tag = host.tagName?.toLowerCase()
11
13
  if (tag === undefined) {
12
14
  return undefined
13
15
  }
14
- return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
16
+ return tag.startsWith(COMPONENT_WRAPPER_PREFIX)
17
+ ? tag.slice(COMPONENT_WRAPPER_PREFIX.length)
18
+ : tag
15
19
  }
@@ -1,3 +1,4 @@
1
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
1
2
  import { claimChild } from '../runtime/claimChild.ts'
2
3
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
4
  import { RENDER } from '../runtime/RENDER.ts'
@@ -33,7 +34,9 @@ function isElement(node: Node): node is Element {
33
34
  child's anchors and shift every parent index past it (same hazard as a block range,
34
35
  but bounded by the wrapper element instead of `[`…`]` markers). */
35
36
  function isComponentWrapper(node: Node): boolean {
36
- return isElement(node) && (node.tagName ?? '').toLowerCase().startsWith('abide-')
37
+ return (
38
+ isElement(node) && (node.tagName ?? '').toLowerCase().startsWith(COMPONENT_WRAPPER_PREFIX)
39
+ )
37
40
  }
38
41
 
39
42
  /* A comment node's data, or undefined for elements/text. A comment is a node that is
@@ -1,3 +1,4 @@
1
+ import { escapeKey } from './runtime/escapeKey.ts'
1
2
  import { localStoragePersistence } from './runtime/localStoragePersistence.ts'
2
3
  import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
3
4
  import type { Doc } from './runtime/types/Doc.ts'
@@ -99,8 +100,10 @@ export function persist(
99
100
  function restore(doc: Doc, saved: unknown): void {
100
101
  const current = doc.snapshot()
101
102
  if (isPlainObject(saved) && isPlainObject(current)) {
103
+ /* `replace` takes a `/`-delimited escaped path, so a top-level key containing
104
+ `/` or `~` must be escaped to a single segment or it'd be mis-routed. */
102
105
  for (const key of Object.keys(saved)) {
103
- doc.replace(key, saved[key])
106
+ doc.replace(escapeKey(key), saved[key])
104
107
  }
105
108
  return
106
109
  }
@@ -14,6 +14,13 @@ import { unescapeKey } from './unescapeKey.ts'
14
14
  import { walkPath } from './walkPath.ts'
15
15
  import { writeNode } from './writeNode.ts'
16
16
 
17
+ /* `path` minus its last segment — the parent container's path, '' at the root.
18
+ The same string `segments.slice(0, -1).join('/')` rebuilds, by one slice. */
19
+ function parentPathOf(path: string): string {
20
+ const lastSlash = path.lastIndexOf('/')
21
+ return lastSlash === -1 ? '' : path.slice(0, lastSlash)
22
+ }
23
+
17
24
  /*
18
25
  Builds a reactive document over `initial`. Each path read for the first time
19
26
  mints a signal node; the node is the notification token, the (mutable) tree is
@@ -132,10 +139,7 @@ export function createDoc(initial: unknown): Doc {
132
139
  remove (computed post-apply, below, to resolve an array append's index). */
133
140
  const before = PATCH_BUS.active ? walkPath(tree, patch.path) : undefined
134
141
  tree = applyPatchToTree(tree, patch, segments)
135
- /* parentPath is patch.path minus its last segment — the same string
136
- `segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
137
- const lastSlash = patch.path.lastIndexOf('/')
138
- const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
142
+ const parentPath = parentPathOf(patch.path)
139
143
  const parentValue = walkPath(tree, parentPath).value
140
144
  const leafKey = segments[segments.length - 1] as string | undefined
141
145
  /* A structural change (add/remove, or an array element replaced by index)
@@ -194,8 +198,7 @@ export function createDoc(initial: unknown): Doc {
194
198
  before: ReturnType<typeof walkPath> | undefined,
195
199
  ): Patch | undefined {
196
200
  if (patch.op === 'add') {
197
- const lastSlash = patch.path.lastIndexOf('/')
198
- const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
201
+ const parentPath = parentPathOf(patch.path)
199
202
  const parent = walkPath(tree, parentPath).value
200
203
  const resolved =
201
204
  Array.isArray(parent) && patch.path.endsWith('/-')
@@ -223,9 +226,19 @@ export function createDoc(initial: unknown): Doc {
223
226
  const node = nodeFor(path)
224
227
  const segments = path.split('/').map(unescapeKey)
225
228
  const leafKey = segments[segments.length - 1] as string
229
+ /* Auto-vivify missing ancestor objects so binding a nested path on a doc
230
+ booted shallow (e.g. `state({})`) doesn't crash, and a later `set` writes
231
+ into the LIVE tree (so snapshot/persist see it). Mirrors the container
232
+ assumption applyPatchToTree makes — except the patch path is authored, this
233
+ walk is compiler-emitted, so the intermediates may not exist yet. */
226
234
  let parent = tree as Record<string, unknown>
227
235
  for (const segment of segments.slice(0, -1)) {
228
- parent = parent[segment] as Record<string, unknown>
236
+ let next = parent[segment]
237
+ if (next === null || typeof next !== 'object') {
238
+ next = {}
239
+ parent[segment] = next
240
+ }
241
+ parent = next as Record<string, unknown>
229
242
  }
230
243
  return {
231
244
  get: () => readNode(node) as T,
@@ -18,5 +18,14 @@ export function seedStreamedResolution(resolution: StreamedResolution): void {
18
18
  if ('miss' in resolution) {
19
19
  return
20
20
  }
21
- activeCacheStore().entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
21
+ /* Only seed when nothing live holds the key — or when the existing entry is itself
22
+ an unconsumed hydrated seed (`hydrated === true`, cleared by the first cache()
23
+ read). A live/settled non-hydrated entry is authoritative; clobbering it with a
24
+ stale snapshot would drop a fresher value (e.g. one a live fetch already wrote). */
25
+ const { entries } = activeCacheStore()
26
+ const existing = entries.get(resolution.key)
27
+ if (existing !== undefined && existing.hydrated !== true) {
28
+ return
29
+ }
30
+ entries.set(resolution.key, cacheEntryFromSnapshot(resolution))
22
31
  }