@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
@@ -80,11 +80,6 @@ export type TemplateNode =
80
80
  the prop name's source offset — the anchor for an excess-prop diagnostic. */
81
81
  props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
82
82
  children: TemplateNode[]
83
- /* `client:idle` / `client:visible` on the tag → hydrate this child as an island
84
- (server markup kept, build deferred to the trigger). Extracted from the attributes
85
- so it never becomes a prop; the build back-end passes it to `mountChild`, SSR and
86
- the prop check ignore it (an island renders fully on the server). */
87
- clientTrigger?: 'idle' | 'visible'
88
83
  }
89
84
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
90
85
  /* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
@@ -1,5 +1,6 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
+ import { assertClaimedText } from './assertClaimedText.ts'
3
4
 
4
5
  /*
5
6
  A static (non-reactive) text node under `parent`: created and appended (create
@@ -12,10 +13,17 @@ export function appendStatic(parent: Node, value: string, splitAlways = false):
12
13
  const hydration = RENDER.hydration
13
14
  if (hydration !== undefined) {
14
15
  const node = claimChild(hydration, parent) as unknown as Text
16
+ const isText = node !== null && typeof node.splitText === 'function'
17
+ /* The claimed SSR node must begin with this literal, or the split misaligns — throw
18
+ legibly at the divergence (only when a text node is actually here; a structural
19
+ mismatch is claimExpected's domain). */
20
+ if (isText) {
21
+ assertClaimedText(node, value)
22
+ }
15
23
  /* Split even on an exact-length consume when a sibling text binding follows
16
24
  (`splitAlways`), so it gets its own node; see appendText for the rationale. */
17
25
  if (
18
- node !== null &&
26
+ isText &&
19
27
  (splitAlways ? value.length <= node.data.length : value.length < node.data.length)
20
28
  ) {
21
29
  node.splitText(value.length)
@@ -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,20 +1,17 @@
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
- import { scheduleWake } from '../runtime/scheduleWake.ts'
11
9
  import { scope } from '../runtime/scope.ts'
12
10
  import { scopeGroup } from '../runtime/scopeGroup.ts'
13
11
  import type { State } from '../runtime/types/State.ts'
14
12
  import { state } from '../state.ts'
15
13
  import { buildDetachedRange } from './buildDetachedRange.ts'
16
14
  import { discardBoundary } from './discardBoundary.ts'
17
- import { firstElementBetween } from './firstElementBetween.ts'
18
15
  import { removeRange } from './removeRange.ts'
19
16
 
20
17
  /*
@@ -60,20 +57,11 @@ export function awaitBlock(
60
57
  let active: { start: Comment; end: Comment; dispose: () => void } | undefined
61
58
  let anchor: Node | undefined
62
59
  let first = true
63
- /* A deferred (inert-adopted) block schedules a wake (idle or visible); cancel it if the
64
- owner tears the block out before it fires, so no late re-run touches a detached anchor. */
65
- let cancelWake: (() => void) | undefined
66
60
  /* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
67
61
  owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
68
62
  `{#for}`/component tears this block out is abandoned — otherwise its settle runs
69
- `place` on the block's now-detached anchor and `insertBefore` throws NotFoundError.
70
- The teardown also cancels a pending wake (above). */
71
- const guard = generationGuard(() => cancelWake?.())
72
- /* Flipped by a deferred block's wake. Read at the top of the effect so the block subscribes
73
- to it and re-runs when it flips — the same re-run path an invalidate takes, so wake needs
74
- no bespoke build logic. Never flips for a non-deferred block, so it costs one inert read
75
- per run there. */
76
- const woken = state(false)
63
+ `place` on the block's now-detached anchor and `insertBefore` throws NotFoundError. */
64
+ const guard = generationGuard()
77
65
  /* The resolved value, held as a reactive cell so the then-branch reads it through its
78
66
  own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
79
67
  the branch — the branch (and any keyed `each` inside it) survives and updates in place,
@@ -192,53 +180,6 @@ export function awaitBlock(
192
180
  active = { start, end, dispose }
193
181
  }
194
182
 
195
- /* Inert adoption — a deferred `{#await cache()}` (large payload, shipped as a `{defer,key}`
196
- resume marker). Adopt the SSR then-branch WITHOUT building its bindings or materializing
197
- the awaited value: the server markup is already correct, so keep it verbatim and skip the
198
- payload decode entirely (the boot-path cost deferral targets). Only subscribe THIS block's
199
- effect to the cache key — reads, decodes and fetches all wait. A later cache.invalidate
200
- re-runs the effect, which then reads the value for real and builds a fresh branch. Sound
201
- because a display-first read replaces the whole branch on re-read anyway, so there is no
202
- extra flash beyond the swap the re-read already performs. */
203
- const adoptInert = (open: Node | null, key: string): void => {
204
- const cursor = hydration as NonNullable<typeof hydration>
205
- const firstKept = open?.nextSibling ?? null
206
- /* Scan to THIS block's close marker, leaving every server node in place. Nested
207
- blocks carry different ids, so the first data match is our own close. */
208
- let node: Node | null = firstKept
209
- while (node !== null && (node as { data?: string }).data !== `/abide:await:${id}`) {
210
- node = node.nextSibling
211
- }
212
- const close = node
213
- cursor.next.set(parent, close?.nextSibling ?? null)
214
- /* Bracket the kept nodes as a `[`…`]` range + park an anchor, identical to `adopt`,
215
- so the first re-run's `place`/`detach` evicts them like any other branch. */
216
- const start = document.createComment(RANGE_OPEN)
217
- parent.insertBefore(start, firstKept ?? close)
218
- const end = document.createComment(RANGE_CLOSE)
219
- parent.insertBefore(end, close)
220
- anchor = document.createTextNode('')
221
- parent.insertBefore(anchor, close)
222
- /* No scope/effects were built for the kept nodes, so disposal only evicts the range. */
223
- active = { start, end, dispose: () => undefined }
224
- /* Not 'then' — the first re-run rebuilds the branch fresh via `place`. */
225
- activeKind = 'pending'
226
- /* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
227
- cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
228
- activeCacheStore().subscribe(key)
229
- /* Wake the inert branch — it's a boot-frame optimization, never a lasting mode. The
230
- wake flips `woken`, re-running the effect through its normal `render` path: the
231
- lazily-seeded cache reads warm, so the branch materializes live and interactive with
232
- no fetch. Closes the "deferred grid stays inert until invalidate" gap.
233
-
234
- Position picks the trigger ('auto'): a branch with an element wakes on VISIBLE (a
235
- below-the-fold grid decodes only when scrolled to, one never reached costs nothing);
236
- an empty branch or a DOM with no observer wakes on IDLE — never lingering inert. */
237
- cancelWake = scheduleWake('auto', firstElementBetween(firstKept, close), () => {
238
- woken.value = true
239
- })
240
- }
241
-
242
183
  /* Discard the SSR boundary and (re)build the block from the live promise, fresh
243
184
  (hydration off) — the recovery path when adoption can't use the server markup. */
244
185
  const rebuildCold = (open: Node | null): void => {
@@ -275,28 +216,28 @@ export function awaitBlock(
275
216
  lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
276
217
  through to the live promise rather than crash hydration. */
277
218
  const raw = RESUME[id]
278
- let decoded: ResumeEntry | DeferMarker | undefined
219
+ let decoded: ResumeEntry | undefined
279
220
  if (raw !== undefined) {
280
221
  try {
281
- decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
222
+ decoded = decodeRefJson(raw) as ResumeEntry
282
223
  } catch {
283
224
  decoded = undefined
284
225
  }
285
226
  }
286
- /* A deferred marker ships the cache key, not the value adopt the server branch inert
287
- and skip the decode. Intercept before the ok/catch logic. Object-guard the `in`: a
288
- corrupt manifest decoding to a primitive/null must read as "no resume" (fall through),
289
- not throw past the decode try/catch and crash hydration. */
290
- if (typeof decoded === 'object' && decoded !== null && 'defer' in decoded) {
291
- adoptInert(open, decoded.key)
292
- return
293
- }
294
- /* Non-deferred: read the promise now so the block subscribes to its reactive source
295
- (a cache key) — warm on resume, so no round-trip — then adopt the resume value /
296
- 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. */
297
230
  const result = promiseThunk()
298
- const entry = decoded as ResumeEntry | undefined
231
+ const entry = decoded
299
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
+ }
300
241
  /* Build the adopted branch around a value CELL (then) so a later re-run updates
301
242
  it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
302
243
  stays OUTSIDE the adopt try/catch so it surfaces rather than triggering the
@@ -354,17 +295,12 @@ export function awaitBlock(
354
295
  }
355
296
 
356
297
  effect(() => {
357
- /* Subscribe to the wake cell every run (including the first, before it can flip) so a
358
- deferred block's idle wake re-runs this effect. Inert for non-deferred blocks. */
359
- woken.value
360
298
  guard.renew()
361
299
  if (first) {
362
300
  first = false
363
301
  if (hydration !== undefined) {
364
- /* firstHydrate reads the promise ITSELF, after checking the resume marker: a
365
- deferred block must not invoke promiseThunk (it would materialize/fetch the
366
- value we're deferring) and subscribes by key instead; every other path reads
367
- 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. */
368
304
  firstHydrate()
369
305
  return
370
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
  }
@@ -29,19 +29,16 @@ export function mountChild(
29
29
  props: Parameters<UiComponent>[1],
30
30
  before: Node | null = null,
31
31
  label?: string,
32
- /* `client:idle` / `client:visible` on the tag → hydrate this child as an island (server
33
- markup kept, build deferred to the trigger). Undefined = hydrate eagerly, as before. */
34
- clientTrigger?: 'idle' | 'visible',
35
32
  ): void {
36
33
  const moduleId = factory.__abideId
37
34
  if (!__ABIDE_DEV__ || !hotReloadEnabled.current || moduleId === undefined) {
38
- mountRange(parent, factory.build, props, before, label, clientTrigger)
35
+ mountRange(parent, factory.build, props, before, label)
39
36
  return
40
37
  }
41
38
  /* Capture the component's model alongside its mount handle, so a later swap can
42
39
  carry its state across (see `hotReplace`). */
43
40
  const { value: handle, model } = captureModelDoc(() =>
44
- mountRange(parent, factory.build, props, before, label, clientTrigger),
41
+ mountRange(parent, factory.build, props, before, label),
45
42
  )
46
43
  const instance = {
47
44
  factory,
@@ -1,15 +1,9 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
2
1
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
2
  import { RENDER } from '../runtime/RENDER.ts'
4
- import { scheduleWake } from '../runtime/scheduleWake.ts'
5
3
  import { scope } from '../runtime/scope.ts'
6
4
  import type { UiProps } from '../runtime/types/UiProps.ts'
7
- import { clearBetween } from './clearBetween.ts'
8
- import { commentData } from './commentData.ts'
9
5
  import { disposeRange } from './disposeRange.ts'
10
6
  import { fillRange } from './fillRange.ts'
11
- import { firstElementBetween } from './firstElementBetween.ts'
12
- import { markerDepthDelta } from './markerDepthDelta.ts'
13
7
  import { openMarker } from './openMarker.ts'
14
8
  import { withScope } from './withScope.ts'
15
9
 
@@ -28,12 +22,6 @@ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the b
28
22
  `anchorCursor`) places the range among static siblings on create; hydrate ignores it
29
23
  (the claim cursor drives placement). Returns the markers + a disposer so the hot path
30
24
  can rebuild in place.
31
-
32
- `clientTrigger` marks the component an ISLAND: on hydrate its server markup is kept
33
- verbatim and its build is SKIPPED, then run on the trigger (`'idle'`/`'visible'`) —
34
- so a below-the-fold widget ships as HTML and wires no effects until scrolled to (or
35
- the first idle gap), off the critical boot path. Create ignores it (there is no server
36
- markup to keep — an island is a hydration-cost optimization). See `adoptIsland`.
37
25
  */
38
26
  // @documentation plumbing
39
27
  export function mountRange(
@@ -42,7 +30,6 @@ export function mountRange(
42
30
  props: UiProps | undefined,
43
31
  before: Node | null = null,
44
32
  label: string | undefined = undefined,
45
- clientTrigger: 'idle' | 'visible' | undefined = undefined,
46
33
  ): { start: Comment; end: Comment; dispose: () => void } {
47
34
  const hydration = RENDER.hydration
48
35
  const start = openMarker(parent, RANGE_OPEN, before)
@@ -50,9 +37,6 @@ export function mountRange(
50
37
  const end = openMarker(parent, RANGE_CLOSE, before)
51
38
  return fillRange(start, end, build, props, label)
52
39
  }
53
- if (clientTrigger !== undefined) {
54
- return adoptIsland(parent, start, build, props, label, clientTrigger, hydration)
55
- }
56
40
  /* Hydrate: adopt the server range in place. Establish the child's lexical scope
57
41
  and render pass (same as `fillRange`), build claiming the existing nodes, then
58
42
  claim the end marker the build's content stops before. */
@@ -60,62 +44,3 @@ export function mountRange(
60
44
  const end = openMarker(parent, RANGE_CLOSE)
61
45
  return { start, end, dispose: disposeRange(scoped, start, end) }
62
46
  }
63
-
64
- /*
65
- Island hydration: keep the server markup, skip the build, wake it later on the trigger. Scan the
66
- child's server nodes to THIS range's depth-matched close (`markerDepthDelta` balances nested
67
- `[`…`]` / `abide:` ranges) — keeping them and advancing the claim cursor past them, so the rest
68
- of the page hydrates around the skipped subtree — then claim the close marker. No scope is built,
69
- so the inert disposer only clears the kept nodes; the wake clears them and builds the child fresh
70
- (hydration off) between the same markers, exactly like a create-path mount.
71
- */
72
- function adoptIsland(
73
- parent: Node,
74
- start: Comment,
75
- build: (host: Node, props?: UiProps) => void,
76
- props: UiProps | undefined,
77
- label: string | undefined,
78
- trigger: 'idle' | 'visible',
79
- hydration: NonNullable<(typeof RENDER)['hydration']>,
80
- ): { start: Comment; end: Comment; dispose: () => void } {
81
- const firstKept = claimChild(hydration, parent)
82
- let node = firstKept
83
- let depth = 0
84
- while (node !== null) {
85
- const data = commentData(node)
86
- if (data !== undefined) {
87
- const delta = markerDepthDelta(data)
88
- /* A depth-0 close is THIS range's own; anything nested balances out first. */
89
- if (delta === -1 && depth === 0) {
90
- break
91
- }
92
- depth += delta
93
- }
94
- node = node.nextSibling
95
- }
96
- hydration.next.set(parent, node)
97
- const end = openMarker(parent, RANGE_CLOSE)
98
-
99
- /* Inert: no scope/effects for the kept nodes, so disposal only evicts the range. The wake
100
- swaps in a real build whose own disposer replaces this one. */
101
- let disposeContent = (): void => clearBetween(start, end)
102
- const wake = (): void => {
103
- disposeContent()
104
- const previous = RENDER.hydration
105
- RENDER.hydration = undefined
106
- try {
107
- disposeContent = fillRange(start, end, build, props, label).dispose
108
- } finally {
109
- RENDER.hydration = previous
110
- }
111
- }
112
- const cancelWake = scheduleWake(trigger, firstElementBetween(firstKept, end), wake)
113
- return {
114
- start,
115
- end,
116
- dispose: () => {
117
- cancelWake()
118
- disposeContent()
119
- },
120
- }
121
- }
@@ -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 {
@@ -1,5 +1,4 @@
1
1
  import { snippet } from '../shared/snippet.ts'
2
- import { deferResume } from './deferResume.ts'
3
2
  import { anchorCursor } from './dom/anchorCursor.ts'
4
3
  import { appendSnippet } from './dom/appendSnippet.ts'
5
4
  import { appendStatic } from './dom/appendStatic.ts'
@@ -8,6 +7,7 @@ import { appendTextAt } from './dom/appendTextAt.ts'
8
7
  import { attach } from './dom/attach.ts'
9
8
  import { attr } from './dom/attr.ts'
10
9
  import { awaitBlock } from './dom/awaitBlock.ts'
10
+ import { bindSelectValue } from './dom/bindSelectValue.ts'
11
11
  import { cloneStatic } from './dom/cloneStatic.ts'
12
12
  import { each } from './dom/each.ts'
13
13
  import { eachAsync } from './dom/eachAsync.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) })