@abide/abide 0.37.0 → 0.38.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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/package.json +2 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  14. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  15. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  17. package/src/lib/server/sockets/defineSocket.ts +6 -1
  18. package/src/lib/shared/cache.ts +6 -0
  19. package/src/lib/shared/createPushIterator.ts +7 -2
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  22. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  23. package/src/lib/ui/compile/asOutlet.ts +30 -0
  24. package/src/lib/ui/compile/compileModule.ts +13 -9
  25. package/src/lib/ui/compile/generateBuild.ts +83 -72
  26. package/src/lib/ui/compile/generateSSR.ts +56 -37
  27. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  28. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  29. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  30. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  31. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  32. package/src/lib/ui/compile/skeletonContext.ts +97 -35
  33. package/src/lib/ui/compile/skeletonable.ts +3 -2
  34. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  35. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  36. package/src/lib/ui/dom/commentData.ts +14 -0
  37. package/src/lib/ui/dom/disposeRange.ts +21 -0
  38. package/src/lib/ui/dom/fillBefore.ts +10 -0
  39. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  40. package/src/lib/ui/dom/fillRange.ts +30 -0
  41. package/src/lib/ui/dom/hydrate.ts +11 -21
  42. package/src/lib/ui/dom/mount.ts +16 -25
  43. package/src/lib/ui/dom/mountChild.ts +27 -14
  44. package/src/lib/ui/dom/mountRange.ts +45 -0
  45. package/src/lib/ui/dom/mountSlot.ts +7 -2
  46. package/src/lib/ui/dom/outlet.ts +62 -0
  47. package/src/lib/ui/dom/scopeLabel.ts +13 -7
  48. package/src/lib/ui/dom/skeleton.ts +11 -27
  49. package/src/lib/ui/dom/withScope.ts +33 -0
  50. package/src/lib/ui/installHotBridge.ts +2 -0
  51. package/src/lib/ui/persist.ts +4 -1
  52. package/src/lib/ui/renderChain.ts +23 -15
  53. package/src/lib/ui/router.ts +78 -38
  54. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  55. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  56. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  57. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  58. package/src/lib/ui/runtime/createDoc.ts +20 -7
  59. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  60. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  61. package/src/lib/ui/runtime/types/Route.ts +6 -5
  62. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  63. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  64. package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
  65. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
@@ -1,3 +1,5 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { isAnchorPositioned } from './isAnchorPositioned.ts'
1
3
  import { isControlFlow } from './isControlFlow.ts'
2
4
  import { isTextLeaf } from './isTextLeaf.ts'
3
5
  import { skeletonable } from './skeletonable.ts'
@@ -5,74 +7,134 @@ import type { SkeletonContext } from './types/SkeletonContext.ts'
5
7
  import type { TemplateNode } from './types/TemplateNode.ts'
6
8
 
7
9
  /*
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.
10
+ The single source of truth for where skeleton markers go AND what their hole indices are.
11
+ One top-down walk records, per node, whether it sits inside a parser-backed skeleton clone
12
+ (`inSkeleton`) and whether its reactive text is interleaved (`markText`) — the two facts
13
+ that decide `<!--a-->` anchor placement and assigns each hole its `el`/`an` index.
14
+ `generateBuild` reads these indices instead of threading its own counter through a second
15
+ document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
15
16
 
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.
17
+ The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
18
+ skeleton the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`). `el` numbers
19
+ element holes in pre-order; `an` numbers anchor holes (interleaved reactive text PARTS,
20
+ control-flow blocks, child components, `<slot>` outlets) in
21
+ document order the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
22
+ the realized DOM, so the compile-time numbers and the runtime positions line up.
23
+
24
+ A fresh-context boundary resets to NOT-in-skeleton (and to no active counter), because the
25
+ content there is built by its own runtime (a control-flow block's branch, a component's slot
26
+ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton.
21
27
  */
22
28
  export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
23
29
  const inSkeleton = new WeakMap<TemplateNode, boolean>()
24
30
  const markText = new WeakMap<TemplateNode, boolean>()
31
+ /* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot)
32
+ OR by the reactive text PART object (a text node carries one anchor per reactive part). */
33
+ const elIndex = new WeakMap<TemplateNode, number>()
34
+ const anIndex = new WeakMap<object, number>()
35
+
36
+ type Counter = { el: number; an: number }
25
37
 
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 {
38
+ /* Walk `node` carrying the context AND the active skeleton counter that apply AT it
39
+ (`counter === undefined` outside any skeleton — no holes are numbered there). */
40
+ function visit(
41
+ node: TemplateNode,
42
+ nodeInSkeleton: boolean,
43
+ nodeMarkText: boolean,
44
+ counter: Counter | undefined,
45
+ ): void {
29
46
  inSkeleton.set(node, nodeInSkeleton)
30
47
  markText.set(node, nodeMarkText)
31
48
 
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.) */
49
+ /* Control-flow blocks, components, and snippets are fresh build contexts. The node
50
+ ITSELF is a hole in the enclosing skeleton (an `<!--a-->` anchor for a block OR a
51
+ component both mount as a marker-bounded range at that anchor); its children
52
+ re-enter the skeleton only via their own roots. */
36
53
  if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
54
+ /* A block or a component takes an anchor only inside an enclosing skeleton (a
55
+ standalone one routes through `generateIf`/`mountChild`, not the skeleton path);
56
+ a snippet declares a builder and is never anchor-positioned (`isAnchorPositioned`). */
57
+ if (counter !== undefined && isAnchorPositioned(node)) {
58
+ anIndex.set(node, counter.an++)
59
+ }
37
60
  for (const child of childrenOf(node)) {
38
- visit(child, false, false)
61
+ visit(child, false, false, undefined)
39
62
  }
40
63
  return
41
64
  }
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. */
65
+ /* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
66
+ already-reset context (and absent counter) through so a skeletonable element inside
67
+ it opens its own skeleton. */
45
68
  if (node.kind === 'branch' || node.kind === 'case') {
46
69
  for (const child of node.children) {
47
- visit(child, nodeInSkeleton, nodeMarkText)
70
+ visit(child, nodeInSkeleton, nodeMarkText, counter)
71
+ }
72
+ return
73
+ }
74
+ if (node.kind === 'text') {
75
+ /* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
76
+ anchor, numbered in document order. A text-leaf's text (markText false) binds
77
+ marker-free via the element, so its parts take no anchor. */
78
+ if (counter !== undefined && nodeMarkText) {
79
+ for (const part of node.parts) {
80
+ if (part.kind !== 'static') {
81
+ anIndex.set(part, counter.an++)
82
+ }
83
+ }
48
84
  }
49
85
  return
50
86
  }
51
87
  if (node.kind !== 'element') {
52
- return // text / script / style carry no skeleton children
88
+ return // script / style carry no skeleton children and no hole
53
89
  }
54
- if (node.tag === 'slot') {
55
- /* The slot's own children are its fallback — a fresh context built by `mountSlot`,
56
- not the enclosing clone. */
90
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
91
+ /* A component `<slot>` content fill OR a layout's `OUTLET_TAG` router fill point
92
+ (`asOutlet`) is an anchor hole in the enclosing skeleton — both mount a marker
93
+ range at the anchor (`mountSlot` / `outlet`). A `<slot>`'s children are its
94
+ fallback (a fresh context); an outlet has none. */
95
+ if (counter !== undefined) {
96
+ anIndex.set(node, counter.an++)
97
+ }
57
98
  for (const child of node.children) {
58
- visit(child, false, false)
99
+ visit(child, false, false, undefined)
59
100
  }
60
101
  return
61
102
  }
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. */
103
+ /* A skeletonable element not already in a skeleton OPENS one (a fresh counter — the
104
+ `generateSkeleton` unit). An element already in a skeleton uses the enclosing
105
+ counter. A static element outside any skeleton numbers nothing. */
106
+ const opensSkeleton = !nodeInSkeleton && skeletonable(node)
65
107
  const childInSkeleton = nodeInSkeleton || skeletonable(node)
108
+ const effectiveCounter = nodeInSkeleton
109
+ ? counter
110
+ : opensSkeleton
111
+ ? { el: 0, an: 0 }
112
+ : undefined
66
113
  const childMarkText = childInSkeleton && !isTextLeaf(node)
114
+
115
+ if (effectiveCounter !== undefined) {
116
+ /* The element is a located hole when it carries a reactive attr/listener/bind, or
117
+ binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
118
+ so holes number in pre-order — the order the runtime's path walk produces them. */
119
+ const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
120
+ const reactiveTextChild = node.children.find(
121
+ (child) =>
122
+ child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
123
+ )
124
+ const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
125
+ if (hasReactiveAttr || textLeafBind) {
126
+ elIndex.set(node, effectiveCounter.el++)
127
+ }
128
+ }
67
129
  for (const child of node.children) {
68
- visit(child, childInSkeleton, childMarkText)
130
+ visit(child, childInSkeleton, childMarkText, effectiveCounter)
69
131
  }
70
132
  }
71
133
 
72
134
  for (const node of nodes) {
73
- visit(node, false, false)
135
+ visit(node, false, false, undefined)
74
136
  }
75
- return { inSkeleton, markText }
137
+ return { inSkeleton, markText, elIndex, anIndex }
76
138
  }
77
139
 
78
140
  /* The child list of any node that has one (control-flow, component, snippet, element);
@@ -1,3 +1,4 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
1
2
  import { isControlFlow } from './isControlFlow.ts'
2
3
  import type { TemplateNode } from './types/TemplateNode.ts'
3
4
 
@@ -22,7 +23,7 @@ function skeletonStructure(node: TemplateNode): boolean {
22
23
  if (node.kind !== 'element') {
23
24
  return false // standalone branch|case
24
25
  }
25
- if (node.tag === 'slot') {
26
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
26
27
  return true
27
28
  }
28
29
  return node.children.every(skeletonStructure)
@@ -43,7 +44,7 @@ function hasHole(node: TemplateNode): boolean {
43
44
  return node.parts.some((part) => part.kind !== 'static')
44
45
  }
45
46
  if (node.kind === 'element') {
46
- if (node.tag === 'slot') {
47
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
47
48
  return true
48
49
  }
49
50
  return node.attrs.some((attr) => attr.kind !== 'static') || node.children.some(hasHole)
@@ -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
  }
@@ -0,0 +1,14 @@
1
+ /* A comment node's data, or undefined for elements/text. A comment is a node that is
2
+ neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
3
+ exposes no `nodeType`, so detect by method. Shared by every marker-range scan
4
+ (`skeleton`'s anchor walk, `outlet`'s close-marker skip) so the convention is probed
5
+ one way everywhere. */
6
+ export function commentData(node: Node): string | undefined {
7
+ if (
8
+ typeof (node as Element).hasAttribute === 'function' ||
9
+ typeof (node as Text).splitText === 'function'
10
+ ) {
11
+ return undefined
12
+ }
13
+ return (node as Comment).data
14
+ }
@@ -0,0 +1,21 @@
1
+ import type { Scope } from '../types/Scope.ts'
2
+ import { clearBetween } from './clearBetween.ts'
3
+
4
+ /*
5
+ The standard teardown for a marker-bounded range (component, layout/page boundary,
6
+ slot): stop the content's reactivity, dispose its lexical scope, and clear the nodes
7
+ between the markers — leaving the markers in place so a hot swap rebuilds the range.
8
+ Shared by every range mount (`fillRange`, `mountRange`, `fillBoundary`) so the one
9
+ disposer contract lives in a single place.
10
+ */
11
+ export function disposeRange(
12
+ scoped: { stop: () => void; lexical: Scope },
13
+ start: Comment,
14
+ end: Comment,
15
+ ): () => void {
16
+ return () => {
17
+ scoped.stop()
18
+ scoped.lexical.dispose()
19
+ clearBetween(start, end)
20
+ }
21
+ }
@@ -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
@@ -0,0 +1,38 @@
1
+ import { RENDER } from '../runtime/RENDER.ts'
2
+ import { scope } from '../runtime/scope.ts'
3
+ import type { UiProps } from '../runtime/types/UiProps.ts'
4
+ import { disposeRange } from './disposeRange.ts'
5
+ import { fillRange } from './fillRange.ts'
6
+ import { withScope } from './withScope.ts'
7
+
8
+ /*
9
+ Mounts a chain layer (layout or page) into an EXISTING outlet boundary — the markers
10
+ a parent layout's `<slot/>` left (`outlet`), or the router's root boundary in `#app`.
11
+ The router fills these boundaries instead of mounting into a host element, so the whole
12
+ page/layout chain composes through one range model (no `<abide-outlet>` wrapper).
13
+
14
+ Create: build the layer's content into a fragment that lands just before `close`
15
+ (`fillRange`). Hydrate: claim the server content in place — park the parent cursor at
16
+ `open.nextSibling` and let the build adopt the existing nodes (the outlet markers
17
+ themselves are located via `PENDING_OUTLET`, not claimed through the cursor). The disposer
18
+ stops the layer's reactivity and clears the boundary — the router calls it to tear a
19
+ divergent layer down before rebuilding the same boundary.
20
+ */
21
+ // @documentation plumbing
22
+ export function fillBoundary(
23
+ open: Comment,
24
+ close: Comment,
25
+ build: (host: Node, props?: UiProps) => void,
26
+ props: UiProps | undefined,
27
+ label: string | undefined,
28
+ ): { dispose: () => void } {
29
+ const hydration = RENDER.hydration
30
+ if (hydration === undefined) {
31
+ return fillRange(open, close, build, props, label)
32
+ }
33
+ /* Hydrate: adopt the server content between the markers in place. */
34
+ const parent = open.parentNode as Node
35
+ hydration.next.set(parent, open.nextSibling)
36
+ const scoped = withScope(label, () => scope(() => build(parent, props)))
37
+ return { dispose: disposeRange(scoped, open, close) }
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+ import { disposeRange } from './disposeRange.ts'
3
+ import { fillBefore } from './fillBefore.ts'
4
+ import { withScope } from './withScope.ts'
5
+
6
+ /*
7
+ Builds a component's content fresh between two existing range markers (the create
8
+ path), under the component's own lexical scope and render pass — the range analog of
9
+ `mount` for a nested child. `build` appends into a fragment (via `fillBefore`) that
10
+ lands just before `end`, so the content sits in the `[ … ]` range the child mounts
11
+ into rather than at the parent's tail; that range is what makes a component
12
+ selector-transparent (a true direct child of its parent, no `<abide-name>` wrapper).
13
+
14
+ Brackets a render pass (a nested child continues the parent's block-id counter) and
15
+ establishes the child's lexical scope in `awaiting` mode so it adopts the model doc
16
+ its first `doc()` creates. The disposer stops the content's reactivity, disposes the
17
+ lexical scope, and clears the range — leaving the markers, so a hot swap rebuilds in
18
+ place. Shared by `mountRange` (create branch) and `hotReplace` (re-fill on edit).
19
+ */
20
+ // @documentation plumbing
21
+ export function fillRange(
22
+ start: Comment,
23
+ end: Comment,
24
+ build: (host: Node, props?: UiProps) => void,
25
+ props: UiProps | undefined,
26
+ label: string | undefined,
27
+ ): { start: Comment; end: Comment; dispose: () => void } {
28
+ const scoped = withScope(label, () => fillBefore(end, (fragment) => build(fragment, props)))
29
+ return { start, end, dispose: disposeRange(scoped, start, end) }
30
+ }
@@ -1,10 +1,7 @@
1
- import { createScope } from '../createScope.ts'
2
- import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
3
- import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
- import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
1
  import { RENDER } from '../runtime/RENDER.ts'
6
2
  import { scope } from '../runtime/scope.ts'
7
3
  import { scopeLabel } from './scopeLabel.ts'
4
+ import { withScope } from './withScope.ts'
8
5
 
9
6
  /*
10
7
  Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
@@ -15,31 +12,24 @@ focus/scroll). Returns a disposer.
15
12
 
16
13
  Adopts the server DOM in place across the framework: static structure (elements
17
14
  + text + bindings), `if`/`else`, keyed `each`, `switch`, `try`, and child
18
- components (with slots) — they hydrate automatically because the wrapper is
19
- claimed while hydration is still active. `await` adopts too when it can resume
15
+ components (with slots) — they hydrate automatically because a child's marker range
16
+ is claimed while hydration is still active (see `mountRange`). `await` adopts too when it can resume
20
17
  the value (a streamed `RESUME[id]` or a warm-sync/cache read); only a genuinely-
21
18
  pending `await` — no resume, not cache-warm — discards its boundary and builds
22
19
  the pending branch fresh (see `awaitBlock`).
23
20
  */
24
21
  // @documentation plumbing
25
- export function hydrate(host: Element, build: (host: Element) => void): () => void {
22
+ export function hydrate(
23
+ host: Element,
24
+ build: (host: Element, props: unknown) => void,
25
+ props?: unknown,
26
+ ): () => void {
26
27
  const previous = RENDER.hydration
27
28
  RENDER.hydration = { next: new Map() }
28
- enterRenderPass()
29
- /* Same lexical scope establishment as `mount` — a hydrated component owns a scope
30
- too, adopting the model its build adopts. */
31
- const parentScope = CURRENT_SCOPE.current
32
- const lexical = createScope({}, parentScope, true, scopeLabel(host))
33
- CURRENT_SCOPE.current = lexical
34
29
  try {
35
- const stop = scope(() => {
36
- try {
37
- build(host)
38
- } finally {
39
- exitRenderPass()
40
- CURRENT_SCOPE.current = parentScope
41
- }
42
- })
30
+ /* Same shared mount core as `mount` (see `withScope`) a hydrated component owns a
31
+ scope too, adopting the model its build adopts — run with the claim cursor active. */
32
+ const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
43
33
  return () => {
44
34
  stop()
45
35
  lexical.dispose()
@@ -1,38 +1,29 @@
1
- import { createScope } from '../createScope.ts'
2
- import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
3
- import { enterRenderPass } from '../runtime/enterRenderPass.ts'
4
- import { exitRenderPass } from '../runtime/exitRenderPass.ts'
5
1
  import { scope } from '../runtime/scope.ts'
6
2
  import { scopeLabel } from './scopeLabel.ts'
3
+ import { withScope } from './withScope.ts'
7
4
 
8
5
  /*
9
- Mounts a component into `host`: runs `build(host)` under an ownership scope so
10
- every binding it creates is collected, and returns a disposer that stops all
11
- reactivity and clears the host. `build` appends its nodes to `host` (via the dom
12
- bindings below). This is the runtime entry the compiler's component output calls.
6
+ Mounts a top-level page/layout into `host` (the router's outlet/root element): runs
7
+ `build(host, props)` under an ownership scope so every binding it creates is
8
+ collected, and returns a disposer that stops all reactivity and clears the host.
9
+ `build` appends its nodes to `host` (via the dom bindings below). This is the runtime
10
+ entry the router calls; a NESTED child instead mounts as a marker range (see
11
+ `mountRange`), so it leaves no wrapper element.
13
12
 
14
13
  Brackets a render pass so the outermost mount resets the block-id counter and an
15
14
  inlined child component's mount continues it — keeping await/try ids aligned with
16
15
  the SSR stream (see `enterRenderPass`).
17
16
  */
18
17
  // @documentation plumbing
19
- export function mount(host: Element, build: (host: Element) => void): () => void {
20
- enterRenderPass()
21
- /* Establish this component's lexical scope, nested under the enclosing one, in
22
- `awaiting` mode so it adopts the model doc the build's first `doc()` creates.
23
- `scope()` and its capabilities resolve to it during the build; the previous
24
- scope is restored after (synchronous build, so the restore is exact). */
25
- const parentScope = CURRENT_SCOPE.current
26
- const lexical = createScope({}, parentScope, true, scopeLabel(host))
27
- CURRENT_SCOPE.current = lexical
28
- const stop = scope(() => {
29
- try {
30
- build(host)
31
- } finally {
32
- exitRenderPass()
33
- CURRENT_SCOPE.current = parentScope
34
- }
35
- })
18
+ export function mount(
19
+ host: Element,
20
+ build: (host: Element, props: unknown) => void,
21
+ props?: unknown,
22
+ ): () => void {
23
+ /* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
24
+ doc the build's first `doc()` creates) and render pass, run the build under it, and
25
+ restore the previous scope the shared mount core (see `withScope`). */
26
+ const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
36
27
  return () => {
37
28
  stop()
38
29
  lexical.dispose()
@@ -3,32 +3,45 @@ import { hotReloadEnabled } from '../runtime/hotReloadEnabled.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { registerHotInstance } from '../runtime/registerHotInstance.ts'
5
5
  import type { UiComponent } from '../runtime/types/UiComponent.ts'
6
+ import { mountRange } from './mountRange.ts'
6
7
 
7
8
  /*
8
- Mounts a child component into its wrapper host. Plain path (production, and dev
9
- without the hot bridge): run the factory exactly the bare call the compiler used
10
- to emit. Hot path (hotReloadEnabled and the factory carries a module id): keep the
11
- mount disposer and record the instance so an edit can dispose + re-run it in place,
12
- and file a cleanup with the mounting owner so the record and its scope leave
13
- together when the parent (or branch/row) tears down. The factory already mounts
14
- under its own scope (see `mount`), so the recorded disposer tears down just this
15
- instance.
9
+ Mounts a child component as a marker-bounded range at `before` in `parent` — no
10
+ wrapper element, so the child's root is a true direct child of the parent (see
11
+ `mountRange`). Plain path (production, and dev without the hot bridge): just run the
12
+ range mount with the component's own `build`. Hot path (hotReloadEnabled and the
13
+ factory carries a module id): keep the mount handle (its range markers + disposer)
14
+ and record the instance so an edit can re-fill the same range in place (see
15
+ `hotReplace`), and file a cleanup with the mounting owner so the record and its scope
16
+ leave together when the parent (or branch/row) tears down.
16
17
  */
17
18
  // @documentation plumbing
18
19
  export function mountChild(
19
- host: Element,
20
+ parent: Node,
20
21
  factory: UiComponent,
21
22
  props: Parameters<UiComponent>[1],
23
+ before: Node | null = null,
24
+ label?: string,
22
25
  ): void {
23
26
  const moduleId = factory.__abideId
24
27
  if (!hotReloadEnabled.current || moduleId === undefined) {
25
- factory(host, props)
28
+ mountRange(parent, factory.build, props, before, label)
26
29
  return
27
30
  }
28
- /* Capture the component's model alongside its disposer, so a later swap can carry
29
- its state across (see `hotReplace`). */
30
- const { dispose, model } = captureModelDoc(() => factory(host, props))
31
- const instance = { host, factory, props, dispose, model }
31
+ /* Capture the component's model alongside its mount handle, so a later swap can
32
+ carry its state across (see `hotReplace`). */
33
+ const { value: handle, model } = captureModelDoc(() =>
34
+ mountRange(parent, factory.build, props, before, label),
35
+ )
36
+ const instance = {
37
+ factory,
38
+ props,
39
+ label,
40
+ start: handle.start,
41
+ end: handle.end,
42
+ dispose: handle.dispose,
43
+ model,
44
+ }
32
45
  const remove = registerHotInstance(moduleId, instance)
33
46
  OWNER.current?.push(() => {
34
47
  instance.dispose()