@abide/abide 0.53.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 (56) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +1 -1
  4. package/package.json +2 -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/cachedShadowProgram.ts +38 -0
  33. package/src/lib/ui/compile/compileComponent.ts +9 -2
  34. package/src/lib/ui/compile/desugarSignals.ts +68 -3
  35. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  36. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  37. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  38. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  39. package/src/lib/ui/compile/renameSignalRefs.ts +18 -24
  40. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
  41. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  42. package/src/lib/ui/dom/awaitBlock.ts +1 -1
  43. package/src/lib/ui/dom/tryBlock.ts +1 -1
  44. package/src/lib/ui/router.ts +8 -0
  45. package/src/lib/ui/runtime/createDoc.ts +5 -1
  46. package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
  47. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  48. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  49. package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
  50. package/src/lib/ui/socketProxy.ts +17 -1
  51. package/src/lib/ui/startClient.ts +30 -0
  52. package/src/lib/ui/types/Scope.ts +1 -1
  53. package/src/lib/ui/watch.ts +18 -5
  54. package/src/serverEntry.ts +15 -0
  55. package/src/lib/shared/patch.ts +0 -41
  56. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
@@ -1,4 +1,5 @@
1
- import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
1
+ import { cachedShadowProgram } from './cachedShadowProgram.ts'
2
+ import type { ShadowProgram } from './createShadowProgram.ts'
2
3
  import { shadowInterpolationClassifier } from './shadowInterpolationClassifier.ts'
3
4
  import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
4
5
 
@@ -12,24 +13,18 @@ The incremental `createShadowLanguageService` does not expose the checker, shado
12
13
  `ts.SourceFile`, or per-file mappings publicly, so v1 uses the one-shot
13
14
  `createShadowProgram` (reused per root) rather than the LS overlay path.
14
15
 
15
- FAIL-OPEN throughout: if the program can't be built, or the file has no shadow, the
16
- classifier is absent (→ today's plain-value binding); and the returned closure wraps
17
- its whole body so ANY throw (resolution failure, missing node, checker hiccup) returns
18
- `'sync'`. A type-resolution problem degrades to today's behavior, never breaks the build.
16
+ FAIL-OPEN throughout: if the program can't be built (warned once per root by
17
+ `cachedShadowProgram`), or the file has no shadow, the classifier is absent (→ today's
18
+ plain-value binding); and the returned closure wraps its whole body so ANY throw
19
+ (resolution failure, missing node, checker hiccup) returns `'sync'`. A type-resolution
20
+ problem degrades to today's behavior, never breaks the build.
19
21
  */
20
22
  export function interpolationClassifierForRoot(
21
23
  cache: Map<string, ShadowProgram | undefined>,
22
24
  root: string,
23
25
  abidePath: string,
24
26
  ): InterpolationClassifier | undefined {
25
- if (!cache.has(root)) {
26
- try {
27
- cache.set(root, createShadowProgram(root))
28
- } catch {
29
- cache.set(root, undefined)
30
- }
31
- }
32
- const shadowProgram = cache.get(root)
27
+ const shadowProgram = cachedShadowProgram(cache, root)
33
28
  if (shadowProgram === undefined) {
34
29
  return undefined
35
30
  }
@@ -0,0 +1,49 @@
1
+ import ts from 'typescript'
2
+
3
+ /* Arithmetic compound assignments → their plain binary counterpart, lowered to a
4
+ read-combine-write (`x += y` → `write(read + y)`). */
5
+ const ARITHMETIC_COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
6
+ [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
7
+ [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
8
+ [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
9
+ [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
10
+ ])
11
+
12
+ /* Logical compound assignments SHORT-CIRCUIT: the write fires only when the guard
13
+ passes (`x ??= v` is `x ?? (x = v)`), so they lower to `read <op> write(right)`, NOT
14
+ an unconditional write — matching JS, so a non-nullish/truthy/falsy guard does no
15
+ needless patch or cell reseed. */
16
+ const LOGICAL_ASSIGNMENT_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
17
+ [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
18
+ [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
19
+ [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
20
+ ])
21
+
22
+ /*
23
+ Lowers an assignment (`=`, arithmetic `+=`, or logical `??=`/`||=`/`&&=`) to a reactive
24
+ write, shared by the `$$model` doc path (lowerDocAccess) and the `$$writeCell` linked-cell
25
+ path (renameSignalRefs) so the two lowerings can't drift. `makeRead` builds the target's
26
+ read expression (only called for compound operators); `makeWrite` wraps a value in the
27
+ write call. Returns undefined for an operator this doesn't handle so the caller can leave
28
+ the node untouched. The write must evaluate to the written value (see `Cell.set` /
29
+ `Doc.replace`) for the logical short-circuit to yield the right result.
30
+ */
31
+ export function lowerCompoundAssignment(
32
+ operator: ts.SyntaxKind,
33
+ makeRead: () => ts.Expression,
34
+ right: ts.Expression,
35
+ makeWrite: (value: ts.Expression) => ts.Expression,
36
+ ): ts.Expression | undefined {
37
+ if (operator === ts.SyntaxKind.EqualsToken) {
38
+ return makeWrite(right)
39
+ }
40
+ const arithmetic = ARITHMETIC_COMPOUND_OPERATORS.get(operator)
41
+ if (arithmetic !== undefined) {
42
+ return makeWrite(ts.factory.createBinaryExpression(makeRead(), arithmetic, right))
43
+ }
44
+ const logical = LOGICAL_ASSIGNMENT_OPERATORS.get(operator)
45
+ if (logical !== undefined) {
46
+ return ts.factory.createBinaryExpression(makeRead(), logical, makeWrite(right))
47
+ }
48
+ return undefined
49
+ }
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { escapeKey } from '../runtime/escapeKey.ts'
3
- import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
3
+ import { lowerCompoundAssignment } from './lowerCompoundAssignment.ts'
4
+ import { lowerUpdateExpression } from './lowerUpdateExpression.ts'
4
5
 
5
6
  /*
6
7
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -91,33 +92,24 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
91
92
  }
92
93
 
93
94
  function visit(node: ts.Node): ts.Node {
94
- /* Assignment (plain or compound) to a doc path → replace patch. */
95
+ /* Assignment (plain, compound, or logical) to a doc path → replace patch. */
95
96
  if (ts.isBinaryExpression(node)) {
96
97
  const segments = pathSegments(node.left as ts.Expression)
97
98
  if (segments) {
98
- const operator = node.operatorToken.kind
99
- if (operator === ts.SyntaxKind.EqualsToken) {
100
- return docCall(docName, 'replace', [
101
- buildPath(segments),
102
- ts.visitNode(node.right, visit) as ts.Expression,
103
- ])
104
- }
105
- const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(operator)
106
- if (binary) {
107
- const next = ts.factory.createBinaryExpression(
108
- docCall(docName, 'read', [buildPath(segments)]),
109
- binary,
110
- ts.visitNode(node.right, visit) as ts.Expression,
111
- )
112
- return docCall(docName, 'replace', [buildPath(segments), next])
99
+ const lowered = lowerCompoundAssignment(
100
+ node.operatorToken.kind,
101
+ () => docCall(docName, 'read', [buildPath(segments)]),
102
+ ts.visitNode(node.right, visit) as ts.Expression,
103
+ (value) => docCall(docName, 'replace', [buildPath(segments), value]),
104
+ )
105
+ if (lowered !== undefined) {
106
+ return lowered
113
107
  }
114
108
  }
115
109
  }
116
- /* `path++` / `++path` / `path--` on a doc path → a replace patch (the same
117
- shape as `+= 1`). A bare `++` would otherwise survive onto the lowered read
118
- (`model.read("n")++` / `cell.get()++`) — invalid, since a call result is not
119
- an lvalue. abide handlers are statements, so the postfix/prefix value is
120
- discarded and both lower identically. Only `++`/`--` are handled here; other
110
+ /* `path++` / `++path` / `path--` on a doc path → a replace patch. A bare `++`
111
+ would otherwise survive onto the lowered read (`model.read("n")++`) — invalid,
112
+ since a call result is not an lvalue. Only `++`/`--` are handled here; other
121
113
  unary operators (`!path`, `-path`) fall through so their operand lowers as a
122
114
  normal read. */
123
115
  if (
@@ -127,16 +119,12 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
127
119
  ) {
128
120
  const segments = pathSegments(node.operand)
129
121
  if (segments) {
130
- const step =
131
- node.operator === ts.SyntaxKind.PlusPlusToken
132
- ? ts.SyntaxKind.PlusToken
133
- : ts.SyntaxKind.MinusToken
134
- const next = ts.factory.createBinaryExpression(
135
- docCall(docName, 'read', [buildPath(segments)]),
136
- step,
137
- ts.factory.createNumericLiteral(1),
122
+ return lowerUpdateExpression(
123
+ ts.isPostfixUnaryExpression(node),
124
+ node.operator === ts.SyntaxKind.PlusPlusToken,
125
+ () => docCall(docName, 'read', [buildPath(segments)]),
126
+ (value) => docCall(docName, 'replace', [buildPath(segments), value]),
138
127
  )
139
- return docCall(docName, 'replace', [buildPath(segments), next])
140
128
  }
141
129
  }
142
130
  /* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`
@@ -0,0 +1,32 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Lowers `x++` / `++x` / `x--` / `--x` on reactive state to a write of the stepped value,
5
+ shared by the `$$model` doc path (lowerDocAccess) and the `$$writeCell` linked-cell path
6
+ (renameSignalRefs). `makeRead`/`makeWrite` mirror `lowerCompoundAssignment`. The bare `++`
7
+ can't survive onto the read form (`$$readCell(x)` / `$$model.read(...)` are calls, not
8
+ lvalues), so it becomes an explicit write of `read ± 1`.
9
+
10
+ Prefix evaluates to the NEW value, which is exactly what the write returns, so
11
+ `write(read ± 1)` is correct as-is. POSTFIX must evaluate to the PREVIOUS value, so its
12
+ result is corrected back by the opposite step (`x++` → `write(read + 1) - 1`) — no temp or
13
+ closure, since the write returns the written value (`Cell.set` / `Doc.replace`). The
14
+ correction is dead when the value is discarded (a statement `x++;`), so it costs nothing
15
+ there.
16
+ */
17
+ export function lowerUpdateExpression(
18
+ isPostfix: boolean,
19
+ isIncrement: boolean,
20
+ makeRead: () => ts.Expression,
21
+ makeWrite: (value: ts.Expression) => ts.Expression,
22
+ ): ts.Expression {
23
+ const step = isIncrement ? ts.SyntaxKind.PlusToken : ts.SyntaxKind.MinusToken
24
+ const write = makeWrite(
25
+ ts.factory.createBinaryExpression(makeRead(), step, ts.factory.createNumericLiteral(1)),
26
+ )
27
+ if (!isPostfix) {
28
+ return write
29
+ }
30
+ const correction = isIncrement ? ts.SyntaxKind.MinusToken : ts.SyntaxKind.PlusToken
31
+ return ts.factory.createBinaryExpression(write, correction, ts.factory.createNumericLiteral(1))
32
+ }
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
- import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
2
+ import { lowerCompoundAssignment } from './lowerCompoundAssignment.ts'
3
+ import { lowerUpdateExpression } from './lowerUpdateExpression.ts'
3
4
 
4
5
  /*
5
6
  Rewrites references to a component's signal bindings into the document form the
@@ -120,21 +121,19 @@ export function signalRefsTransformer(
120
121
  !shadowed.has(node.left.text)
121
122
  ) {
122
123
  const target = node.left.text
123
- const right = ts.visitNode(node.right, visit) as ts.Expression
124
- if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
125
- return writeCellCall(target, right)
126
- }
127
- const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(node.operatorToken.kind)
128
- if (binary !== undefined) {
129
- return writeCellCall(
130
- target,
131
- ts.factory.createBinaryExpression(cellReadCall(target), binary, right),
132
- )
124
+ const lowered = lowerCompoundAssignment(
125
+ node.operatorToken.kind,
126
+ () => cellReadCall(target),
127
+ ts.visitNode(node.right, visit) as ts.Expression,
128
+ (value) => writeCellCall(target, value),
129
+ )
130
+ if (lowered !== undefined) {
131
+ return lowered
133
132
  }
134
133
  }
135
134
  /* `draft++` / `++draft` / `draft--` on a `linked` cell → a `$$writeCell` of the
136
- stepped value, mirroring the `+= 1` shape (the bare `++` would otherwise land
137
- on `$$readCell(draft)`, an invalid lvalue). */
135
+ stepped value (the bare `++` would otherwise land on `$$readCell(draft)`, an
136
+ invalid lvalue). */
138
137
  if (
139
138
  (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
140
139
  (node.operator === ts.SyntaxKind.PlusPlusToken ||
@@ -143,17 +142,12 @@ export function signalRefsTransformer(
143
142
  cellReadNames.has(node.operand.text) &&
144
143
  !shadowed.has(node.operand.text)
145
144
  ) {
146
- const step =
147
- node.operator === ts.SyntaxKind.PlusPlusToken
148
- ? ts.SyntaxKind.PlusToken
149
- : ts.SyntaxKind.MinusToken
150
- return writeCellCall(
151
- node.operand.text,
152
- ts.factory.createBinaryExpression(
153
- cellReadCall(node.operand.text),
154
- step,
155
- ts.factory.createNumericLiteral(1),
156
- ),
145
+ const target = node.operand.text
146
+ return lowerUpdateExpression(
147
+ ts.isPostfixUnaryExpression(node),
148
+ node.operator === ts.SyntaxKind.PlusPlusToken,
149
+ () => cellReadCall(target),
150
+ (value) => writeCellCall(target, value),
157
151
  )
158
152
  }
159
153
  /* Shorthand `{ count }` → `{ count: $$model.count }` / `{ total: total.value }`,
@@ -1,5 +1,6 @@
1
+ import { cachedShadowProgram } from './cachedShadowProgram.ts'
1
2
  import { classifyInterpolationType } from './classifyInterpolationType.ts'
2
- import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
3
+ import type { ShadowProgram } from './createShadowProgram.ts'
3
4
  import { nodeAtShadowOffset } from './nodeAtShadowOffset.ts'
4
5
  import { shadowNaming } from './shadowNaming.ts'
5
6
  import { sourceToShadowOffset } from './sourceToShadowOffset.ts'
@@ -18,25 +19,19 @@ as `state.computed` — either way the seed sub-expression maps back to source a
18
19
  own type), so the identical `sourceToShadowOffset → nodeAtShadowOffset → classifyInterpolation
19
20
  Type` pipeline the interpolation half uses resolves a seed with no node-finder variant.
20
21
 
21
- FAIL-OPEN, but NOT to `'sync'`: if the program can't build, the file has no shadow, the seed's
22
- location doesn't map, no node is found, or the checker throws, the classifier is absent /
23
- returns `undefined` the signal the caller reads as "degrade to `isBareCallComputed`", NOT as
24
- "the seed is sync". Collapsing failure to `'sync'` (as the interpolation classifier does) would
25
- mis-route a failed stream seed to the lazy `derive` slot instead of today's syntax heuristic.
22
+ FAIL-OPEN, but NOT to `'sync'`: if the program can't build (warned once per root by
23
+ `cachedShadowProgram`), the file has no shadow, the seed's location doesn't map, no node is
24
+ found, or the checker throws, the classifier is absent / returns `undefined` the signal the
25
+ caller reads as "degrade to `isBareCallComputed`", NOT as "the seed is sync". Collapsing failure
26
+ to `'sync'` (as the interpolation classifier does) would mis-route a failed stream seed to the
27
+ lazy `derive` slot instead of today's syntax heuristic.
26
28
  */
27
29
  export function seedTypeClassifierForRoot(
28
30
  cache: Map<string, ShadowProgram | undefined>,
29
31
  root: string,
30
32
  abidePath: string,
31
33
  ): SeedTypeClassifier | undefined {
32
- if (!cache.has(root)) {
33
- try {
34
- cache.set(root, createShadowProgram(root))
35
- } catch {
36
- cache.set(root, undefined)
37
- }
38
- }
39
- const shadowProgram = cache.get(root)
34
+ const shadowProgram = cachedShadowProgram(cache, root)
40
35
  if (shadowProgram === undefined) {
41
36
  return undefined
42
37
  }
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { AbideCompileError } from './AbideCompileError.ts'
2
3
 
3
4
  /*
4
5
  Folds a cell-source `watch(source, handler)` into the auto-tracked thunk form
@@ -51,7 +52,7 @@ export function wrapReactionCellSources(
51
52
  expression is read ONCE as a plain value (outside any tracking scope) and handed to the
52
53
  runtime as a dead value: nothing subscribes, the reaction never fires. It can't be folded
53
54
  like a bare cell (a member access isn't a whole-cell subscription), so return the base
54
- name to warn on and leave the source for the author to rewrite. */
55
+ name to REJECT on and point the author at the corrective forms. */
55
56
  function cellMemberBase(source: ts.Expression): string | undefined {
56
57
  if (
57
58
  (ts.isPropertyAccessExpression(source) || ts.isElementAccessExpression(source)) &&
@@ -95,19 +96,22 @@ export function wrapReactionCellSources(
95
96
  )
96
97
  return ts.factory.createCallExpression(node.expression, undefined, [thunk])
97
98
  }
98
- /* Warn on the inert member-expression form `watch(s.foo, handler)` — it looks valid but
99
- subscribes to nothing (see `cellMemberBase`). Not foldable; the fix is the author's. */
99
+ /* Reject the inert member-expression form `watch(s.foo, handler)` — it looks valid but
100
+ subscribes to nothing (see `cellMemberBase`), so the handler silently never fires. Not
101
+ foldable; a compile error turns the silent no-op into a caught mistake. A member of a
102
+ cell is never itself a cell (no per-property cells), so this can't reject a valid watch. */
103
+ const memberBase = sourceArg !== undefined ? cellMemberBase(sourceArg) : undefined
100
104
  if (
101
105
  ts.isCallExpression(node) &&
102
106
  ts.isIdentifier(node.expression) &&
103
107
  watchLocalNames.has(node.expression.text) &&
104
108
  node.arguments.length === 2 &&
105
109
  sourceArg !== undefined &&
106
- cellMemberBase(sourceArg) !== undefined
110
+ memberBase !== undefined
107
111
  ) {
108
112
  const sourceText = sourceArg.getText(root)
109
- console.warn(
110
- `[abide] \`watch(${sourceText}, …)\` does not track \`${sourceText}\` — a member access on a cell is read once as a plain value (abide has no per-property cells), so the reaction is inert. Wrap it in a thunk (\`watch(() => …(${sourceText}))\`) or watch the whole cell (\`watch(${cellMemberBase(sourceArg)}, v => …)\`).`,
113
+ throw new AbideCompileError(
114
+ `[abide] \`watch(${sourceText}, …)\` does not track \`${sourceText}\` — a member access on a cell is read once as a plain value (abide has no per-property cells), so the reaction never fires. Wrap it in a thunk (\`watch(() => …(${sourceText}))\`) or watch the whole cell (\`watch(${memberBase}, v => …)\`).`,
111
115
  )
112
116
  }
113
117
  return ts.visitEachChild(node, visit, context)
@@ -7,8 +7,8 @@ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
7
7
  import { RENDER } from '../runtime/RENDER.ts'
8
8
  import type { ResumeEntry } from '../runtime/RESUME.ts'
9
9
  import { RESUME } from '../runtime/RESUME.ts'
10
- import { scopeGroup } from '../runtime/scopeGroup.ts'
11
10
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
11
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
12
12
  import type { State } from '../runtime/types/State.ts'
13
13
  import { withoutHydration } from '../runtime/withoutHydration.ts'
14
14
  import { state } from '../state.ts'
@@ -4,8 +4,8 @@ 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 { scopeGroup } from '../runtime/scopeGroup.ts'
8
7
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
8
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
9
9
  import type { Boundary } from '../runtime/types/Boundary.ts'
10
10
  import type { State } from '../runtime/types/State.ts'
11
11
  import { withoutHydration } from '../runtime/withoutHydration.ts'
@@ -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
@@ -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,
@@ -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