@abide/abide 0.52.0 → 0.54.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 (80) hide show
  1. package/AGENTS.md +25 -12
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +8 -6
  4. package/package.json +3 -2
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
  8. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  9. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  10. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  11. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  12. package/src/lib/server/sockets/defineSocket.ts +11 -1
  13. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  14. package/src/lib/shared/SOCKET_SEED.ts +13 -0
  15. package/src/lib/shared/amend.ts +48 -0
  16. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  17. package/src/lib/shared/applyAmendLocally.ts +20 -0
  18. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  19. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  20. package/src/lib/shared/cache.ts +35 -10
  21. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  22. package/src/lib/shared/createCacheStore.ts +8 -0
  23. package/src/lib/shared/peek.ts +1 -1
  24. package/src/lib/shared/socketTailsSlot.ts +13 -0
  25. package/src/lib/shared/types/AmendApply.ts +15 -0
  26. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  27. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  28. package/src/lib/shared/types/SocketTails.ts +13 -0
  29. package/src/lib/shared/types/SsrPayload.ts +6 -0
  30. package/src/lib/test/createTestApp.ts +6 -0
  31. package/src/lib/ui/amendReaderHook.ts +125 -0
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  33. package/src/lib/ui/compile/analyzeComponent.ts +2 -0
  34. package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
  35. package/src/lib/ui/compile/compileComponent.ts +10 -1
  36. package/src/lib/ui/compile/compileSSR.ts +12 -0
  37. package/src/lib/ui/compile/compileShadow.ts +37 -34
  38. package/src/lib/ui/compile/desugarSignals.ts +127 -34
  39. package/src/lib/ui/compile/generateBuild.ts +5 -1
  40. package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
  41. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  42. package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
  43. package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
  44. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  45. package/src/lib/ui/compile/lowerContext.ts +4 -0
  46. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  47. package/src/lib/ui/compile/lowerScript.ts +11 -1
  48. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  49. package/src/lib/ui/compile/renameSignalRefs.ts +36 -24
  50. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
  51. package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
  52. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  53. package/src/lib/ui/dom/appendText.ts +29 -8
  54. package/src/lib/ui/dom/appendTextAt.ts +21 -4
  55. package/src/lib/ui/dom/attr.ts +16 -1
  56. package/src/lib/ui/dom/awaitBlock.ts +34 -13
  57. package/src/lib/ui/dom/each.ts +10 -2
  58. package/src/lib/ui/dom/eachAsync.ts +9 -1
  59. package/src/lib/ui/dom/readCellBlocking.ts +29 -0
  60. package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
  61. package/src/lib/ui/dom/spreadAttrs.ts +47 -1
  62. package/src/lib/ui/dom/switchBlock.ts +19 -2
  63. package/src/lib/ui/dom/tryBlock.ts +10 -0
  64. package/src/lib/ui/dom/when.ts +19 -1
  65. package/src/lib/ui/dom/withSuspense.ts +22 -0
  66. package/src/lib/ui/router.ts +8 -0
  67. package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
  68. package/src/lib/ui/runtime/createDoc.ts +5 -1
  69. package/src/lib/ui/runtime/flushEffects.ts +18 -10
  70. package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
  71. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  72. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  73. package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
  74. package/src/lib/ui/socketProxy.ts +17 -1
  75. package/src/lib/ui/startClient.ts +30 -0
  76. package/src/lib/ui/types/Scope.ts +1 -1
  77. package/src/lib/ui/watch.ts +25 -7
  78. package/src/serverEntry.ts +15 -0
  79. package/src/lib/shared/patch.ts +0 -41
  80. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
@@ -1,7 +1,7 @@
1
+ import { amend } from './amend.ts'
1
2
  import { invalidate } from './invalidate.ts'
2
3
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
3
4
  import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
4
- import { patch } from './patch.ts'
5
5
  import { peek } from './peek.ts'
6
6
  import { pending } from './pending.ts'
7
7
  import { refresh } from './refresh.ts'
@@ -12,13 +12,13 @@ import type { RemoteFunction } from './types/RemoteFunction.ts'
12
12
  /*
13
13
  Attaches the pre-bound selector sugar onto an assembled RemoteFunction:
14
14
  `fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / invalidate / peek,
15
- `fn.patch(args?, updater)` ≡ `patch(fn, args, updater)`, and `fn.error(args?)` — the typed
15
+ `fn.amend(args?, value | updater)` ≡ `amend(fn, args, )`, and `fn.error(args?)` — the typed
16
16
  last error from the rpc error registry (most-recent across the rpc when args omitted, that
17
17
  exact call when given). The cached read is the bare call `fn(args, opts)` itself; refetch is
18
18
  `fn.refresh(args?)`. The methods only reference the globals at call time, so the shared import
19
19
  edge carries no module-init dependency (safe against any cache ↔ createRemoteFunction cycle).
20
20
  Attached in createRemoteFunction so the server (defineRpc) and client (remoteProxy) shapes are
21
- identical. `patch` is attached uniformly; the type omits it for a streaming rpc (harmless at
21
+ identical. `amend` is attached uniformly; the type omits it for a streaming rpc (harmless at
22
22
  runtime — a stream has no cache entry).
23
23
  */
24
24
  export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args, Return>): void {
@@ -28,8 +28,8 @@ export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args,
28
28
  refresh: (args?: Args) => refresh(fn, args),
29
29
  invalidate: (args?: Args) => invalidate(fn, args),
30
30
  peek: (args?: Args) => peek(fn, args),
31
- patch: (argsOrUpdater?: unknown, updater?: unknown) =>
32
- (patch as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
31
+ amend: (argsOrUpdater?: unknown, updater?: unknown) =>
32
+ (amend as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
33
33
  /* Reaction sugar is client-only (`watch` is a ui primitive that must not ride into a
34
34
  server bundle), so the shared attach binds an inert no-op — the server (defineRpc)
35
35
  shape carries `.watch` too, and an author `fn.watch(…)` surviving the SSR effect-strip
@@ -22,15 +22,22 @@ Bare iteration is the live stream (replay 0); `.tail(n)` seeds from the
22
22
  retained tail (no-arg = the whole tail). Each iterator mints its own sub id for
23
23
  lifecycle routing (end/err), and `hooks.replayed` fires in-band after the
24
24
  replay batch so a window reader commits its seed strictly before any live frame.
25
+
26
+ `initialFrame` warm-seeds `lastFrame` at construction (socketProxy passes the
27
+ server's retained frame from `__SSR__.sockets`) so `peek()` returns the SSR value
28
+ at hydration instead of undefined — congruent with the server HTML until the ws
29
+ delivers its own replay of the same frame.
25
30
  */
26
31
  export function buildSocketOverChannel<T>(
27
32
  name: string,
28
33
  resolveChannel: () => SocketChannel,
34
+ initialFrame?: T,
29
35
  ): Socket<T> {
30
36
  /* The latest frame seen across every iterator of this socket — what peek() returns and
31
37
  what refresh() re-seeds from the server tail. A plain slot (not reactive): peek(socket)
32
- is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. */
33
- let lastFrame: T | undefined
38
+ is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. Seeded
39
+ from the SSR-retained frame so a not-yet-connected client's peek matches the server. */
40
+ let lastFrame: T | undefined = initialFrame
34
41
  function iterate(replay: number | undefined, hooks?: TailHooks): AsyncIterable<T> {
35
42
  return {
36
43
  [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
@@ -920,26 +920,27 @@ cached list). The next value is stored onto `entry.value`, which the read path
920
920
  serves warm (cloned per read), so it persists across reads until a refresh /
921
921
  invalidate replaces the entry. `current` comes from `entry.value` when already
922
922
  materialized, else decoded from the settled/in-flight promise (async — readers
923
- re-render when the patch lands). Follows the selector grammar, but only fn / args
924
- / tags select a value to mutate; a not-yet-read key has nothing to patch.
923
+ re-render when the amend lands). Follows the selector grammar, but only fn / args
924
+ / tags select a value to mutate; a not-yet-read key has nothing to amend.
925
925
  */
926
- function patch<Args, Return>(
926
+ function amend<Args, Return>(
927
927
  arg: CacheSelector<Args, Return>,
928
928
  args: Args | undefined,
929
929
  updater: (current: Return) => Return,
930
+ isValue: boolean,
930
931
  ): void {
931
932
  const prefix = selectorPrefix(arg, args)
932
933
  const matches = selectorMatcher(arg, args, prefix)
933
934
  for (const store of cacheStores()) {
934
935
  for (const entry of store.entries.values()) {
935
936
  if (matches(entry)) {
936
- applyPatch(store, entry, updater as (current: unknown) => unknown)
937
+ applyAmend(store, entry, updater as (current: unknown) => unknown, isValue)
937
938
  }
938
939
  }
939
940
  }
940
941
  }
941
942
 
942
- cache.patch = patch
943
+ cache.amend = amend
943
944
 
944
945
  /*
945
946
  Synchronous, non-triggering value probe: returns the currently-retained value for
@@ -947,7 +948,7 @@ a call, or undefined when nothing is retained (no entry, or not yet settled). Ne
947
948
  invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
948
949
  key's lifecycle channel (the exact key is a one-entry prefix, same as
949
950
  pending(fn, args)) so a state.computed / on / template scope re-runs both when the
950
- value changes (invalidate, patch) and when a retained value lands —
951
+ value changes (invalidate, amend) and when a retained value lands —
951
952
  materializeRetained's async decode signals only markLifecycle, no invalidate event,
952
953
  so without the lifecycle tap a scope that read undefined would never see the value
953
954
  arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
@@ -1003,21 +1004,26 @@ function peek<Args, Return>(
1003
1004
  cache.peek = peek
1004
1005
 
1005
1006
  /*
1006
- Applies one entry's patch: materialize the current decoded value (warm value if
1007
+ Applies one entry's amend: materialize the current decoded value (warm value if
1007
1008
  present, else decode the promise — a Response for a remote entry, cloned so the
1008
1009
  readers' own clones still succeed), run the updater, store it warm, and emit so
1009
- readers re-read. Fire-and-forget on the async branch: patch() stays sync-return.
1010
+ readers re-read. Fire-and-forget on the async branch: amend() stays sync-return.
1010
1011
  */
1011
- function applyPatch(
1012
+ function applyAmend(
1012
1013
  store: CacheStore,
1013
1014
  entry: CacheEntry,
1014
1015
  updater: (current: unknown) => unknown,
1016
+ isValue: boolean,
1015
1017
  ): void {
1016
1018
  function apply(current: unknown): void {
1017
1019
  entry.value = updater(current)
1018
1020
  notify(store, [entry.key], [entry.key])
1019
1021
  }
1020
- if (entry.value !== undefined) {
1022
+ /* A value-form amend is current-independent (its updater ignores the argument), so set it
1023
+ directly and synchronously like the wire path (amendByKey) — never defer to the in-flight
1024
+ promise. Deferring would drop the value if that fetch rejects (the reject handler below
1025
+ swallows it), diverging local value-amend from the broadcast receive path. */
1026
+ if (isValue || entry.value !== undefined) {
1021
1027
  apply(entry.value)
1022
1028
  return
1023
1029
  }
@@ -1030,6 +1036,25 @@ function applyPatch(
1030
1036
  currentValue.then(apply, () => undefined)
1031
1037
  }
1032
1038
 
1039
+ /*
1040
+ Wire-driven value apply (ADR-0043): set the retained value of the entry at an exact
1041
+ cache key and re-render its readers — the receive half of a server amend broadcast. A
1042
+ direct set, not the applyAmend materialize-current dance: the frame carries the
1043
+ authoritative value now, and the receiving subscriber is by construction reading this
1044
+ key. A key with no local entry is a no-op (nothing to amend).
1045
+ */
1046
+ function amendByKey(key: string, value: unknown): void {
1047
+ for (const store of cacheStores()) {
1048
+ const entry = store.entries.get(key)
1049
+ if (entry !== undefined) {
1050
+ entry.value = value
1051
+ notify(store, [entry.key], [entry.key])
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ cache.amendByKey = amendByKey
1057
+
1033
1058
  /*
1034
1059
  Event-driven cache maintenance: subscribes to a NamedAsyncIterable (socket or rpc
1035
1060
  stream) and runs `handler` once per frame — the declarative home for "this
@@ -0,0 +1,11 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { CacheReaderHook } from './types/CacheReaderHook.ts'
3
+
4
+ /*
5
+ Client-only seam for the reactive-reader lifecycle (ADR-0043). No fallback: unresolved,
6
+ get() is undefined and the store's engage/disengage calls are inert — so the server's
7
+ request store and isolated unit tests do nothing. The client entry (startClient) installs
8
+ a hook that opens a per-key amend value subscription on a key's first reader and closes it
9
+ on its last, keeping the subscription set congruent with what this tab is reading.
10
+ */
11
+ export const cacheReaderSocketSlot = createResolverSlot<CacheReaderHook>()
@@ -1,4 +1,5 @@
1
1
  import { REACTIVE_CONTEXT } from '../ui/runtime/REACTIVE_CONTEXT.ts'
2
+ import { cacheReaderSocketSlot } from './cacheReaderSocketSlot.ts'
2
3
  import { createLifecycleChannel } from './createLifecycleChannel.ts'
3
4
  import { createSubscriber } from './createSubscriber.ts'
4
5
  import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
@@ -62,10 +63,17 @@ export function createCacheStore(): CacheStore {
62
63
  Drop the marker so the tab-scoped store can't accrete one per
63
64
  invalidated-but-never-reread key over a session. */
64
65
  pendingRefresh.delete(key)
66
+ /* Last reader gone: close any per-key amend subscription opened on engage
67
+ (ADR-0043). Inert unless the client entry installed the hook. */
68
+ cacheReaderSocketSlot.get()?.disengage(key)
65
69
  }
66
70
  }
67
71
  })
68
72
  subscribers.set(key, registered)
73
+ /* First reader for this key: open a per-key amend value subscription (ADR-0043), so a
74
+ server amend(args, value) push lands on a key this tab is actually reading. Inert on
75
+ the server / in unit tests (slot unresolved). Paired with disengage in the cleanup. */
76
+ cacheReaderSocketSlot.get()?.engage(key)
69
77
  registered()
70
78
  }
71
79
 
@@ -8,7 +8,7 @@ import type { Socket } from './types/Socket.ts'
8
8
  The value member of the probe family: the currently-retained value, synchronously,
9
9
  without triggering anything — `T | undefined` (undefined when nothing is retained
10
10
  yet). For a cached read it is the retained cache value (reactive in a tracking
11
- scope — re-runs when a refresh lands or a patch mutates it; a one-shot snapshot
11
+ scope — re-runs when a refresh lands or an amend mutates it; a one-shot snapshot
12
12
  otherwise). For a subscribable (socket / stream) it is the latest frame, read off
13
13
  the source's own `.peek()`. Instance sugar `getFoo.peek(args?)` ≡ `peek(getFoo,
14
14
  args?)`, `socket.peek()` ≡ `peek(socket)`.
@@ -0,0 +1,13 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { SocketTails } from './types/SocketTails.ts'
3
+
4
+ /*
5
+ The active socket-tail slot — mirrors `resolvedCellsSlot`, but deliberately WITHOUT a lazily-created
6
+ shared fallback. `defineSocket`'s server `peek()` records into it on every retained-frame read, and a
7
+ socket peeked outside a request (a ws message handler, boot, cron) has no request store — a shared
8
+ fallback would then accumulate entries no page render ever drains, a slow leak. With no fallback,
9
+ `get()` returns undefined off-request and the record is skipped. During an SSR render the server
10
+ entry's resolver returns the request store's list, which `createUiPageRenderer` drains into
11
+ `__SSR__.sockets`.
12
+ */
13
+ export const socketTailsSlot = createResolverSlot<SocketTails>()
@@ -0,0 +1,15 @@
1
+ import type { CacheSelector } from './CacheSelector.ts'
2
+
3
+ /*
4
+ The side-swappable applier stored in amendBroadcastSlot (ADR-0043). Given a selector,
5
+ its args, whether the payload is a concrete value (vs. an updater closure), and the
6
+ payload itself, either apply it to this side's store (client / fallback) or broadcast
7
+ the keyed value to every authorized reader (server). Only the value form is
8
+ broadcastable; the server resolver throws on an updater (a closure has no wire form).
9
+ */
10
+ export type AmendApply = <Args, Return>(
11
+ selector: CacheSelector<Args, Return>,
12
+ args: Args | undefined,
13
+ isValue: boolean,
14
+ payload: Return | ((current: Return) => Return),
15
+ ) => void
@@ -0,0 +1,11 @@
1
+ /*
2
+ The client-installed reactive-reader lifecycle hook stored in cacheReaderSocketSlot
3
+ (ADR-0043). The cache store calls engage(key) when a key gains its first live reader
4
+ and disengage(key) when it loses its last, so the client can open/close a per-key
5
+ amend value subscription whose lifetime tracks "a reader is holding this key on
6
+ screen". Inert (slot unresolved) on the server and in isolated unit tests.
7
+ */
8
+ export interface CacheReaderHook {
9
+ engage(key: string): void
10
+ disengage(key: string): void
11
+ }
@@ -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)
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
+ }
@@ -56,6 +56,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
56
56
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
57
57
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
58
58
  { name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
59
+ { name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
59
60
  { name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
60
61
  { name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
61
62
  { name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
@@ -120,6 +120,7 @@ export function analyzeComponent(
120
120
  derivedNames,
121
121
  computedNames,
122
122
  cellReadNames,
123
+ blockingCellNames: allBlockingCellNames,
123
124
  droppedReactiveImports,
124
125
  } = lowerScript(
125
126
  fullScriptBody,
@@ -142,6 +143,7 @@ export function analyzeComponent(
142
143
  derivedNames,
143
144
  computedNames,
144
145
  cellReadNames,
146
+ blockingCellNames: allBlockingCellNames,
145
147
  nodes,
146
148
  styles,
147
149
  /* Hydration adopts every block in place — including `await`, which resumes
@@ -0,0 +1,38 @@
1
+ import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
2
+
3
+ /*
4
+ Get-or-build the warm shadow program for a project root, cached once per root and shared by
5
+ the interpolation and seed classifiers (whichever is requested first for a root warms it; the
6
+ other reuses it). The shadow program is what makes type-directed async detection possible —
7
+ resolving whether `{getFoo()}` is a promise/stream and lifting it to a streaming/blocking cell.
8
+
9
+ On a build FAILURE the program is cached as `undefined` and the classifiers degrade to today's
10
+ plain-value / syntactic behavior — the build never breaks. But the failure is WARNED once per
11
+ root: a silent `undefined` disables type-directed async detection for EVERY component in the
12
+ root, so a broken tsconfig would otherwise ship a bare `{getFoo()}` as the literal
13
+ `[object Promise]` text with no signal at all. The warn fires once (guarded by `cache.has`), so
14
+ the two classifiers sharing a cache never double-report.
15
+
16
+ `build` is injectable so the warn-once path is testable without a broken project on disk;
17
+ callers pass nothing and get `createShadowProgram`.
18
+ */
19
+ export function cachedShadowProgram(
20
+ cache: Map<string, ShadowProgram | undefined>,
21
+ root: string,
22
+ build: (root: string) => ShadowProgram = createShadowProgram,
23
+ ): ShadowProgram | undefined {
24
+ if (!cache.has(root)) {
25
+ try {
26
+ cache.set(root, build(root))
27
+ } catch (error) {
28
+ cache.set(root, undefined)
29
+ const detail = error instanceof Error ? error.message : String(error)
30
+ console.warn(
31
+ `[abide] couldn't build the type program for ${root}: ${detail}\n` +
32
+ ' type-directed async detection is disabled for this project — a bare promise/stream\n' +
33
+ ' read like {getFoo()} renders as text instead of streaming. Check the tsconfig.',
34
+ )
35
+ }
36
+ }
37
+ return cache.get(root)
38
+ }
@@ -29,7 +29,15 @@ 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, nodes } = resolved
32
+ const {
33
+ script,
34
+ stateNames,
35
+ derivedNames,
36
+ computedNames,
37
+ cellReadNames,
38
+ blockingCellNames,
39
+ nodes,
40
+ } = resolved
33
41
  const build = generateBuild(
34
42
  nodes,
35
43
  'host',
@@ -38,6 +46,7 @@ export function compileComponent(
38
46
  computedNames,
39
47
  isLayout,
40
48
  cellReadNames,
49
+ blockingCellNames,
41
50
  )
42
51
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
43
52
  injected at runtime; the build only needs the `data-a-…` scope attributes on
@@ -66,6 +66,18 @@ export function compileSSR(
66
66
  flightDecls,
67
67
  hasStagedChildren,
68
68
  } = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout, cellReadNames)
69
+ /* ADR-0042 D7 (barrier completeness): every async cell — and so every BLOCKING (`await`) cell —
70
+ is declared in the lowered script (`lowered`), which the body below emits BEFORE the
71
+ `$$settleAsyncCells` barrier; the template back-end (`ssr`) constructs no cells. A blocking
72
+ cell built AFTER its barrier would ship an unresolved value the client then SUSPENDS against,
73
+ desyncing hydration. The invariant holds by construction today (cells are a script-block
74
+ construct); assert it as a tripwire so a future template lowering that emits a cell
75
+ construction fails the build LOUDLY here instead of as a runtime hydration desync. */
76
+ if (/\$\$scope\(\)\.(trackedComputed|computed|linked)\(/.test(ssr)) {
77
+ throw new Error(
78
+ '[abide] a reactive cell is constructed in template output, after the async-cell barrier (ADR-0042 D7) — cells must be declared in the component script so they settle before the SSR flush.',
79
+ )
80
+ }
69
81
  /* ADR-0039: a component with hoistable children declares `$childSlots` (the body walk reserves an
70
82
  output slot per child) and, after the walk, awaits `$$finalizeStreamedChildren` — which fills
71
83
  each slot inline (settled child, byte-identical to the old inline await) or with a streaming