@abide/abide 0.33.1 → 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 (160) hide show
  1. package/AGENTS.md +39 -34
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +21 -21
  4. package/package.json +5 -5
  5. package/src/abideResolverPlugin.ts +1 -1
  6. package/src/build.ts +1 -1
  7. package/src/compile.ts +1 -1
  8. package/src/lib/bundle/BundleMenu.ts +1 -1
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/bundle/BundleWindow.ts +1 -1
  11. package/src/lib/bundle/bundled.ts +1 -1
  12. package/src/lib/bundle/disconnected.abide +105 -75
  13. package/src/lib/bundle/onMenu.ts +1 -1
  14. package/src/lib/mcp/createMcpServer.ts +1 -1
  15. package/src/lib/server/AppModule.ts +1 -1
  16. package/src/lib/server/DELETE.ts +1 -1
  17. package/src/lib/server/GET.ts +1 -1
  18. package/src/lib/server/HEAD.ts +1 -1
  19. package/src/lib/server/PATCH.ts +1 -1
  20. package/src/lib/server/POST.ts +1 -1
  21. package/src/lib/server/PUT.ts +1 -1
  22. package/src/lib/server/agent.ts +1 -1
  23. package/src/lib/server/appDataDir.ts +1 -1
  24. package/src/lib/server/cookies.ts +1 -1
  25. package/src/lib/server/env.ts +1 -1
  26. package/src/lib/server/error.ts +1 -1
  27. package/src/lib/server/json.ts +1 -1
  28. package/src/lib/server/jsonl.ts +1 -1
  29. package/src/lib/server/prompts/definePrompt.ts +1 -1
  30. package/src/lib/server/prompts/renderPromptTemplate.ts +1 -1
  31. package/src/lib/server/reachable.ts +1 -1
  32. package/src/lib/server/redirect.ts +1 -1
  33. package/src/lib/server/request.ts +1 -1
  34. package/src/lib/server/rpc/defineVerb.ts +1 -1
  35. package/src/lib/server/runtime/buildCacheSnapshot.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +2 -2
  37. package/src/lib/server/runtime/types/InspectorContext.ts +2 -2
  38. package/src/lib/server/server.ts +1 -1
  39. package/src/lib/server/socket.ts +1 -1
  40. package/src/lib/server/sockets/defineSocket.ts +1 -1
  41. package/src/lib/server/sse.ts +1 -1
  42. package/src/lib/shared/HttpError.ts +1 -1
  43. package/src/lib/shared/cache.ts +18 -18
  44. package/src/lib/shared/createSubscriber.ts +1 -1
  45. package/src/lib/shared/health.ts +1 -1
  46. package/src/lib/shared/html.ts +1 -1
  47. package/src/lib/shared/isSubscribable.ts +1 -1
  48. package/src/lib/shared/log.ts +1 -1
  49. package/src/lib/shared/online.ts +1 -1
  50. package/src/lib/shared/page.ts +1 -1
  51. package/src/lib/shared/pending.ts +2 -2
  52. package/src/lib/shared/probeRegistries.ts +2 -2
  53. package/src/lib/shared/refreshing.ts +2 -2
  54. package/src/lib/shared/selectorMatcher.ts +8 -8
  55. package/src/lib/shared/selectorPrefix.ts +1 -1
  56. package/src/lib/shared/snippet.ts +1 -1
  57. package/src/lib/shared/toTagSet.ts +4 -0
  58. package/src/lib/shared/trace.ts +1 -1
  59. package/src/lib/shared/types/CacheEntry.ts +3 -3
  60. package/src/lib/shared/types/CacheOptions.ts +3 -3
  61. package/src/lib/shared/types/CacheSelector.ts +2 -2
  62. package/src/lib/shared/url.ts +1 -1
  63. package/src/lib/shared/withJsonSchema.ts +1 -1
  64. package/src/lib/test/assertAgentFrameConformance.ts +1 -1
  65. package/src/lib/test/createScriptedSurface.ts +1 -1
  66. package/src/lib/test/createTestApp.ts +1 -1
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -2
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +3 -4
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/analyzeComponent.ts +2 -1
  71. package/src/lib/ui/compile/compileComponent.ts +3 -3
  72. package/src/lib/ui/compile/compileModule.ts +30 -2
  73. package/src/lib/ui/compile/compileSSR.ts +5 -5
  74. package/src/lib/ui/compile/compileShadow.ts +54 -36
  75. package/src/lib/ui/compile/desugarSignals.ts +147 -18
  76. package/src/lib/ui/compile/generateBuild.ts +15 -28
  77. package/src/lib/ui/compile/generateSSR.ts +20 -13
  78. package/src/lib/ui/compile/isControlFlow.ts +18 -0
  79. package/src/lib/ui/compile/isTextLeaf.ts +15 -0
  80. package/src/lib/ui/compile/lowerContext.ts +45 -5
  81. package/src/lib/ui/compile/prepareNestedScript.ts +12 -8
  82. package/src/lib/ui/compile/renameSignalRefs.ts +21 -4
  83. package/src/lib/ui/compile/skeletonable.ts +1 -14
  84. package/src/lib/ui/compile/types/AnalyzedComponent.ts +1 -0
  85. package/src/lib/ui/computed.ts +32 -0
  86. package/src/lib/ui/createScope.ts +91 -0
  87. package/src/lib/ui/dom/anchorCursor.ts +1 -1
  88. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  89. package/src/lib/ui/dom/appendStatic.ts +1 -1
  90. package/src/lib/ui/dom/appendText.ts +1 -1
  91. package/src/lib/ui/dom/appendTextAt.ts +1 -1
  92. package/src/lib/ui/dom/applyResolved.ts +1 -1
  93. package/src/lib/ui/dom/attach.ts +7 -2
  94. package/src/lib/ui/dom/attr.ts +1 -1
  95. package/src/lib/ui/dom/awaitBlock.ts +1 -1
  96. package/src/lib/ui/dom/cloneStatic.ts +1 -1
  97. package/src/lib/ui/dom/each.ts +4 -3
  98. package/src/lib/ui/dom/eachAsync.ts +1 -1
  99. package/src/lib/ui/dom/hydrate.ts +20 -6
  100. package/src/lib/ui/dom/mount.ts +12 -1
  101. package/src/lib/ui/dom/mountChild.ts +6 -2
  102. package/src/lib/ui/dom/mountSlot.ts +1 -1
  103. package/src/lib/ui/dom/on.ts +10 -4
  104. package/src/lib/ui/dom/openMarker.ts +3 -3
  105. package/src/lib/ui/dom/skeleton.ts +13 -5
  106. package/src/lib/ui/dom/switchBlock.ts +1 -1
  107. package/src/lib/ui/dom/text.ts +1 -1
  108. package/src/lib/ui/dom/tryBlock.ts +1 -1
  109. package/src/lib/ui/dom/when.ts +1 -1
  110. package/src/lib/ui/effect.ts +7 -2
  111. package/src/lib/ui/enterScope.ts +18 -0
  112. package/src/lib/ui/exitScope.ts +8 -0
  113. package/src/lib/ui/history.ts +102 -0
  114. package/src/lib/ui/installHotBridge.ts +6 -8
  115. package/src/lib/ui/linked.ts +0 -1
  116. package/src/lib/ui/navigate.ts +1 -1
  117. package/src/lib/ui/outbox.ts +115 -0
  118. package/src/lib/ui/persist.ts +112 -0
  119. package/src/lib/ui/probeNavigation.ts +1 -1
  120. package/src/lib/ui/remoteProxy.ts +1 -1
  121. package/src/lib/ui/renderToStream.ts +1 -1
  122. package/src/lib/ui/router.ts +1 -1
  123. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +10 -0
  124. package/src/lib/ui/runtime/OUTLET_TAG.ts +1 -1
  125. package/src/lib/ui/runtime/PATCH_BUS.ts +28 -0
  126. package/src/lib/ui/runtime/RESUME.ts +1 -1
  127. package/src/lib/ui/runtime/captureModelDoc.ts +29 -0
  128. package/src/lib/ui/runtime/claimExpected.ts +26 -0
  129. package/src/lib/ui/runtime/createComputedNode.ts +1 -1
  130. package/src/lib/ui/runtime/createDoc.ts +73 -3
  131. package/src/lib/ui/runtime/enterRenderPass.ts +1 -1
  132. package/src/lib/ui/runtime/escapeKey.ts +13 -0
  133. package/src/lib/ui/runtime/exitRenderPass.ts +1 -1
  134. package/src/lib/ui/runtime/hotReplace.ts +17 -8
  135. package/src/lib/ui/runtime/inScope.ts +19 -0
  136. package/src/lib/ui/runtime/localStoragePersistence.ts +34 -0
  137. package/src/lib/ui/runtime/nextBlockId.ts +1 -1
  138. package/src/lib/ui/runtime/seedModelDoc.ts +26 -0
  139. package/src/lib/ui/runtime/types/{Derived.ts → Computed.ts} +1 -1
  140. package/src/lib/ui/runtime/types/Doc.ts +3 -0
  141. package/src/lib/ui/runtime/types/HotInstance.ts +6 -2
  142. package/src/lib/ui/runtime/types/PatchEvent.ts +16 -0
  143. package/src/lib/ui/runtime/unescapeKey.ts +12 -0
  144. package/src/lib/ui/runtime/walkPath.ts +3 -1
  145. package/src/lib/ui/scope.ts +19 -0
  146. package/src/lib/ui/socketProxy.ts +1 -1
  147. package/src/lib/ui/startClient.ts +1 -1
  148. package/src/lib/ui/state.ts +1 -2
  149. package/src/lib/ui/sync.ts +42 -0
  150. package/src/lib/ui/tail.ts +1 -1
  151. package/src/lib/ui/types/History.ts +14 -0
  152. package/src/lib/ui/types/Outbox.ts +14 -0
  153. package/src/lib/ui/types/PersistHandle.ts +11 -0
  154. package/src/lib/ui/types/PersistenceStore.ts +12 -0
  155. package/src/lib/ui/types/Scope.ts +57 -0
  156. package/src/lib/ui/types/SyncTransport.ts +13 -0
  157. package/src/preload.ts +1 -1
  158. package/src/lib/shared/toScopeSet.ts +0 -4
  159. package/src/lib/ui/derived.ts +0 -49
  160. package/src/lib/ui/doc.ts +0 -15
@@ -6,23 +6,25 @@ import type { CompiledShadow, ShadowMapping } from './types/CompiledShadow.ts'
6
6
  import type { TemplateNode } from './types/TemplateNode.ts'
7
7
 
8
8
  /*
9
- Framework callables the `.abide` loader injects into a component's scope, imported
10
- into the shadow with their real types so author calls (`effect`, `html`, …)
11
- type-check. `state`/`derived` are imported too as a fallback for stray uses; their
12
- declarations are rewritten to value types so the import is normally unused (fine
13
- the shadow program disables noUnusedLocals). `prop` has no runtime export, so it
14
- never appears here every `prop()` declaration is rewritten away. `$props` is the
15
- legacy untyped prop bag (pre-`prop()` sugar) made available raw.
9
+ Framework callables the `.abide` loader injects into a component's scope. `effect`,
10
+ `html`, `snippet`, and `scope` keep their real published types via imports so author
11
+ calls type-check `scope()` is the authored reactive surface (`scope().state(...)` /
12
+ `.computed(...)` / `.undo()` …), so it must resolve like any import. `state`/`linked`/
13
+ `computed` are ALSO declared ambiently as a fallback for the rare bare/nested use the
14
+ top-level rewrite doesn't project (their top-level declarations become value types, so
15
+ these are normally unused fine, the shadow disables noUnusedLocals). `prop` is sugar
16
+ too, but every `prop()` declaration is rewritten away, so it never appears here.
17
+ `$props` is the legacy untyped prop bag (pre-`prop()` sugar) made available raw.
16
18
  */
17
- const SHADOW_PREAMBLE = `import { state } from '${ABIDE_PACKAGE_NAME}/ui/state'
18
- import { linked } from '${ABIDE_PACKAGE_NAME}/ui/linked'
19
- import { derived } from '${ABIDE_PACKAGE_NAME}/ui/derived'
20
- import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
21
- import { doc } from '${ABIDE_PACKAGE_NAME}/ui/doc'
19
+ const SHADOW_PREAMBLE = `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
22
20
  import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
23
21
  import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'
22
+ import { scope } from '${ABIDE_PACKAGE_NAME}/ui/scope'
23
+ declare function state<T>(initial?: T, transform?: (next: T, previous: T) => T): { value: T }
24
+ declare function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): { value: T }
25
+ declare function computed<T>(compute: () => T): { readonly value: T }
24
26
  declare const $props: Record<string, (() => unknown) | undefined>
25
- void [state, linked, derived, effect, doc, html, snippet]
27
+ void [effect, html, snippet, scope]
26
28
  `
27
29
 
28
30
  /*
@@ -35,10 +37,10 @@ returned segments.
35
37
 
36
38
  The script's signal surface is rewritten to value types:
37
39
  let count = state(0) → let count = (0)
38
- const total = derived(() => …) → const total = (() => …)()
40
+ const total = computed(() => …) → const total = (() => …)()
39
41
  let title = prop<string>('t') → Props field + `let title = props['t']`
40
42
  Everything else (functions, plain consts, imports) is emitted verbatim, so
41
- expressions inside it (e.g. a derived's compute body) are checked and mapped too.
43
+ expressions inside it (e.g. a computed's compute body) are checked and mapped too.
42
44
  */
43
45
  export function compileShadow(source: string): CompiledShadow {
44
46
  const builder = createBuilder()
@@ -188,7 +190,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
188
190
  projects the leading script's scope: reactive declarations become their value
189
191
  type, every other statement stays verbatim. Returns the projected source text
190
192
  (unmapped — nested scripts carry no source offset yet), so a branch's markup
191
- reads a nested signal as its value type instead of the raw `State`/`Derived`. */
193
+ reads a nested signal as its value type instead of the raw `State`/`Computed`. */
192
194
  function projectNestedScript(code: string): string {
193
195
  const file = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
194
196
  const verbatim = (node: ts.Node): string => code.slice(node.getStart(file), node.getEnd())
@@ -205,7 +207,7 @@ function projectNestedScript(code: string): string {
205
207
  .join('\n')
206
208
  }
207
209
 
208
- /* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
210
+ /* The `state`/`computed`/`prop` declarations in a variable statement, or undefined
209
211
  if it isn't one declaring them (so the caller emits it verbatim). A statement
210
212
  mixing reactive and plain declarations is rare; treated as all-verbatim. */
211
213
  function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[] | undefined {
@@ -217,19 +219,21 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
217
219
  return reactive.length === declarations.length && reactive.length > 0 ? reactive : undefined
218
220
  }
219
221
 
220
- /* The callee name of a `NAME = state(...)` / `linked(...)` / `derived(...)` /
221
- `prop(...)` decl. */
222
+ /* The callee name of a `NAME = state(...)` / `linked(...)` / `computed(...)` /
223
+ `prop(...)` decl — bare or the explicit scope form (`scope().state(...)` / `c.state(...)`),
224
+ receiver-agnostic (the method name marks it reactive). */
222
225
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
223
226
  const initializer = declaration.initializer
224
- if (
225
- initializer !== undefined &&
226
- ts.isCallExpression(initializer) &&
227
- ts.isIdentifier(initializer.expression) &&
228
- REACTIVE_CALLEES.has(initializer.expression.text)
229
- ) {
230
- return initializer.expression.text
227
+ if (initializer === undefined || !ts.isCallExpression(initializer)) {
228
+ return undefined
231
229
  }
232
- return undefined
230
+ const callee = initializer.expression
231
+ const name = ts.isIdentifier(callee)
232
+ ? callee.text
233
+ : ts.isPropertyAccessExpression(callee)
234
+ ? callee.name.text
235
+ : undefined
236
+ return name !== undefined && REACTIVE_CALLEES.has(name) ? name : undefined
233
237
  }
234
238
 
235
239
  /* Builds one scope line for a reactive declaration, projecting it to its value
@@ -242,7 +246,10 @@ function scopeLineFor(
242
246
  ): ScopeLine {
243
247
  const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
244
248
  const call = declaration.initializer as ts.CallExpression
245
- const callee = (call.expression as ts.Identifier).text
249
+ /* Bare callee (`state`) or member callee (`scope().state` / `c.state`). */
250
+ const callee = ts.isPropertyAccessExpression(call.expression)
251
+ ? call.expression.name.text
252
+ : (call.expression as ts.Identifier).text
246
253
  if (callee === 'state') {
247
254
  /* state<T>(initial): T is the value type — carry it onto the `let` so an
248
255
  explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
@@ -257,23 +264,31 @@ function scopeLineFor(
257
264
  a guard like `x !== undefined` collapse to `never`). Unguarded access is
258
265
  then correctly flagged possibly-undefined; a guard narrows cleanly. */
259
266
  const valueType = annotation === '' ? ': unknown' : `${annotation} | undefined`
260
- return { text: `let ${name}!${valueType};`, segments: [] }
267
+ /* map the binding name (offset 4, past `let `) so hover/go-to resolve on it */
268
+ return { text: `let ${name}!${valueType};`, segments: [span(declaration.name, 4)] }
261
269
  }
262
270
  const prefix = `let ${name}${annotation} = (`
263
- return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
271
+ return {
272
+ text: `${prefix}${verbatim(init)});`,
273
+ segments: [span(declaration.name, 4), span(init, prefix.length)],
274
+ }
264
275
  }
265
- if (callee === 'derived' || callee === 'linked') {
266
- /* derived<T>(compute) / linked<T>(seed): T is the value type — the call's
276
+ if (callee === 'computed' || callee === 'linked') {
277
+ /* computed<T>(compute) / linked<T>(seed): T is the value type — the call's
267
278
  first arg is a thunk, so invoking it yields the value. Annotate so an
268
279
  explicit type argument isn't lost to inference of the thunk's return. */
269
280
  const typeNode = call.typeArguments?.[0]
270
281
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
271
282
  const fn = call.arguments[0]
272
283
  if (fn === undefined) {
273
- return { text: `const ${name} = undefined;`, segments: [] }
284
+ /* map the binding name (offset 6, past `const `) for hover/go-to */
285
+ return { text: `const ${name} = undefined;`, segments: [span(declaration.name, 6)] }
274
286
  }
275
287
  const prefix = `const ${name}${annotation} = (`
276
- return { text: `${prefix}${verbatim(fn)})();`, segments: [span(fn, prefix.length)] }
288
+ return {
289
+ text: `${prefix}${verbatim(fn)})();`,
290
+ segments: [span(declaration.name, 6), span(fn, prefix.length)],
291
+ }
277
292
  }
278
293
  /* prop<T>('key'): Props field `key[?]: T`, scope binding read from props. */
279
294
  const key = call.arguments[0]
@@ -282,7 +297,10 @@ function scopeLineFor(
282
297
  const typeText = typeNode === undefined ? 'unknown' : verbatim(typeNode)
283
298
  const optional = typeNode === undefined || /\bundefined\b/.test(typeText)
284
299
  props.push(` ${JSON.stringify(keyText)}${optional ? '?' : ''}: ${typeText}`)
285
- return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
300
+ return {
301
+ text: `let ${name} = props[${JSON.stringify(keyText)}];`,
302
+ segments: [span(declaration.name, 4)],
303
+ }
286
304
  }
287
305
 
288
306
  /* Emits a sibling list — each node standalone via `emitNode`. */
@@ -464,7 +482,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
464
482
  return
465
483
  case 'script':
466
484
  /* A scoped reactive `<script>`: value-project its reactive declarations to
467
- their value types (`derived(…)` → the computed value, `state(…)` → the
485
+ their value types (`computed(…)` → the computed value, `state(…)` → the
468
486
  initial) exactly as the leading script's scope lines are, so the branch's
469
487
  markup type-checks a nested signal as its value — matching the runtime,
470
488
  which derefs nested-script signals through the rest of the branch. Emitted
@@ -2,20 +2,46 @@ import ts from 'typescript'
2
2
  import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
3
3
  import { renameSignalRefs } from './renameSignalRefs.ts'
4
4
 
5
+ /* The reactive primitives that must be reached through a scope (`prop` is excluded — it
6
+ reads parent-passed props, not scope data, so it stays a bare call). A bare call to one
7
+ of these is a compile error: reactive state is owned by a scope and the surface must show
8
+ it (`scope().state(...)`), so a reader always sees the scope interaction. */
9
+ const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed'])
10
+
11
+ /* Throws if the script calls a scope primitive bare (`state(0)`) instead of through a scope
12
+ (`scope().state(0)` / `c.state(0)`). Walks all calls, so a stray one nested in a function
13
+ is caught too, not just top-level declarations. */
14
+ function assertScopedPrimitives(source: ts.SourceFile): void {
15
+ const visit = (node: ts.Node): void => {
16
+ if (
17
+ ts.isCallExpression(node) &&
18
+ ts.isIdentifier(node.expression) &&
19
+ SCOPE_PRIMITIVES.has(node.expression.text)
20
+ ) {
21
+ const name = node.expression.text
22
+ throw new Error(
23
+ `abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
24
+ )
25
+ }
26
+ ts.forEachChild(node, visit)
27
+ }
28
+ visit(source)
29
+ }
30
+
5
31
  /*
6
32
  Desugars the signal surface into the document form. A component's `<script>`
7
33
  declares reactive state as signals:
8
34
 
9
35
  let count = state(0)
10
36
  let items = state([])
11
- const total = derived(() => count + items.length)
37
+ const total = computed(() => count + items.length)
12
38
 
13
39
  This collects the binding names, turns each plain `state(initial)` declaration
14
40
  into an initialising assignment on a shared `model` document (in source order, so
15
41
  a later state can read an earlier one), keeps the rest, then renames every
16
42
  reference through `renameSignalRefs`. Plain `state` becomes `model.x` access that
17
43
  `lowerDocAccess` lowers to patches/reads — the document substrate's deep,
18
- fine-grained, serializable reactivity for free. `linked`, `derived`, and
44
+ fine-grained, serializable reactivity for free. `linked`, `computed`, and
19
45
  `state(initial, transform)` stay verbatim as runtime `.value` cells (referenced
20
46
  as `name.value`): they own no doc slot, so they reseed/recompute on resume rather
21
47
  than serialize. No reactive declarations → the script is returned untouched (the
@@ -25,10 +51,13 @@ export function desugarSignals(scriptBody: string): {
25
51
  code: string
26
52
  stateNames: Set<string>
27
53
  derivedNames: Set<string>
54
+ computedNames: Set<string>
28
55
  } {
29
56
  const source = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
57
+ assertScopedPrimitives(source)
30
58
  const stateNames = new Set<string>()
31
59
  const derivedNames = new Set<string>()
60
+ const computedNames = new Set<string>()
32
61
  for (const statement of source.statements) {
33
62
  if (!ts.isVariableStatement(statement)) {
34
63
  continue
@@ -41,39 +70,129 @@ export function desugarSignals(scriptBody: string): {
41
70
  if (isPlainStateSlot(declaration)) {
42
71
  /* Plain `state(initial)` → a serializable `model` doc slot. */
43
72
  stateNames.add(declaration.name.text)
73
+ } else if (isComputedSlot(declaration)) {
74
+ /* Read-only `computed(compute)` / `prop` → a computed `scope().derive`
75
+ doc slot, referenced as `name()` (its string-free reader): a function
76
+ of other paths, recomputed via the graph, never stored/serialized. */
77
+ computedNames.add(declaration.name.text)
44
78
  } else if (callee !== undefined && REACTIVE_CALLEES.has(callee)) {
45
- /* `.value` cells, referenced as `name.value`: `linked`, `derived`, a
46
- `prop` (reads like a read-only derived), and `state(initial, transform)`
47
- (the transform must run on writes, so it can't be a bare doc slot). */
79
+ /* `.value` cells: `linked` and `state(initial, transform)` they own
80
+ a local store, so they stay cells (`computed` is always the read-only
81
+ slot above; there is no writable-computed cell). */
48
82
  derivedNames.add(declaration.name.text)
49
83
  }
50
84
  }
51
85
  }
52
- if (stateNames.size === 0 && derivedNames.size === 0) {
53
- return { code: scriptBody, stateNames, derivedNames }
86
+ if (stateNames.size === 0 && derivedNames.size === 0 && computedNames.size === 0) {
87
+ return { code: scriptBody, stateNames, derivedNames, computedNames }
54
88
  }
55
89
 
56
90
  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
57
91
  const lines: string[] = []
58
92
  if (stateNames.size > 0) {
59
- lines.push('const model = doc({})')
93
+ lines.push('const model = scope()')
60
94
  }
61
95
  for (const statement of source.statements) {
62
96
  const stateAssignments = stateDeclarationAssignments(statement, printer, source)
97
+ const computedDeclarations = computedDeclarationLines(statement, printer, source)
63
98
  const propDeclarations = propDeclarationLines(statement, printer, source)
99
+ const cellDeclarations = cellDeclarationLines(statement, printer, source)
64
100
  if (stateAssignments !== undefined) {
65
101
  lines.push(...stateAssignments)
102
+ } else if (computedDeclarations !== undefined) {
103
+ lines.push(...computedDeclarations)
66
104
  } else if (propDeclarations !== undefined) {
67
105
  lines.push(...propDeclarations)
106
+ } else if (cellDeclarations !== undefined) {
107
+ lines.push(...cellDeclarations)
68
108
  } else {
69
109
  lines.push(printer.printNode(ts.EmitHint.Unspecified, statement, source))
70
110
  }
71
111
  }
72
112
  return {
73
- code: renameSignalRefs(lines.join('\n'), stateNames, derivedNames),
113
+ code: renameSignalRefs(lines.join('\n'), stateNames, derivedNames, computedNames),
74
114
  stateNames,
75
115
  derivedNames,
116
+ computedNames,
117
+ }
118
+ }
119
+
120
+ /* True for a read-only computed slot — `computed(compute)` with no write-through
121
+ `set`, or a `prop` (a read-only computed over the parent thunk). The writable
122
+ `computed(compute, set)` lens keeps a `.value` cell (handled by the caller). */
123
+ function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
124
+ const callee = signalCallee(declaration)
125
+ if (callee === 'prop') {
126
+ return true
127
+ }
128
+ const initializer = declaration.initializer
129
+ return (
130
+ callee === 'computed' &&
131
+ initializer !== undefined &&
132
+ ts.isCallExpression(initializer) &&
133
+ initializer.arguments.length === 1
134
+ )
135
+ }
136
+
137
+ /* A `.value`-cell signal declaration — a `linked` or a `state(initial, transform)` —
138
+ routed onto the scope: `linked(seed)` → `const x = scope().linked(seed)`. The cell
139
+ stays a standalone non-serializing cell (its refs stay `name.value`); the rewrite only
140
+ removes the bare runtime import so the sole reactive surface is `scope()`. */
141
+ function cellDeclarationLines(
142
+ statement: ts.Statement,
143
+ printer: ts.Printer,
144
+ source: ts.SourceFile,
145
+ ): string[] | undefined {
146
+ if (!ts.isVariableStatement(statement)) {
147
+ return undefined
148
+ }
149
+ const lines: string[] = []
150
+ for (const declaration of statement.declarationList.declarations) {
151
+ const callee = signalCallee(declaration)
152
+ if (
153
+ !ts.isIdentifier(declaration.name) ||
154
+ (callee !== 'linked' && callee !== 'state') ||
155
+ isPlainStateSlot(declaration)
156
+ ) {
157
+ return undefined
158
+ }
159
+ const args = (declaration.initializer as ts.CallExpression).arguments
160
+ .map((argument) => printer.printNode(ts.EmitHint.Unspecified, argument, source))
161
+ .join(', ')
162
+ lines.push(`const ${declaration.name.text} = scope().${callee}(${args})`)
163
+ }
164
+ return lines
165
+ }
166
+
167
+ /* `let total = computed(compute)` → `const total = scope().derive("total", compute)`
168
+ — a computed doc slot whose reader the references lower to `total()`. */
169
+ function computedDeclarationLines(
170
+ statement: ts.Statement,
171
+ printer: ts.Printer,
172
+ source: ts.SourceFile,
173
+ ): string[] | undefined {
174
+ if (!ts.isVariableStatement(statement)) {
175
+ return undefined
176
+ }
177
+ const lines: string[] = []
178
+ for (const declaration of statement.declarationList.declarations) {
179
+ if (
180
+ !ts.isIdentifier(declaration.name) ||
181
+ signalCallee(declaration) !== 'computed' ||
182
+ !isComputedSlot(declaration)
183
+ ) {
184
+ return undefined
185
+ }
186
+ const compute = (declaration.initializer as ts.CallExpression).arguments[0]
187
+ const computeText =
188
+ compute === undefined
189
+ ? '() => undefined'
190
+ : printer.printNode(ts.EmitHint.Unspecified, compute, source)
191
+ lines.push(
192
+ `const ${declaration.name.text} = scope().derive(${JSON.stringify(declaration.name.text)}, ${computeText})`,
193
+ )
76
194
  }
195
+ return lines
77
196
  }
78
197
 
79
198
  /* True for `state(initial, transform)` — the write-coercion transform forces a
@@ -94,20 +213,28 @@ function isPlainStateSlot(declaration: ts.VariableDeclaration): boolean {
94
213
  return signalCallee(declaration) === 'state' && !hasTransform(declaration)
95
214
  }
96
215
 
97
- /* The callee name of a `NAME = state(...)` / `derived(...)` declaration, else undefined. */
216
+ /* The callee name of a reactive declaration, else undefined. Recognises both the bare
217
+ form (`NAME = state(...)`) and the explicit scope form (`NAME = scope().state(...)` or
218
+ `NAME = c.state(...)` for any scope handle `c`) — receiver-agnostic: the METHOD name is
219
+ what marks the binding reactive. Since `scope()` is the ambient scope (one object per
220
+ level), the receiver is irrelevant to lowering — the slot keys off the binding name and
221
+ lands on the same `model`, so the explicit form lowers exactly like the bare one. */
98
222
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
99
223
  const initializer = declaration.initializer
100
- if (
101
- initializer !== undefined &&
102
- ts.isCallExpression(initializer) &&
103
- ts.isIdentifier(initializer.expression)
104
- ) {
105
- return initializer.expression.text
224
+ if (initializer === undefined || !ts.isCallExpression(initializer)) {
225
+ return undefined
226
+ }
227
+ const callee = initializer.expression
228
+ if (ts.isIdentifier(callee)) {
229
+ return callee.text
230
+ }
231
+ if (ts.isPropertyAccessExpression(callee)) {
232
+ return callee.name.text
106
233
  }
107
234
  return undefined
108
235
  }
109
236
 
110
- /* If `statement` declares `prop(...)` bindings, returns a reactive derived over
237
+ /* If `statement` declares `prop(...)` bindings, returns a reactive computed over
111
238
  the parent-supplied `$props` thunk for each; otherwise undefined. The optional
112
239
  call (`?.()`) tolerates an omitted prop. */
113
240
  function propDeclarationLines(
@@ -126,7 +253,9 @@ function propDeclarationLines(
126
253
  const key = (declaration.initializer as ts.CallExpression).arguments[0]
127
254
  const keyText =
128
255
  key === undefined ? "''" : printer.printNode(ts.EmitHint.Unspecified, key, source)
129
- lines.push(`const ${declaration.name.text} = derived(() => $props[${keyText}]?.())`)
256
+ lines.push(
257
+ `const ${declaration.name.text} = scope().derive(${JSON.stringify(declaration.name.text)}, () => $props[${keyText}]?.())`,
258
+ )
130
259
  }
131
260
  return lines
132
261
  }
@@ -3,6 +3,8 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
3
  import { bindListenEvent } from './bindListenEvent.ts'
4
4
  import { componentWrapperTag } from './componentWrapperTag.ts'
5
5
  import { groupBindParts } from './groupBindParts.ts'
6
+ import { isControlFlow } from './isControlFlow.ts'
7
+ import { isTextLeaf } from './isTextLeaf.ts'
6
8
  import { lowerContext } from './lowerContext.ts'
7
9
  import { scopeAttr } from './scopeAttr.ts'
8
10
  import { staticAttr } from './staticAttr.ts'
@@ -24,6 +26,7 @@ export function generateBuild(
24
26
  hostVar: string,
25
27
  stateNames: ReadonlySet<string>,
26
28
  derivedNames: ReadonlySet<string>,
29
+ computedNames: ReadonlySet<string>,
27
30
  isLayout = false,
28
31
  ): string {
29
32
  let counter = 0
@@ -34,7 +37,9 @@ export function generateBuild(
34
37
  expression: lowerExpression,
35
38
  statement: lowerStatement,
36
39
  withNestedScripts,
37
- } = lowerContext(stateNames, derivedNames)
40
+ bindRead,
41
+ bindWrite,
42
+ } = lowerContext(stateNames, derivedNames, computedNames)
38
43
 
39
44
  /* Emits the wiring for one non-static attribute against an already-obtained skeleton
40
45
  element var — reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
@@ -75,14 +80,15 @@ export function generateBuild(
75
80
  `on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
76
81
  )
77
82
  }
78
- /* Two-way: drive the property from the path, and write the path back on the
83
+ /* Two-way: drive the property from the bind target, and write it back on the
79
84
  property's native event (`input` for most fields, but `toggle` for
80
- `<details open>`, `change` for checked/select). The path is an lvalue, so
81
- the write lowers to an assignment. */
85
+ `<details open>`, `change` for checked/select). An lvalue target reads as
86
+ itself and writes by assignment; an accessor object (`{ get, set }`) reads via
87
+ `.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. */
82
88
  const event = bindListenEvent(attr.property, node.tag)
83
89
  return (
84
- `effect(() => { ${varName}.${attr.property} = ${lowerExpression(attr.code)}; });\n` +
85
- `on(${varName}, ${JSON.stringify(event)}, () => { ${lowerStatement(`${attr.code} = ${varName}.${attr.property}`)} });\n`
90
+ `effect(() => { ${varName}.${attr.property} = ${bindRead(attr.code)}; });\n` +
91
+ `on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
86
92
  )
87
93
  }
88
94
 
@@ -117,7 +123,7 @@ export function generateBuild(
117
123
  })
118
124
  .join('')
119
125
  }
120
- if (isControlFlowNode(node)) {
126
+ if (isControlFlow(node)) {
121
127
  /* A control-flow block at its position: an `<!--a-->` anchor in the clone, the
122
128
  block mounted at it. `anchorCursor` parks the hydrate cursor past the anchor
123
129
  and returns the create insertion reference; the block's parent is the located
@@ -172,10 +178,7 @@ export function generateBuild(
172
178
  /* A text-leaf (only text/style children) with reactive text binds marker-free via
173
179
  `generateChildren` on the located element; otherwise reactive text is interleaved
174
180
  and uses `<!--a-->` anchors during the child recursion below. */
175
- const isTextLeaf = node.children.every(
176
- (child) => child.kind === 'text' || child.kind === 'style',
177
- )
178
- const textLeafBind = hasReactiveText && isTextLeaf
181
+ const textLeafBind = hasReactiveText && isTextLeaf(node)
179
182
  let openTag = `<${node.tag}`
180
183
  let elVar = ''
181
184
  if (hasReactiveAttr || textLeafBind) {
@@ -496,11 +499,7 @@ export function generateBuild(
496
499
  (child) =>
497
500
  child.kind === 'element' ||
498
501
  child.kind === 'component' ||
499
- child.kind === 'if' ||
500
- child.kind === 'each' ||
501
- child.kind === 'await' ||
502
- child.kind === 'try' ||
503
- child.kind === 'switch' ||
502
+ isControlFlow(child) ||
504
503
  child.kind === 'snippet' ||
505
504
  (child.kind === 'text' && !isWhitespaceText(child)),
506
505
  )
@@ -603,18 +602,6 @@ export function generateBuild(
603
602
  return generateChildren(isLayout ? nodes.map(asOutlet) : nodes, hostVar)
604
603
  }
605
604
 
606
- /* A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton each mounts at
607
- an `<!--a-->` anchor cloned into its located parent at the block's position. */
608
- function isControlFlowNode(node: TemplateNode): boolean {
609
- return (
610
- node.kind === 'if' ||
611
- node.kind === 'each' ||
612
- node.kind === 'await' ||
613
- node.kind === 'switch' ||
614
- node.kind === 'try'
615
- )
616
- }
617
-
618
605
  /* A text node that is purely whitespace (no interpolation, only blank static
619
606
  parts). Both back-ends drop it, so it neither contributes markup nor breaks a
620
607
  static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
@@ -1,6 +1,8 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
2
  import { componentWrapperTag } from './componentWrapperTag.ts'
3
3
  import { groupBindParts } from './groupBindParts.ts'
4
+ import { isControlFlow } from './isControlFlow.ts'
5
+ import { isTextLeaf } from './isTextLeaf.ts'
4
6
  import { lowerContext } from './lowerContext.ts'
5
7
  import { scopeAttr } from './scopeAttr.ts'
6
8
  import { skeletonable } from './skeletonable.ts'
@@ -35,6 +37,7 @@ export function generateSSR(
35
37
  nodes: TemplateNode[],
36
38
  stateNames: ReadonlySet<string>,
37
39
  derivedNames: ReadonlySet<string>,
40
+ computedNames: ReadonlySet<string>,
38
41
  isLayout = false,
39
42
  ): string {
40
43
  /* Compile-time counter for unique temp var names (runtime block ids, child render
@@ -47,7 +50,8 @@ export function generateSSR(
47
50
  expression: lowerExpression,
48
51
  statement,
49
52
  withNestedScripts,
50
- } = lowerContext(stateNames, derivedNames)
53
+ bindRead,
54
+ } = lowerContext(stateNames, derivedNames, computedNames)
51
55
 
52
56
  /* A scoped-script body for SSR: the shared lowering, then strip effects
53
57
  (client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
@@ -98,6 +102,12 @@ export function generateSSR(
98
102
  const anchorMark = (target: string): string => (inSkeleton ? push(target, '<!--a-->') : '')
99
103
 
100
104
  function generate(node: TemplateNode, target: string): string {
105
+ /* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
106
+ context. Emit it ONCE here — one decision site gated by the shared `isControlFlow`,
107
+ mirroring the client's single `skeletonMarkup` control-flow branch — rather than
108
+ prefixing each block kind's own `anchorMark` call (six sites that had to stay in
109
+ lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
110
+ const anchor = isControlFlow(node) ? anchorMark(target) : ''
101
111
  if (node.kind === 'text') {
102
112
  return node.parts
103
113
  .map((part) => {
@@ -119,7 +129,7 @@ export function generateSSR(
119
129
  if (elseBranch !== undefined && elseBranch.kind === 'case') {
120
130
  code += ` else {\n${branchContent(elseBranch.children, target)}}`
121
131
  }
122
- return `${anchorMark(target)}${openRange(target)}${code}\n${closeRange(target)}`
132
+ return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
123
133
  }
124
134
  if (node.kind === 'switch') {
125
135
  const cases = node.children.filter(
@@ -137,7 +147,7 @@ export function generateSSR(
137
147
  if (fallback !== undefined) {
138
148
  code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
139
149
  }
140
- return `${anchorMark(target)}${openRange(target)}${code}}\n${closeRange(target)}`
150
+ return `${anchor}${openRange(target)}${code}}\n${closeRange(target)}`
141
151
  }
142
152
  if (node.kind === 'case') {
143
153
  return ''
@@ -165,15 +175,15 @@ export function generateSSR(
165
175
  skeleton the `<!--a-->` anchor still marks its position (the client mounts
166
176
  there); no range markers, since there are no server rows to claim. */
167
177
  if (node.async) {
168
- return anchorMark(target)
178
+ return anchor
169
179
  }
170
- return `${anchorMark(target)}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
180
+ return `${anchor}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
171
181
  }
172
182
  if (node.kind === 'await') {
173
- return `${anchorMark(target)}${generateAwait(node, target)}`
183
+ return `${anchor}${generateAwait(node, target)}`
174
184
  }
175
185
  if (node.kind === 'try') {
176
- return `${anchorMark(target)}${generateTry(node, target)}`
186
+ return `${anchor}${generateTry(node, target)}`
177
187
  }
178
188
  if (node.kind === 'branch') {
179
189
  return ''
@@ -239,9 +249,9 @@ export function generateSSR(
239
249
  } else if (attr.kind === 'bind' && attr.property === 'checked') {
240
250
  /* A boolean property — its mere presence means checked, so emit the
241
251
  attribute only when truthy (a string `checked="false"` still checks). */
242
- code += `${target}.push((${lowerExpression(attr.code)}) ? ' checked' : '');\n`
252
+ code += `${target}.push((${bindRead(attr.code)}) ? ' checked' : '');\n`
243
253
  } else if (attr.kind === 'bind') {
244
- code += `${target}.push(${JSON.stringify(` ${attr.property}="`)} + $esc(${lowerExpression(attr.code)}) + '"');\n`
254
+ code += `${target}.push(${JSON.stringify(` ${attr.property}="`)} + $esc(${bindRead(attr.code)}) + '"');\n`
245
255
  }
246
256
  }
247
257
  code += push(target, '>')
@@ -254,10 +264,7 @@ export function generateSSR(
254
264
  inSkeleton = true
255
265
  }
256
266
  const previousMark = markText
257
- const isTextLeaf = node.children.every(
258
- (child) => child.kind === 'text' || child.kind === 'style',
259
- )
260
- markText = inSkeleton && !isTextLeaf
267
+ markText = inSkeleton && !isTextLeaf(node)
261
268
  /* A `<script>` child scopes its bindings to this element's subtree. */
262
269
  code += withNestedScripts(node.children, () => generateInto(node.children, target))
263
270
  markText = previousMark
@@ -0,0 +1,18 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /*
4
+ A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton each mounts at an
5
+ `<!--a-->` anchor cloned into its located parent at the block's position (see `anchorCursor`),
6
+ so a block can sit ANYWHERE among static siblings. The shared classification under block
7
+ anchor placement: both back-ends — and `skeletonable` — consult this so they agree on which
8
+ nodes are anchor-positioned, the way `componentWrapperTag`/`isTextLeaf` already are.
9
+ */
10
+ export function isControlFlow(node: TemplateNode): boolean {
11
+ return (
12
+ node.kind === 'if' ||
13
+ node.kind === 'each' ||
14
+ node.kind === 'await' ||
15
+ node.kind === 'switch' ||
16
+ node.kind === 'try'
17
+ )
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /*
4
+ An element whose children are ONLY text and `<style>` — the common `<h1>{x}</h1>` shape.
5
+ The shared anchor-placement decision for reactive text: BOTH back-ends consult this so they
6
+ agree on where a text anchor goes. A text-leaf binds its reactive text marker-free on the
7
+ located element (`generateBuild`) / emits it without an `<!--a-->` prefix (`generateSSR`);
8
+ reactive text interleaved with element children is anchor-positioned instead. Computing it
9
+ once keeps the SSR string and the client skeleton from disagreeing about a `<!--a-->` — the
10
+ first slice of lifting the positional model out of the two parallel traversals, alongside
11
+ the already-shared `componentWrapperTag` and `skeletonable`.
12
+ */
13
+ export function isTextLeaf(node: Extract<TemplateNode, { kind: 'element' }>): boolean {
14
+ return node.children.every((child) => child.kind === 'text' || child.kind === 'style')
15
+ }