@abide/abide 0.53.0 → 0.55.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 (129) hide show
  1. package/AGENTS.md +330 -494
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +166 -146
  4. package/package.json +3 -4
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/rpc/defineRpc.ts +2 -1
  7. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  9. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  10. package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
  11. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  12. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  13. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  14. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  15. package/src/lib/server/sockets/defineSocket.ts +11 -1
  16. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  17. package/src/lib/shared/SEEDS.ts +33 -0
  18. package/src/lib/shared/SOCKET_SEED.ts +12 -0
  19. package/src/lib/shared/amend.ts +48 -0
  20. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  21. package/src/lib/shared/applyAmendLocally.ts +20 -0
  22. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  23. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  24. package/src/lib/shared/cache.ts +203 -84
  25. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  26. package/src/lib/shared/createCacheStore.ts +8 -0
  27. package/src/lib/shared/createRemoteFunction.ts +4 -1
  28. package/src/lib/shared/encodeSeedValue.ts +24 -0
  29. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  30. package/src/lib/shared/peek.ts +4 -7
  31. package/src/lib/shared/socketTailsSlot.ts +13 -0
  32. package/src/lib/shared/types/AmendApply.ts +15 -0
  33. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  34. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  35. package/src/lib/shared/types/SocketTails.ts +13 -0
  36. package/src/lib/shared/types/SsrPayload.ts +6 -0
  37. package/src/lib/test/createTestApp.ts +6 -0
  38. package/src/lib/ui/amendReaderHook.ts +125 -0
  39. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  40. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  41. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  42. package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
  43. package/src/lib/ui/compile/compileComponent.ts +1 -3
  44. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  45. package/src/lib/ui/compile/desugarSignals.ts +65 -36
  46. package/src/lib/ui/compile/generateBuild.ts +25 -24
  47. package/src/lib/ui/compile/generateSSR.ts +37 -24
  48. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  49. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  50. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  51. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  52. package/src/lib/ui/compile/lowerContext.ts +26 -6
  53. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  54. package/src/lib/ui/compile/lowerScript.ts +1 -11
  55. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  56. package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
  57. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
  58. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  59. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  60. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  61. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  62. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  63. package/src/lib/ui/computed.ts +16 -0
  64. package/src/lib/ui/createScope.ts +4 -3
  65. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  66. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  67. package/src/lib/ui/dom/appendStatic.ts +11 -20
  68. package/src/lib/ui/dom/appendText.ts +41 -43
  69. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  70. package/src/lib/ui/dom/awaitBlock.ts +31 -45
  71. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  72. package/src/lib/ui/dom/claimText.ts +38 -0
  73. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  74. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  75. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  76. package/src/lib/ui/dom/each.ts +3 -5
  77. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  78. package/src/lib/ui/dom/hydrate.ts +5 -12
  79. package/src/lib/ui/dom/mountChild.ts +73 -5
  80. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  81. package/src/lib/ui/dom/openMarker.ts +2 -4
  82. package/src/lib/ui/dom/outlet.ts +4 -3
  83. package/src/lib/ui/dom/readCell.ts +26 -11
  84. package/src/lib/ui/dom/skeleton.ts +2 -7
  85. package/src/lib/ui/dom/switchBlock.ts +2 -1
  86. package/src/lib/ui/dom/tryBlock.ts +27 -39
  87. package/src/lib/ui/dom/when.ts +6 -2
  88. package/src/lib/ui/linked.ts +14 -0
  89. package/src/lib/ui/resumeSeedScript.ts +1 -1
  90. package/src/lib/ui/router.ts +12 -16
  91. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  92. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  93. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  94. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  95. package/src/lib/ui/runtime/RENDER.ts +7 -5
  96. package/src/lib/ui/runtime/RESUME.ts +5 -7
  97. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  98. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  99. package/src/lib/ui/runtime/claimChild.ts +5 -7
  100. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  101. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  102. package/src/lib/ui/runtime/claimRun.ts +22 -0
  103. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  104. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  105. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  106. package/src/lib/ui/runtime/createDoc.ts +5 -1
  107. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  108. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  109. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  110. package/src/lib/ui/runtime/readNode.ts +21 -1
  111. package/src/lib/ui/runtime/renderPath.ts +2 -2
  112. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  113. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  114. package/src/lib/ui/runtime/runNode.ts +14 -0
  115. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  116. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  117. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  118. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  119. package/src/lib/ui/settleAsyncCells.ts +30 -3
  120. package/src/lib/ui/socketProxy.ts +17 -1
  121. package/src/lib/ui/startClient.ts +23 -0
  122. package/src/lib/ui/tryEncodeResume.ts +6 -15
  123. package/src/lib/ui/types/Scope.ts +1 -1
  124. package/src/lib/ui/watch.ts +21 -16
  125. package/src/serverEntry.ts +15 -0
  126. package/src/lib/shared/patch.ts +0 -41
  127. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
  128. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  129. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
@@ -71,7 +71,7 @@ export type RemoteFunction<
71
71
  reloads lazily, while `refresh(args?)` REFETCHES now keeping the stale value visible — both
72
72
  apply locally on the client and broadcast to every connected client from the server.
73
73
  `peek(args?)` reads the retained value synchronously (a streaming rpc peeks its latest
74
- frame). `patch` is fetch-only, so it is omitted for a streaming rpc (a stream isn't a
74
+ frame). `amend` is fetch-only, so it is omitted for a streaming rpc (a stream isn't a
75
75
  memoized value) via the intersection below. */
76
76
  pending(args?: Args): boolean
77
77
  refreshing(args?: Args): boolean
@@ -84,22 +84,33 @@ export type RemoteFunction<
84
84
  shadow the server `error()` thrower). Reads the rpc error registry captured at the call
85
85
  boundary; the cache is untouched. */
86
86
  error(args?: Args): RpcError<Errors> | undefined
87
- /* Client-only reaction sugar: `fn.watch(handler)` / `fn.watch(args, handler)` ≡
88
- `watch(fn, …)` — runs the smart read reactively and pipes each resolved value to the
89
- handler, returning a scope-tied disposer. Reaction is a client concern (`watch` is a ui
90
- primitive), so this is an inert no-op server-side; an author `fn.watch(…)` surviving the
91
- SSR effect-strip (which leaves member calls intact) is a safe no-op there. The real method
92
- is attached client-side by remoteProxy. */
93
- watch(handler: (value: Return) => void): () => void
94
- watch(args: Args, handler: (value: Return) => void): () => void
95
- } /* `patch` is fetch-only: a streaming rpc has no single memoized value to mutate, so the
96
- method is present only when `Return` is not an AsyncIterable. Two signatures: with args
97
- (`patch(args, updater)`) and without (`patch(updater)` — every args-variant). Tuple-wrapped
98
- so the conditional doesn't distribute over a `never` Return (an error-only rpc). */ & ([
99
- Return,
100
- ] extends [AsyncIterable<unknown>]
101
- ? Record<never, never>
87
+ } /* Client-only reaction sugar: `fn.watch(handler)` / `fn.watch(args, handler)` ≡ `watch(fn, …)`
88
+ — runs the smart read reactively and pipes each resolved value to the handler, returning a
89
+ scope-tied disposer. Inert no-op server-side (reaction is a ui concern; the real method is
90
+ attached client-side by remoteProxy). The keyed overload collapses away for a no-input rpc
91
+ (`undefined extends Args`) — there is no key to scope to (ADR-0043 §5, type-only). */ & (undefined extends Args
92
+ ? {
93
+ watch(handler: (value: Return) => void): () => void
94
+ }
102
95
  : {
103
- patch(args: Args | undefined, updater: (current: Return) => Return): void
104
- patch(updater: (current: Return) => Return): void
105
- })
96
+ watch(handler: (value: Return) => void): () => void
97
+ watch(args: Args, handler: (value: Return) => void): () => void
98
+ }) /* `amend` is fetch-only: a streaming rpc has no single memoized value to mutate, so the
99
+ method is present only when `Return` is not an AsyncIterable. The last argument is a
100
+ replacement `Return` value (broadcastable — ADR-0043) or an updater `(current) => Return`
101
+ (a local closure). For a keyed rpc the value/updater takes the args key first; the bare
102
+ `amend(updater)` covers every args-variant (local-only). For a no-input rpc the key
103
+ collapses away (`amend(value)` / `amend(updater)`). Tuple-wrapped so the conditional
104
+ doesn't distribute over a `never` Return (an error-only rpc). */ &
105
+ ([Return] extends [AsyncIterable<unknown>]
106
+ ? Record<never, never>
107
+ : undefined extends Args
108
+ ? {
109
+ amend(value: Return): void
110
+ amend(updater: (current: Return) => Return): void
111
+ }
112
+ : {
113
+ amend(args: Args, value: Return): void
114
+ amend(args: Args, updater: (current: Return) => Return): void
115
+ amend(updater: (current: Return) => Return): void
116
+ })
@@ -0,0 +1,13 @@
1
+ /*
2
+ The per-request list of retained socket frames read via `peek(socket)` during an SSR render, each
3
+ keyed by the socket's name. `defineSocket`'s server `peek()` pushes an entry when a retained frame is
4
+ read server-side; the page renderer stamps them into `__SSR__.sockets` (ref-json-encoded, last write
5
+ per name winning) so the client seeds the socket's latest frame WARM — a not-yet-connected client's
6
+ `peek(socket)` then returns the same retained value the SSR render committed to instead of `undefined`,
7
+ which would otherwise diverge from the server HTML and trip a hydration desync. Sibling of
8
+ `ResolvedCells` — sockets carry a server value forward like cells, not withhold on the client like
9
+ cache (whose server peek is uniformly undefined).
10
+ */
11
+ export type SocketTails = {
12
+ entries: { name: string; value: unknown }[]
13
+ }
@@ -27,4 +27,10 @@ export type SsrPayload = SsrBootState & {
27
27
  write to each slot adopts the server value, so a plain `state(uuid())`/`state(Date.now())`
28
28
  keeps the SSR value instead of recomputing a divergent one. */
29
29
  docs?: Record<string, string>
30
+ /* Retained socket frames read via `peek(socket)` during SSR, keyed by socket name → ref-json
31
+ string. `startClient` seeds these into `SOCKET_SEED`; a hydrating `socketProxy` adopts the frame
32
+ as its `lastFrame` so `peek(socket)` returns the SAME retained value the server rendered instead
33
+ of `undefined` on the not-yet-connected client — otherwise the two disagree and hydration
34
+ discards the server markup. */
35
+ sockets?: Record<string, string>
30
36
  }
@@ -47,6 +47,7 @@ import { pendingAsyncCellsSlot } from '../shared/pendingAsyncCellsSlot.ts'
47
47
  import { resolvedCellsSlot } from '../shared/resolvedCellsSlot.ts'
48
48
  import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
49
49
  import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
50
+ import { socketTailsSlot } from '../shared/socketTailsSlot.ts'
50
51
  import { streamedCellsSlot } from '../shared/streamedCellsSlot.ts'
51
52
  import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
52
53
  import type { Socket } from '../shared/types/Socket.ts'
@@ -111,6 +112,7 @@ export async function createTestApp(): Promise<TestApp> {
111
112
  pendingAsyncCellsResolver: pendingAsyncCellsSlot.resolver,
112
113
  resolvedCellsResolver: resolvedCellsSlot.resolver,
113
114
  streamedCellsResolver: streamedCellsSlot.resolver,
115
+ socketTailsResolver: socketTailsSlot.resolver,
114
116
  docSnapshotsResolver: docSnapshotsSlot.resolver,
115
117
  activeServer: serverSlot.active,
116
118
  pageRender: pageRenderSlot.render,
@@ -129,6 +131,9 @@ export async function createTestApp(): Promise<TestApp> {
129
131
  const sharedStreamedCells = { entries: [] }
130
132
  streamedCellsSlot.resolver = () =>
131
133
  requestContext.getStore()?.streamedCells ?? sharedStreamedCells
134
+ /* No shared fallback — mirrors serverEntry: `defineSocket.peek` records off-request too, so a
135
+ fallback would leak. Off-request this resolves undefined and the record is skipped. */
136
+ socketTailsSlot.resolver = () => requestContext.getStore()?.socketTails
132
137
  const sharedDocSnapshots = { entries: [] }
133
138
  docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
134
139
  pageSlot.resolver = resolvePageSnapshot
@@ -218,6 +223,7 @@ export async function createTestApp(): Promise<TestApp> {
218
223
  pendingAsyncCellsSlot.resolver = previous.pendingAsyncCellsResolver
219
224
  resolvedCellsSlot.resolver = previous.resolvedCellsResolver
220
225
  streamedCellsSlot.resolver = previous.streamedCellsResolver
226
+ socketTailsSlot.resolver = previous.socketTailsResolver
221
227
  docSnapshotsSlot.resolver = previous.docSnapshotsResolver
222
228
  serverSlot.active = previous.activeServer
223
229
  pageRenderSlot.render = previous.pageRender
@@ -0,0 +1,125 @@
1
+ import { AMEND_TOPIC_PREFIX } from '../shared/AMEND_TOPIC_PREFIX.ts'
2
+ import { abideLog } from '../shared/abideLog.ts'
3
+ import { cache } from '../shared/cache.ts'
4
+ import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
5
+ import type { CacheReaderHook } from '../shared/types/CacheReaderHook.ts'
6
+ import type { Socket } from '../shared/types/Socket.ts'
7
+ import { socketProxy } from './socketProxy.ts'
8
+
9
+ /* A cache key is a broadcastable amend target only when it carries cross-client wire
10
+ identity — a remote call key (`METHOD /url…`, per keyForRemoteCall). Producer keys
11
+ (per-process reference ids) and anything else can't be pushed across clients, so we
12
+ never open a subscription for them. */
13
+ const REMOTE_KEY = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD) \//
14
+
15
+ /*
16
+ The client-only reactive-reader lifecycle hook (ADR-0043) installed into
17
+ cacheReaderSocketSlot by startClient. When a remote key gains its first live reader it
18
+ opens a subscription to that call's reserved amend topic and folds each pushed value
19
+ into the cache; when the key loses its last reader it closes the subscription — so the
20
+ set of subscribed topics stays congruent with the set of keys this tab is reading (and
21
+ therefore already authorized for). Refcounted per key so co-readers (or the active/shared
22
+ store both reporting a reader) share one subscription.
23
+
24
+ The channel is live-only (no tail): a value pushed while this tab was disconnected is
25
+ missed, so on a transport loss the hook re-opens a fresh live iterator AND refreshes the
26
+ key — the ADR-0043 convergence rule, reconciling against authoritative server state
27
+ rather than leaving a silently-stale value. `openSocket` is injectable so a test can drive
28
+ frames over its own channel without a live ws.
29
+ */
30
+ export function createAmendReaderHook(
31
+ openSocket: (name: string) => Socket<unknown> = socketProxy,
32
+ ): { hook: CacheReaderHook; dispose: () => void } {
33
+ const open = new Map<string, { readers: number; dispose: () => void }>()
34
+
35
+ function subscribe(key: string): () => void {
36
+ const socket = openSocket(AMEND_TOPIC_PREFIX + key)
37
+ const controller = new AbortController()
38
+ /* Opening the shared ws channel must never propagate into the cache read that engaged
39
+ this key — a read can't fail because an optional real-time subscription couldn't open
40
+ (e.g. no ws transport). Log and give up on this key's push channel; SWR still holds. */
41
+ let iterator: AsyncIterator<unknown>
42
+ try {
43
+ iterator = socket[Symbol.asyncIterator]()
44
+ } catch (error) {
45
+ abideLog.error(error)
46
+ return () => undefined
47
+ }
48
+ ;(async () => {
49
+ while (!controller.signal.aborted) {
50
+ let next: IteratorResult<unknown>
51
+ try {
52
+ next = await iterator.next()
53
+ } catch (error) {
54
+ if (controller.signal.aborted) {
55
+ return
56
+ }
57
+ /* Transport loss: reconcile the possibly-missed value against server truth,
58
+ then re-open a fresh LIVE subscription (no replay) and keep going. */
59
+ if (error instanceof SocketDisconnectedError) {
60
+ cache.refreshMatching((entry) => entry.key === key, key, undefined, key)
61
+ try {
62
+ iterator = socket[Symbol.asyncIterator]()
63
+ } catch (reopenError) {
64
+ abideLog.error(reopenError)
65
+ return
66
+ }
67
+ continue
68
+ }
69
+ abideLog.error(error)
70
+ return
71
+ }
72
+ if (controller.signal.aborted || next.done === true) {
73
+ return
74
+ }
75
+ /* One malformed frame must not tear down the live pipe. Frames come only from the
76
+ trusted server (clientPublish is false), so this is defense in depth. */
77
+ try {
78
+ cache.amendByKey(key, next.value)
79
+ } catch (error) {
80
+ abideLog.error(error)
81
+ }
82
+ }
83
+ })()
84
+ return () => {
85
+ controller.abort()
86
+ iterator.return?.(undefined)?.catch(() => undefined)
87
+ }
88
+ }
89
+
90
+ const hook: CacheReaderHook = {
91
+ engage(key) {
92
+ if (!REMOTE_KEY.test(key)) {
93
+ return
94
+ }
95
+ const existing = open.get(key)
96
+ if (existing) {
97
+ existing.readers += 1
98
+ return
99
+ }
100
+ open.set(key, { readers: 1, dispose: subscribe(key) })
101
+ },
102
+ disengage(key) {
103
+ const existing = open.get(key)
104
+ if (existing === undefined) {
105
+ return
106
+ }
107
+ existing.readers -= 1
108
+ if (existing.readers <= 0) {
109
+ existing.dispose()
110
+ open.delete(key)
111
+ }
112
+ },
113
+ }
114
+
115
+ /* Tear down every open subscription — for the startClient disposer, in case reads are
116
+ still mounted when the whole client is torn down (e.g. a test harness). */
117
+ function dispose(): void {
118
+ for (const entry of open.values()) {
119
+ entry.dispose()
120
+ }
121
+ open.clear()
122
+ }
123
+
124
+ return { hook, dispose }
125
+ }
@@ -38,16 +38,12 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
38
38
  { name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
39
39
  { name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
40
40
  { name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
41
+ { name: 'awaitSubject', specifier: 'ui/dom/awaitSubject', alias: '$$awaitSubject' },
41
42
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
42
43
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
43
44
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
44
45
  { name: 'outlet', specifier: 'ui/dom/outlet', alias: '$$outlet' },
45
46
  { name: 'mountChild', specifier: 'ui/dom/mountChild', alias: '$$mountChild' },
46
- {
47
- name: 'mountStreamedChild',
48
- specifier: 'ui/dom/mountStreamedChild',
49
- alias: '$$mountStreamedChild',
50
- },
51
47
  { name: 'mergeProps', specifier: 'ui/dom/mergeProps', alias: '$$mergeProps' },
52
48
  { name: 'spreadProps', specifier: 'ui/dom/spreadProps', alias: '$$spreadProps' },
53
49
  { name: 'restProps', specifier: 'ui/dom/restProps', alias: '$$restProps' },
@@ -56,7 +52,6 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
56
52
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
57
53
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
58
54
  { name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
59
- { name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
60
55
  { name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
61
56
  { name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
62
57
  { name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
@@ -120,7 +120,6 @@ export function analyzeComponent(
120
120
  derivedNames,
121
121
  computedNames,
122
122
  cellReadNames,
123
- blockingCellNames: allBlockingCellNames,
124
123
  droppedReactiveImports,
125
124
  } = lowerScript(
126
125
  fullScriptBody,
@@ -143,7 +142,6 @@ export function analyzeComponent(
143
142
  derivedNames,
144
143
  computedNames,
145
144
  cellReadNames,
146
- blockingCellNames: allBlockingCellNames,
147
145
  nodes,
148
146
  styles,
149
147
  /* Hydration adopts every block in place — including `await`, which resumes
@@ -0,0 +1,23 @@
1
+ /*
2
+ The lowered `{#await X}` subject, normalised so a cell subject AWAITS its resolution
3
+ (ADR-0047): a BARE async-cell reference is passed RAW (not peeked to `$$readCell(cell)`)
4
+ and wrapped in `$$awaitSubject(...)`, which resolves a cell to a promise-of-its-value —
5
+ so `{#await rates}` (a cell) shows pending then the value; any other subject lowers
6
+ unchanged, so `{#await getFoo()}` (a plain promise) is byte-identical. A name currently
7
+ SHADOWED by a block binding (`{#for rates of …}` / `{:then rates}` over a component cell
8
+ `rates`) is the binder's local, not the cell — it lowers through `lowerExpression` like any
9
+ other subject, matching how every other reference site honours the shadow stack. ONE
10
+ decision site shared by both back-ends (the client `awaitBlock` emit and SSR's blocking /
11
+ streaming / flight-hoist emits), so the bare-cell classification can't drift between them.
12
+ */
13
+ export function awaitSubjectExpr(
14
+ promiseCode: string,
15
+ cellReadNames: ReadonlySet<string>,
16
+ isShadowed: (name: string) => boolean,
17
+ lowerExpression: (code: string) => string,
18
+ ): string {
19
+ const bare = promiseCode.trim()
20
+ return cellReadNames.has(bare) && !isShadowed(bare)
21
+ ? `$$awaitSubject(${bare})`
22
+ : lowerExpression(promiseCode)
23
+ }
@@ -0,0 +1,39 @@
1
+ import { messageFromError } from '../../shared/messageFromError.ts'
2
+ import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
3
+
4
+ /*
5
+ Get-or-build the warm shadow program for a project root, cached once per root and shared by
6
+ the interpolation and seed classifiers (whichever is requested first for a root warms it; the
7
+ other reuses it). The shadow program is what makes type-directed async detection possible —
8
+ resolving whether `{getFoo()}` is a promise/stream and lifting it to a streaming/blocking cell.
9
+
10
+ On a build FAILURE the program is cached as `undefined` and the classifiers degrade to today's
11
+ plain-value / syntactic behavior — the build never breaks. But the failure is WARNED once per
12
+ root: a silent `undefined` disables type-directed async detection for EVERY component in the
13
+ root, so a broken tsconfig would otherwise ship a bare `{getFoo()}` as the literal
14
+ `[object Promise]` text with no signal at all. The warn fires once (guarded by `cache.has`), so
15
+ the two classifiers sharing a cache never double-report.
16
+
17
+ `build` is injectable so the warn-once path is testable without a broken project on disk;
18
+ callers pass nothing and get `createShadowProgram`.
19
+ */
20
+ export function cachedShadowProgram(
21
+ cache: Map<string, ShadowProgram | undefined>,
22
+ root: string,
23
+ build: (root: string) => ShadowProgram = createShadowProgram,
24
+ ): ShadowProgram | undefined {
25
+ if (!cache.has(root)) {
26
+ try {
27
+ cache.set(root, build(root))
28
+ } catch (error) {
29
+ cache.set(root, undefined)
30
+ const detail = messageFromError(error)
31
+ console.warn(
32
+ `[abide] couldn't build the type program for ${root}: ${detail}\n` +
33
+ ' type-directed async detection is disabled for this project — a bare promise/stream\n' +
34
+ ' read like {getFoo()} renders as text instead of streaming. Check the tsconfig.',
35
+ )
36
+ }
37
+ }
38
+ return cache.get(root)
39
+ }
@@ -29,8 +29,7 @@ export function compileComponent(
29
29
  a direct caller (tests) omits it and the front-end runs here — threading `classify` and
30
30
  `seedClassify` so type-directed interpolation + cell lowering happen on this path too. */
31
31
  const resolved = analyzed ?? analyzeComponent(source, scopeSeed, classify, seedClassify)
32
- const { script, stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames, nodes } =
33
- resolved
32
+ const { script, stateNames, derivedNames, computedNames, cellReadNames, nodes } = resolved
34
33
  const build = generateBuild(
35
34
  nodes,
36
35
  'host',
@@ -39,7 +38,6 @@ export function compileComponent(
39
38
  computedNames,
40
39
  isLayout,
41
40
  cellReadNames,
42
- blockingCellNames,
43
41
  )
44
42
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
45
43
  injected at runtime; the build only needs the `data-a-…` scope attributes on
@@ -0,0 +1,39 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /*
4
+ Document-order (pre-order) numbering of every `<Child/>` mount site in a template — the
5
+ ordinal a child's render-path roots under (`mountChild` on the client, `$$withPath(ordinal)`
6
+ / `$$renderPath(ordinal)` on the server). ONE walk both back-ends read (the `skeletonContext`
7
+ pattern), replacing the per-back-end `childOrdinal++` counters that numbered components in
8
+ each generator's own EMISSION order — an order that could silently diverge (build emits a
9
+ component's slot content before taking its own ordinal; SSR emits a switch's default case
10
+ after later cases). Numbering from the tree, not the emission, makes SSR↔client ordinal
11
+ congruence hold by construction.
12
+
13
+ Returns the lookup, not the map, so the missing-entry policy lives here too: a miss means a
14
+ back-end reached a mount site the shared walk didn't number — a structural divergence,
15
+ surfaced loudly at compile time rather than as a runtime warm-seed/boundary desync.
16
+ */
17
+ export function componentOrdinals(nodes: TemplateNode[]): (node: TemplateNode) => number {
18
+ const ordinals = new WeakMap<TemplateNode, number>()
19
+ let next = 0
20
+ const walk = (children: TemplateNode[]): void => {
21
+ for (const child of children) {
22
+ if (child.kind === 'component') {
23
+ ordinals.set(child, next)
24
+ next += 1
25
+ }
26
+ if ('children' in child) {
27
+ walk(child.children)
28
+ }
29
+ }
30
+ }
31
+ walk(nodes)
32
+ return (node: TemplateNode): number => {
33
+ const ordinal = ordinals.get(node)
34
+ if (ordinal === undefined) {
35
+ throw new Error('[abide] component mount site not numbered by the shared ordinal walk')
36
+ }
37
+ return ordinal
38
+ }
39
+ }
@@ -5,6 +5,7 @@ import { type ReactiveImportBindings, reactiveImportBindings } from './resolveRe
5
5
  import { signalCallee } from './signalCallee.ts'
6
6
  import type { InterpolationKind } from './types/InterpolationKind.ts'
7
7
  import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
8
+ import { wrapSeed } from './wrapSeed.ts'
8
9
 
9
10
  /* The routing decision for a no-marker `computed(seed)`: `true` routes to the eager
10
11
  `trackedComputed` stream cell (read via `$$readCell`, the `cellReadNames` bucket), `false`
@@ -14,25 +15,21 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
14
15
 
15
16
  const factory = ts.factory
16
17
 
17
- /* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
18
- `function` argument passes through unchanged (the author already wrote the thunk), any
19
- other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
20
- expression contains a top-level `await` (`computed(await load())` → `async () => await
21
- load()`), which is exactly the marker the runtime primitive uses to route to an async cell. */
22
- function wrapSeed(argument: ts.Expression): ts.Expression {
23
- if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
24
- return argument
25
- }
26
- const modifiers = hasTopLevelAwait(argument)
27
- ? [factory.createModifier(ts.SyntaxKind.AsyncKeyword)]
28
- : undefined
18
+ /* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
19
+ type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
20
+ (`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
21
+ value; a plain `() => getFoo()` thunk would fall to trackedComputed's lazy opaque path (its
22
+ probe self-identifies only a stream, never a promise). The parens keep a comma/ternary seed
23
+ a single await operand. Distinct from `wrapSeed`, which only makes the arrow async when the
24
+ arg already carries a top-level `await`. */
25
+ function wrapAwaitSeed(argument: ts.Expression): ts.Expression {
29
26
  return factory.createArrowFunction(
30
- modifiers,
27
+ [factory.createModifier(ts.SyntaxKind.AsyncKeyword)],
31
28
  undefined,
32
29
  [],
33
30
  undefined,
34
31
  factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
35
- argument,
32
+ factory.createAwaitExpression(factory.createParenthesizedExpression(argument)),
36
33
  )
37
34
  }
38
35
 
@@ -213,10 +210,6 @@ export function desugarSignals(
213
210
  derivedNames: Set<string>
214
211
  computedNames: Set<string>
215
212
  cellReadNames: Set<string>
216
- /* The full BLOCKING cell set (ADR-0042): the template-injected `await` cells passed in, unioned
217
- with the script-level `await` computeds/linked collected here. The client template lowering
218
- reads these via `$$readCellBlocking` (suspend-on-pending). */
219
- blockingCellNames: Set<string>
220
213
  } {
221
214
  assertNoRemovedReaders(source)
222
215
  /* The full set of names written anywhere (this script + the template). A prop in this set
@@ -235,10 +228,6 @@ export function desugarSignals(
235
228
  One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
236
229
  sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
237
230
  const cellReadNames = new Set<string>()
238
- /* Script-level BLOCKING cell names (ADR-0042 D6): a `computed`/`linked` whose seed carries a
239
- top-level `await`. Unioned with the template-injected `blockingCellNames` and returned so the
240
- CLIENT template lowering reads them via `$$readCellBlocking` (suspend-on-pending). */
241
- const scriptBlockingNames = new Set<string>()
242
231
  /* A `props()` destructure must be lowered even when it declares no reactive binding
243
232
  (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
244
233
  let hasPropsDestructure = false
@@ -275,6 +264,21 @@ export function desugarSignals(
275
264
  }
276
265
  return isBareCallComputed(declaration)
277
266
  }
267
+ /* A type-directed PROMISE seed (ADR-0023/0043): a no-`await` `state.computed(getFoo())` whose
268
+ seed's checker type resolves to a promise. Routed to an eager STREAMING async cell that
269
+ unwraps the resolved value and reactively re-resolves when its tracked deps change — the
270
+ script-level twin of a bare async interpolation (ADR-0032). Only fires with a warm classifier
271
+ ('promise'); fail-open (no program) leaves it to `isEagerStreamComputed`'s bare-call probe,
272
+ and the `await`-marker seed is excluded (its wrapped thunk is already async). Kept separate
273
+ from `isEagerStreamComputed` so `computedStatements` can wrap it as `async () => await (seed)`
274
+ (a stream seed stays a bare probe thunk). */
275
+ const isPromiseComputed: EagerStreamPredicate = (declaration) => {
276
+ const argument = seedArgument(declaration)
277
+ if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
278
+ return false
279
+ }
280
+ return seedKind(argument) === 'promise'
281
+ }
278
282
  for (const statement of source.statements) {
279
283
  if (!ts.isVariableStatement(statement)) {
280
284
  continue
@@ -321,26 +325,26 @@ export function desugarSignals(
321
325
  `name()` (a sync/promise seed), or an eager cell read via `$$readCell(name)`
322
326
  when the wrapped seed is async (an `await`-lowered / passthrough-`async` thunk)
323
327
  or its seed type resolves to a stream (`isEagerStreamComputed`, ADR-0023). */
324
- if (isAsyncComputed(declaration) || isEagerStreamComputed(declaration)) {
328
+ if (
329
+ isAsyncComputed(declaration) ||
330
+ isEagerStreamComputed(declaration) ||
331
+ isPromiseComputed(declaration)
332
+ ) {
333
+ /* Async `computed` (author `await`), an eager stream, or a promise seed — all
334
+ read via `$$readCell(name)`. Whether a pending read PAUSES (author `await`,
335
+ a blocking cell) or peeks `undefined` (streaming) is carried by the cell
336
+ itself at construction (the `trackedComputed(thunk, streaming)` flag), so the
337
+ read form is uniform and nothing blocking-specific is collected here. */
325
338
  cellReadNames.add(declaration.name.text)
326
- /* A blocking async computed (author `await`) reads suspend-on-pending. */
327
- const argument = seedArgument(declaration)
328
- if (argument !== undefined && isBlockingSeed(argument)) {
329
- scriptBlockingNames.add(declaration.name.text)
330
- }
331
339
  } else {
332
340
  computedNames.add(declaration.name.text)
333
341
  }
334
342
  } else if (callee === 'linked') {
335
343
  /* `linked` → a cell read via `$$readCell(name)`: a plain `State` when the seed
336
344
  is synchronous, an `AsyncState` when it tracks a promise/stream — one read
337
- shape auto-tracks whichever source the runtime primitive resolved to. */
345
+ shape auto-tracks whichever source the runtime primitive resolved to. Its
346
+ blocking-ness (author `await`) likewise rides on the constructed cell. */
338
347
  cellReadNames.add(declaration.name.text)
339
- /* A blocking async linked (author `await`) reads suspend-on-pending, like computed. */
340
- const argument = seedArgument(declaration)
341
- if (argument !== undefined && isBlockingSeed(argument)) {
342
- scriptBlockingNames.add(declaration.name.text)
343
- }
344
348
  } else if (callee === 'state') {
345
349
  /* `state(initial, transform)` → a `.value` cell (its write-coercion transform
346
350
  forces a local store); referenced as `name.value`, unchanged. */
@@ -389,6 +393,7 @@ export function desugarSignals(
389
393
  blockingCellNames,
390
394
  writtenNames,
391
395
  isEagerStreamComputed,
396
+ isPromiseComputed,
392
397
  ),
393
398
  )
394
399
  }
@@ -401,7 +406,6 @@ export function desugarSignals(
401
406
  derivedNames,
402
407
  computedNames,
403
408
  cellReadNames,
404
- blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
405
409
  }
406
410
  }
407
411
 
@@ -417,12 +421,19 @@ function loweredStatement(
417
421
  blockingCellNames: ReadonlySet<string>,
418
422
  writtenNames: ReadonlySet<string>,
419
423
  isEagerStreamComputed: EagerStreamPredicate,
424
+ isPromiseComputed: EagerStreamPredicate,
420
425
  ): ts.Statement[] {
421
426
  rejectMixedDeclaration(statement, bindings)
422
427
  return (
423
428
  injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
424
429
  stateAssignmentStatements(statement, bindings) ??
425
- computedStatements(statement, bindings, signalNames, isEagerStreamComputed) ??
430
+ computedStatements(
431
+ statement,
432
+ bindings,
433
+ signalNames,
434
+ isEagerStreamComputed,
435
+ isPromiseComputed,
436
+ ) ??
426
437
  propsStatements(statement, bindings, writtenNames) ??
427
438
  cellStatements(statement, bindings, signalNames) ?? [statement]
428
439
  )
@@ -613,6 +624,7 @@ function computedStatements(
613
624
  bindings: ReactiveImportBindings,
614
625
  signalNames: ReadonlySet<string>,
615
626
  isEagerStreamComputed: EagerStreamPredicate,
627
+ isPromiseComputed: EagerStreamPredicate,
616
628
  ): ts.Statement[] | undefined {
617
629
  if (!ts.isVariableStatement(statement)) {
618
630
  return undefined
@@ -659,6 +671,23 @@ function computedStatements(
659
671
  ]),
660
672
  ),
661
673
  )
674
+ } else if (argument !== undefined && isPromiseComputed(declaration)) {
675
+ /* Type-directed PROMISE seed (ADR-0023/0043): a bare `state.computed(getFoo())` whose
676
+ seed resolves to a promise. Wrap it as `async () => await (seed)` so createAsyncCell
677
+ unwraps the resolved value — a plain `() => getFoo()` thunk would fall to
678
+ trackedComputed's lazy opaque path (its probe self-identifies only a stream). Passed
679
+ `streaming: true` (the `true` arg): no author `await` → it does NOT join the SSR
680
+ blocking barrier — the shell ships pending and the client resolves + reactively
681
+ re-resolves, the ADR-0032 no-await tier. `await getFoo()` stays the BLOCKING form. */
682
+ statements.push(
683
+ constDeclaration(
684
+ name,
685
+ scopeMethodCall('trackedComputed', [
686
+ wrapAwaitSeed(argument),
687
+ factory.createTrue(),
688
+ ]),
689
+ ),
690
+ )
662
691
  } else if (isEagerStreamComputed(declaration)) {
663
692
  /* Stream seed → the eager `trackedComputed`, which probes the seed and auto-tracks a
664
693
  stream (`AsyncComputed`) or falls back to a lazy computed; read via `$$readCell`.