@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
@@ -10,15 +10,42 @@ earlier settled layer's cells. `Promise.allSettled` never rejects the render: a
10
10
  rejected cell settles into its `error()` state, surfaced at the read site (via
11
11
  `$$readCell`), not here. On the client the list is always empty (cells register
12
12
  server-side only), so the barrier is a no-op await.
13
+
14
+ It drains to a FIXPOINT — "await until this branch of the graph stops pausing" —
15
+ not a single snapshot. Pending propagates down dependency edges: a blocking cell
16
+ whose seed reads a still-pending blocking dependency stays pending and registers no
17
+ promise (its seed threw a `SuspenseSignal`, caught as pending, not error). When the
18
+ dependency settles inside this await, the reactive flush is synchronous, so the
19
+ dependent's seed re-runs and registers ITS promise before the loop re-checks — the
20
+ whole chain resolves in order. The per-render list isolation (`isolateCellBarrier`)
21
+ keeps the loop draining only this render's cascade, never a concurrent sibling's.
22
+
23
+ The round cap bounds a pathological feedback edge — a cell whose settle writes a
24
+ signal its own seed reads re-registers a fresh promise every round, and an
25
+ unbounded fixpoint would hang the SSR request forever. Legitimate chains resolve
26
+ one dependency level per round, so the cap is far above any real graph; hitting
27
+ it renders with whatever settled (the old snapshot-drain behavior) and warns.
13
28
  */
29
+ const MAX_SETTLE_ROUNDS = 1000
30
+
14
31
  // @documentation plumbing
15
32
  export async function settleAsyncCells(): Promise<void> {
16
33
  const pending = activePendingCells()
17
34
  if (pending === undefined) {
18
35
  return
19
36
  }
20
- const promises = pending.promises.splice(0)
21
- if (promises.length > 0) {
22
- await Promise.allSettled(promises)
37
+ let rounds = 0
38
+ while (pending.promises.length > 0) {
39
+ if (rounds >= MAX_SETTLE_ROUNDS) {
40
+ console.warn(
41
+ '[abide] settleAsyncCells did not reach a fixpoint after ' +
42
+ `${MAX_SETTLE_ROUNDS} rounds — an async cell is re-registering ` +
43
+ 'a promise on every settle (a seed that reads state its own ' +
44
+ 'resolution writes). Rendering with the values settled so far.',
45
+ )
46
+ break
47
+ }
48
+ rounds += 1
49
+ await Promise.allSettled(pending.promises.splice(0))
23
50
  }
24
51
  }
@@ -1,4 +1,6 @@
1
1
  import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
2
+ import { decodeRefJson } from '../shared/decodeRefJson.ts'
3
+ import { SOCKET_SEED } from '../shared/SOCKET_SEED.ts'
2
4
  import type { Socket } from '../shared/types/Socket.ts'
3
5
  import { getSocketChannel } from './socketChannel.ts'
4
6
  import { watch } from './watch.ts'
@@ -22,7 +24,21 @@ socketProxy API, not the wire layer.
22
24
  */
23
25
  // @documentation plumbing
24
26
  export function socketProxy<T>(name: string): Socket<T> {
25
- const socket = buildSocketOverChannel<T>(name, getSocketChannel)
27
+ /* Warm-seed from the server's retained frame (shipped in `__SSR__.sockets`, drained into
28
+ SOCKET_SEED by startClient before mount) so `peek(socket)` returns the SAME value the SSR
29
+ render committed to instead of undefined on this not-yet-connected client — otherwise the two
30
+ disagree and hydration discards the server markup. A frame that failed to serialize server-side
31
+ simply isn't present, and the socket falls back to a cold peek. */
32
+ const seeded = SOCKET_SEED[name]
33
+ let initialFrame: T | undefined
34
+ if (seeded !== undefined) {
35
+ try {
36
+ initialFrame = decodeRefJson(seeded) as T
37
+ } catch {
38
+ initialFrame = undefined
39
+ }
40
+ }
41
+ const socket = buildSocketOverChannel<T>(name, getSocketChannel, initialFrame)
26
42
  /* Overwrite the shared builder's inert `.watch` with the real reaction sugar
27
43
  (`socket.watch(handler)` ≡ `watch(socket, handler)`). Attached here so the ui-only
28
44
  `watch` primitive never rides into a server bundle. */
@@ -1,11 +1,16 @@
1
+ import { amendBroadcastSlot } from '../shared/amendBroadcastSlot.ts'
2
+ import { applyAmendLocally } from '../shared/applyAmendLocally.ts'
1
3
  import { applyCacheStalenessLocally } from '../shared/applyCacheStalenessLocally.ts'
4
+ import { cacheReaderSocketSlot } from '../shared/cacheReaderSocketSlot.ts'
2
5
  import { cacheStalenessSlot } from '../shared/cacheStalenessSlot.ts'
3
6
  import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
4
7
  import { createCacheStore } from '../shared/createCacheStore.ts'
5
8
  import { pageSlot } from '../shared/pageSlot.ts'
9
+ import { SOCKET_SEED } from '../shared/SOCKET_SEED.ts'
6
10
  import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
7
11
  import type { SsrPayload } from '../shared/types/SsrPayload.ts'
8
12
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
13
+ import { createAmendReaderHook } from './amendReaderHook.ts'
9
14
  import { probeNavigation } from './probeNavigation.ts'
10
15
  import { router } from './router.ts'
11
16
  import { CELL_SEED } from './runtime/CELL_SEED.ts'
@@ -57,6 +62,13 @@ export function startClient(
57
62
  broadcaster instead — the ADR-0041 side-swap). Same function as the slot fallback so
58
63
  the local-apply path can't diverge. */
59
64
  cacheStalenessSlot.resolver = () => applyCacheStalenessLocally
65
+ /* amend(args, value) applies to THIS tab's cache too — the server entry broadcasts instead
66
+ (the ADR-0043 side-swap). Same function as the slot fallback for the same reason. */
67
+ amendBroadcastSlot.resolver = () => applyAmendLocally
68
+ /* Open/close a per-call amend value subscription as reactive readers of a key come and go
69
+ (ADR-0043), so a server amend(args, value) push lands on keys this tab is reading. */
70
+ const amendReader = createAmendReaderHook()
71
+ cacheReaderSocketSlot.resolver = () => amendReader.hook
60
72
  /* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
61
73
  (inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
62
74
  {#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
@@ -80,6 +92,12 @@ export function startClient(
80
92
  if (ssr.docs !== undefined) {
81
93
  Object.assign(DOC_SEED, ssr.docs)
82
94
  }
95
+ /* Seed the socket warm partition into SOCKET_SEED before mount: a hydrating `socketProxy` reads
96
+ its name key to seed `lastFrame`, so `peek(socket)` returns the server's retained frame instead
97
+ of undefined on the not-yet-connected client — congruent with the SSR HTML. */
98
+ if (ssr.sockets !== undefined) {
99
+ Object.assign(SOCKET_SEED, ssr.sockets)
100
+ }
83
101
  /* Keep the cache channel live past boot: replace the head's buffering collector with
84
102
  the store-connected sink so a post-boot resolution — the inline doc-stream cache
85
103
  script — seeds the store through `seedStreamedResolution` directly instead of pushing
@@ -94,6 +112,11 @@ export function startClient(
94
112
  const disposeRouter = router(target, routes, layoutRoutes, probeNavigation)
95
113
  return () => {
96
114
  disposeStaleness()
115
+ amendReader.dispose()
116
+ /* Clear the reader hook so a post-teardown cache read doesn't re-open amend subscriptions
117
+ — unlike the staleness/store slots (whose leaked resolver stays benign), a live hook on
118
+ the read path must not outlive the client it belongs to. */
119
+ cacheReaderSocketSlot.resolver = undefined
97
120
  disposeRouter()
98
121
  }
99
122
  }
@@ -1,20 +1,11 @@
1
- import { encodeRefJson } from '../shared/encodeRefJson.ts'
1
+ import { encodeSeedValue } from '../shared/encodeSeedValue.ts'
2
2
  import type { ResumeEntry } from './runtime/RESUME.ts'
3
3
 
4
- /* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized.
5
- encodeRefJson is total (cycles become back-references, functions fold to undefined),
6
- but a pathological throw must not blank the surrounding seed/stream — drop just this
7
- entry and warn, so the client re-runs that one branch's promise while every other
4
+ /* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized — the
5
+ await-resume arm of the shared serialize-or-degrade policy (`encodeSeedValue`): drop
6
+ just this entry, so the client re-runs that one branch's promise while every other
8
7
  branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
9
- (`resumeSeedScript`) paths so the serialize-or-refetch policy lives in one place. */
8
+ (`resumeSeedScript`) paths. */
10
9
  export function tryEncodeResume(entry: ResumeEntry, id: number | string): string | undefined {
11
- try {
12
- return encodeRefJson(entry)
13
- } catch (cause) {
14
- console.warn(
15
- `[abide] resume for await ${id} is not serializable; client will refetch it`,
16
- cause,
17
- )
18
- return undefined
19
- }
10
+ return encodeSeedValue(entry, `resume for await ${id}`, 'the client will refetch it')
20
11
  }
@@ -25,7 +25,7 @@ export type Scope = {
25
25
  readonly parent: Scope | undefined
26
26
  /* data — mirrors Doc */
27
27
  read: <T>(path: string) => T
28
- replace: (path: string, value: unknown) => void
28
+ replace: (path: string, value: unknown) => unknown
29
29
  add: (path: string, value: unknown) => void
30
30
  remove: (path: string) => void
31
31
  apply: (patch: Patch) => void
@@ -1,4 +1,5 @@
1
1
  import { cache } from '../shared/cache.ts'
2
+ import { isAsyncIterable } from '../shared/isAsyncIterable.ts'
2
3
  import { REMOTE_FUNCTION } from '../shared/REMOTE_FUNCTION.ts'
3
4
  import type { CacheOnContext } from '../shared/types/CacheOnContext.ts'
4
5
  import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
@@ -8,6 +9,7 @@ import { effect } from './effect.ts'
8
9
  import { generationGuard } from './runtime/generationGuard.ts'
9
10
  import type { EffectResult } from './runtime/types/EffectResult.ts'
10
11
  import type { State } from './runtime/types/State.ts'
12
+ import { untrack } from './runtime/untrack.ts'
11
13
 
12
14
  /*
13
15
  The single reaction primitive: `watch(source, handler)` names its trigger and runs
@@ -25,6 +27,14 @@ into a server bundle; server-side it is an inert no-op. Unlike bare `watch(…)`
25
27
  the SSR back-end strips — a `.watch(…)` member call survives to the server and relies on
26
28
  that inert stub.
27
29
 
30
+ Tracking contract (ADR-0044): the named source(s) are the sole triggers — the handler
31
+ is a sink, run untracked, so a reactive read in its body never becomes an accidental
32
+ extra trigger. Only the bare `watch(thunk)` form auto-tracks everything it reads (that
33
+ IS its job — it is the compiler binding). This makes all explicit-source forms uniform:
34
+ the subscribable (cache.on, per-frame) and rpc (handler in a .then microtask) branches
35
+ already ran their handlers out of the tracking window; the two synchronous cell branches
36
+ now match by wrapping the handler in `untrack`.
37
+
28
38
  Sources (discriminated at runtime, monomorphic per branch):
29
39
  watch(thunk) // compiler binding form — auto-tracked, == effect(thunk)
30
40
  watch(count, n => …) // a state cell → handler(newValue)
@@ -77,7 +87,7 @@ export function watch(
77
87
  const handler = argsOrHandler as (value: unknown, context?: CacheOnContext) => void
78
88
  /* A subscribable (socket / stream): per-frame delivery with reconnect-replay — the
79
89
  existing cache.on loop is the single implementation this branch delegates to. */
80
- if (isSubscribable(source)) {
90
+ if (isAsyncIterable(source)) {
81
91
  return cache.on(
82
92
  source as NamedAsyncIterable<unknown>,
83
93
  handler as (frame: unknown, context: CacheOnContext) => void | Promise<void>,
@@ -85,7 +95,7 @@ export function watch(
85
95
  }
86
96
  /* An rpc without args → run the smart read reactively, pipe its value to the handler. A
87
97
  RemoteFunction is a callable, so accept `function` too — a state cell is a branded-free
88
- object and never matches, and a streaming rpc was already caught by isSubscribable above. */
98
+ object and never matches, and a streaming rpc was already caught by isAsyncIterable above. */
89
99
  if (
90
100
  source !== null &&
91
101
  (typeof source === 'object' || typeof source === 'function') &&
@@ -93,34 +103,29 @@ export function watch(
93
103
  ) {
94
104
  return reactToRpc(source, undefined, handler)
95
105
  }
96
- /* Multiple cells → fire on any change; hand the handler the current values. */
106
+ /* Multiple cells → fire on any change; hand the handler the current values. Read every
107
+ cell (the tracked triggers), then run the handler untracked so a reactive read in its
108
+ body is not captured as an extra trigger (ADR-0044). */
97
109
  if (Array.isArray(source)) {
98
110
  const cells = source as ReadonlyArray<State<unknown>>
99
111
  return effect(() => {
100
112
  const values = cells.map((cell) => cell.value)
101
- handler(values)
113
+ untrack(() => handler(values))
102
114
  })
103
115
  }
104
- /* A single state cell → handler(newValue) on change. */
116
+ /* A single state cell → handler(newValue) on change. The cell read is the tracked
117
+ trigger; the handler is a sink, run untracked (ADR-0044). */
105
118
  const cell = source as State<unknown>
106
119
  return effect(() => {
107
- handler(cell.value)
120
+ const value = cell.value
121
+ untrack(() => handler(value))
108
122
  })
109
123
  }
110
124
 
111
- /* True for a socket / rpc stream — anything async-iterable. */
112
- function isSubscribable(source: unknown): boolean {
113
- return (
114
- source !== null &&
115
- (typeof source === 'object' || typeof source === 'function') &&
116
- typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
117
- )
118
- }
119
-
120
125
  /*
121
126
  Reacts to an rpc's cached value: an effect that runs the smart read (subscribing
122
127
  its key synchronously, so the effect re-runs when the value changes — a refresh,
123
- a patch, an invalidate) and pipes the resolved value to the handler. Triggers the
128
+ an amend, an invalidate) and pipes the resolved value to the handler. Triggers the
124
129
  read, unlike peek: `watch` observes a live query, so it keeps it flowing.
125
130
  */
126
131
  function reactToRpc(fn: unknown, args: unknown, handler: (value: unknown) => void): () => void {
@@ -26,10 +26,12 @@ import { shell } from './_virtual/shell.ts'
26
26
  import { sockets } from './_virtual/sockets.ts'
27
27
  import { exitWithParent } from './lib/bundle/exitWithParent.ts'
28
28
  import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
29
+ import { broadcastAmend } from './lib/server/runtime/amendBroadcaster.ts'
29
30
  import { broadcastCacheStaleness } from './lib/server/runtime/cacheStalenessBroadcaster.ts'
30
31
  import { createServer } from './lib/server/runtime/createServer.ts'
31
32
  import { requestContext } from './lib/server/runtime/requestContext.ts'
32
33
  import { resolvePageSnapshot } from './lib/server/runtime/resolvePageSnapshot.ts'
34
+ import { amendBroadcastSlot } from './lib/shared/amendBroadcastSlot.ts'
33
35
  import { cacheStalenessSlot } from './lib/shared/cacheStalenessSlot.ts'
34
36
  import { cacheStoreSlot } from './lib/shared/cacheStoreSlot.ts'
35
37
  import { createCacheStore } from './lib/shared/createCacheStore.ts'
@@ -40,6 +42,7 @@ import { pendingAsyncCellsSlot } from './lib/shared/pendingAsyncCellsSlot.ts'
40
42
  import { resolvedCellsSlot } from './lib/shared/resolvedCellsSlot.ts'
41
43
  import { runningAsStandaloneBinary } from './lib/shared/runningAsStandaloneBinary.ts'
42
44
  import { sharedCacheStoreSlot } from './lib/shared/sharedCacheStoreSlot.ts'
45
+ import { socketTailsSlot } from './lib/shared/socketTailsSlot.ts'
43
46
  import { streamedCellsSlot } from './lib/shared/streamedCellsSlot.ts'
44
47
 
45
48
  /*
@@ -89,6 +92,14 @@ so its server socket code never enters the client reachability graph.
89
92
  */
90
93
  cacheStalenessSlot.resolver = () => broadcastCacheStaleness
91
94
 
95
+ /*
96
+ Server-side amend(args, value) broadcasts the keyed value to every authorized reader
97
+ instead of mutating a throwaway request store (ADR-0043). Imported ONLY here so the
98
+ broadcaster's socket code never enters the client reachability graph — the same import
99
+ discipline as the staleness broadcaster above.
100
+ */
101
+ amendBroadcastSlot.resolver = () => broadcastAmend
102
+
92
103
  /* One process-wide fallback list for reads with no request in flight (boot/cron/socket) —
93
104
  real requests each carry their own `pendingAsyncCells`, so the SSR barrier drains a
94
105
  per-request list and concurrent renders never cross-contaminate. */
@@ -99,6 +110,10 @@ const sharedResolvedCells = { entries: [] }
99
110
  resolvedCellsSlot.resolver = () => requestContext.getStore()?.resolvedCells ?? sharedResolvedCells
100
111
  const sharedStreamedCells = { entries: [] }
101
112
  streamedCellsSlot.resolver = () => requestContext.getStore()?.streamedCells ?? sharedStreamedCells
113
+ /* No shared fallback: `defineSocket.peek` records into this on every retained-frame read, including
114
+ reads outside any request (ws handlers, boot) — a fallback list would accumulate entries no render
115
+ ever drains. Off-request this resolves undefined and the record is skipped (see socketTailsSlot). */
116
+ socketTailsSlot.resolver = () => requestContext.getStore()?.socketTails
102
117
  const sharedDocSnapshots = { entries: [] }
103
118
  docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
104
119
 
@@ -1,41 +0,0 @@
1
- import { cache } from './cache.ts'
2
- import type { CacheOptions } from './types/CacheOptions.ts'
3
- import type { CacheSelector } from './types/CacheSelector.ts'
4
- import type { RemoteFunction } from './types/RemoteFunction.ts'
5
-
6
- /*
7
- Mutate the retained value of the matching cached read(s) in place — reactive
8
- (readers re-render), no network. The optimistic-update / real-time primitive:
9
- patch a cached list from a socket frame (`on(chat, m => getList.patch(l => [...l,
10
- m]))`) or apply an optimistic edit before a write lands. The updater receives the
11
- current decoded value and returns the next.
12
-
13
- patch(getFoo, args, updater) → that exact call
14
- patch(getFoo, updater) → every args-variant of that rpc
15
- patch({ tags }, updater) → every entry sharing a tag
16
-
17
- Instance sugar `getFoo.patch(args?, updater)` ≡ `patch(getFoo, args, updater)`.
18
- The updater is always the last argument; a not-yet-read key has nothing to patch.
19
- */
20
- // @documentation cache
21
- export function patch<Args, Return>(
22
- fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
23
- args: Args | undefined,
24
- updater: (current: Return) => Return,
25
- ): void
26
- export function patch<Args, Return>(
27
- fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
28
- updater: (current: Return) => Return,
29
- ): void
30
- export function patch<Return>(
31
- selector: Pick<CacheOptions, 'tags'>,
32
- updater: (current: Return) => Return,
33
- ): void
34
- export function patch(selector: unknown, argsOrUpdater: unknown, maybeUpdater?: unknown): void {
35
- /* Updater is always last: 3 args → (selector, args, updater); 2 args → (selector, updater). */
36
- const updater = (maybeUpdater !== undefined ? maybeUpdater : argsOrUpdater) as (
37
- current: unknown,
38
- ) => unknown
39
- const args = maybeUpdater !== undefined ? argsOrUpdater : undefined
40
- cache.patch(selector as CacheSelector<unknown, unknown>, args, updater)
41
- }
@@ -1,19 +0,0 @@
1
- import ts from 'typescript'
2
-
3
- /*
4
- Maps a compound-assignment operator to its plain binary counterpart, for lowering
5
- `x += y` into an unconditional read-combine-write. Logical assignments
6
- (`||=`/`&&=`/`??=`) lower the same way — the patch/cell write always fires,
7
- consistent with how `+=` lowers. Shared by lowerDocAccess (the `$$model` write path)
8
- and renameSignalRefs (the `$$writeCell` linked-cell path) so the two lowerings can't
9
- drift.
10
- */
11
- export const COMPOUND_ASSIGNMENT_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
12
- [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
13
- [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
14
- [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
15
- [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
16
- [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
17
- [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
18
- [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
19
- ])
@@ -1,84 +0,0 @@
1
- import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
3
- import { OWNER } from '../runtime/OWNER.ts'
4
- import { RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
5
- import { RENDER } from '../runtime/RENDER.ts'
6
- import type { UiComponent } from '../runtime/types/UiComponent.ts'
7
- import { withOptionalPath } from '../runtime/withOptionalPath.ts'
8
- import { withoutHydration } from '../runtime/withoutHydration.ts'
9
- import { discardBoundary } from './discardBoundary.ts'
10
- import { mountRange } from './mountRange.ts'
11
- import { openMarker } from './openMarker.ts'
12
-
13
- /*
14
- The client mount for a HOISTABLE child (ADR-0039) — a DUAL-MODE adopter. The server decides per
15
- render whether a hoistable child inlined (fast/settled) or streamed (still-pending), and the client
16
- build can't know which, so this probes the hydration cursor:
17
-
18
- - `<!--[-->` (RANGE_OPEN) next → the server INLINED the child; adopt its range exactly as
19
- `mountChild` does (byte-identical to the pre-ADR-0039 path).
20
- - `<!--abide:await:CHILDPATH-->` next → the server STREAMED it; `__abideSwap` has already spliced
21
- the child's `<!--[-->…<!--]-->` between the boundary markers before the bundle ran, so claim the
22
- boundary open, adopt the inner range in place, claim the boundary close.
23
- - no hydration (a client-side navigation) → a plain create-mode mount, like `mountChild`.
24
-
25
- CHILDPATH is the child's render-path — `withPath(ordinal, () => CURRENT_PATH.current)` — computed the
26
- same way the server's `renderPath(ordinal)` wrote the boundary id, so the two agree with no counter.
27
- A streamed child re-mounts (no RESUME value of its own — the boundary is html-only); nested `{#await}`
28
- blocks and async cells inside it adopt through their own existing channels.
29
- */
30
- // @documentation plumbing
31
- export function mountStreamedChild(
32
- parent: Node,
33
- factory: UiComponent,
34
- props: Parameters<UiComponent>[1],
35
- before: Node | null = null,
36
- ordinal?: number,
37
- ): void {
38
- const run = <T>(build: () => T): T => withOptionalPath(ordinal, build)
39
- const mount = (): { dispose: () => void } =>
40
- run(() => mountRange(parent, factory.build, props, before))
41
-
42
- const hydration = RENDER.hydration
43
- if (hydration === undefined) {
44
- /* Call mount() first, THEN register its dispose — `OWNER.current?.push(mount())` would
45
- short-circuit the whole expression (never mounting) when there is no owner. */
46
- const handle = mount()
47
- OWNER.current?.push(handle.dispose)
48
- return
49
- }
50
-
51
- /* Probe the cursor without consuming it. Streamed iff the next node is this child's boundary. */
52
- const childPath = run(() => CURRENT_PATH.current)
53
- const cursor = claimChild(hydration, parent)
54
- const streamed =
55
- cursor !== null &&
56
- cursor.nodeType === 8 &&
57
- (cursor as Comment).data === `abide:await:${childPath}`
58
-
59
- if (!streamed) {
60
- /* INLINE — the server inlined a settled child; adopt its `[ … ]` range in place. */
61
- const handle = mount()
62
- OWNER.current?.push(handle.dispose)
63
- return
64
- }
65
-
66
- /* STREAMED — claim the boundary open (advances the cursor to the swapped-in `[`). */
67
- const open = openMarker(parent, `abide:await:${childPath}`)
68
- const inner = claimChild(hydration, parent)
69
- const warm = inner !== null && inner.nodeType === 8 && (inner as Comment).data === RANGE_OPEN
70
- if (warm) {
71
- const handle = mount()
72
- openMarker(parent, `/abide:await:${childPath}`)
73
- OWNER.current?.push(handle.dispose)
74
- return
75
- }
76
- /* COLD (defensive — renderToStream drains every await before closing, so a warm boundary is the
77
- deterministic path). The fragment never arrived: discard the empty boundary and create-mount
78
- the child fresh at that position; its streamed cells resolve post-mount via receiveStreamedCell. */
79
- const after = discardBoundary(parent, open, `/abide:await:${childPath}`, hydration)
80
- const handle = withoutHydration(() =>
81
- run(() => mountRange(parent, factory.build, props, after)),
82
- )
83
- OWNER.current?.push(handle.dispose)
84
- }
@@ -1,29 +0,0 @@
1
- import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
- import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
3
- import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
4
- import { readCell } from './readCell.ts'
5
-
6
- /*
7
- The blocking-cell read the compiler lowers to `$$readCellBlocking(NAME)` for an `await`-marked
8
- cell (ADR-0042 D5.3). Exactly `readCell`, with one added rule ahead of it: a cell with no value
9
- *yet* throws a `SuspenseSignal` so the enclosing region SUSPENDS until the value resolves rather
10
- than rendering against `undefined`. This is what makes an `await` binding read as a resolved `T`
11
- (never `undefined`-while-pending) on both sides — the client mirrors the server flush barrier.
12
-
13
- The suspend is keyed on `cell.pending()` (in-flight AND no value), NOT `peek() === undefined`:
14
- - a blocking cell that legitimately resolves to `undefined` is not pending, so it does not
15
- suspend forever;
16
- - a warm-seeded cell on hydrate is `refreshing()` (has value, in flight), not `pending()`, so it
17
- reads its held value and adopts the SSR markup with no suspend and no flash (ADR-0042 D4).
18
- Everything past the pending gate — the throwing peek (error-with-no-retained-value → `AsyncCellError`
19
- to `{#try}`, a retained value winning over pending/error via SWR), a function call, or a `.value`
20
- read — is `readCell` verbatim, so the two reads can never drift.
21
- */
22
- // @documentation plumbing
23
- export function readCellBlocking(cell: unknown): unknown {
24
- /* No value yet → suspend the reading region (distinct from an error, which `readCell` routes). */
25
- if (isAsyncCell(cell) && (cell as AsyncComputed<unknown>).pending()) {
26
- throw new SuspenseSignal(cell as AsyncComputed<unknown>)
27
- }
28
- return readCell(cell)
29
- }