@abide/abide 0.41.0 → 0.42.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 (61) hide show
  1. package/AGENTS.md +333 -326
  2. package/CHANGELOG.md +92 -0
  3. package/README.md +91 -83
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  15. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  16. package/src/lib/ui/compile/compileShadow.ts +46 -9
  17. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  18. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  19. package/src/lib/ui/compile/generateBuild.ts +13 -6
  20. package/src/lib/ui/compile/generateSSR.ts +23 -14
  21. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  22. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  23. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  24. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  25. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  26. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  27. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  28. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  29. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  30. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  31. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  32. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  33. package/src/lib/ui/dom/appendText.ts +1 -5
  34. package/src/lib/ui/dom/applyResolved.ts +4 -9
  35. package/src/lib/ui/dom/awaitBlock.ts +37 -31
  36. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  37. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  38. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  39. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  40. package/src/lib/ui/dom/each.ts +7 -11
  41. package/src/lib/ui/dom/eachAsync.ts +12 -17
  42. package/src/lib/ui/dom/isComment.ts +6 -0
  43. package/src/lib/ui/dom/isElement.ts +6 -0
  44. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  45. package/src/lib/ui/dom/mountRange.ts +4 -3
  46. package/src/lib/ui/dom/mountSlot.ts +4 -3
  47. package/src/lib/ui/dom/on.ts +6 -1
  48. package/src/lib/ui/dom/replaceRange.ts +24 -0
  49. package/src/lib/ui/dom/skeleton.ts +35 -92
  50. package/src/lib/ui/dom/switchBlock.ts +13 -10
  51. package/src/lib/ui/dom/when.ts +13 -10
  52. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  53. package/src/lib/ui/runtime/batch.ts +22 -0
  54. package/src/lib/ui/runtime/clientPage.ts +3 -8
  55. package/src/lib/ui/runtime/createDoc.ts +12 -16
  56. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  57. package/src/lib/ui/seedResolved.ts +28 -0
  58. package/src/lib/ui/startClient.ts +6 -4
  59. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
  60. package/template/.zed/settings.json +4 -0
  61. package/template/src/ui/pages/page.abide +4 -4
@@ -1,5 +1,5 @@
1
- import { RESUME } from '../runtime/RESUME.ts'
2
- import { seedStreamedResolution } from '../seedStreamedResolution.ts'
1
+ import { seedResolved } from '../seedResolved.ts'
2
+ import { isComment } from './isComment.ts'
3
3
 
4
4
  /*
5
5
  Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
@@ -31,7 +31,7 @@ export function applyResolved(root: Element, frame: string): void {
31
31
  bundle-consumed stream can't adopt a resolved branch while dropping its cache key. */
32
32
  if (resolved.nodeName === 'ABIDE-CACHE') {
33
33
  try {
34
- seedStreamedResolution(JSON.parse(resolved.textContent ?? 'null'))
34
+ seedResolved({ kind: 'cache', resolution: JSON.parse(resolved.textContent ?? 'null') })
35
35
  } catch {
36
36
  /* malformed payload — leave unseeded; the read falls back to a live fetch */
37
37
  }
@@ -47,7 +47,7 @@ export function applyResolved(root: Element, frame: string): void {
47
47
  recording it lets a later hydrate adopt this branch (no re-fetch). */
48
48
  const payload = resolved.firstChild as Element | null
49
49
  if (payload !== null && payload.nodeName === 'SCRIPT') {
50
- RESUME[Number(id)] = payload.textContent ?? ''
50
+ seedResolved({ kind: 'resume', id: Number(id), resume: payload.textContent ?? '' })
51
51
  payload.remove()
52
52
  }
53
53
  const open = `abide:await:${id}`
@@ -72,11 +72,6 @@ export function applyResolved(root: Element, frame: string): void {
72
72
  }
73
73
  }
74
74
 
75
- /* A comment node carrying exactly `data`. */
76
- function isComment(node: Node, data: string): boolean {
77
- return (node as { data?: string }).data === data && node.childNodes.length === 0
78
- }
79
-
80
75
  /* Depth-first search for the parent + open-marker comment of a boundary. */
81
76
  function findBoundary(node: Node, open: string): { parent: Node; start: Node } | undefined {
82
77
  for (const child of [...node.childNodes]) {
@@ -1,6 +1,7 @@
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'
@@ -8,14 +9,17 @@ import { scope } from '../runtime/scope.ts'
8
9
  import { scopeGroup } from '../runtime/scopeGroup.ts'
9
10
  import type { State } from '../runtime/types/State.ts'
10
11
  import { state } from '../state.ts'
12
+ import { buildDetachedRange } from './buildDetachedRange.ts'
11
13
  import { discardBoundary } from './discardBoundary.ts'
12
- import { enterNamespace } from './enterNamespace.ts'
14
+ import { removeRange } from './removeRange.ts'
13
15
 
14
16
  /*
15
17
  Async binding — the runtime for `<template await>`. Renders the pending branch,
16
18
  then swaps to the resolved branch (with the value) or the error branch on settle.
17
- Each branch is a RANGE of element roots, tracked as a node array so a multi-root
18
- 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.
19
23
 
20
24
  The read runs inside a abide-ui `effect`, so it's reactive: `abide/shared/cache`'s
21
25
  store subscribes the key it reads to this effect (createSubscriber is abide-ui-
@@ -49,7 +53,7 @@ export function awaitBlock(
49
53
  /* The live branch's scope, registered with the owner so it disposes on owner
50
54
  teardown — not only when a settle/re-run swaps branches via detach. */
51
55
  const group = scopeGroup()
52
- let active: { nodes: Node[]; dispose: () => void } | undefined
56
+ let active: { start: Comment; end: Comment; dispose: () => void } | undefined
53
57
  let anchor: Node | undefined
54
58
  let first = true
55
59
  /* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
@@ -67,29 +71,26 @@ export function awaitBlock(
67
71
  const detach = (): void => {
68
72
  if (active !== undefined) {
69
73
  active.dispose()
70
- /* Remove via each node's LIVE parent, not the captured `parent` — when this
71
- await is a bare child of a control-flow branch, `parent` is the branch's
72
- build fragment, emptied into the document once the enclosing block placed
73
- it (`place` already inserts via `anchor.parentNode` for the same reason). */
74
- for (const node of active.nodes) {
75
- node.parentNode?.removeChild(node)
76
- }
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)
77
79
  active = undefined
78
80
  }
79
81
  }
80
82
 
81
- /* Replace the current content with a freshly-built branch, before the anchor. The
82
- branch builds into a fragment (so any content — components, text, nested blocks
83
- — 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`. */
84
87
  const place = (build: (parent: Node) => void): void => {
85
88
  detach()
86
- const fragment = document.createDocumentFragment()
87
- const dispose = group.track(
88
- enterNamespace(anchor?.parentNode ?? parent, () => scope(() => build(fragment))),
89
- )
90
- const nodes = [...fragment.childNodes]
91
- ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
92
- 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 }
93
94
  }
94
95
 
95
96
  /* Settle to a resolved value. then→then updates the cell in place — the branch and its
@@ -151,23 +152,28 @@ export function awaitBlock(
151
152
  )
152
153
  }
153
154
 
154
- /* Adopt an SSR-resolved branch in place (its content claims the existing nodes),
155
- then park an anchor just before the close marker for later swaps. The adopted
156
- 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). */
157
161
  const adopt = (open: Node | null, build: (parent: Node) => void): void => {
158
162
  const cursor = hydration as NonNullable<typeof hydration>
159
- cursor.next.set(parent, open?.nextSibling ?? null)
163
+ const firstAdopted = open?.nextSibling ?? null
164
+ cursor.next.set(parent, firstAdopted)
160
165
  const dispose = group.track(scope(() => build(parent)))
161
166
  const close = claimChild(cursor, parent)
162
167
  cursor.next.set(parent, close?.nextSibling ?? null)
163
- const nodes: Node[] = []
164
- for (let node = open?.nextSibling ?? null; node !== null && node !== close; ) {
165
- nodes.push(node)
166
- node = node.nextSibling
167
- }
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)
168
174
  anchor = document.createTextNode('')
169
175
  parent.insertBefore(anchor, close)
170
- active = { nodes, dispose }
176
+ active = { start, end, dispose }
171
177
  }
172
178
 
173
179
  /* Discard the SSR boundary and (re)build the block from the live promise, fresh
@@ -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
+ }
@@ -6,7 +6,7 @@ import { scope } from '../runtime/scope.ts'
6
6
  import { scopeGroup } from '../runtime/scopeGroup.ts'
7
7
  import type { State } from '../runtime/types/State.ts'
8
8
  import { state } from '../state.ts'
9
- import { enterNamespace } from './enterNamespace.ts'
9
+ import { buildDetachedRange } from './buildDetachedRange.ts'
10
10
  import { moveRange } from './moveRange.ts'
11
11
  import { removeRange } from './removeRange.ts'
12
12
  import type { EachRow } from './types/EachRow.ts'
@@ -70,17 +70,13 @@ export function each<T>(
70
70
  hydration.next.set(parent, end.nextSibling)
71
71
  return { start, end, dispose, cell, indexCell }
72
72
  }
73
- const start = document.createComment('[')
74
- const end = document.createComment(']')
75
- const pending = document.createDocumentFragment()
76
- pending.appendChild(start)
77
- /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
78
- built into the detached fragment are namespaced, not built as HTML. */
79
- const dispose = group.track(
80
- enterNamespace(parent, () => scope(() => render(pending, cell as State<T>, indexCell))),
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),
81
78
  )
82
- pending.appendChild(end)
83
- return { start, end, dispose, cell, indexCell, pending }
79
+ return { start, end, dispose: group.track(dispose), cell, indexCell, pending: fragment }
84
80
  }
85
81
 
86
82
  /* Place a row so its range ends just before `cursor`: insert a fresh row's
@@ -2,11 +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
6
  import type { State } from '../runtime/types/State.ts'
8
7
  import { state } from '../state.ts'
9
- import { enterNamespace } from './enterNamespace.ts'
8
+ import { buildDetachedRange } from './buildDetachedRange.ts'
10
9
  import { removeRange } from './removeRange.ts'
11
10
  import type { EachRow } from './types/EachRow.ts'
12
11
 
@@ -49,24 +48,20 @@ export function eachAsync<T>(
49
48
  parent.insertBefore(anchor, before) // `before` places rows before a static suffix
50
49
  }
51
50
 
52
- /* Build a content range and insert it just before the anchor (arrival order). A bare
53
- range (no item cell) — the data-row site adds the cell, the error branch needs none. */
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
54
  const insertRange = (
55
55
  build: (into: Node) => void,
56
56
  ): { start: Node; end: Node; dispose: () => void } => {
57
- const start = document.createComment('[')
58
- const end = document.createComment(']')
59
- const fragment = document.createDocumentFragment()
60
- fragment.appendChild(start)
61
- const dispose = group.track(
62
- enterNamespace(anchor.parentNode ?? parent, () => scope(() => build(fragment))),
63
- )
64
- fragment.appendChild(end)
65
- /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
66
- control-flow branch, the captured `parent` is the branch's build fragment,
67
- emptied into the document once the enclosing block placed it. */
68
- ;(anchor.parentNode ?? parent).insertBefore(fragment, anchor)
69
- return { start, end, dispose }
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) }
70
65
  }
71
66
 
72
67
  /* The mounted `<template catch>` range, disposed when a fresh run re-streams. */
@@ -0,0 +1,6 @@
1
+ /* A comment node carrying exactly `data`. Empty-child check distinguishes the
2
+ `<!--data-->` marker from an element that happens to expose a `data` property.
3
+ Shared by the marker-range scanners in appendText/applyResolved. */
4
+ export function isComment(node: Node, data: string): boolean {
5
+ return (node as { data?: string }).data === data && node.childNodes.length === 0
6
+ }
@@ -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
+ }
@@ -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
+ }