@abide/abide 0.38.0 → 0.39.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 (82) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +10 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  21. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  22. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
  23. package/src/lib/ui/compile/asOutlet.ts +6 -5
  24. package/src/lib/ui/compile/compileModule.ts +18 -11
  25. package/src/lib/ui/compile/compileSSR.ts +32 -9
  26. package/src/lib/ui/compile/compileShadow.ts +11 -3
  27. package/src/lib/ui/compile/composeProps.ts +53 -0
  28. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  29. package/src/lib/ui/compile/generateBuild.ts +100 -61
  30. package/src/lib/ui/compile/generateSSR.ts +226 -73
  31. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  32. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  33. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  34. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  35. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  36. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  37. package/src/lib/ui/compile/skeletonable.ts +3 -2
  38. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  39. package/src/lib/ui/compile/staticAttr.ts +1 -1
  40. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  41. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  42. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  43. package/src/lib/ui/dom/commentData.ts +14 -0
  44. package/src/lib/ui/dom/disposeRange.ts +21 -0
  45. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  46. package/src/lib/ui/dom/fillRange.ts +30 -0
  47. package/src/lib/ui/dom/hydrate.ts +11 -21
  48. package/src/lib/ui/dom/mergeProps.ts +32 -0
  49. package/src/lib/ui/dom/mount.ts +16 -25
  50. package/src/lib/ui/dom/mountChild.ts +27 -14
  51. package/src/lib/ui/dom/mountRange.ts +45 -0
  52. package/src/lib/ui/dom/outlet.ts +62 -0
  53. package/src/lib/ui/dom/readCall.ts +27 -0
  54. package/src/lib/ui/dom/restProps.ts +32 -0
  55. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  56. package/src/lib/ui/dom/skeleton.ts +11 -30
  57. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  58. package/src/lib/ui/dom/spreadProps.ts +32 -0
  59. package/src/lib/ui/dom/withScope.ts +33 -0
  60. package/src/lib/ui/installHotBridge.ts +12 -0
  61. package/src/lib/ui/remoteProxy.ts +68 -36
  62. package/src/lib/ui/renderChain.ts +49 -39
  63. package/src/lib/ui/renderToStream.ts +69 -61
  64. package/src/lib/ui/resumeSeedScript.ts +17 -0
  65. package/src/lib/ui/router.ts +155 -85
  66. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  67. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  68. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  69. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  72. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  73. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  74. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  75. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  76. package/src/lib/ui/runtime/types/Route.ts +6 -5
  77. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  78. package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
  79. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  80. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  81. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
  82. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -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'
@@ -13,9 +15,9 @@ that decide `<!--a-->` anchor placement — and assigns each hole its `el`/`an`
13
15
  document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
14
16
 
15
17
  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
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
19
21
  document order — the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
20
22
  the realized DOM, so the compile-time numbers and the runtime positions line up.
21
23
 
@@ -26,8 +28,8 @@ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclos
26
28
  export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
27
29
  const inSkeleton = new WeakMap<TemplateNode, boolean>()
28
30
  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
+ /* 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). */
31
33
  const elIndex = new WeakMap<TemplateNode, number>()
32
34
  const anIndex = new WeakMap<object, number>()
33
35
 
@@ -45,21 +47,16 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
45
47
  markText.set(node, nodeMarkText)
46
48
 
47
49
  /* 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. */
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. */
50
53
  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
+ /* 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)) {
54
58
  anIndex.set(node, counter.an++)
55
59
  }
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
- }
63
60
  for (const child of childrenOf(node)) {
64
61
  visit(child, false, false, undefined)
65
62
  }
@@ -90,9 +87,11 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
90
87
  if (node.kind !== 'element') {
91
88
  return // script / style carry no skeleton children and no hole
92
89
  }
93
- if (node.tag === 'slot') {
94
- /* The slot outlet is an anchor hole in the enclosing skeleton; its children are
95
- the fallback a fresh context built by `mountSlot`. */
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. */
96
95
  if (counter !== undefined) {
97
96
  anIndex.set(node, counter.an++)
98
97
  }
@@ -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)
@@ -0,0 +1,27 @@
1
+ import type { TemplateAttr } from './types/TemplateAttr.ts'
2
+
3
+ /*
4
+ The explicitly-authored attribute names on a native element that also carries a
5
+ `{...spread}`. An explicit attribute wins over a spread key of the same name, so
6
+ both back-ends pass this set to skip those keys from the spread — keeping the
7
+ server string and the client DOM congruent (no duplicate attribute, and no
8
+ SSR-first-wins vs client-spread-wins divergence). Excluding by NAME (not by a
9
+ runtime present/absent check) means the two sides agree regardless of a dynamic
10
+ attribute's runtime value. Static and dynamic (`{expr}`) attrs contribute their
11
+ name, a `bind:` its property, an `on<event>` its `on…` form; `attach`/`spread`
12
+ carry no attribute name.
13
+ */
14
+ export function spreadExcludedNames(attrs: TemplateAttr[]): string[] {
15
+ return attrs.flatMap((attr) => {
16
+ if (attr.kind === 'static' || attr.kind === 'expression') {
17
+ return [attr.name]
18
+ }
19
+ if (attr.kind === 'bind') {
20
+ return [attr.property]
21
+ }
22
+ if (attr.kind === 'event') {
23
+ return [`on${attr.event}`]
24
+ }
25
+ return []
26
+ })
27
+ }
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from './escapeHtml.ts'
1
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
2
 
3
3
  /*
4
4
  Serializes one static attribute to its markup fragment, leading space included.
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from './escapeHtml.ts'
1
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
2
 
3
3
  /*
4
4
  Serializes one static text part to its markup: whitespace-only parts drop (both
@@ -6,7 +6,9 @@ reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
6
6
  `bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
7
7
  writable doc path read into the property and written back on input; `attach` is
8
8
  `attach={code}` where `code` evaluates to an attachment `(node) => teardown` run
9
- at build with node-lifetime teardown. `loc` is the
9
+ at build with node-lifetime teardown; `spread` is `{...code}` where `code`
10
+ evaluates to an object whose own keys each become a prop on a component or an
11
+ attribute on a native element. `loc` is the
10
12
  absolute offset of `code` in the original `.abide` source (see TextPart) —
11
13
  optional, set only when the parser tracks positions for the type-checking shadow.
12
14
  */
@@ -16,3 +18,4 @@ export type TemplateAttr =
16
18
  | { kind: 'event'; event: string; code: string; loc?: number }
17
19
  | { kind: 'bind'; property: string; code: string; loc?: number }
18
20
  | { kind: 'attach'; code: string; loc?: number }
21
+ | { kind: 'spread'; code: string; loc?: number }
@@ -61,7 +61,9 @@ export type TemplateNode =
61
61
  | {
62
62
  kind: 'component'
63
63
  name: string
64
- props: { name: string; code: string; loc?: number }[]
64
+ /* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
65
+ `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
66
+ props: { name: string; code: string; loc?: number; spread?: boolean }[]
65
67
  children: TemplateNode[]
66
68
  }
67
69
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
@@ -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
+ }
@@ -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()
@@ -0,0 +1,32 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+
3
+ /*
4
+ Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
5
+ value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
6
+ layer (`$children`) — into one prop bag, last layer wins per key (source order, like
7
+ JSX). A key resolves by scanning layers in reverse for the first that carries it, so
8
+ an explicit prop after a spread overrides it and vice-versa. Emitted only when a
9
+ component carries a spread; the plain object literal stays the path otherwise.
10
+ */
11
+ // @documentation plumbing
12
+ export function mergeProps(layers: Record<string | symbol, unknown>[]): UiProps {
13
+ return new Proxy(Object.create(null), {
14
+ get(_target, key) {
15
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
16
+ const layer = layers[index]
17
+ if (layer !== undefined && key in layer) {
18
+ return layer[key]
19
+ }
20
+ }
21
+ return undefined
22
+ },
23
+ has: (_target, key) => layers.some((layer) => key in layer),
24
+ /* Enumerable across every layer (deduped), so `restProps` can collect a
25
+ parent-spread bag's keys. */
26
+ ownKeys: () => [...new Set(layers.flatMap((layer) => Reflect.ownKeys(layer)))],
27
+ getOwnPropertyDescriptor: (_target, key) =>
28
+ layers.some((layer) => key in layer)
29
+ ? { enumerable: true, configurable: true }
30
+ : undefined,
31
+ }) as UiProps
32
+ }
@@ -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()
@@ -0,0 +1,45 @@
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 { openMarker } from './openMarker.ts'
7
+ import { withScope } from './withScope.ts'
8
+
9
+ /*
10
+ Mounts a nested child component as a marker-bounded range — the wrapper-free
11
+ replacement for the old `<abide-name display:contents>` host. A component positions
12
+ and hydrates exactly like a control-flow block: its content lives in a `[ … ]` range,
13
+ so the child's real root is a TRUE direct child of the parent and structural CSS
14
+ (`>`, `:first-child`, `space-x`, grid placement) reaches it with no indirection —
15
+ which `display:contents` could never give, since it hides the wrapper from layout but
16
+ not from the selector tree.
17
+
18
+ Create fills the range before the end marker (`fillRange`); hydrate claims the server
19
+ range in place (claim the start marker, build claims the content, claim the end
20
+ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the block's
21
+ `anchorCursor`) places the range among static siblings on create; hydrate ignores it
22
+ (the claim cursor drives placement). Returns the markers + a disposer so the hot path
23
+ can rebuild in place.
24
+ */
25
+ // @documentation plumbing
26
+ export function mountRange(
27
+ parent: Node,
28
+ build: (host: Node, props?: UiProps) => void,
29
+ props: UiProps | undefined,
30
+ before: Node | null = null,
31
+ label: string | undefined = undefined,
32
+ ): { start: Comment; end: Comment; dispose: () => void } {
33
+ const hydration = RENDER.hydration
34
+ const start = openMarker(parent, '[', before)
35
+ if (hydration === undefined) {
36
+ const end = openMarker(parent, ']', before)
37
+ return fillRange(start, end, build, props, label)
38
+ }
39
+ /* Hydrate: adopt the server range in place. Establish the child's lexical scope
40
+ and render pass (same as `fillRange`), build claiming the existing nodes, then
41
+ claim the end marker the build's content stops before. */
42
+ const scoped = withScope(label, () => scope(() => build(parent, props)))
43
+ const end = openMarker(parent, ']')
44
+ return { start, end, dispose: disposeRange(scoped, start, end) }
45
+ }
@@ -0,0 +1,62 @@
1
+ import { claimExpected } from '../runtime/claimExpected.ts'
2
+ import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
3
+ import { PENDING_OUTLET } from '../runtime/PENDING_OUTLET.ts'
4
+ import { RENDER } from '../runtime/RENDER.ts'
5
+ import { commentData } from './commentData.ts'
6
+
7
+ /*
8
+ A layout's `<slot/>` outlet: an empty `<!--abide:outlet-->`…`<!--/abide:outlet-->`
9
+ boundary the router fills with the next chain layer (`fillBoundary`). No wrapper
10
+ element, so the filled child lays out as a direct child of the slot's parent.
11
+
12
+ Create: insert the empty boundary at `before` (the anchor's cursor). Hydrate: claim the
13
+ open marker, then SKIP the server-rendered child content — the router re-claims it when
14
+ it fills the boundary — by advancing the parent's claim cursor past the MATCHING close
15
+ (depth-counting nested outlets), so the layout's own claims after the slot stay aligned.
16
+ Records the boundary in `PENDING_OUTLET` (and returns it) so the router learns where the
17
+ next chain layer mounts without scanning the DOM.
18
+ */
19
+ // @documentation plumbing
20
+ export function outlet(
21
+ parent: Node,
22
+ before: Node | null = null,
23
+ ): { open: Comment; close: Comment } {
24
+ const hydration = RENDER.hydration
25
+ if (hydration === undefined) {
26
+ const open = document.createComment(OUTLET_OPEN)
27
+ const close = document.createComment(OUTLET_CLOSE)
28
+ parent.insertBefore(open, before)
29
+ parent.insertBefore(close, before)
30
+ PENDING_OUTLET.current = { open, close }
31
+ return { open, close }
32
+ }
33
+ const open = claimExpected(hydration, parent, 'outlet open marker') as Comment
34
+ /* Skip to the matching close: depth-count outlet markers (nested child-layer slots
35
+ are balanced), so a layout wrapping another layout skips its WHOLE subtree. */
36
+ let depth = 1
37
+ let node: Node | null = open.nextSibling
38
+ while (node !== null) {
39
+ const data = commentData(node)
40
+ if (data === OUTLET_OPEN) {
41
+ depth += 1
42
+ } else if (data === OUTLET_CLOSE) {
43
+ depth -= 1
44
+ if (depth === 0) {
45
+ break
46
+ }
47
+ }
48
+ node = node.nextSibling
49
+ }
50
+ /* No matching close — the server stream ended inside the slot (a mid-render error
51
+ or truncation). Throw AT the divergence like `claimExpected`, rather than store a
52
+ null close that a later `clearBetween(open, null)` would over-clear to end-of-parent. */
53
+ if (node === null) {
54
+ throw new Error(
55
+ '[abide] hydration desync: outlet open marker has no matching close — the server DOM is truncated inside a layout slot.',
56
+ )
57
+ }
58
+ const close = node as Comment
59
+ hydration.next.set(parent, close.nextSibling)
60
+ PENDING_OUTLET.current = { open, close }
61
+ return { open, close }
62
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ Guarded method call on a reactive-document read. The doc-access lowering rewrites
3
+ `model.draft.trim()` into `model.read("draft").trim()`; when that read is nullish
4
+ the bare form throws the engine's opaque `undefined is not an object (evaluating
5
+ '…read("draft").trim()')`, naming only the desugared call. This wraps the
6
+ non-optional call so the throw names the authored scope path and member instead —
7
+ the source-mapped stack frame still resolves to the `.abide` line, so message and
8
+ location together read in authored terms. Both opaque engine errors are covered: a
9
+ nullish receiver (`undefined is not an object`) AND a present receiver whose member
10
+ is not callable (`… is not a function`, the misspelled/missing-method case).
11
+ Optional-chained calls are left bare: `?.` means skip-if-absent, not throw, so
12
+ guarding them would change semantics. `.apply(target, …)` preserves the receiver, so
13
+ the method sees the same `this` the bare `target.member(…)` would.
14
+ */
15
+ // @documentation plumbing
16
+ export function readCall(target: unknown, path: string, member: string, args: unknown[]): unknown {
17
+ if (target === undefined || target === null) {
18
+ throw new TypeError(`abide: cannot call .${member}() — scope value "${path}" is ${target}`)
19
+ }
20
+ const method = (target as Record<string, unknown>)[member]
21
+ if (typeof method !== 'function') {
22
+ throw new TypeError(
23
+ `abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
24
+ )
25
+ }
26
+ return (method as (...args: unknown[]) => unknown).apply(target, args)
27
+ }