@abide/abide 0.33.0 → 0.34.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 (163) hide show
  1. package/AGENTS.md +39 -34
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +21 -21
  4. package/bin/abide.ts +6 -2
  5. package/package.json +5 -5
  6. package/src/abideResolverPlugin.ts +1 -1
  7. package/src/build.ts +1 -1
  8. package/src/compile.ts +1 -1
  9. package/src/devEntry.ts +12 -1
  10. package/src/lib/bundle/BundleMenu.ts +1 -1
  11. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  12. package/src/lib/bundle/BundleWindow.ts +1 -1
  13. package/src/lib/bundle/bundled.ts +1 -1
  14. package/src/lib/bundle/disconnected.abide +105 -75
  15. package/src/lib/bundle/exitWithParent.ts +7 -7
  16. package/src/lib/bundle/onMenu.ts +1 -1
  17. package/src/lib/mcp/createMcpServer.ts +1 -1
  18. package/src/lib/server/AppModule.ts +1 -1
  19. package/src/lib/server/DELETE.ts +1 -1
  20. package/src/lib/server/GET.ts +1 -1
  21. package/src/lib/server/HEAD.ts +1 -1
  22. package/src/lib/server/PATCH.ts +1 -1
  23. package/src/lib/server/POST.ts +1 -1
  24. package/src/lib/server/PUT.ts +1 -1
  25. package/src/lib/server/agent.ts +1 -1
  26. package/src/lib/server/appDataDir.ts +1 -1
  27. package/src/lib/server/cookies.ts +1 -1
  28. package/src/lib/server/env.ts +1 -1
  29. package/src/lib/server/error.ts +1 -1
  30. package/src/lib/server/json.ts +1 -1
  31. package/src/lib/server/jsonl.ts +1 -1
  32. package/src/lib/server/prompts/definePrompt.ts +1 -1
  33. package/src/lib/server/prompts/renderPromptTemplate.ts +1 -1
  34. package/src/lib/server/reachable.ts +1 -1
  35. package/src/lib/server/redirect.ts +1 -1
  36. package/src/lib/server/request.ts +1 -1
  37. package/src/lib/server/rpc/defineVerb.ts +1 -1
  38. package/src/lib/server/runtime/buildCacheSnapshot.ts +1 -1
  39. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +2 -2
  40. package/src/lib/server/runtime/types/InspectorContext.ts +2 -2
  41. package/src/lib/server/server.ts +1 -1
  42. package/src/lib/server/socket.ts +1 -1
  43. package/src/lib/server/sockets/defineSocket.ts +1 -1
  44. package/src/lib/server/sse.ts +1 -1
  45. package/src/lib/shared/HttpError.ts +1 -1
  46. package/src/lib/shared/cache.ts +18 -18
  47. package/src/lib/shared/createSubscriber.ts +1 -1
  48. package/src/lib/shared/health.ts +1 -1
  49. package/src/lib/shared/html.ts +1 -1
  50. package/src/lib/shared/isSubscribable.ts +1 -1
  51. package/src/lib/shared/log.ts +1 -1
  52. package/src/lib/shared/online.ts +1 -1
  53. package/src/lib/shared/page.ts +1 -1
  54. package/src/lib/shared/pending.ts +2 -2
  55. package/src/lib/shared/probeRegistries.ts +2 -2
  56. package/src/lib/shared/refreshing.ts +2 -2
  57. package/src/lib/shared/selectorMatcher.ts +8 -8
  58. package/src/lib/shared/selectorPrefix.ts +1 -1
  59. package/src/lib/shared/snippet.ts +1 -1
  60. package/src/lib/shared/toTagSet.ts +4 -0
  61. package/src/lib/shared/trace.ts +1 -1
  62. package/src/lib/shared/types/CacheEntry.ts +3 -3
  63. package/src/lib/shared/types/CacheOptions.ts +3 -3
  64. package/src/lib/shared/types/CacheSelector.ts +2 -2
  65. package/src/lib/shared/url.ts +1 -1
  66. package/src/lib/shared/withJsonSchema.ts +1 -1
  67. package/src/lib/test/assertAgentFrameConformance.ts +1 -1
  68. package/src/lib/test/createScriptedSurface.ts +1 -1
  69. package/src/lib/test/createTestApp.ts +1 -1
  70. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -2
  71. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +3 -4
  72. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  73. package/src/lib/ui/compile/analyzeComponent.ts +2 -1
  74. package/src/lib/ui/compile/compileComponent.ts +3 -3
  75. package/src/lib/ui/compile/compileModule.ts +30 -2
  76. package/src/lib/ui/compile/compileSSR.ts +5 -5
  77. package/src/lib/ui/compile/compileShadow.ts +54 -36
  78. package/src/lib/ui/compile/desugarSignals.ts +147 -18
  79. package/src/lib/ui/compile/generateBuild.ts +15 -28
  80. package/src/lib/ui/compile/generateSSR.ts +20 -13
  81. package/src/lib/ui/compile/isControlFlow.ts +18 -0
  82. package/src/lib/ui/compile/isTextLeaf.ts +15 -0
  83. package/src/lib/ui/compile/lowerContext.ts +45 -5
  84. package/src/lib/ui/compile/prepareNestedScript.ts +12 -8
  85. package/src/lib/ui/compile/renameSignalRefs.ts +21 -4
  86. package/src/lib/ui/compile/skeletonable.ts +1 -14
  87. package/src/lib/ui/compile/types/AnalyzedComponent.ts +1 -0
  88. package/src/lib/ui/computed.ts +32 -0
  89. package/src/lib/ui/createScope.ts +91 -0
  90. package/src/lib/ui/dom/anchorCursor.ts +1 -1
  91. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  92. package/src/lib/ui/dom/appendStatic.ts +1 -1
  93. package/src/lib/ui/dom/appendText.ts +1 -1
  94. package/src/lib/ui/dom/appendTextAt.ts +1 -1
  95. package/src/lib/ui/dom/applyResolved.ts +1 -1
  96. package/src/lib/ui/dom/attach.ts +7 -2
  97. package/src/lib/ui/dom/attr.ts +1 -1
  98. package/src/lib/ui/dom/awaitBlock.ts +1 -1
  99. package/src/lib/ui/dom/cloneStatic.ts +1 -1
  100. package/src/lib/ui/dom/each.ts +4 -3
  101. package/src/lib/ui/dom/eachAsync.ts +1 -1
  102. package/src/lib/ui/dom/hydrate.ts +20 -6
  103. package/src/lib/ui/dom/mount.ts +12 -1
  104. package/src/lib/ui/dom/mountChild.ts +6 -2
  105. package/src/lib/ui/dom/mountSlot.ts +1 -1
  106. package/src/lib/ui/dom/on.ts +10 -4
  107. package/src/lib/ui/dom/openMarker.ts +3 -3
  108. package/src/lib/ui/dom/skeleton.ts +13 -5
  109. package/src/lib/ui/dom/switchBlock.ts +1 -1
  110. package/src/lib/ui/dom/text.ts +1 -1
  111. package/src/lib/ui/dom/tryBlock.ts +1 -1
  112. package/src/lib/ui/dom/when.ts +1 -1
  113. package/src/lib/ui/effect.ts +7 -2
  114. package/src/lib/ui/enterScope.ts +18 -0
  115. package/src/lib/ui/exitScope.ts +8 -0
  116. package/src/lib/ui/history.ts +102 -0
  117. package/src/lib/ui/installHotBridge.ts +6 -8
  118. package/src/lib/ui/linked.ts +0 -1
  119. package/src/lib/ui/navigate.ts +1 -1
  120. package/src/lib/ui/outbox.ts +115 -0
  121. package/src/lib/ui/persist.ts +112 -0
  122. package/src/lib/ui/probeNavigation.ts +1 -1
  123. package/src/lib/ui/remoteProxy.ts +1 -1
  124. package/src/lib/ui/renderToStream.ts +1 -1
  125. package/src/lib/ui/router.ts +1 -1
  126. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +10 -0
  127. package/src/lib/ui/runtime/OUTLET_TAG.ts +1 -1
  128. package/src/lib/ui/runtime/PATCH_BUS.ts +28 -0
  129. package/src/lib/ui/runtime/RESUME.ts +1 -1
  130. package/src/lib/ui/runtime/captureModelDoc.ts +29 -0
  131. package/src/lib/ui/runtime/claimExpected.ts +26 -0
  132. package/src/lib/ui/runtime/createComputedNode.ts +1 -1
  133. package/src/lib/ui/runtime/createDoc.ts +73 -3
  134. package/src/lib/ui/runtime/enterRenderPass.ts +1 -1
  135. package/src/lib/ui/runtime/escapeKey.ts +13 -0
  136. package/src/lib/ui/runtime/exitRenderPass.ts +1 -1
  137. package/src/lib/ui/runtime/hotReplace.ts +17 -8
  138. package/src/lib/ui/runtime/inScope.ts +19 -0
  139. package/src/lib/ui/runtime/localStoragePersistence.ts +34 -0
  140. package/src/lib/ui/runtime/nextBlockId.ts +1 -1
  141. package/src/lib/ui/runtime/seedModelDoc.ts +26 -0
  142. package/src/lib/ui/runtime/types/{Derived.ts → Computed.ts} +1 -1
  143. package/src/lib/ui/runtime/types/Doc.ts +3 -0
  144. package/src/lib/ui/runtime/types/HotInstance.ts +6 -2
  145. package/src/lib/ui/runtime/types/PatchEvent.ts +16 -0
  146. package/src/lib/ui/runtime/unescapeKey.ts +12 -0
  147. package/src/lib/ui/runtime/walkPath.ts +3 -1
  148. package/src/lib/ui/scope.ts +19 -0
  149. package/src/lib/ui/socketProxy.ts +1 -1
  150. package/src/lib/ui/startClient.ts +1 -1
  151. package/src/lib/ui/state.ts +1 -2
  152. package/src/lib/ui/sync.ts +42 -0
  153. package/src/lib/ui/tail.ts +1 -1
  154. package/src/lib/ui/types/History.ts +14 -0
  155. package/src/lib/ui/types/Outbox.ts +14 -0
  156. package/src/lib/ui/types/PersistHandle.ts +11 -0
  157. package/src/lib/ui/types/PersistenceStore.ts +12 -0
  158. package/src/lib/ui/types/Scope.ts +57 -0
  159. package/src/lib/ui/types/SyncTransport.ts +13 -0
  160. package/src/preload.ts +1 -1
  161. package/src/lib/shared/toScopeSet.ts +0 -4
  162. package/src/lib/ui/derived.ts +0 -49
  163. package/src/lib/ui/doc.ts +0 -15
@@ -15,9 +15,13 @@ fix lands here once instead of in lockstep across `generateBuild`/`generateSSR`.
15
15
  SSR's effect-stripping stays a caller-side wrap (`stripEffects`), the one real
16
16
  asymmetry; the node-walk skeletons stay in each back-end.
17
17
  */
18
- export function lowerContext(stateNames: ReadonlySet<string>, derivedNames: ReadonlySet<string>) {
18
+ export function lowerContext(
19
+ stateNames: ReadonlySet<string>,
20
+ derivedNames: ReadonlySet<string>,
21
+ computedNames: ReadonlySet<string> = new Set(),
22
+ ) {
19
23
  /* Branch-scoped signal bindings (from nested `<script>`s) — they deref to
20
- `.value` like a `derived`. Pushed while a branch's script + markup compile,
24
+ `.value` like a `computed`. Pushed while a branch's script + markup compile,
21
25
  popped after, so they shadow only within that subtree. */
22
26
  const localDerived = new Set<string>()
23
27
  const derefScope = (): ReadonlySet<string> =>
@@ -28,16 +32,52 @@ export function lowerContext(stateNames: ReadonlySet<string>, derivedNames: Read
28
32
  expression, not a block of labeled statements, through both rewrite passes;
29
33
  the wrapper is then peeled back off. */
30
34
  function expression(code: string): string {
31
- const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope())
35
+ const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope(), computedNames)
32
36
  return unwrapParens(lowerDocAccess(renamed, 'model').trim().replace(/;$/, ''))
33
37
  }
34
38
 
35
39
  /* As above but keeps the trailing `;` for a statement/handler body. */
36
40
  function statement(code: string): string {
37
- const renamed = renameSignalRefs(code, stateNames, derefScope())
41
+ const renamed = renameSignalRefs(code, stateNames, derefScope(), computedNames)
38
42
  return lowerDocAccess(renamed, 'model').trim()
39
43
  }
40
44
 
45
+ /* A two-way bind target is either an LVALUE (`count`, `model.lines[i]`) — reads as
46
+ itself, writes by assignment — or an ACCESSOR object from `bind:value={{ get, set }}`
47
+ — reads via `.get()`, writes via `.set(v)`. The accessor is the only way to bind a
48
+ value whose write goes somewhere other than the read target (the replacement for the
49
+ old writable `computed(compute, set)` lens). A read-only `computed` is NOT an lvalue,
50
+ so binding one bare is a compile error pointing at the accessor form. */
51
+ const isAccessorBind = (code: string): boolean => code.trim().startsWith('{')
52
+
53
+ function guardWritableBind(code: string): void {
54
+ const name = code.trim()
55
+ if (computedNames.has(name)) {
56
+ throw new Error(
57
+ `bind: \`${name}\` is a read-only computed — bind a writable target, or an accessor: bind:value={{ get: () => ${name}, set: (next) => { /* write the source */ } }}`,
58
+ )
59
+ }
60
+ }
61
+
62
+ /* The expression a two-way bind reads its current value from. */
63
+ function bindRead(code: string): string {
64
+ if (isAccessorBind(code)) {
65
+ return `(${expression(code)}).get()`
66
+ }
67
+ guardWritableBind(code)
68
+ return expression(code)
69
+ }
70
+
71
+ /* The statement a two-way bind runs to write `valueExpr` (a raw runtime expression,
72
+ e.g. `el.value`) back into its target. */
73
+ function bindWrite(code: string, valueExpr: string): string {
74
+ if (isAccessorBind(code)) {
75
+ return `(${expression(code)}).set(${valueExpr});`
76
+ }
77
+ guardWritableBind(code)
78
+ return statement(`${code} = ${valueExpr}`)
79
+ }
80
+
41
81
  /* Adds any `<script>` children's binding names to the deref scope (so the script
42
82
  bodies and the branch's markup auto-deref them), runs `body` within that scope,
43
83
  then pops the names it added. Returns whatever `body` produces. */
@@ -60,5 +100,5 @@ export function lowerContext(stateNames: ReadonlySet<string>, derivedNames: Read
60
100
  return result
61
101
  }
62
102
 
63
- return { expression, statement, withNestedScripts }
103
+ return { expression, statement, withNestedScripts, bindRead, bindWrite }
64
104
  }
@@ -3,7 +3,7 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
3
3
 
4
4
  /*
5
5
  The signal binding names a `<script>` nested in a control-flow branch declares
6
- (`state`/`linked`/`derived`/`prop`). The back-end adds them to the deref scope so both the
6
+ (`state`/`linked`/`computed`/`prop`). The back-end adds them to the deref scope so both the
7
7
  script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
8
8
  signals (local to the branch's render, owned by its scope, re-seeded from the
9
9
  in-scope data each mount), unlike the top-level component script which desugars to
@@ -30,15 +30,19 @@ export function nestedBindingNames(code: string): Set<string> {
30
30
  return names
31
31
  }
32
32
 
33
- /* The callee name of a `NAME = state(...)` / `derived(...)` / `prop(...)` declaration. */
33
+ /* The callee name of a `NAME = state(...)` / `computed(...)` / `prop(...)` declaration
34
+ bare or the explicit scope form (`scope().state(...)` / `c.state(...)`), receiver-agnostic. */
34
35
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
35
36
  const initializer = declaration.initializer
36
- if (
37
- initializer !== undefined &&
38
- ts.isCallExpression(initializer) &&
39
- ts.isIdentifier(initializer.expression)
40
- ) {
41
- return initializer.expression.text
37
+ if (initializer === undefined || !ts.isCallExpression(initializer)) {
38
+ return undefined
39
+ }
40
+ const callee = initializer.expression
41
+ if (ts.isIdentifier(callee)) {
42
+ return callee.text
43
+ }
44
+ if (ts.isPropertyAccessExpression(callee)) {
45
+ return callee.name.text
42
46
  }
43
47
  return undefined
44
48
  }
@@ -3,7 +3,7 @@ import ts from 'typescript'
3
3
  /*
4
4
  Rewrites references to a component's signal bindings into the document form the
5
5
  rest of the pipeline understands: a `state` binding `count` becomes `model.count`
6
- (data access `lowerDocAccess` then lowers to a patch/read), and a `derived`
6
+ (data access `lowerDocAccess` then lowers to a patch/read), and a `computed`
7
7
  binding `total` becomes `total.value`. Only value-position identifiers are
8
8
  touched — declaration names, parameter names, and property names are collected
9
9
  into a skip set first, and object shorthand (`{ count }`) is expanded to
@@ -14,6 +14,7 @@ export function renameSignalRefs(
14
14
  code: string,
15
15
  stateNames: ReadonlySet<string>,
16
16
  derivedNames: ReadonlySet<string>,
17
+ computedNames: ReadonlySet<string> = new Set(),
17
18
  ): string {
18
19
  const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
19
20
 
@@ -47,13 +48,23 @@ export function renameSignalRefs(
47
48
  const visit = (node: ts.Node): ts.Node => {
48
49
  /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`. */
49
50
  if (ts.isShorthandPropertyAssignment(node)) {
50
- const replacement = referenceFor(node.name.text, stateNames, derivedNames)
51
+ const replacement = referenceFor(
52
+ node.name.text,
53
+ stateNames,
54
+ derivedNames,
55
+ computedNames,
56
+ )
51
57
  if (replacement !== undefined) {
52
58
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
53
59
  }
54
60
  }
55
61
  if (ts.isIdentifier(node) && !skip.has(node)) {
56
- const replacement = referenceFor(node.text, stateNames, derivedNames)
62
+ const replacement = referenceFor(
63
+ node.text,
64
+ stateNames,
65
+ derivedNames,
66
+ computedNames,
67
+ )
57
68
  if (replacement !== undefined) {
58
69
  return replacement
59
70
  }
@@ -69,15 +80,21 @@ export function renameSignalRefs(
69
80
  return output
70
81
  }
71
82
 
72
- /* `model.<name>` for a state binding, `<name>.value` for a derived one, else undefined. */
83
+ /* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
84
+ string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
85
+ (linked / lens / transform-state), else undefined. */
73
86
  function referenceFor(
74
87
  name: string,
75
88
  stateNames: ReadonlySet<string>,
76
89
  derivedNames: ReadonlySet<string>,
90
+ computedNames: ReadonlySet<string>,
77
91
  ): ts.Expression | undefined {
78
92
  if (stateNames.has(name)) {
79
93
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('model'), name)
80
94
  }
95
+ if (computedNames.has(name)) {
96
+ return ts.factory.createCallExpression(ts.factory.createIdentifier(name), undefined, [])
97
+ }
81
98
  if (derivedNames.has(name)) {
82
99
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
83
100
  }
@@ -1,19 +1,6 @@
1
+ import { isControlFlow } from './isControlFlow.ts'
1
2
  import type { TemplateNode } from './types/TemplateNode.ts'
2
3
 
3
- /* A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton it mounts at an
4
- `<!--a-->` anchor cloned into the located parent at the block's position (see
5
- `anchorCursor`), so a block can sit ANYWHERE among static siblings — no contiguity or
6
- prefix-shape constraint. */
7
- function isControlFlow(node: TemplateNode): boolean {
8
- return (
9
- node.kind === 'if' ||
10
- node.kind === 'each' ||
11
- node.kind === 'await' ||
12
- node.kind === 'switch' ||
13
- node.kind === 'try'
14
- )
15
- }
16
-
17
4
  /* Whether a subtree is skeleton STRUCTURE — anything the parser-backed clone can carry with
18
5
  its holes mounted in place: elements, `<style>`, text (static OR reactive), child
19
6
  components (their wrapper element is positioned in the skeleton), control-flow blocks and
@@ -13,6 +13,7 @@ export type AnalyzedComponent = {
13
13
  imports: string
14
14
  stateNames: Set<string>
15
15
  derivedNames: Set<string>
16
+ computedNames: Set<string>
16
17
  nodes: TemplateNode[]
17
18
  /* One entry per non-empty `<style>` in the template (in source order): the scope
18
19
  attribute its covered elements carry (annotated onto `nodes`) and the scoped
@@ -0,0 +1,32 @@
1
+ import { createComputedNode } from './runtime/createComputedNode.ts'
2
+ import { OWNER } from './runtime/OWNER.ts'
3
+ import { readNode } from './runtime/readNode.ts'
4
+ import type { Computed } from './runtime/types/Computed.ts'
5
+ import { unlinkDeps } from './runtime/unlinkDeps.ts'
6
+
7
+ /*
8
+ A reactive cell computed from other cells — the abide replacement for `$derived`.
9
+ Named `computed` (not `derived`) and ALWAYS read-only: unlike Svelte's reassignable
10
+ `$derived`, a computed is purely a function of its inputs, the way Vue/Angular
11
+ `computed` is. There is no local store and nothing to assign — a write to a computed
12
+ would be a write to its *sources*, which is expressed at the binding site instead
13
+ (`bind:value={{ get, set }}`), where the write actually originates. Lazy: it
14
+ recomputes on read only when a dependency changed, and never serializes (it is
15
+ re-derived from its inputs on resume). Read via `.value`.
16
+ */
17
+ export function computed<T>(compute: () => T): Computed<T> {
18
+ const node = createComputedNode(compute as () => unknown)
19
+ /* Tear down with the enclosing scope, the way an effect does. A computed only
20
+ unlinks from its sources when it re-runs (`runNode` re-tracking); one read
21
+ once and then abandoned never re-runs, so absent this it would sit in its
22
+ source signals' subscriber lists forever — re-marked dirty on every write to
23
+ them — for the source's lifetime. Outside a scope it owns its own life (no-op). */
24
+ if (OWNER.current !== undefined) {
25
+ OWNER.current.push(() => unlinkDeps(node))
26
+ }
27
+ return {
28
+ get value(): T {
29
+ return readNode(node) as T
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,91 @@
1
+ import { computed } from './computed.ts'
2
+ import { history } from './history.ts'
3
+ import { linked } from './linked.ts'
4
+ import { persist as persistDoc } from './persist.ts'
5
+ import { createDoc } from './runtime/createDoc.ts'
6
+ import type { Doc } from './runtime/types/Doc.ts'
7
+ import { state } from './state.ts'
8
+ import { sync } from './sync.ts'
9
+ import type { History } from './types/History.ts'
10
+ import type { PersistHandle } from './types/PersistHandle.ts'
11
+ import type { Scope } from './types/Scope.ts'
12
+
13
+ /* A process-stable counter id. The serialization-stable LEXICAL id (route +
14
+ component + tree position) that `persistent`/`sync` want across reloads/peers
15
+ is stamped by the compiler later; until then a scope's id is unique within a
16
+ run — enough for in-session undo and the bus, and as an explicit key's fallback. */
17
+ let nextId = 0
18
+
19
+ /*
20
+ Builds a lexical scope. Its data is a document — created eagerly from `initial`,
21
+ or (when `awaiting`) ADOPTED from the first `doc()` a component body creates under
22
+ it, so a scope can wrap the component's own model without changing the data
23
+ lowering. Data methods mirror `Doc` and delegate to that document; capabilities
24
+ are lazy (`undoable`/`persistent` attach `history`/`persist` to it on first call);
25
+ `child` nests; `dispose` tears the subtree down children-first.
26
+ */
27
+ export function createScope(
28
+ initial: unknown = {},
29
+ parent: Scope | undefined = undefined,
30
+ awaiting = false,
31
+ ): Scope {
32
+ /* Eager unless awaiting adoption; `data()` lazily mints an empty doc if a body
33
+ never created one (a stateless component that still reaches for its scope). */
34
+ let document: Doc | undefined = awaiting ? undefined : createDoc(initial)
35
+ const data = (): Doc => (document ??= createDoc({}))
36
+ const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
37
+ const children: Scope[] = []
38
+ let past: History | undefined
39
+ let persistence: PersistHandle | undefined
40
+ let unsync: (() => void) | undefined
41
+
42
+ const self: Scope = {
43
+ id,
44
+ parent,
45
+ read: (path) => data().read(path),
46
+ replace: (path, value) => data().replace(path, value),
47
+ add: (path, value) => data().add(path, value),
48
+ remove: (path) => data().remove(path),
49
+ apply: (patch) => data().apply(patch),
50
+ cell: (path) => data().cell(path),
51
+ derive: (path, compute) => data().derive(path, compute),
52
+ snapshot: () => data().snapshot(),
53
+ /* The `.value`-cell signal forms, namespaced under the scope — standalone
54
+ non-serializing cells (owned by the render scope), reached only here. */
55
+ state,
56
+ linked,
57
+ computed,
58
+ child: (childInitial = {}) => {
59
+ const created = createScope(childInitial, self)
60
+ children.push(created)
61
+ return created
62
+ },
63
+ root: () => (parent === undefined ? self : parent.root()),
64
+ record: (options) => {
65
+ past ??= history(data(), options)
66
+ },
67
+ persist: (key) => {
68
+ persistence ??= persistDoc(data(), key ?? id)
69
+ },
70
+ broadcast: (transport) => {
71
+ unsync ??= sync(data(), transport)
72
+ },
73
+ undo: () => past?.undo(),
74
+ redo: () => past?.redo(),
75
+ canUndo: () => past?.canUndo() ?? false,
76
+ canRedo: () => past?.canRedo() ?? false,
77
+ dispose: () => {
78
+ for (const created of children) {
79
+ created.dispose()
80
+ }
81
+ children.length = 0
82
+ past?.dispose()
83
+ past = undefined
84
+ persistence?.dispose()
85
+ persistence = undefined
86
+ unsync?.()
87
+ unsync = undefined
88
+ },
89
+ }
90
+ return self
91
+ }
@@ -13,7 +13,7 @@ the parent's claim cursor there, so the block claims its own server range in pla
13
13
  block ignores the returned reference on hydrate). `parentNode` is the located element the
14
14
  anchor was cloned into.
15
15
  */
16
- // @readme plumbing
16
+ // @documentation plumbing
17
17
  export function anchorCursor(anchor: Node): Node | null {
18
18
  const reference = anchor.nextSibling
19
19
  const hydration = RENDER.hydration
@@ -14,7 +14,7 @@ On hydrate the builder runs against the server-rendered nodes between the
14
14
  claim them in place. The cursor is advanced past the open marker before, and past
15
15
  the close marker after, so the markers themselves are skipped.
16
16
  */
17
- // @readme plumbing
17
+ // @documentation plumbing
18
18
  export function appendSnippet(parent: Node, read: () => unknown): void {
19
19
  const builder = snippetPayload(read())
20
20
  if (typeof builder !== 'function') {
@@ -7,7 +7,7 @@ mode), or claimed from the server-rendered text (hydrate mode). As with reactive
7
7
  text, a merged SSR text node is split at this literal's length so the next claim
8
8
  lines up; nothing is bound since the text never changes.
9
9
  */
10
- // @readme plumbing
10
+ // @documentation plumbing
11
11
  export function appendStatic(parent: Node, value: string, splitAlways = false): void {
12
12
  const hydration = RENDER.hydration
13
13
  if (hydration !== undefined) {
@@ -20,7 +20,7 @@ between `<!--abide:html-->`/`<!--/abide:html-->` markers are adopted (hydrate),
20
20
  a change re-parses and swaps. A binding is text or raw for its lifetime (decided by
21
21
  its first value), so plain text — the common case — stays a cheap single node.
22
22
  */
23
- // @readme plumbing
23
+ // @documentation plumbing
24
24
  export function appendText(parent: Node, read: () => unknown, splitAlways = false): void {
25
25
  /* A snippet call (`{row(args)}`) mounts its builder; a `html\`\`` value inserts
26
26
  raw markup; everything else is escaped text — decided by the first value. */
@@ -21,7 +21,7 @@ Hydrate delegates to `appendText` with the cursor temporarily pointed at the anc
21
21
  content (the server rendered `<!--a-->value`, so the value is the anchor's next sibling),
22
22
  reusing its text-split / snippet / raw-html claiming.
23
23
  */
24
- // @readme plumbing
24
+ // @documentation plumbing
25
25
  export function appendTextAt(anchor: Node, read: () => unknown): void {
26
26
  const parent = anchor.parentNode as Node
27
27
  if (RENDER.hydration !== undefined) {
@@ -9,7 +9,7 @@ the pending nodes between the markers, and inserts the resolved content in their
9
9
  place. The pending shell painted instantly; this swaps in each value as it
10
10
  arrives — completing the out-of-order streaming loop on the client.
11
11
  */
12
- // @readme plumbing
12
+ // @documentation plumbing
13
13
  export function applyResolved(root: Element, frame: string): void {
14
14
  const holder = document.createElement('div')
15
15
  holder.innerHTML = frame
@@ -1,3 +1,5 @@
1
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
2
+ import { inScope } from '../runtime/inScope.ts'
1
3
  import { OWNER } from '../runtime/OWNER.ts'
2
4
  import { toTeardown } from '../runtime/toTeardown.ts'
3
5
  import type { EffectResult } from '../runtime/types/EffectResult.ts'
@@ -11,10 +13,13 @@ the attachment sets up. Node-lifetime, not reactive — for param reactivity put
11
13
  async; its teardown then runs once the promise settles. Runtime target for an
12
14
  `attach={…}` binding.
13
15
  */
14
- // @readme plumbing
16
+ // @documentation plumbing
15
17
  export function attach(element: Element, attachment: (node: Element) => EffectResult): void {
18
+ /* The attachment body runs now (scope already current); pin the teardown, which
19
+ fires on dispose, so an ambient `scope()` in it resolves the component. */
20
+ const captured = CURRENT_SCOPE.current
16
21
  const teardown = toTeardown(attachment(element))
17
22
  if (teardown !== undefined && OWNER.current !== undefined) {
18
- OWNER.current.push(teardown)
23
+ OWNER.current.push(() => inScope(captured, teardown))
19
24
  }
20
25
  }
@@ -6,7 +6,7 @@ and false/null/undefined removes it (the standard present/absent semantics);
6
6
  anything else is stringified. One effect per bound attribute, so only the
7
7
  changed attribute touches the DOM.
8
8
  */
9
- // @readme plumbing
9
+ // @documentation plumbing
10
10
  export function attr(element: Element, name: string, read: () => unknown): void {
11
11
  effect(() => {
12
12
  const value = read()
@@ -25,7 +25,7 @@ Hydration adopts in place, by precedence:
25
25
  After the first (adopting) run, later invalidations swap content before an anchor
26
26
  parked just before the close marker.
27
27
  */
28
- // @readme plumbing
28
+ // @documentation plumbing
29
29
  export function awaitBlock(
30
30
  parent: Node,
31
31
  id: number,
@@ -17,7 +17,7 @@ claim cursor past the run's top-level nodes (nothing inside binds, so none need
17
17
  claiming individually). The node count comes from the parsed template, which has
18
18
  the same top-level node count as the server markup.
19
19
  */
20
- // @readme plumbing
20
+ // @documentation plumbing
21
21
  export function cloneStatic(parent: Node, html: string): void {
22
22
  /* When `parent` is a foreign element built imperatively, the run's markup has no
23
23
  foreign ancestor of its own, so a bare `<path>` would parse into the HTML
@@ -1,5 +1,6 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { claimChild } from '../runtime/claimChild.ts'
3
+ import { claimExpected } from '../runtime/claimExpected.ts'
3
4
  import { OWNER } from '../runtime/OWNER.ts'
4
5
  import { RENDER } from '../runtime/RENDER.ts'
5
6
  import { scope } from '../runtime/scope.ts'
@@ -26,7 +27,7 @@ On hydrate the SSR rows are already in place and in order: each row claims its
26
27
  markers and content where they sit (no reordering), the anchor parks after them,
27
28
  and the first reconcile is skipped.
28
29
  */
29
- // @readme plumbing
30
+ // @documentation plumbing
30
31
  export function each<T>(
31
32
  parent: Node,
32
33
  items: () => Iterable<T>,
@@ -44,10 +45,10 @@ export function each<T>(
44
45
  const buildRow = (item: T): EachRow => {
45
46
  const hydration = RENDER.hydration
46
47
  if (hydration !== undefined) {
47
- const start = claimChild(hydration, parent) as Node
48
+ const start = claimExpected(hydration, parent, 'each row start marker')
48
49
  hydration.next.set(parent, start.nextSibling)
49
50
  const dispose = scope(() => render(parent, item))
50
- const end = claimChild(hydration, parent) as Node
51
+ const end = claimExpected(hydration, parent, 'each row end marker')
51
52
  hydration.next.set(parent, end.nextSibling)
52
53
  return { start, end, dispose }
53
54
  }
@@ -21,7 +21,7 @@ On a mid-stream rejection the already-streamed rows stay and the `<template catc
21
21
  branch (`renderCatch`) renders after them; absent a catch branch the rejection
22
22
  surfaces (re-throws), mirroring `<template await>`.
23
23
  */
24
- // @readme plumbing
24
+ // @documentation plumbing
25
25
  export function eachAsync<T>(
26
26
  parent: Node,
27
27
  items: () => AsyncIterable<T>,
@@ -1,3 +1,5 @@
1
+ import { createScope } from '../createScope.ts'
2
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
1
3
  import { enterRenderPass } from '../runtime/enterRenderPass.ts'
2
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
3
5
  import { RENDER } from '../runtime/RENDER.ts'
@@ -11,24 +13,36 @@ and reactive effects to the server's markup in place (no re-render, preserved
11
13
  focus/scroll). Returns a disposer.
12
14
 
13
15
  Adopts the server DOM in place across the framework: static structure (elements
14
- + text + bindings), `if`/`else`, keyed `each`, `switch`, and child components
15
- (with slots) — they hydrate automatically because the wrapper is claimed while
16
- hydration is still active. The only block not yet adopted is `await` (it
17
- interacts with the streamed stream-swap); a component using `await` should `mount`.
16
+ + text + bindings), `if`/`else`, keyed `each`, `switch`, `try`, and child
17
+ components (with slots) — they hydrate automatically because the wrapper is
18
+ claimed while hydration is still active. `await` adopts too when it can resume
19
+ the value (a streamed `RESUME[id]` or a warm-sync/cache read); only a genuinely-
20
+ pending `await` — no resume, not cache-warm — discards its boundary and builds
21
+ the pending branch fresh (see `awaitBlock`).
18
22
  */
19
- // @readme plumbing
23
+ // @documentation plumbing
20
24
  export function hydrate(host: Element, build: (host: Element) => void): () => void {
21
25
  const previous = RENDER.hydration
22
26
  RENDER.hydration = { next: new Map() }
23
27
  enterRenderPass()
28
+ /* Same lexical scope establishment as `mount` — a hydrated component owns a scope
29
+ too, adopting the model its build adopts. */
30
+ const parentScope = CURRENT_SCOPE.current
31
+ const lexical = createScope({}, parentScope, true)
32
+ CURRENT_SCOPE.current = lexical
24
33
  try {
25
- return scope(() => {
34
+ const stop = scope(() => {
26
35
  try {
27
36
  build(host)
28
37
  } finally {
29
38
  exitRenderPass()
39
+ CURRENT_SCOPE.current = parentScope
30
40
  }
31
41
  })
42
+ return () => {
43
+ stop()
44
+ lexical.dispose()
45
+ }
32
46
  } finally {
33
47
  RENDER.hydration = previous
34
48
  }
@@ -1,3 +1,5 @@
1
+ import { createScope } from '../createScope.ts'
2
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
1
3
  import { enterRenderPass } from '../runtime/enterRenderPass.ts'
2
4
  import { exitRenderPass } from '../runtime/exitRenderPass.ts'
3
5
  import { scope } from '../runtime/scope.ts'
@@ -12,18 +14,27 @@ Brackets a render pass so the outermost mount resets the block-id counter and an
12
14
  inlined child component's mount continues it — keeping await/try ids aligned with
13
15
  the SSR stream (see `enterRenderPass`).
14
16
  */
15
- // @readme plumbing
17
+ // @documentation plumbing
16
18
  export function mount(host: Element, build: (host: Element) => void): () => void {
17
19
  enterRenderPass()
20
+ /* Establish this component's lexical scope, nested under the enclosing one, in
21
+ `awaiting` mode so it adopts the model doc the build's first `doc()` creates.
22
+ `scope()` and its capabilities resolve to it during the build; the previous
23
+ scope is restored after (synchronous build, so the restore is exact). */
24
+ const parentScope = CURRENT_SCOPE.current
25
+ const lexical = createScope({}, parentScope, true)
26
+ CURRENT_SCOPE.current = lexical
18
27
  const stop = scope(() => {
19
28
  try {
20
29
  build(host)
21
30
  } finally {
22
31
  exitRenderPass()
32
+ CURRENT_SCOPE.current = parentScope
23
33
  }
24
34
  })
25
35
  return () => {
26
36
  stop()
37
+ lexical.dispose()
27
38
  host.textContent = ''
28
39
  }
29
40
  }
@@ -1,3 +1,4 @@
1
+ import { captureModelDoc } from '../runtime/captureModelDoc.ts'
1
2
  import { hotReloadEnabled } from '../runtime/hotReloadEnabled.ts'
2
3
  import { OWNER } from '../runtime/OWNER.ts'
3
4
  import { registerHotInstance } from '../runtime/registerHotInstance.ts'
@@ -13,7 +14,7 @@ together when the parent (or branch/row) tears down. The factory already mounts
13
14
  under its own scope (see `mount`), so the recorded disposer tears down just this
14
15
  instance.
15
16
  */
16
- // @readme plumbing
17
+ // @documentation plumbing
17
18
  export function mountChild(
18
19
  host: Element,
19
20
  factory: UiComponent,
@@ -24,7 +25,10 @@ export function mountChild(
24
25
  factory(host, props)
25
26
  return
26
27
  }
27
- const instance = { host, factory, props, dispose: factory(host, props) }
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 }
28
32
  const remove = registerHotInstance(moduleId, instance)
29
33
  OWNER.current?.push(() => {
30
34
  instance.dispose()
@@ -14,7 +14,7 @@ claiming.
14
14
  Create fills the range before the end marker; hydrate adopts the server range in place
15
15
  (claiming from the parked cursor). Mirrors `when` without the conditional swap.
16
16
  */
17
- // @readme plumbing
17
+ // @documentation plumbing
18
18
  export function mountSlot(
19
19
  parent: Node,
20
20
  render: (host: Node) => void,
@@ -1,15 +1,21 @@
1
+ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
2
+ import { inScope } from '../runtime/inScope.ts'
1
3
  import { OWNER } from '../runtime/OWNER.ts'
2
4
 
3
5
  /*
4
6
  Attaches an event listener and registers its removal with the current ownership
5
7
  scope, so a component's listeners detach when it disposes. This is the runtime
6
8
  target for an `onclick={…}` binding; the handler body is where the compiler's
7
- lowered patches (`model.replace(...)`) run.
9
+ lowered patches (`model.replace(...)`) run. The handler is pinned to the scope it
10
+ was attached under, so an ambient `scope()` inside it (e.g. `scope().undo()`)
11
+ resolves the component, not whatever is current when the event fires.
8
12
  */
9
- // @readme plumbing
13
+ // @documentation plumbing
10
14
  export function on(element: Element, type: string, handler: EventListener): void {
11
- element.addEventListener(type, handler)
15
+ const captured = CURRENT_SCOPE.current
16
+ const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
17
+ element.addEventListener(type, wrapped)
12
18
  if (OWNER.current !== undefined) {
13
- OWNER.current.push(() => element.removeEventListener(type, handler))
19
+ OWNER.current.push(() => element.removeEventListener(type, wrapped))
14
20
  }
15
21
  }