@abide/abide 0.46.0 → 0.48.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 (194) hide show
  1. package/AGENTS.md +370 -320
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  27. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  28. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  29. package/src/lib/server/runtime/createServer.ts +101 -71
  30. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  31. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  33. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  34. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  35. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  37. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  38. package/src/lib/server/socket.ts +1 -1
  39. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  40. package/src/lib/server/sockets/defineSocket.ts +26 -7
  41. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  42. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  43. package/src/lib/server/sse.ts +2 -2
  44. package/src/lib/shared/DEFER.ts +8 -0
  45. package/src/lib/shared/activeCacheStore.ts +2 -2
  46. package/src/lib/shared/activePage.ts +2 -2
  47. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  48. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  49. package/src/lib/shared/basePath.ts +2 -2
  50. package/src/lib/shared/baseSlot.ts +6 -5
  51. package/src/lib/shared/bodyValueForKind.ts +1 -2
  52. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  53. package/src/lib/shared/cache.ts +522 -117
  54. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  55. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  56. package/src/lib/shared/cacheStores.ts +3 -3
  57. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  58. package/src/lib/shared/createRemoteFunction.ts +58 -9
  59. package/src/lib/shared/createResolverSlot.ts +24 -31
  60. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  61. package/src/lib/shared/decodeRefJson.ts +4 -2
  62. package/src/lib/shared/decodeResponse.ts +8 -6
  63. package/src/lib/shared/done.ts +14 -0
  64. package/src/lib/shared/encodeRefJson.ts +4 -2
  65. package/src/lib/shared/hydratingSlot.ts +12 -0
  66. package/src/lib/shared/isLayoutFile.ts +12 -0
  67. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  68. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  69. package/src/lib/shared/matchRoute.ts +175 -0
  70. package/src/lib/shared/normalizePathname.ts +11 -0
  71. package/src/lib/shared/pageSlot.ts +14 -5
  72. package/src/lib/shared/pageUrlForFile.ts +3 -3
  73. package/src/lib/shared/parseRouteSegments.ts +16 -7
  74. package/src/lib/shared/patch.ts +41 -0
  75. package/src/lib/shared/peek.ts +35 -0
  76. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  77. package/src/lib/shared/prepareRpcModule.ts +98 -16
  78. package/src/lib/shared/prepareSocketModule.ts +11 -15
  79. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  80. package/src/lib/shared/reachable.ts +102 -0
  81. package/src/lib/shared/refresh.ts +22 -0
  82. package/src/lib/shared/requestScopeSlot.ts +10 -7
  83. package/src/lib/shared/routeParamsShape.ts +9 -9
  84. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  85. package/src/lib/shared/selectorPrefix.ts +3 -10
  86. package/src/lib/shared/setOwnProperty.ts +19 -0
  87. package/src/lib/shared/sharedCacheStore.ts +14 -0
  88. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  89. package/src/lib/shared/snippet.ts +1 -1
  90. package/src/lib/shared/subscribableProbes.ts +111 -0
  91. package/src/lib/shared/tailProbeSlot.ts +8 -1
  92. package/src/lib/shared/types/CacheEntry.ts +9 -15
  93. package/src/lib/shared/types/CacheOptions.ts +23 -11
  94. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  95. package/src/lib/shared/types/CacheStats.ts +1 -1
  96. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  97. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  98. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  99. package/src/lib/shared/types/RpcError.ts +26 -0
  100. package/src/lib/shared/types/SmartReadOptions.ts +36 -0
  101. package/src/lib/shared/types/Socket.ts +54 -0
  102. package/src/lib/shared/types/SsrBootState.ts +27 -0
  103. package/src/lib/shared/types/SsrPayload.ts +21 -0
  104. package/src/lib/shared/types/Subscribable.ts +13 -13
  105. package/src/lib/shared/types/TailHooks.ts +2 -1
  106. package/src/lib/shared/url.ts +29 -14
  107. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  108. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  109. package/src/lib/test/createScriptedSurface.ts +1 -1
  110. package/src/lib/test/createTestApp.ts +8 -8
  111. package/src/lib/test/createTestSocketChannel.ts +3 -3
  112. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  113. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  114. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  115. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  116. package/src/lib/ui/compile/compileModule.ts +137 -11
  117. package/src/lib/ui/compile/compileShadow.ts +101 -84
  118. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  119. package/src/lib/ui/compile/generateBuild.ts +48 -16
  120. package/src/lib/ui/compile/generateSSR.ts +117 -14
  121. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  122. package/src/lib/ui/compile/lowerContext.ts +10 -4
  123. package/src/lib/ui/compile/lowerScript.ts +72 -5
  124. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  125. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  126. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  127. package/src/lib/ui/compile/signalCallee.ts +24 -0
  128. package/src/lib/ui/compile/stripEffects.ts +14 -11
  129. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  130. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  131. package/src/lib/ui/dom/appendStatic.ts +9 -1
  132. package/src/lib/ui/dom/appendText.ts +15 -3
  133. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  134. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  135. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  136. package/src/lib/ui/dom/each.ts +12 -1
  137. package/src/lib/ui/dom/hydrate.ts +10 -0
  138. package/src/lib/ui/dom/mountChild.ts +2 -5
  139. package/src/lib/ui/dom/mountRange.ts +0 -75
  140. package/src/lib/ui/effect.ts +4 -3
  141. package/src/lib/ui/installHotBridge.ts +4 -2
  142. package/src/lib/ui/remoteProxy.ts +18 -2
  143. package/src/lib/ui/renderToStream.ts +9 -13
  144. package/src/lib/ui/resumeSeedScript.ts +2 -2
  145. package/src/lib/ui/router.ts +21 -2
  146. package/src/lib/ui/runtime/RESUME.ts +0 -6
  147. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  148. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  149. package/src/lib/ui/scope.ts +6 -3
  150. package/src/lib/ui/seedBootState.ts +53 -0
  151. package/src/lib/ui/socketChannel.ts +2 -2
  152. package/src/lib/ui/socketProxy.ts +9 -3
  153. package/src/lib/ui/startClient.ts +17 -31
  154. package/src/lib/ui/state.ts +44 -13
  155. package/src/lib/ui/sync.ts +11 -5
  156. package/src/lib/ui/tryEncodeResume.ts +2 -5
  157. package/src/lib/ui/types/Scope.ts +14 -10
  158. package/src/lib/ui/watch.ts +140 -0
  159. package/src/serverEntry.ts +13 -11
  160. package/template/CLAUDE.md +1 -1
  161. package/template/package.json +1 -1
  162. package/template/src/server/rpc/getHello.ts +3 -3
  163. package/template/src/ui/pages/page.abide +2 -2
  164. package/template/test/app.test.ts +1 -1
  165. package/src/lib/server/reachable.ts +0 -45
  166. package/src/lib/server/sockets/types/Socket.ts +0 -23
  167. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  168. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  169. package/src/lib/shared/baseResolver.ts +0 -10
  170. package/src/lib/shared/cacheKeyOf.ts +0 -7
  171. package/src/lib/shared/cacheKeyStore.ts +0 -8
  172. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  173. package/src/lib/shared/globalCacheStore.ts +0 -15
  174. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  175. package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
  176. package/src/lib/shared/pageResolver.ts +0 -16
  177. package/src/lib/shared/recordCacheKey.ts +0 -6
  178. package/src/lib/shared/requestScopeResolver.ts +0 -12
  179. package/src/lib/shared/setBaseResolver.ts +0 -4
  180. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  181. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  182. package/src/lib/shared/setPageResolver.ts +0 -4
  183. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  184. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  185. package/src/lib/shared/types/TailOptions.ts +0 -10
  186. package/src/lib/ui/deferResume.ts +0 -36
  187. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  188. package/src/lib/ui/matchRoute.ts +0 -106
  189. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  190. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  191. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  192. package/src/lib/ui/tail.ts +0 -324
  193. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  194. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,66 +1,23 @@
1
1
  import ts from 'typescript'
2
- import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
2
+ import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
3
+ import { signalCallee } from './signalCallee.ts'
3
4
 
4
5
  const factory = ts.factory
5
6
 
6
- /* The reactive primitives that must be reached through a scope. A bare call to one of
7
- these is a compile error: a reactive primitive is owned by a scope and the surface must
8
- show it (`scope().state(...)`), so a reader always sees the scope interaction. `effect`
9
- is here tooa reaction is scope-owned (it tears down with the scope), so it joins the
10
- one surface; unlike the cells it stays a runtime call (`scope().effect(...)` passes
11
- through to the `effect` helper), not a doc slot. */
12
- const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed', 'effect'])
13
-
14
- /* The primitive names a top-level `const { state, computed } = scope()` destructure binds.
15
- Such a name is scope-bound — its bare call below is the destructured method, not a stray
16
- global — so it is exempt from the bare-primitive error. Only a destructure of a `scope()`
17
- call counts (receiver-agnostic on the callee name, matching signalCallee); an aliased
18
- binding (`{ state: s }`) is not recognised, so the canonical name must be kept. */
19
- function scopeDestructuredPrimitives(source: ts.SourceFile): Set<string> {
20
- const bound = new Set<string>()
21
- for (const statement of source.statements) {
22
- if (!ts.isVariableStatement(statement)) {
23
- continue
24
- }
25
- for (const declaration of statement.declarationList.declarations) {
26
- if (
27
- signalCallee(declaration) === 'scope' &&
28
- ts.isObjectBindingPattern(declaration.name)
29
- ) {
30
- for (const element of declaration.name.elements) {
31
- if (
32
- element.propertyName === undefined &&
33
- ts.isIdentifier(element.name) &&
34
- SCOPE_PRIMITIVES.has(element.name.text)
35
- ) {
36
- bound.add(element.name.text)
37
- }
38
- }
39
- }
40
- }
41
- }
42
- return bound
43
- }
44
-
45
- /* Throws on a bare scope primitive (`state(0)` instead of `scope().state(0)`) or on the
46
- removed `prop(...)` reader — props are now read by destructuring `props()`. Walks all
47
- calls, so a stray one nested in a function is caught too, not just top-level declarations.
48
- A primitive destructured from `scope()` at the top is scope-bound and exempt. */
49
- function assertScopedPrimitives(source: ts.SourceFile): void {
50
- const scopeBound = scopeDestructuredPrimitives(source)
7
+ /* Throws on the removed `prop(...)` reader props are now read by destructuring `props()`.
8
+ Walks all calls, so a stray one nested in a function is caught too. Bare reactive
9
+ primitives (`state(0)`) are the SURFACE now (recognised by import binding + lowered), so
10
+ they no longer throw only the withdrawn `prop` reader does. */
11
+ function assertNoRemovedReaders(source: ts.SourceFile): void {
51
12
  const visit = (node: ts.Node): void => {
52
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
53
- const name = node.expression.text
54
- if (SCOPE_PRIMITIVES.has(name) && !scopeBound.has(name)) {
55
- throw new Error(
56
- `abide: bare \`${name}(...)\` is not allowed — a reactive primitive lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
57
- )
58
- }
59
- if (name === 'prop') {
60
- throw new Error(
61
- 'abide: `prop(...)` has been removed — read props by destructuring `props()`, e.g. `const { name } = props()` (with a default: `const { name = fallback } = props()`).',
62
- )
63
- }
13
+ if (
14
+ ts.isCallExpression(node) &&
15
+ ts.isIdentifier(node.expression) &&
16
+ node.expression.text === 'prop'
17
+ ) {
18
+ throw new Error(
19
+ 'abide: `prop(...)` has been removed — read props by destructuring `props()`, e.g. `const { name } = props()` (with a default: `const { name = fallback } = props()`).',
20
+ )
64
21
  }
65
22
  ts.forEachChild(node, visit)
66
23
  }
@@ -93,7 +50,11 @@ export function desugarSignals(source: ts.SourceFile): {
93
50
  derivedNames: Set<string>
94
51
  computedNames: Set<string>
95
52
  } {
96
- assertScopedPrimitives(source)
53
+ assertNoRemovedReaders(source)
54
+ /* The file's reactive import bindings — each local (alias-safe) mapped to its
55
+ canonical primitive. The single recognition authority: every callee below resolves
56
+ against these import bindings and nothing else. */
57
+ const bindings = reactiveImportBindings(source)
97
58
  const stateNames = new Set<string>()
98
59
  const derivedNames = new Set<string>()
99
60
  const computedNames = new Set<string>()
@@ -105,7 +66,7 @@ export function desugarSignals(source: ts.SourceFile): {
105
66
  continue
106
67
  }
107
68
  for (const declaration of statement.declarationList.declarations) {
108
- const callee = signalCallee(declaration)
69
+ const callee = signalCallee(declaration, bindings)
109
70
  if (callee === 'props') {
110
71
  /* `const {…, ...rest} = props()` — each named binding is a read-only computed
111
72
  over the parent thunk (read as `name()`); a `...rest` binding stays a plain
@@ -124,15 +85,15 @@ export function desugarSignals(source: ts.SourceFile): {
124
85
  if (!ts.isIdentifier(declaration.name)) {
125
86
  continue
126
87
  }
127
- if (isPlainStateSlot(declaration)) {
88
+ if (isPlainStateSlot(declaration, bindings)) {
128
89
  /* Plain `state(initial)` → a serializable `model` doc slot. */
129
90
  stateNames.add(declaration.name.text)
130
- } else if (isComputedSlot(declaration)) {
91
+ } else if (isComputedSlot(declaration, bindings)) {
131
92
  /* Read-only `computed(compute)` → a computed `scope().derive` doc slot,
132
93
  referenced as `name()` (its string-free reader): a function of other
133
94
  paths, recomputed via the graph, never stored/serialized. */
134
95
  computedNames.add(declaration.name.text)
135
- } else if (callee !== undefined && REACTIVE_CALLEES.has(callee)) {
96
+ } else if (callee === 'linked' || callee === 'state') {
136
97
  /* `.value` cells: `linked` and `state(initial, transform)` — they own
137
98
  a local store, so they stay cells (`computed` is always the read-only
138
99
  slot above; there is no writable-computed cell). */
@@ -163,7 +124,7 @@ export function desugarSignals(source: ts.SourceFile): {
163
124
  )
164
125
  }
165
126
  for (const statement of root.statements) {
166
- statements.push(...loweredStatement(statement))
127
+ statements.push(...loweredStatement(statement, bindings))
167
128
  }
168
129
  return factory.updateSourceFile(root, statements)
169
130
  }
@@ -175,13 +136,65 @@ export function desugarSignals(source: ts.SourceFile): {
175
136
  assignments, computed → `scope().derive` consts, props → derive consts (+ restProps),
176
137
  cells → `scope().<callee>(...)` consts; anything else passes through verbatim. The
177
138
  per-statement dispatch mirrors the name-collection pass. */
178
- function loweredStatement(statement: ts.Statement): ts.Statement[] {
139
+ function loweredStatement(
140
+ statement: ts.Statement,
141
+ bindings: ReactiveImportBindings,
142
+ ): ts.Statement[] {
143
+ rejectMixedDeclaration(statement, bindings)
179
144
  return (
180
- stateAssignmentStatements(statement) ??
181
- computedStatements(statement) ??
182
- propsStatements(statement) ??
183
- cellStatements(statement) ?? [statement]
145
+ stateAssignmentStatements(statement, bindings) ??
146
+ computedStatements(statement, bindings) ??
147
+ propsStatements(statement, bindings) ??
148
+ cellStatements(statement, bindings) ?? [statement]
149
+ )
150
+ }
151
+
152
+ /* Each lowering function above is all-or-nothing per VariableStatement: it returns
153
+ undefined the moment one declaration doesn't match its kind, so the whole statement
154
+ passes through verbatim. A statement mixing a reactive declaration with a differently-
155
+ lowered one (`let count = state(0), step = 5`) therefore ships literal — but the
156
+ collection pass already registered `count`, so references lower to `$$model.read("count")`
157
+ with no `$$model.count = 0` seed (silent undefined). Reject it with a legible error so
158
+ the author splits the declarations; a checker-free compiler must fail loud, not
159
+ mis-lower silently. Same-kind lists (`let a = state(0), b = state(1)`) are fine. */
160
+ function rejectMixedDeclaration(statement: ts.Statement, bindings: ReactiveImportBindings): void {
161
+ if (!ts.isVariableStatement(statement)) {
162
+ return
163
+ }
164
+ const kinds = new Set(
165
+ statement.declarationList.declarations.map((declaration) =>
166
+ loweringKind(declaration, bindings),
167
+ ),
184
168
  )
169
+ /* Any two distinct kinds break: each lowering function bails on the non-matching
170
+ declaration, so the statement ships verbatim. A uniform list (size 1) is fine. */
171
+ if (kinds.size > 1) {
172
+ throw new Error(
173
+ 'abide: declare each reactive signal in its own statement — a `let`/`const` that mixes a reactive declaration (state/linked/computed/props) with another kind cannot be lowered as one statement.',
174
+ )
175
+ }
176
+ }
177
+
178
+ /* The lowering bucket a single declaration falls into — mirrors the name-collection
179
+ dispatch so `rejectMixedDeclaration` can detect a statement spanning more than one. */
180
+ function loweringKind(
181
+ declaration: ts.VariableDeclaration,
182
+ bindings: ReactiveImportBindings,
183
+ ): 'state' | 'computed' | 'props' | 'cell' | 'plain' {
184
+ const callee = signalCallee(declaration, bindings)
185
+ if (callee === 'props') {
186
+ return 'props'
187
+ }
188
+ if (isPlainStateSlot(declaration, bindings)) {
189
+ return 'state'
190
+ }
191
+ if (isComputedSlot(declaration, bindings)) {
192
+ return 'computed'
193
+ }
194
+ if (callee === 'linked' || callee === 'state') {
195
+ return 'cell'
196
+ }
197
+ return 'plain'
185
198
  }
186
199
 
187
200
  /* `const NAME = <init>` — a fresh const declaration reusing the original initializer node. */
@@ -210,10 +223,13 @@ function scopeMethodCall(method: string, args: readonly ts.Expression[]): ts.Cal
210
223
  /* True for a read-only computed slot — `computed(compute)` with no write-through
211
224
  `set`. The writable `computed(compute, set)` lens keeps a `.value` cell (handled by
212
225
  the caller). */
213
- function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
226
+ function isComputedSlot(
227
+ declaration: ts.VariableDeclaration,
228
+ bindings: ReactiveImportBindings,
229
+ ): boolean {
214
230
  const initializer = declaration.initializer
215
231
  return (
216
- signalCallee(declaration) === 'computed' &&
232
+ signalCallee(declaration, bindings) === 'computed' &&
217
233
  initializer !== undefined &&
218
234
  ts.isCallExpression(initializer) &&
219
235
  initializer.arguments.length === 1
@@ -224,17 +240,20 @@ function isComputedSlot(declaration: ts.VariableDeclaration): boolean {
224
240
  routed onto the scope: `linked(seed)` → `const x = scope().linked(seed)`. The cell
225
241
  stays a standalone non-serializing cell (its refs stay `name.value`); the rewrite only
226
242
  removes the bare runtime import so the sole reactive surface is `scope()`. */
227
- function cellStatements(statement: ts.Statement): ts.Statement[] | undefined {
243
+ function cellStatements(
244
+ statement: ts.Statement,
245
+ bindings: ReactiveImportBindings,
246
+ ): ts.Statement[] | undefined {
228
247
  if (!ts.isVariableStatement(statement)) {
229
248
  return undefined
230
249
  }
231
250
  const statements: ts.Statement[] = []
232
251
  for (const declaration of statement.declarationList.declarations) {
233
- const callee = signalCallee(declaration)
252
+ const callee = signalCallee(declaration, bindings)
234
253
  if (
235
254
  !ts.isIdentifier(declaration.name) ||
236
255
  (callee !== 'linked' && callee !== 'state') ||
237
- isPlainStateSlot(declaration)
256
+ isPlainStateSlot(declaration, bindings)
238
257
  ) {
239
258
  return undefined
240
259
  }
@@ -246,7 +265,10 @@ function cellStatements(statement: ts.Statement): ts.Statement[] | undefined {
246
265
 
247
266
  /* `let total = computed(compute)` → `const total = scope().derive("total", compute)`
248
267
  — a computed doc slot whose reader the references lower to `total()`. */
249
- function computedStatements(statement: ts.Statement): ts.Statement[] | undefined {
268
+ function computedStatements(
269
+ statement: ts.Statement,
270
+ bindings: ReactiveImportBindings,
271
+ ): ts.Statement[] | undefined {
250
272
  if (!ts.isVariableStatement(statement)) {
251
273
  return undefined
252
274
  }
@@ -254,8 +276,8 @@ function computedStatements(statement: ts.Statement): ts.Statement[] | undefined
254
276
  for (const declaration of statement.declarationList.declarations) {
255
277
  if (
256
278
  !ts.isIdentifier(declaration.name) ||
257
- signalCallee(declaration) !== 'computed' ||
258
- !isComputedSlot(declaration)
279
+ signalCallee(declaration, bindings) !== 'computed' ||
280
+ !isComputedSlot(declaration, bindings)
259
281
  ) {
260
282
  return undefined
261
283
  }
@@ -294,29 +316,11 @@ function hasTransform(declaration: ts.VariableDeclaration): boolean {
294
316
  /* A plain `state(initial)` with no transform → a serializable `model` doc slot;
295
317
  every other reactive declaration is a `.value` cell. The one rule shared by the
296
318
  name-collection pass and the slot lowering. */
297
- function isPlainStateSlot(declaration: ts.VariableDeclaration): boolean {
298
- return signalCallee(declaration) === 'state' && !hasTransform(declaration)
299
- }
300
-
301
- /* The callee name of a reactive declaration, else undefined. Recognises both the bare
302
- form (`NAME = state(...)`) and the explicit scope form (`NAME = scope().state(...)` or
303
- `NAME = c.state(...)` for any scope handle `c`) — receiver-agnostic: the METHOD name is
304
- what marks the binding reactive. Since `scope()` is the ambient scope (one object per
305
- level), the receiver is irrelevant to lowering — the slot keys off the binding name and
306
- lands on the same `model`, so the explicit form lowers exactly like the bare one. */
307
- function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
308
- const initializer = declaration.initializer
309
- if (initializer === undefined || !ts.isCallExpression(initializer)) {
310
- return undefined
311
- }
312
- const callee = initializer.expression
313
- if (ts.isIdentifier(callee)) {
314
- return callee.text
315
- }
316
- if (ts.isPropertyAccessExpression(callee)) {
317
- return callee.name.text
318
- }
319
- return undefined
319
+ function isPlainStateSlot(
320
+ declaration: ts.VariableDeclaration,
321
+ bindings: ReactiveImportBindings,
322
+ ): boolean {
323
+ return signalCallee(declaration, bindings) === 'state' && !hasTransform(declaration)
320
324
  }
321
325
 
322
326
  /* One destructured prop: the local binding name, the parent prop key it reads, and
@@ -375,17 +379,23 @@ function propsBindingKey(element: ts.BindingElement): string {
375
379
  read as `name()` — plus a `const rest = restProps($props, [consumed])` for a rest
376
380
  binding; otherwise undefined. The `?? default` applies the binding's `= default`
377
381
  fallback when the prop is absent. */
378
- function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
382
+ function propsStatements(
383
+ statement: ts.Statement,
384
+ bindings: ReactiveImportBindings,
385
+ ): ts.Statement[] | undefined {
379
386
  if (!ts.isVariableStatement(statement)) {
380
387
  return undefined
381
388
  }
382
389
  const statements: ts.Statement[] = []
383
390
  for (const declaration of statement.declarationList.declarations) {
384
- if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
391
+ if (
392
+ signalCallee(declaration, bindings) !== 'props' ||
393
+ !ts.isObjectBindingPattern(declaration.name)
394
+ ) {
385
395
  return undefined
386
396
  }
387
- const { bindings, rest } = propsDestructure(declaration)
388
- for (const { local, key, initializer } of bindings) {
397
+ const { bindings: propBindings, rest } = propsDestructure(declaration)
398
+ for (const { local, key, initializer } of propBindings) {
389
399
  /* `$props["key"]?.()` — the parent thunk, optionally called. */
390
400
  const read = factory.createCallChain(
391
401
  factory.createElementAccessExpression(
@@ -422,7 +432,7 @@ function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
422
432
  }
423
433
  /* The rest bag gathers every prop not named above (and not `$children`). */
424
434
  if (rest !== undefined) {
425
- const consumed = bindings.map((binding) => factory.createStringLiteral(binding.key))
435
+ const consumed = propBindings.map((binding) => factory.createStringLiteral(binding.key))
426
436
  statements.push(
427
437
  constDeclaration(
428
438
  rest,
@@ -443,13 +453,16 @@ function propsStatements(statement: ts.Statement): ts.Statement[] | undefined {
443
453
 
444
454
  /* If `statement` declares `state(...)` bindings, returns `model.<name> = <init>`
445
455
  assignment statements (one per declaration); otherwise undefined. */
446
- function stateAssignmentStatements(statement: ts.Statement): ts.Statement[] | undefined {
456
+ function stateAssignmentStatements(
457
+ statement: ts.Statement,
458
+ bindings: ReactiveImportBindings,
459
+ ): ts.Statement[] | undefined {
447
460
  if (!ts.isVariableStatement(statement)) {
448
461
  return undefined
449
462
  }
450
463
  const statements: ts.Statement[] = []
451
464
  for (const declaration of statement.declarationList.declarations) {
452
- if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration)) {
465
+ if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration, bindings)) {
453
466
  /* Only a plain `state(initial)` becomes a slot; `state(initial, transform)`
454
467
  (and everything else) is a `.value` cell — pass it through so the
455
468
  runtime call (and its transform) survives. */
@@ -22,6 +22,7 @@ import { skeletonContext } from './skeletonContext.ts'
22
22
  import { snippetPlan } from './snippetPlan.ts'
23
23
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
24
24
  import { staticAttr } from './staticAttr.ts'
25
+ import { staticAttrValue } from './staticAttrValue.ts'
25
26
  import { staticTextPart } from './staticTextPart.ts'
26
27
  import { switchPlan } from './switchPlan.ts'
27
28
  import { tryPlan } from './tryPlan.ts'
@@ -43,7 +44,7 @@ attributes, reactive `attr`/`text` bindings, `on` listeners, keyed `each`, and
43
44
  conditional `when`. Every embedded expression is first rewritten from the signal
44
45
  surface (`count` → `model.count`) and then lowered to the doc patch/read API
45
46
  (cell-hoisting runs over the whole result afterwards). The output operates on
46
- `hostVar` and expects the `$$`-aliased dom bindings (`$$effect`, `$$each`, …)
47
+ `hostVar` and expects the `$$`-aliased dom bindings (`$$watch`, `$$each`, …)
47
48
  and the component's `$$model` (emitted by `desugarSignals`) in scope — the body
48
49
  the component compiler wraps and hoists cells into.
49
50
  */
@@ -154,11 +155,11 @@ export function generateBuild(
154
155
  /* `class:<name>` — toggle the class by truthiness; surgical, no element re-render.
155
156
  Layers on top of any static `class="…"` in the skeleton (classList is additive). */
156
157
  if (attr.kind === 'class') {
157
- return `$$effect(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
158
+ return `$$watch(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
158
159
  }
159
160
  /* `style:<property>` — write one inline style / custom property reactively. */
160
161
  if (attr.kind === 'style') {
161
- return `$$effect(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
162
+ return `$$watch(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
162
163
  }
163
164
  if (attr.property === 'group') {
164
165
  /* Grouped two-way: radio binds the path to the single checked `value`;
@@ -171,24 +172,46 @@ export function generateBuild(
171
172
  const value = lowerExpression(valueCode)
172
173
  if (isRadio) {
173
174
  return (
174
- `$$effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
175
+ `$$watch(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
175
176
  `$$on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
176
177
  )
177
178
  }
178
179
  return (
179
- `$$effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
180
+ `$$watch(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
180
181
  `$$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`
181
182
  )
182
183
  }
184
+ /* `<select bind:value>` — options frequently mount after this binding runs (a
185
+ `{#for}` child, an async list) and the browser drops a `value` set naming a
186
+ not-yet-present option, so route to `bindSelectValue`: it re-applies on option
187
+ changes via a MutationObserver and switches single/array semantics on `multiple`
188
+ (`<select multiple>` binds an array of the selected option values). The read/write
189
+ are the same lvalue/accessor forms every bind uses; the helper decides how to
190
+ apply and collect them. */
191
+ if (attr.property === 'value' && node.tag === 'select') {
192
+ const multiple = staticAttrValue(node, 'multiple') !== undefined
193
+ return `$$bindSelectValue(${varName}, () => (${bindRead(attr.code)}), ($selectValue) => { ${bindWrite(attr.code, '$selectValue')} }, ${multiple});\n`
194
+ }
183
195
  /* Two-way: drive the property from the bind target, and write it back on the
184
196
  property's native event (`input` for most fields, but `toggle` for
185
197
  `<details open>`, `change` for checked/select). An lvalue target reads as
186
198
  itself and writes by assignment; an accessor object (`{ get, set }`) reads via
187
- `.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. */
199
+ `.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. A numeric input
200
+ (`type="number"`/`"range"`) reports its edit as a string on `el.value`, which
201
+ would corrupt number-typed state; source the write-back from `valueAsNumber`
202
+ instead (empty field → `undefined`), gated on a statically-known type. */
188
203
  const event = bindListenEvent(attr.property, node.tag)
204
+ const staticType = staticAttrValue(node, 'type')
205
+ const isNumericInput =
206
+ attr.property === 'value' &&
207
+ node.tag === 'input' &&
208
+ (staticType === 'number' || staticType === 'range')
209
+ const writeSource = isNumericInput
210
+ ? `(${varName}.value === '' ? undefined : ${varName}.valueAsNumber)`
211
+ : `${varName}.${attr.property}`
189
212
  return (
190
- `$$effect(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
191
- `$$on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
213
+ `$$watch(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
214
+ `$$on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, writeSource)} });\n`
192
215
  )
193
216
  }
194
217
 
@@ -310,12 +333,12 @@ export function generateBuild(
310
333
  skeleton). */
311
334
  if (plan.merge.mergeClassBuild) {
312
335
  binds.push(
313
- `$$effect(${namedThunk('class_merge', `${elVar}.setAttribute("class", [${plan.merge.classParts.join(', ')}].filter(Boolean).join(' '));`)});\n`,
336
+ `$$watch(${namedThunk('class_merge', `${elVar}.setAttribute("class", [${plan.merge.classParts.join(', ')}].filter(Boolean).join(' '));`)});\n`,
314
337
  )
315
338
  }
316
339
  if (plan.merge.mergeStyleBuild) {
317
340
  binds.push(
318
- `$$effect(${namedThunk('style_merge', `${elVar}.setAttribute("style", [${plan.merge.styleParts.join(', ')}].filter(Boolean).join(';'));`)});\n`,
341
+ `$$watch(${namedThunk('style_merge', `${elVar}.setAttribute("style", [${plan.merge.styleParts.join(', ')}].filter(Boolean).join(';'));`)});\n`,
319
342
  )
320
343
  }
321
344
  for (const { attr, mergedBuild } of plan.attrs) {
@@ -558,11 +581,7 @@ export function generateBuild(
558
581
  parentVar: string,
559
582
  before: string,
560
583
  ): string {
561
- /* `client:idle`/`client:visible` the island trigger, the 6th mountChild arg. Omitted
562
- (the common case) leaves the call byte-identical to before — eager hydration. */
563
- const trigger =
564
- node.clientTrigger !== undefined ? `, ${JSON.stringify(node.clientTrigger)}` : ''
565
- return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)}${trigger});\n`
584
+ return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
566
585
  }
567
586
 
568
587
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -719,7 +738,20 @@ export function generateBuild(
719
738
  destructures the item via `plan.as` to read its leaves. */
720
739
  const rawItemParam = isPlainIdentifier(plan.as) ? plan.as : nextVar('k')
721
740
  const keyParam = plan.key === undefined ? rawItemParam : plan.as
722
- const keyExpression = plan.key === undefined ? rawItemParam : lowerExpression(plan.key)
741
+ /* The key callback binds the RAW item via `plan.as`, so lower the key expression with
742
+ the item's names shadowed as PLAIN locals — otherwise a `by item.id` whose `item`
743
+ collides with a component signal rewrites to `$$model.read("item/id")` (every row
744
+ keys off the same signal → keyed reconciliation degenerates). The plain kind (not
745
+ the row body's reactive `.value` kind) keeps it the bare param the callback binds. */
746
+ const keyExpression =
747
+ plan.key === undefined
748
+ ? rawItemParam
749
+ : withBindings(
750
+ withShadow,
751
+ [plan.bindings[0] as Binding],
752
+ () => 'plain',
753
+ () => lowerExpression(plan.key as string),
754
+ )
723
755
  /* The item is the row's `reactive` binding (its `.value` cell + per-leaf reader prefix
724
756
  rendered by `reactiveBinding`); `index="i"` is a second reactive cell param, a plain
725
757
  identifier read as `i.value` (no prefix). The names of BOTH enter the deref scope via