@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,3 +1,5 @@
1
+ import { effect } from '../effect.ts'
2
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
1
3
  import { attr } from './attr.ts'
2
4
  import { on } from './on.ts'
3
5
 
@@ -19,7 +21,51 @@ export function spreadAttrs(
19
21
  exclude: string[] = [],
20
22
  ): void {
21
23
  const skip = new Set(exclude)
22
- const object = source()
24
+ let object: Record<string, unknown>
25
+ try {
26
+ object = source()
27
+ } catch (signal) {
28
+ /* A spread over a PENDING blocking `await` read throws a `SuspenseSignal` here (ADR-0042):
29
+ its keys can't be enumerated yet. Binding the empty object now would create no `attr`/`on`
30
+ effects, so the attributes would never appear after the cell resolves — instead defer the
31
+ enumeration into a one-shot effect that re-runs on settle (the read tracked its cell). The
32
+ effect is pinned to this build's scope, so the `attr` binds it creates own their lifetime
33
+ correctly; a `spread` flag makes the enumeration fire exactly once (later value changes
34
+ re-read per key via `attr`, never re-enumerate). */
35
+ if (!(signal instanceof SuspenseSignal)) {
36
+ throw signal
37
+ }
38
+ let spread = false
39
+ effect(() => {
40
+ let resolved: Record<string, unknown>
41
+ try {
42
+ resolved = source()
43
+ } catch (retry) {
44
+ if (!(retry instanceof SuspenseSignal)) {
45
+ throw retry
46
+ }
47
+ return
48
+ }
49
+ if (spread) {
50
+ return
51
+ }
52
+ spread = true
53
+ bindKeys(element, source, resolved, skip)
54
+ })
55
+ return
56
+ }
57
+ bindKeys(element, source, object, skip)
58
+ }
59
+
60
+ /* Binds each own key of `object` onto `element`: an `on<event>` function attaches as a listener,
61
+ any other non-function value binds as a reactive attribute re-reading `source()[key]` per change.
62
+ A key in `skip` is left to its explicit binding. */
63
+ function bindKeys(
64
+ element: Element,
65
+ source: () => Record<string, unknown>,
66
+ object: Record<string, unknown>,
67
+ skip: Set<string>,
68
+ ): void {
23
69
  for (const key in object) {
24
70
  if (skip.has(key)) {
25
71
  continue
@@ -1,3 +1,4 @@
1
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
1
2
  import { mountSwappableRange } from './mountSwappableRange.ts'
2
3
  import type { SwitchCase } from './types/SwitchCase.ts'
3
4
 
@@ -47,8 +48,24 @@ export function switchBlock(
47
48
  parent,
48
49
  /* A pending bare async subject (compiler-supplied `isPending`) selects no case — not
49
50
  even the default — so the block renders nothing until the cell settles, rather than
50
- conflating "still loading" with a subject that matched the default. */
51
- () => (isPending?.() === true ? -1 : select(subject())),
51
+ conflating "still loading" with a subject that matched the default. A blocking `await`
52
+ cell embedded in the subject or a `match` (a member access / compound the `isPending`
53
+ gate doesn't cover) throws a `SuspenseSignal` while pending (ADR-0042); select no case
54
+ until it settles, exactly as a bare async subject holds — the read tracked its cell, so
55
+ the swap effect re-runs on resolve. */
56
+ () => {
57
+ if (isPending?.() === true) {
58
+ return -1
59
+ }
60
+ try {
61
+ return select(subject())
62
+ } catch (signal) {
63
+ if (!(signal instanceof SuspenseSignal)) {
64
+ throw signal
65
+ }
66
+ return -1
67
+ }
68
+ },
52
69
  (index) => {
53
70
  const chosen = index === -1 ? undefined : cases[index]
54
71
  return chosen && ((p) => chosen.render(p))
@@ -4,6 +4,7 @@ import { CURRENT_BOUNDARY } from '../runtime/CURRENT_BOUNDARY.ts'
4
4
  import { claimExpected } from '../runtime/claimExpected.ts'
5
5
  import { OWNER } from '../runtime/OWNER.ts'
6
6
  import { RENDER } from '../runtime/RENDER.ts'
7
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
7
8
  import { scopeGroup } from '../runtime/scopeGroup.ts'
8
9
  import type { Boundary } from '../runtime/types/Boundary.ts'
9
10
  import type { State } from '../runtime/types/State.ts'
@@ -104,6 +105,15 @@ export function tryBlock(
104
105
  activeKind = 'try'
105
106
  errorCell = undefined
106
107
  } catch (error) {
108
+ /* A `SuspenseSignal` is "value pending", not an error, and must NEVER render `{:catch}`
109
+ (ADR-0042 D3, mirroring `flushEffects`, which refuses to route it to a boundary) — that
110
+ would flash the author's catch during loading and, since `rewatch` only arms on an
111
+ `AsyncCellError`, stick there forever. Every reading region withholds its own suspend
112
+ locally (post-ADR-0042 the `{#await}`/`{#each await}` subjects included), so this is
113
+ unreachable in a compiled template; rethrow defensively rather than mislabel it. */
114
+ if (error instanceof SuspenseSignal) {
115
+ throw error
116
+ }
107
117
  if (!hasCatch) {
108
118
  throw error
109
119
  }
@@ -1,3 +1,4 @@
1
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
1
2
  import { mountSwappableRange } from './mountSwappableRange.ts'
2
3
 
3
4
  /*
@@ -26,7 +27,24 @@ export function when(
26
27
  ): void {
27
28
  mountSwappableRange(
28
29
  parent,
29
- () => (isPending?.() === true ? 'pending' : condition() ? 'then' : 'else'),
30
+ () => {
31
+ if (isPending?.() === true) {
32
+ return 'pending'
33
+ }
34
+ /* A blocking `await` cell embedded in the condition — a member access or compound the
35
+ whole-subject `isPending` gate doesn't cover (`{#if !sources.length}`, `{#if user &&
36
+ await load()}`) — throws a `SuspenseSignal` while pending (ADR-0042). Withhold the
37
+ block (render neither branch) until it settles, exactly as a bare async subject holds;
38
+ the read tracked its cell, so the swap effect re-runs on resolve. */
39
+ try {
40
+ return condition() ? 'then' : 'else'
41
+ } catch (signal) {
42
+ if (!(signal instanceof SuspenseSignal)) {
43
+ throw signal
44
+ }
45
+ return 'pending'
46
+ }
47
+ },
30
48
  (branch) => (branch === 'then' ? render : branch === 'else' ? renderElse : undefined),
31
49
  before,
32
50
  /* An async subject (compiler supplies `isPending`) can render a different branch on the
@@ -0,0 +1,22 @@
1
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
2
+
3
+ /*
4
+ Runs `read`, returning `fallback` when it SUSPENDS — a pending blocking `await` read throws a
5
+ `SuspenseSignal` (ADR-0042). The reading region shows `fallback` until the value resolves; the
6
+ throwing read subscribed the enclosing effect to the cell, so the effect re-runs and fills in on
7
+ settle. Any non-suspense error propagates unchanged (a real rejection still reaches `{#try}`). The
8
+ single place the `instanceof SuspenseSignal` swallow lives for a value-returning bind, so the read
9
+ sites (`each`, `watch`, …) share one definition rather than copying the try/catch. Text binds use
10
+ the sibling `readTextOrSuspend` (fallback `''` with String coercion); an attribute leaves itself
11
+ unset inline (a side effect, not a fallback value).
12
+ */
13
+ export function withSuspense<T>(read: () => T, fallback: T): T {
14
+ try {
15
+ return read()
16
+ } catch (signal) {
17
+ if (!(signal instanceof SuspenseSignal)) {
18
+ throw signal
19
+ }
20
+ return fallback
21
+ }
22
+ }
@@ -12,6 +12,7 @@ import { exitRenderPass } from './runtime/exitRenderPass.ts'
12
12
  import { historyEntries } from './runtime/historyEntries.ts'
13
13
  import { PENDING_OUTLET } from './runtime/PENDING_OUTLET.ts'
14
14
  import { RENDER } from './runtime/RENDER.ts'
15
+ import { restoreWarmSeeds } from './runtime/restoreWarmSeeds.ts'
15
16
  import { runtimePath } from './runtime/runtimePath.ts'
16
17
  import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
17
18
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
@@ -566,6 +567,13 @@ export function router(
566
567
  disposeFrom(0)
567
568
  rootBoundary = undefined
568
569
  mountedPageKey = undefined
570
+ /* Re-seed the cell/doc warm partitions the failed hydration pass consumed,
571
+ so the cold rebuild re-adopts the SSR-resolved values instead of
572
+ refetching — a cold refetch leaves blocking `await` cells pending, and a
573
+ top-level `$$readCellBlocking` sits in no suspense region, so its
574
+ SuspenseSignal would escape this rebuild (it runs inside the catch, past
575
+ the try) and kill the mount. Restored, the rebuild reads settled. */
576
+ restoreWarmSeeds()
569
577
  buildFrom(0, chainKeys, layoutViews, pageView, key, params, false)
570
578
  }
571
579
  /* Reapply the destination entry's scroll once its DOM exists — a
@@ -0,0 +1,24 @@
1
+ import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
2
+
3
+ /*
4
+ The sentinel a compiled blocking read (`$$readCellBlocking`, an `await`-marked cell) raises
5
+ when the cell has no value *yet* — "not resolved," not an error (ADR-0042 D3). It is a sibling
6
+ of `AsyncCellError` but a DISTINCT class so the two are told apart by `instanceof`: a suspend is
7
+ caught LOCALLY by the reading region — each DOM primitive (`appendText`, `attr`, `each`,
8
+ `spreadAttrs`, `watch`, and the `when`/`switchBlock` condition) swallows it and withholds to an
9
+ empty fallback, re-running on settle because the throwing read already subscribed the region's
10
+ effect to the cell. It must never be handled as an error: `flushEffects` refuses to route a
11
+ `SuspenseSignal` to a reactive `{#try}` (that would flash the author's `{:catch}` during loading),
12
+ while an `AsyncCellError` still passes through. It carries the originating cell for diagnostics.
13
+ Extends `Error` only to satisfy throw-an-Error lint and match `AsyncCellError`; it carries no
14
+ `cause` — there is nothing wrong, the value is simply pending.
15
+ */
16
+ export class SuspenseSignal extends Error {
17
+ readonly cell: AsyncComputed<unknown>
18
+
19
+ constructor(cell: AsyncComputed<unknown>) {
20
+ super('abide: suspense (value pending)')
21
+ this.name = 'SuspenseSignal'
22
+ this.cell = cell
23
+ }
24
+ }
@@ -312,6 +312,7 @@ export function createDoc(initial: unknown): Doc {
312
312
  parent ??= resolveParent()
313
313
  parent[leafKey] = value
314
314
  writeNode(node, value)
315
+ return value
315
316
  },
316
317
  }
317
318
  }
@@ -321,7 +322,10 @@ export function createDoc(initial: unknown): Doc {
321
322
  cell,
322
323
  derive,
323
324
  apply,
324
- replace: (path, value) => apply({ op: 'replace', path, value }),
325
+ replace: (path, value) => {
326
+ apply({ op: 'replace', path, value })
327
+ return value
328
+ },
325
329
  add: (path, value) => apply({ op: 'add', path, value }),
326
330
  remove: (path) => apply({ op: 'remove', path }),
327
331
  snapshot: () => tree,
@@ -1,6 +1,7 @@
1
1
  import { boundaryFor } from './boundaryFor.ts'
2
2
  import { NODE_STATE } from './NODE_STATE.ts'
3
3
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
4
+ import { SuspenseSignal } from './SuspenseSignal.ts'
4
5
  import type { ReactiveNode } from './types/ReactiveNode.ts'
5
6
  import { updateIfNecessary } from './updateIfNecessary.ts'
6
7
 
@@ -50,16 +51,23 @@ function drain(): void {
50
51
  try {
51
52
  updateIfNecessary(node)
52
53
  } catch (error) {
53
- /* A node built inside a reactive `{#try}` routes its throw to that boundary
54
- (swap the guarded content to the catch branch) instead of the rethrow
55
- fallback this is the "later re-run throw" the sync boundary could not
56
- catch. Reset it CLEAN either way so a later write to its deps can re-queue
57
- it (otherwise `mark`'s CLEAN→dirty gate leaves it permanently inert). */
58
- const boundary = boundaryFor.get(node)
59
- if (boundary !== undefined) {
60
- boundary.handle(error)
61
- node.status = NODE_STATE.CLEAN
62
- continue
54
+ /* A `SuspenseSignal` is "no value yet", not an error, and must NEVER route to a
55
+ reactive `{#try}` (that would flash the author's `{:catch}` during loading,
56
+ ADR-0042 D3). Every reading region catches its own suspend locally and re-runs on
57
+ settle (the throwing read subscribed its cell), so a `SuspenseSignal` reaches here
58
+ only if it escaped an unguarded read surface it via the error collector below
59
+ rather than leaking it to a boundary. A normal error routes to the node's nearest
60
+ `{#try}` boundary: swap the guarded content to the catch branch instead of the
61
+ rethrow fallback — the "later re-run throw" the sync boundary could not catch.
62
+ Reset it CLEAN either way so a later write to its deps can re-queue it (otherwise
63
+ `mark`'s CLEAN→dirty gate leaves it permanently inert). */
64
+ if (!(error instanceof SuspenseSignal)) {
65
+ const boundary = boundaryFor.get(node)
66
+ if (boundary !== undefined) {
67
+ boundary.handle(error)
68
+ node.status = NODE_STATE.CLEAN
69
+ continue
70
+ }
63
71
  }
64
72
  /* No boundary — one effect throwing must not strand the effects queued behind
65
73
  it (they live in this same `batch`, unreachable once we swap
@@ -0,0 +1,20 @@
1
+ import { CELL_SEED } from './CELL_SEED.ts'
2
+ import { DOC_SEED } from './DOC_SEED.ts'
3
+ import { warmSeedBackup } from './warmSeedBackup.ts'
4
+
5
+ /*
6
+ Re-populate the async-cell and doc-state warm-seed manifests from the pristine SSR copies stashed at
7
+ boot, undoing the consume-once deletes the failed hydration pass made. The router's hydration-desync
8
+ recovery calls this before it rebuilds the page COLD: with the seeds restored, each rebuilt cell
9
+ re-adopts its SSR-resolved value (reads settled, not pending) instead of refetching — so a blocking
10
+ `await` cell can't throw an uncaught SuspenseSignal at mount, and the cold render shows real data with
11
+ no loading flash. A no-op when boot never seeded (a fresh client-only mount).
12
+ */
13
+ export function restoreWarmSeeds(): void {
14
+ if (warmSeedBackup.cells !== undefined) {
15
+ Object.assign(CELL_SEED, warmSeedBackup.cells)
16
+ }
17
+ if (warmSeedBackup.docs !== undefined) {
18
+ Object.assign(DOC_SEED, warmSeedBackup.docs)
19
+ }
20
+ }
@@ -1,5 +1,7 @@
1
1
  /* A stable reactive accessor bound to one document path: `get` subscribes the
2
2
  running observer, `set` mutates that path and wakes its readers — all path
3
3
  resolution done once at bind time, so the access itself is string-free. This
4
- is the shape the template compiler emits for a `{path}` read / write. */
5
- export type Cell<T> = { get: () => T; set: (value: T) => void }
4
+ is the shape the template compiler emits for a `{path}` read / write. `set`
5
+ returns the written value so a lowered assignment evaluates to that value in
6
+ expression position (chained `a = b = x`, the postfix `x++` correction). */
7
+ export type Cell<T> = { get: () => T; set: (value: T) => T }
@@ -15,7 +15,9 @@ export type Doc = {
15
15
  stored/serialized/journalled) and returns a string-free reader bound to it. */
16
16
  derive: <T>(path: string, compute: () => T) => () => T
17
17
  apply: (patch: Patch) => void
18
- replace: (path: string, value: unknown) => void
18
+ /* Returns the written value so a lowered assignment/`++` evaluates to it in
19
+ expression position (mirrored by `Scope.replace` and `Cell.set`). */
20
+ replace: (path: string, value: unknown) => unknown
19
21
  add: (path: string, value: unknown) => void
20
22
  remove: (path: string) => void
21
23
  snapshot: () => unknown
@@ -0,0 +1,10 @@
1
+ import type { SsrPayload } from '../../shared/types/SsrPayload.ts'
2
+
3
+ /* The pristine SSR warm-seed manifests (`__SSR__.cells` / `__SSR__.docs`), stashed by `startClient`
4
+ before the hydration pass drains their client copies — each adopted seed is DELETED from CELL_SEED /
5
+ consumed from DOC_SEED as it hydrates. `Object.assign`-ing into those seeds leaves these source
6
+ records untouched, so they stay a clean re-seed source. The router's discard→cold-rebuild recovery
7
+ reads them via `restoreWarmSeeds` so the rebuilt cells re-adopt the SSR-resolved values instead of
8
+ refetching cold — a cold refetch would leave a blocking `await` cell pending and throw an uncaught
9
+ SuspenseSignal at mount, killing the page. Empty on a fresh client-only mount (nothing seeded). */
10
+ export const warmSeedBackup: { cells?: SsrPayload['cells']; docs?: SsrPayload['docs'] } = {}
@@ -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,17 +1,23 @@
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'
12
17
  import { clientPage } from './runtime/clientPage.ts'
13
18
  import { DOC_SEED } from './runtime/DOC_SEED.ts'
14
19
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
20
+ import { warmSeedBackup } from './runtime/warmSeedBackup.ts'
15
21
  import { seedBootState } from './seedBootState.ts'
16
22
  import { seedStreamedResolution } from './seedStreamedResolution.ts'
17
23
  import { subscribeCacheStaleness } from './subscribeCacheStaleness.ts'
@@ -57,6 +63,13 @@ export function startClient(
57
63
  broadcaster instead — the ADR-0041 side-swap). Same function as the slot fallback so
58
64
  the local-apply path can't diverge. */
59
65
  cacheStalenessSlot.resolver = () => applyCacheStalenessLocally
66
+ /* amend(args, value) applies to THIS tab's cache too — the server entry broadcasts instead
67
+ (the ADR-0043 side-swap). Same function as the slot fallback for the same reason. */
68
+ amendBroadcastSlot.resolver = () => applyAmendLocally
69
+ /* Open/close a per-call amend value subscription as reactive readers of a key come and go
70
+ (ADR-0043), so a server amend(args, value) push lands on keys this tab is reading. */
71
+ const amendReader = createAmendReaderHook()
72
+ cacheReaderSocketSlot.resolver = () => amendReader.hook
60
73
  /* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
61
74
  (inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
62
75
  {#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
@@ -80,6 +93,18 @@ export function startClient(
80
93
  if (ssr.docs !== undefined) {
81
94
  Object.assign(DOC_SEED, ssr.docs)
82
95
  }
96
+ /* Seed the socket warm partition into SOCKET_SEED before mount: a hydrating `socketProxy` reads
97
+ its name key to seed `lastFrame`, so `peek(socket)` returns the server's retained frame instead
98
+ of undefined on the not-yet-connected client — congruent with the SSR HTML. */
99
+ if (ssr.sockets !== undefined) {
100
+ Object.assign(SOCKET_SEED, ssr.sockets)
101
+ }
102
+ /* Stash the pristine cell/doc manifests for the router's hydration-desync recovery: the hydration
103
+ pass consumes CELL_SEED/DOC_SEED (each adopted key is deleted), so a discard→cold-rebuild would
104
+ otherwise refetch every blocking cell — leaving it pending and throwing an uncaught
105
+ SuspenseSignal at mount. `restoreWarmSeeds` re-seeds from these before the cold rebuild. */
106
+ warmSeedBackup.cells = ssr.cells
107
+ warmSeedBackup.docs = ssr.docs
83
108
  /* Keep the cache channel live past boot: replace the head's buffering collector with
84
109
  the store-connected sink so a post-boot resolution — the inline doc-stream cache
85
110
  script — seeds the store through `seedStreamedResolution` directly instead of pushing
@@ -94,6 +119,11 @@ export function startClient(
94
119
  const disposeRouter = router(target, routes, layoutRoutes, probeNavigation)
95
120
  return () => {
96
121
  disposeStaleness()
122
+ amendReader.dispose()
123
+ /* Clear the reader hook so a post-teardown cache read doesn't re-open amend subscriptions
124
+ — unlike the staleness/store slots (whose leaked resolver stays benign), a live hook on
125
+ the read path must not outlive the client it belongs to. */
126
+ cacheReaderSocketSlot.resolver = undefined
97
127
  disposeRouter()
98
128
  }
99
129
  }
@@ -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
@@ -3,10 +3,12 @@ import { REMOTE_FUNCTION } from '../shared/REMOTE_FUNCTION.ts'
3
3
  import type { CacheOnContext } from '../shared/types/CacheOnContext.ts'
4
4
  import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
5
5
  import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
6
+ import { withSuspense } from './dom/withSuspense.ts'
6
7
  import { effect } from './effect.ts'
7
8
  import { generationGuard } from './runtime/generationGuard.ts'
8
9
  import type { EffectResult } from './runtime/types/EffectResult.ts'
9
10
  import type { State } from './runtime/types/State.ts'
11
+ import { untrack } from './runtime/untrack.ts'
10
12
 
11
13
  /*
12
14
  The single reaction primitive: `watch(source, handler)` names its trigger and runs
@@ -24,6 +26,14 @@ into a server bundle; server-side it is an inert no-op. Unlike bare `watch(…)`
24
26
  the SSR back-end strips — a `.watch(…)` member call survives to the server and relies on
25
27
  that inert stub.
26
28
 
29
+ Tracking contract (ADR-0044): the named source(s) are the sole triggers — the handler
30
+ is a sink, run untracked, so a reactive read in its body never becomes an accidental
31
+ extra trigger. Only the bare `watch(thunk)` form auto-tracks everything it reads (that
32
+ IS its job — it is the compiler binding). This makes all explicit-source forms uniform:
33
+ the subscribable (cache.on, per-frame) and rpc (handler in a .then microtask) branches
34
+ already ran their handlers out of the tracking window; the two synchronous cell branches
35
+ now match by wrapping the handler in `untrack`.
36
+
27
37
  Sources (discriminated at runtime, monomorphic per branch):
28
38
  watch(thunk) // compiler binding form — auto-tracked, == effect(thunk)
29
39
  watch(count, n => …) // a state cell → handler(newValue)
@@ -61,9 +71,13 @@ export function watch(
61
71
  also silence the compiler's generated bindings, which build on the client where the test
62
72
  DOM sets no global `window`). SSR-safety comes from the compiler stripping author
63
73
  `watch(...)` calls, and the subscribable branch's own server guard (cache.on). */
64
- /* Compiler binding form watch(thunk): a bare auto-tracked effect (== today's $$effect). */
74
+ /* Compiler binding form watch(thunk): a bare auto-tracked effect (== today's $$effect). A
75
+ pending blocking `await` read throws a `SuspenseSignal` (ADR-0042) — withhold this reaction
76
+ (e.g. leave a `class:`/`style:` binding untouched) until the value resolves; the read tracked
77
+ its cell, so the effect re-runs on settle. Rethrows any real error. */
65
78
  if (argsOrHandler === undefined) {
66
- return effect(source as () => EffectResult)
79
+ const thunk = source as () => EffectResult
80
+ return effect(() => withSuspense<EffectResult>(thunk, undefined))
67
81
  }
68
82
  /* watch(fn, args, handler): an rpc selector with explicit args. */
69
83
  if (maybeHandler !== undefined) {
@@ -88,18 +102,22 @@ export function watch(
88
102
  ) {
89
103
  return reactToRpc(source, undefined, handler)
90
104
  }
91
- /* Multiple cells → fire on any change; hand the handler the current values. */
105
+ /* Multiple cells → fire on any change; hand the handler the current values. Read every
106
+ cell (the tracked triggers), then run the handler untracked so a reactive read in its
107
+ body is not captured as an extra trigger (ADR-0044). */
92
108
  if (Array.isArray(source)) {
93
109
  const cells = source as ReadonlyArray<State<unknown>>
94
110
  return effect(() => {
95
111
  const values = cells.map((cell) => cell.value)
96
- handler(values)
112
+ untrack(() => handler(values))
97
113
  })
98
114
  }
99
- /* A single state cell → handler(newValue) on change. */
115
+ /* A single state cell → handler(newValue) on change. The cell read is the tracked
116
+ trigger; the handler is a sink, run untracked (ADR-0044). */
100
117
  const cell = source as State<unknown>
101
118
  return effect(() => {
102
- handler(cell.value)
119
+ const value = cell.value
120
+ untrack(() => handler(value))
103
121
  })
104
122
  }
105
123
 
@@ -115,7 +133,7 @@ function isSubscribable(source: unknown): boolean {
115
133
  /*
116
134
  Reacts to an rpc's cached value: an effect that runs the smart read (subscribing
117
135
  its key synchronously, so the effect re-runs when the value changes — a refresh,
118
- a patch, an invalidate) and pipes the resolved value to the handler. Triggers the
136
+ an amend, an invalidate) and pipes the resolved value to the handler. Triggers the
119
137
  read, unlike peek: `watch` observes a live query, so it keeps it flowing.
120
138
  */
121
139
  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
- ])