@abide/abide 0.45.0 → 0.47.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 (176) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +96 -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 +7 -1
  26. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +47 -11
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  118. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  119. package/src/lib/ui/compile/signalCallee.ts +24 -0
  120. package/src/lib/ui/compile/stripEffects.ts +14 -11
  121. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  122. package/src/lib/ui/dom/appendStatic.ts +9 -1
  123. package/src/lib/ui/dom/appendText.ts +15 -3
  124. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  125. package/src/lib/ui/dom/awaitBlock.ts +17 -56
  126. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  127. package/src/lib/ui/dom/each.ts +12 -1
  128. package/src/lib/ui/dom/hydrate.ts +10 -0
  129. package/src/lib/ui/effect.ts +4 -3
  130. package/src/lib/ui/installHotBridge.ts +4 -2
  131. package/src/lib/ui/remoteProxy.ts +18 -2
  132. package/src/lib/ui/renderToStream.ts +9 -13
  133. package/src/lib/ui/resumeSeedScript.ts +2 -2
  134. package/src/lib/ui/router.ts +21 -2
  135. package/src/lib/ui/runtime/RESUME.ts +0 -6
  136. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  137. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  138. package/src/lib/ui/scope.ts +6 -3
  139. package/src/lib/ui/seedBootState.ts +53 -0
  140. package/src/lib/ui/socketChannel.ts +2 -2
  141. package/src/lib/ui/socketProxy.ts +9 -3
  142. package/src/lib/ui/startClient.ts +16 -30
  143. package/src/lib/ui/state.ts +44 -13
  144. package/src/lib/ui/sync.ts +11 -5
  145. package/src/lib/ui/tryEncodeResume.ts +2 -5
  146. package/src/lib/ui/types/Scope.ts +14 -10
  147. package/src/lib/ui/watch.ts +140 -0
  148. package/src/serverEntry.ts +6 -6
  149. package/template/CLAUDE.md +1 -1
  150. package/template/package.json +1 -1
  151. package/template/src/server/rpc/getHello.ts +3 -3
  152. package/template/src/ui/pages/page.abide +2 -2
  153. package/template/test/app.test.ts +1 -1
  154. package/src/lib/server/reachable.ts +0 -45
  155. package/src/lib/server/sockets/types/Socket.ts +0 -23
  156. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  157. package/src/lib/shared/baseResolver.ts +0 -10
  158. package/src/lib/shared/cacheKeyOf.ts +0 -7
  159. package/src/lib/shared/cacheKeyStore.ts +0 -8
  160. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  161. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  162. package/src/lib/shared/pageResolver.ts +0 -16
  163. package/src/lib/shared/recordCacheKey.ts +0 -6
  164. package/src/lib/shared/requestScopeResolver.ts +0 -12
  165. package/src/lib/shared/setBaseResolver.ts +0 -4
  166. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  167. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  168. package/src/lib/shared/setPageResolver.ts +0 -4
  169. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  170. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  171. package/src/lib/shared/types/TailOptions.ts +0 -10
  172. package/src/lib/ui/deferResume.ts +0 -29
  173. package/src/lib/ui/matchRoute.ts +0 -106
  174. package/src/lib/ui/tail.ts +0 -324
  175. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  176. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
4
4
  import { claimChild } from '../runtime/claimChild.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { appendSnippet } from './appendSnippet.ts'
7
+ import { assertClaimedText } from './assertClaimedText.ts'
7
8
  import { isComment } from './isComment.ts'
8
9
  import { parseRawNodes } from './parseRawNodes.ts'
9
10
 
@@ -49,6 +50,12 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
49
50
  const node = (
50
51
  isText ? claimed : parent.insertBefore(document.createTextNode(''), claimed)
51
52
  ) as Text
53
+ /* The claimed SSR node must begin with this binding's value, or the split below
54
+ lands mid-run and orphans the tail — throw legibly at the divergence instead. A
55
+ synthesized empty node is this binding's own, so there's nothing to disagree. */
56
+ if (isText) {
57
+ assertClaimedText(node, value)
58
+ }
52
59
  /* Peel this binding's text off the merged SSR node. A non-final binding in a
53
60
  run (`splitAlways`) splits even when it consumes the whole node, leaving an
54
61
  empty node for the next binding — otherwise an interpolation that renders to
@@ -87,12 +94,17 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
87
94
  let nodes: Node[] = []
88
95
 
89
96
  const set = (value: string): void => {
97
+ /* Insert/remove via the anchor's LIVE parent, not the build-time `parent` — when
98
+ this interpolation is a bare child of a control-flow branch, `parent` is the
99
+ branch's build fragment, which the enclosing block has since emptied into the
100
+ document (same reason each/awaitBlock use the anchor's parentNode). */
101
+ const liveParent = anchor.parentNode ?? parent
90
102
  for (const node of nodes) {
91
- parent.removeChild(node)
103
+ liveParent.removeChild(node)
92
104
  }
93
- nodes = parseRawNodes(parent, value)
105
+ nodes = parseRawNodes(liveParent, value)
94
106
  for (const node of nodes) {
95
- parent.insertBefore(node, anchor)
107
+ liveParent.insertBefore(node, anchor)
96
108
  }
97
109
  }
98
110
 
@@ -0,0 +1,20 @@
1
+ /*
2
+ A text claim that MUST match the server's rendered text. On hydrate,
3
+ appendStatic/appendText adopt a merged SSR text node and split it at the CLIENT
4
+ value's length, trusting the client renders the same string the server did. When
5
+ they diverge — a binding whose value only materializes client-side (a peeked cache
6
+ value, `Date.now()`, a random) — the split lands mid-run and orphans the tail,
7
+ silently corrupting the DOM several nodes downstream. This turns that into a legible
8
+ desync AT the divergence, the text counterpart of `claimExpected` for structure.
9
+
10
+ The claimed node's data must BEGIN with this binding's value: a merged run appends
11
+ the following siblings' text AFTER it, so the value is exactly the leading slice. A
12
+ mismatch means SSR and the client build disagree on the text here.
13
+ */
14
+ export function assertClaimedText(node: Text, value: string): void {
15
+ if (!node.data.startsWith(value)) {
16
+ throw new Error(
17
+ `[abide] hydration desync: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
18
+ )
19
+ }
20
+ }
@@ -1,11 +1,10 @@
1
- import { activeCacheStore } from '../../shared/activeCacheStore.ts'
2
1
  import { decodeRefJson } from '../../shared/decodeRefJson.ts'
3
2
  import { effect } from '../effect.ts'
4
3
  import { claimChild } from '../runtime/claimChild.ts'
5
4
  import { generationGuard } from '../runtime/generationGuard.ts'
6
5
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
7
6
  import { RENDER } from '../runtime/RENDER.ts'
8
- import type { DeferMarker, ResumeEntry } from '../runtime/RESUME.ts'
7
+ import type { ResumeEntry } from '../runtime/RESUME.ts'
9
8
  import { RESUME } from '../runtime/RESUME.ts'
10
9
  import { scope } from '../runtime/scope.ts'
11
10
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -181,42 +180,6 @@ export function awaitBlock(
181
180
  active = { start, end, dispose }
182
181
  }
183
182
 
184
- /* Inert adoption — a deferred `{#await cache()}` (large payload, shipped as a `{defer,key}`
185
- resume marker). Adopt the SSR then-branch WITHOUT building its bindings or materializing
186
- the awaited value: the server markup is already correct, so keep it verbatim and skip the
187
- payload decode entirely (the boot-path cost deferral targets). Only subscribe THIS block's
188
- effect to the cache key — reads, decodes and fetches all wait. A later cache.invalidate
189
- re-runs the effect, which then reads the value for real and builds a fresh branch. Sound
190
- because a display-first read replaces the whole branch on re-read anyway, so there is no
191
- extra flash beyond the swap the re-read already performs. */
192
- const adoptInert = (open: Node | null, key: string): void => {
193
- const cursor = hydration as NonNullable<typeof hydration>
194
- const firstKept = open?.nextSibling ?? null
195
- /* Scan to THIS block's close marker, leaving every server node in place. Nested
196
- blocks carry different ids, so the first data match is our own close. */
197
- let node: Node | null = firstKept
198
- while (node !== null && (node as { data?: string }).data !== `/abide:await:${id}`) {
199
- node = node.nextSibling
200
- }
201
- const close = node
202
- cursor.next.set(parent, close?.nextSibling ?? null)
203
- /* Bracket the kept nodes as a `[`…`]` range + park an anchor, identical to `adopt`,
204
- so the first re-run's `place`/`detach` evicts them like any other branch. */
205
- const start = document.createComment(RANGE_OPEN)
206
- parent.insertBefore(start, firstKept ?? close)
207
- const end = document.createComment(RANGE_CLOSE)
208
- parent.insertBefore(end, close)
209
- anchor = document.createTextNode('')
210
- parent.insertBefore(anchor, close)
211
- /* No scope/effects were built for the kept nodes, so disposal only evicts the range. */
212
- active = { start, end, dispose: () => undefined }
213
- /* Not 'then' — the first re-run rebuilds the branch fresh via `place`. */
214
- activeKind = 'pending'
215
- /* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
216
- cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
217
- activeCacheStore().subscribe(key)
218
- }
219
-
220
183
  /* Discard the SSR boundary and (re)build the block from the live promise, fresh
221
184
  (hydration off) — the recovery path when adoption can't use the server markup. */
222
185
  const rebuildCold = (open: Node | null): void => {
@@ -253,28 +216,28 @@ export function awaitBlock(
253
216
  lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
254
217
  through to the live promise rather than crash hydration. */
255
218
  const raw = RESUME[id]
256
- let decoded: ResumeEntry | DeferMarker | undefined
219
+ let decoded: ResumeEntry | undefined
257
220
  if (raw !== undefined) {
258
221
  try {
259
- decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
222
+ decoded = decodeRefJson(raw) as ResumeEntry
260
223
  } catch {
261
224
  decoded = undefined
262
225
  }
263
226
  }
264
- /* A deferred marker ships the cache key, not the value adopt the server branch inert
265
- and skip the decode. Intercept before the ok/catch logic. Object-guard the `in`: a
266
- corrupt manifest decoding to a primitive/null must read as "no resume" (fall through),
267
- not throw past the decode try/catch and crash hydration. */
268
- if (typeof decoded === 'object' && decoded !== null && 'defer' in decoded) {
269
- adoptInert(open, decoded.key)
270
- return
271
- }
272
- /* Non-deferred: read the promise now so the block subscribes to its reactive source
273
- (a cache key) — warm on resume, so no round-trip — then adopt the resume value /
274
- warm-sync result below, or discard and build the pending branch fresh. */
227
+ /* Read the promise now so the block subscribes to its reactive source (a cache key)
228
+ warm on resume, so no round-trip — then adopt the resume value / warm-sync result
229
+ below, or discard and build the pending branch fresh. */
275
230
  const result = promiseThunk()
276
- const entry = decoded as ResumeEntry | undefined
231
+ const entry = decoded
277
232
  if (entry !== undefined) {
233
+ /* The resume entry drives the adopted branch below; the live `result` read to
234
+ subscribe the reactive source is discarded here. Swallow its rejection — a
235
+ cold-cache warm-seed miss starts a real fetch, and if that fetch rejects it
236
+ would otherwise surface as an unhandledrejection at boot (process-fatal under
237
+ Bun) instead of being harmlessly dropped. */
238
+ if (isThenable(result)) {
239
+ void (result as PromiseLike<unknown>).then(undefined, () => undefined)
240
+ }
278
241
  /* Build the adopted branch around a value CELL (then) so a later re-run updates
279
242
  it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
280
243
  stays OUTSIDE the adopt try/catch so it surfaces rather than triggering the
@@ -336,10 +299,8 @@ export function awaitBlock(
336
299
  if (first) {
337
300
  first = false
338
301
  if (hydration !== undefined) {
339
- /* firstHydrate reads the promise ITSELF, after checking the resume marker: a
340
- deferred block must not invoke promiseThunk (it would materialize/fetch the
341
- value we're deferring) and subscribes by key instead; every other path reads
342
- it so the block subscribes to its reactive source (a cache key). */
302
+ /* firstHydrate reads the promise ITSELF so the block subscribes to its reactive
303
+ source (a cache key), then adopts the resume value / warm-sync result. */
343
304
  firstHydrate()
344
305
  return
345
306
  }
@@ -0,0 +1,48 @@
1
+ import { effect } from '../effect.ts'
2
+ import { on } from './on.ts'
3
+
4
+ /*
5
+ Two-way `bind:value` for a `<select>`. The bound value drives the selection through
6
+ a reactive effect, and the user's pick writes back on `change`. A plain `bind:value`
7
+ would set `el.value` once, up front — but a `<select>`'s options are frequently
8
+ mounted AFTER the binding runs (a `{#for}` child, an async list), and the browser
9
+ silently drops a `value` assignment naming an option that isn't present yet. So a
10
+ `MutationObserver` re-applies the selection whenever the option set changes (its
11
+ callback runs in a microtask before paint, so no visible flash), disconnected on
12
+ teardown. `multiple` switches single-value (`el.value`) semantics for array
13
+ membership: each option's `selected` is set from the bound array, and the write-back
14
+ collects `selectedOptions` back into an array.
15
+ */
16
+ // @documentation plumbing
17
+ export function bindSelectValue(
18
+ element: HTMLSelectElement,
19
+ read: () => unknown,
20
+ write: (value: unknown) => void,
21
+ multiple: boolean,
22
+ ): void {
23
+ /* Push the bound value into the DOM selection. */
24
+ const apply = (): void => {
25
+ if (multiple) {
26
+ const selected = (read() as unknown[]) ?? []
27
+ for (const option of element.options) {
28
+ option.selected = selected.includes(option.value)
29
+ }
30
+ } else {
31
+ element.value = read() as string
32
+ }
33
+ }
34
+ /* Read the current DOM selection back out for the write. */
35
+ const collect = (): unknown =>
36
+ multiple ? Array.from(element.selectedOptions, (option) => option.value) : element.value
37
+ /* Re-apply whenever the bound value changes (this effect tracks `read`). */
38
+ effect(apply)
39
+ /* Re-apply whenever the option set changes — covers `{#for}`/async options that
40
+ mount after the first apply. This effect reads nothing reactive, so it runs once
41
+ and only its teardown (disconnect) fires on dispose. */
42
+ effect(() => {
43
+ const observer = new MutationObserver(apply)
44
+ observer.observe(element, { childList: true, subtree: true })
45
+ return () => observer.disconnect()
46
+ })
47
+ on(element, 'change', () => write(collect()))
48
+ }
@@ -107,7 +107,18 @@ export function each<T>(
107
107
  if (hydration !== undefined) {
108
108
  let position = 0
109
109
  for (const item of items()) {
110
- rows.set(keyOf(item), buildRow(item, position)) // claims the SSR row where it sits
110
+ const key = keyOf(item)
111
+ const row = buildRow(item, position) // claims the SSR row where it sits
112
+ if (rows.has(key)) {
113
+ /* Duplicate key: this row already claimed its SSR markers, but the map holds
114
+ one row per key (create mode keeps the FIRST built row too). Dispose+remove
115
+ this later duplicate so it isn't orphaned on screen forever — the reconcile
116
+ prune only reaches rows still in the map. */
117
+ row.dispose()
118
+ removeRange(row.start, row.end)
119
+ } else {
120
+ rows.set(key, row)
121
+ }
111
122
  position += 1
112
123
  }
113
124
  anchor = document.createTextNode('')
@@ -1,3 +1,5 @@
1
+ import { hydratingSlot } from '../../shared/hydratingSlot.ts'
2
+ import { wakeHydrationPeeks } from '../../shared/wakeHydrationPeeks.ts'
1
3
  import { RENDER } from '../runtime/RENDER.ts'
2
4
  import { scope } from '../runtime/scope.ts'
3
5
  import { scopeLabel } from './scopeLabel.ts'
@@ -25,7 +27,9 @@ export function hydrate(
25
27
  props?: unknown,
26
28
  ): () => void {
27
29
  const previous = RENDER.hydration
30
+ const previousHydrating = hydratingSlot.active
28
31
  RENDER.hydration = { next: new Map() }
32
+ hydratingSlot.active = true
29
33
  try {
30
34
  /* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
31
35
  scope too, adopting the model its build adopts — run with the claim cursor active. */
@@ -33,5 +37,11 @@ export function hydrate(
33
37
  return () => lexical.dispose()
34
38
  } finally {
35
39
  RENDER.hydration = previous
40
+ hydratingSlot.active = previousHydrating
41
+ /* Wake the peeks this pass withheld — only when unwinding the OUTERMOST pass, so a
42
+ nested child hydrate doesn't fire it early (save/restore, not a bare reset). */
43
+ if (!previousHydrating) {
44
+ wakeHydrationPeeks()
45
+ }
36
46
  }
37
47
  }
@@ -10,9 +10,10 @@ dispose — and may be async (its teardown then runs once the promise settles, b
10
10
  only the reads before its first `await` are tracked). Returns a dispose that runs
11
11
  the final teardown and detaches it from the graph. This is abide's from-scratch
12
12
  effect primitive: the open-on-first-read / close-on-last-reader lifecycle,
13
- grounded in abide's own reactive core. Authored as `scope().effect(...)` (the bare
14
- name is reserved); this module export stays for the generated binding code that
15
- calls it directly (and for the SSR strip), so it carries no standalone demo.
13
+ grounded in abide's own reactive core. Imported and called bare
14
+ (`import { effect } from '@abide/abide/ui/effect'`); the compiler resolves the import
15
+ binding (alias-safe) and the SSR back-end strips the call (effects are client
16
+ lifecycle). Generated binding code also calls it directly.
16
17
  */
17
18
  // @documentation plumbing
18
19
  export function effect(fn: () => EffectResult): () => void {
@@ -7,6 +7,7 @@ import { appendTextAt } from './dom/appendTextAt.ts'
7
7
  import { attach } from './dom/attach.ts'
8
8
  import { attr } from './dom/attr.ts'
9
9
  import { awaitBlock } from './dom/awaitBlock.ts'
10
+ import { bindSelectValue } from './dom/bindSelectValue.ts'
10
11
  import { cloneStatic } from './dom/cloneStatic.ts'
11
12
  import { each } from './dom/each.ts'
12
13
  import { eachAsync } from './dom/eachAsync.ts'
@@ -25,7 +26,6 @@ import { spreadProps } from './dom/spreadProps.ts'
25
26
  import { switchBlock } from './dom/switchBlock.ts'
26
27
  import { tryBlock } from './dom/tryBlock.ts'
27
28
  import { when } from './dom/when.ts'
28
- import { deferResume } from './deferResume.ts'
29
29
  import { effect } from './effect.ts'
30
30
  import { enterScope } from './enterScope.ts'
31
31
  import { exitScope } from './exitScope.ts'
@@ -36,6 +36,7 @@ import { hotReloadEnabled } from './runtime/hotReloadEnabled.ts'
36
36
  import { hotReplace } from './runtime/hotReplace.ts'
37
37
  import { nextBlockId } from './runtime/nextBlockId.ts'
38
38
  import { scope } from './scope.ts'
39
+ import { watch } from './watch.ts'
39
40
 
40
41
  /*
41
42
  Dev-only: exposes the abide-ui runtime plus `hotReplace` on `window.__abide`, and
@@ -54,6 +55,7 @@ export function installHotBridge(): void {
54
55
  enterScope,
55
56
  exitScope,
56
57
  effect,
58
+ watch,
57
59
  mount,
58
60
  appendText,
59
61
  appendTextAt,
@@ -65,11 +67,11 @@ export function installHotBridge(): void {
65
67
  attr,
66
68
  on,
67
69
  attach,
70
+ bindSelectValue,
68
71
  each,
69
72
  eachAsync,
70
73
  when,
71
74
  awaitBlock,
72
- deferResume,
73
75
  tryBlock,
74
76
  switchBlock,
75
77
  mountSlot,
@@ -17,16 +17,20 @@ import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
17
17
  import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
18
18
  import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
19
19
  import type { PersistenceStore } from './types/PersistenceStore.ts'
20
+ import { watch } from './watch.ts'
20
21
 
21
22
  /* The framework-reserved `HttpError.kind` for a request the durable outbox parked because
22
23
  the server was unreachable — distinct from a handler-declared error name. Lets a caller
23
24
  branch with `error instanceof HttpError && error.kind === 'queued'`. */
24
25
  const QUEUED = 'queued'
25
26
 
26
- /* A durable RPC's per-call options. `outbox: true` parks an unreachable call for replay;
27
- `store` exists for testing production uses the default persistence store. */
27
+ /* Build-time options the bundler stamps onto the client proxy stub. `outbox: true` parks an
28
+ unreachable call for replay; `streaming: true` (handler returns jsonl()/sse()) makes the bare
29
+ call return the Subscribable directly; `store` exists for testing (production uses the default
30
+ persistence store). */
28
31
  export type DurableOptions = {
29
32
  outbox?: boolean
33
+ streaming?: boolean
30
34
  store?: PersistenceStore
31
35
  }
32
36
 
@@ -75,6 +79,7 @@ export function remoteProxy<Args, Return>(
75
79
  method,
76
80
  url,
77
81
  clients: browserClientFlags,
82
+ streaming: durable?.streaming ?? false,
78
83
  /*
79
84
  The Request URL carries the mount base so the fetch routes through the
80
85
  proxy (/v2/rpc/…); the cache key keeps the bare `url` (keyForRemoteCall
@@ -143,6 +148,17 @@ export function remoteProxy<Args, Return>(
143
148
  )
144
149
  },
145
150
  })
151
+ /* Overwrite the inert `.watch` the shared attach bound: on the client the real reaction
152
+ sugar routes to the `watch` ui primitive (`fn.watch(handler)` / `fn.watch(args, handler)`).
153
+ Attached here — not in the shared attach — so `watch` never rides into a server bundle. */
154
+ Object.assign(fn, {
155
+ watch: (argsOrHandler: unknown, handler?: unknown) =>
156
+ (watch as (source: unknown, a?: unknown, b?: unknown) => () => void)(
157
+ fn,
158
+ argsOrHandler,
159
+ handler,
160
+ ),
161
+ })
146
162
  if (durable?.outbox === true) {
147
163
  queue = getOrCreateOutboxQueue<Args, Return>(url, fn, durable)
148
164
  Object.assign(fn, { outbox: outboxFace(queue) })
@@ -1,6 +1,5 @@
1
- import { deferResume } from './deferResume.ts'
2
1
  import { resumeSeedScript } from './resumeSeedScript.ts'
3
- import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
2
+ import type { ResumeEntry } from './runtime/RESUME.ts'
4
3
  import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
5
4
  import { tryEncodeResume } from './tryEncodeResume.ts'
6
5
 
@@ -40,8 +39,8 @@ export async function* renderToStream(
40
39
  instead of refetching. (`resume` is the render body's live object, so late writes
41
40
  appear here.) */
42
41
  const seededResume = new Set<number>(Object.keys(resume).map(Number))
43
- const resumeDelta = (): Record<number, ResumeEntry | DeferMarker> => {
44
- const delta: Record<number, ResumeEntry | DeferMarker> = {}
42
+ const resumeDelta = (): Record<number, ResumeEntry> => {
43
+ const delta: Record<number, ResumeEntry> = {}
45
44
  for (const [key, entry] of Object.entries(resume)) {
46
45
  const id = Number(key)
47
46
  if (!seededResume.has(id)) {
@@ -84,22 +83,19 @@ export async function* renderToStream(
84
83
  }
85
84
  }
86
85
 
87
- type Settled = { id: number; html: string; resume: ResumeEntry | DeferMarker }
86
+ type Settled = { id: number; html: string; resume: ResumeEntry }
88
87
 
89
88
  /* Awaits one streaming block's promise and renders the resolved or error branch to
90
89
  HTML (the renderers are async so a nested `await` block composes), capturing the
91
90
  value (serializable) for the resume manifest. Errors serialize as their message —
92
- enough for the catch branch, without leaking a stack. A cache-backed value defers via
93
- `deferResume` (a `{defer,key}` marker + lazy body seed) — the client adopts the streamed
94
- branch inert instead of decoding the value; a non-cache value ships inline as before. */
91
+ enough for the catch branch, without leaking a stack. The resolved value ships inline
92
+ so hydration adopts the streamed branch warm. */
95
93
  function settle(block: SsrAwait): Promise<Settled> {
96
- /* Keep the promise: cache() tagged it with its key, which `deferResume` reads to defer. */
97
- const pending = block.promise()
98
- return Promise.resolve(pending).then(
94
+ return Promise.resolve(block.promise()).then(
99
95
  async (value) => ({
100
96
  id: block.id,
101
97
  html: await block.then(value),
102
- resume: deferResume(pending, value),
98
+ resume: { ok: true, value },
103
99
  }),
104
100
  async (error) => {
105
101
  /* No catch branch → surface the rejection (500 before the first flush,
@@ -123,6 +119,6 @@ function settle(block: SsrAwait): Promise<Settled> {
123
119
  strings. `tryEncodeResume` handles the serialize-or-refetch policy (undefined → no
124
120
  script → the swap consumers skip registration → hydration re-runs that one promise).
125
121
  `applyResolved`/the inline swap script store it via `.textContent`; `awaitBlock` decodes it. */
126
- function encodeStreamResume(resume: ResumeEntry | DeferMarker, id: number): string | undefined {
122
+ function encodeStreamResume(resume: ResumeEntry, id: number): string | undefined {
127
123
  return tryEncodeResume(resume, id)?.replace(/</g, '\\u003c')
128
124
  }
@@ -1,5 +1,5 @@
1
1
  import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
2
- import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
2
+ import type { ResumeEntry } from './runtime/RESUME.ts'
3
3
  import { tryEncodeResume } from './tryEncodeResume.ts'
4
4
 
5
5
  /* A self-contained `<script>` seeding the await-resume manifest with the blocking
@@ -11,7 +11,7 @@ import { tryEncodeResume } from './tryEncodeResume.ts'
11
11
  early or parse as a line terminator. Shared by the buffered (`createUiPageRenderer`)
12
12
  and streaming (`renderToStream`) paths. */
13
13
  // @documentation plumbing
14
- export function resumeSeedScript(resume: Record<number, ResumeEntry | DeferMarker>): string {
14
+ export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
15
15
  /* ref-json (not JSON) so a value carrying cycles or shared back-references — a
16
16
  media tree with parent↔child links — seeds instead of being dropped. `tryEncodeResume`
17
17
  drops just an unserializable entry (the client re-runs that one branch's promise),
@@ -1,8 +1,10 @@
1
+ import { hydratingSlot } from '../shared/hydratingSlot.ts'
1
2
  import { layoutChainForRoute } from '../shared/layoutChainForRoute.ts'
3
+ import { matchRoute } from '../shared/matchRoute.ts'
4
+ import { wakeHydrationPeeks } from '../shared/wakeHydrationPeeks.ts'
2
5
  import { fillBoundary } from './dom/fillBoundary.ts'
3
6
  import { outlet } from './dom/outlet.ts'
4
7
  import { effect } from './effect.ts'
5
- import { matchRoute } from './matchRoute.ts'
6
8
  import { navigatePath } from './navigate.ts'
7
9
  import { CHILD_PRESENT } from './runtime/CHILD_PRESENT.ts'
8
10
  import { clientPage } from './runtime/clientPage.ts'
@@ -81,7 +83,8 @@ function clearRecoveryReloads(url: string): void {
81
83
 
82
84
  /*
83
85
  A minimal client router on the History API. `router` matches the current path
84
- against the route patterns (literal / `[name]` / `[...rest]`, via matchRoute),
86
+ against the route patterns (literal / `[name]` / `[[name]]` / `[...rest]`, via
87
+ the shared matchRoute — the same matcher the server dispatches with),
85
88
  imports the matching page's chunk — plus every `layout.abide` chunk that wraps it
86
89
  (`layoutChainForRoute`) — on demand, and mounts them as a chain: each layout into
87
90
  the previous layout's `<slot/>` outlet, the page into the innermost outlet.
@@ -249,13 +252,21 @@ export function router(
249
252
  }
250
253
  if (hydrating) {
251
254
  const previous = RENDER.hydration
255
+ const previousHydrating = hydratingSlot.active
252
256
  RENDER.hydration = { next: new Map() }
257
+ hydratingSlot.active = true
253
258
  enterRenderPass()
254
259
  try {
255
260
  run()
256
261
  } finally {
257
262
  exitRenderPass()
258
263
  RENDER.hydration = previous
264
+ hydratingSlot.active = previousHydrating
265
+ /* Wake the peeks this pass withheld for SSR congruence, now that the pass is
266
+ over and the warm value is congruent to show. Only on the outermost unwind. */
267
+ if (!previousHydrating) {
268
+ wakeHydrationPeeks()
269
+ }
259
270
  }
260
271
  return
261
272
  }
@@ -428,6 +439,14 @@ export function router(
428
439
  ])
429
440
  .then(([pageView, resolvedLayouts, decision]) => {
430
441
  if (token !== sequence || disposed) {
442
+ /* Consume the first-paint flag even when superseded: the boot SSR DOM
443
+ is valid only for THIS boot navigation. Leaving `first` true would
444
+ let a navigation that started before this settled (to a different
445
+ page) skip its auth probe and hydrate the boot URL's SSR DOM as its
446
+ own — claimExpected then throws and commit swallows it, stranding a
447
+ broken page. Cleared here → the superseding nav builds create-mode
448
+ (clearing the stale SSR DOM) and runs its probe. */
449
+ first = false
431
450
  return
432
451
  }
433
452
  /* handle() redirected: go where it pointed, replacing the blocked
@@ -16,12 +16,6 @@
16
16
  same reference. */
17
17
  export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
18
18
 
19
- /* Deferred-resume marker (Tier 2): a large cache-backed `{#await cache()}` ships this in
20
- place of its value — just the cache key, so hydration adopts the server branch inert and
21
- pays no value decode. The client seeds that key lazily and materializes it only on a later
22
- re-read. Discriminated from a ResumeEntry by the `defer` field. */
23
- export type DeferMarker = { defer: true; key: string }
24
-
25
19
  const globalScope = globalThis as { __abideResume?: Record<number, string> }
26
20
  globalScope.__abideResume ??= {}
27
21
 
@@ -9,7 +9,7 @@ with `enterScope`/`exitScope` and `await`s inline between them (blocking `{#awai
9
9
  child renders, slots, a top-level `await`). A module global held across those awaits
10
10
  interleaves across concurrent requests — one render resumes to read another's scope.
11
11
  Keying the ambient off the per-request store (which the async context propagates
12
- correctly) isolates it. Mirrors `requestScopeResolver`'s server-installed slot; the
12
+ correctly) isolates it. Mirrors `requestScopeSlot`'s server-installed slot; the
13
13
  indirection cost lands at build/effect-creation time, never on the signal hot path.
14
14
  */
15
15
  type ScopeBacking = { get(): Scope | undefined; set(value: Scope | undefined): void }
@@ -1,4 +1,4 @@
1
- import type { DeferMarker, ResumeEntry } from '../RESUME.ts'
1
+ import type { ResumeEntry } from '../RESUME.ts'
2
2
 
3
3
  /* One STREAMING await block captured during SSR (no `then` on the `await` tag): its
4
4
  boundary id, the promise to await, and the async string-renderers for the resolved
@@ -18,11 +18,10 @@ export type SsrAwait = {
18
18
  /* The result of a component's server `render()`: the pending-shell HTML, the
19
19
  serializable document snapshot for client resume, the STREAMING await blocks to
20
20
  flush out of order, and `resume` — the inline-rendered BLOCKING await values keyed
21
- by boundary id, seeded into the manifest so hydration adopts them without a refetch. A
22
- deferred (cache-backed) blocking value is a `DeferMarker` in place of the value. */
21
+ by boundary id, seeded into the manifest so hydration adopts them without a refetch. */
23
22
  export type SsrRender = {
24
23
  html: string
25
24
  state: unknown
26
25
  awaits: SsrAwait[]
27
- resume: Record<number, ResumeEntry | DeferMarker>
26
+ resume: Record<number, ResumeEntry>
28
27
  }
@@ -5,10 +5,13 @@ import type { Scope } from './types/Scope.ts'
5
5
  /*
6
6
  Resolves the current lexical scope — established per lexical level by the compiler,
7
7
  so it reads "where you are" with no handle. Outside any scope (boot, a script) it
8
- mints a detached root once and reuses it. The returned value is passable: hand it to
9
- a child or a helper and it can read/extend/undo that scope (walk up via `.root()`).
8
+ mints a detached root once and reuses it. Now the INTERNAL lowering host: the author
9
+ reactive surface is the imported `state`/`state.linked`/`state.computed`/`effect`; the
10
+ compiler lowers each onto this scope (`$$scope().derive`/`.linked`/`.effect`), and
11
+ `state.share`/`.shared` route through it. Still published so generated code and the
12
+ type-checking shadow can import it; not part of the author-facing surface.
10
13
  */
11
- // @documentation reactive-state
14
+ // @documentation plumbing
12
15
  export function scope(): Scope {
13
16
  if (!CURRENT_SCOPE.current) {
14
17
  CURRENT_SCOPE.current = createScope()
@@ -0,0 +1,53 @@
1
+ import { baseSlot } from '../shared/baseSlot.ts'
2
+ import { createTraceContext } from '../shared/createTraceContext.ts'
3
+ import { healthSeedSlot } from '../shared/healthSeedSlot.ts'
4
+ import { requestScopeSlot } from '../shared/requestScopeSlot.ts'
5
+ import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
6
+ import { setAppName } from '../shared/setAppName.ts'
7
+ import type { SsrBootState } from '../shared/types/SsrBootState.ts'
8
+
9
+ /* Seeds every `__SSR__` boot-state field into its ambient slot. The map is typed
10
+ EXHAUSTIVELY over `SsrBootState`, so a boot field can't be added to the payload
11
+ without a seeder here — the compile error is what guarantees a stamped field is
12
+ never silently dropped client-side. Base is seeded first (object-key order), as the
13
+ prior hand-written sequence required: the cache seed that follows keys call urls
14
+ through the base resolver. */
15
+ const SEED: { [K in keyof Required<SsrBootState>]: (value: SsrBootState[K]) => void } = {
16
+ base: (value) => {
17
+ baseSlot.resolver = () => value ?? ''
18
+ },
19
+ app: (value) => setAppName(value),
20
+ health: (value) => {
21
+ healthSeedSlot.payload = value
22
+ },
23
+ clientTimeout: (value) => {
24
+ rpcTimeoutSlot.ms = value
25
+ },
26
+ /* Continue the SSR request's trace (or mint a fresh sampled one if the stamp is
27
+ missing) and publish the browser's request scope: trace(), log line prefixes, and
28
+ the RPC traceparent header resolve through it. elapsedMs is navigation-relative —
29
+ exactly what performance.now() measures — and the path tracks client navigations
30
+ because the resolver reads location per call. */
31
+ trace: (value) => {
32
+ const traceContext = createTraceContext(value)
33
+ requestScopeSlot.resolver = () => ({
34
+ trace: traceContext,
35
+ elapsedMs: performance.now(),
36
+ method: 'GET',
37
+ path: typeof location === 'undefined' ? '' : location.pathname,
38
+ })
39
+ },
40
+ }
41
+
42
+ /* Applies one seeder to its own field — generic over the key so the seeder and its
43
+ value stay correlated (a bare `SEED[key](boot[key])` loses the correlation). */
44
+ function seedField<K extends keyof SsrBootState>(key: K, boot: SsrBootState): void {
45
+ SEED[key](boot[key])
46
+ }
47
+
48
+ /* Seeds all boot-state slots from the payload, before mount. */
49
+ export function seedBootState(boot: SsrBootState): void {
50
+ for (const key of Object.keys(SEED) as Array<keyof SsrBootState>) {
51
+ seedField(key, boot)
52
+ }
53
+ }
@@ -1,10 +1,10 @@
1
- import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
2
- import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
3
1
  import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
4
2
  import { decodeRefJson } from '../shared/decodeRefJson.ts'
5
3
  import { encodeRefJson } from '../shared/encodeRefJson.ts'
6
4
  import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
7
5
  import type { SocketChannel } from '../shared/types/SocketChannel.ts'
6
+ import type { SocketClientFrame } from '../shared/types/SocketClientFrame.ts'
7
+ import type { SocketServerFrame } from '../shared/types/SocketServerFrame.ts'
8
8
  import { withBase } from '../shared/withBase.ts'
9
9
 
10
10
  let singleton: SocketChannel | undefined