@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.
- package/AGENTS.md +9 -5
- package/CHANGELOG.md +57 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/checkAbide.ts +1 -1
- package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +11 -1
- package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
- package/src/lib/shared/SOCKET_SEED.ts +13 -0
- package/src/lib/shared/amend.ts +48 -0
- package/src/lib/shared/amendBroadcastSlot.ts +19 -0
- package/src/lib/shared/applyAmendLocally.ts +20 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
- package/src/lib/shared/buildSocketOverChannel.ts +9 -2
- package/src/lib/shared/cache.ts +35 -10
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- package/src/lib/shared/peek.ts +1 -1
- package/src/lib/shared/socketTailsSlot.ts +13 -0
- package/src/lib/shared/types/AmendApply.ts +15 -0
- package/src/lib/shared/types/CacheReaderHook.ts +11 -0
- package/src/lib/shared/types/RemoteFunction.ts +30 -19
- package/src/lib/shared/types/SocketTails.ts +13 -0
- package/src/lib/shared/types/SsrPayload.ts +6 -0
- package/src/lib/test/createTestApp.ts +6 -0
- package/src/lib/ui/amendReaderHook.ts +125 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
- package/src/lib/ui/compile/compileComponent.ts +9 -2
- package/src/lib/ui/compile/desugarSignals.ts +68 -3
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +18 -24
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/dom/awaitBlock.ts +1 -1
- package/src/lib/ui/dom/tryBlock.ts +1 -1
- package/src/lib/ui/router.ts +8 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
- package/src/lib/ui/runtime/types/Cell.ts +4 -2
- package/src/lib/ui/runtime/types/Doc.ts +3 -1
- package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
- package/src/lib/ui/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +30 -0
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +18 -5
- package/src/serverEntry.ts +15 -0
- package/src/lib/shared/patch.ts +0 -41
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
16
|
-
classifier is absent (→ today's
|
|
17
|
-
|
|
18
|
-
`'sync'`. A type-resolution
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 {
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
137
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 {
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
mis-route a failed stream seed to the
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
99
|
-
subscribes to nothing (see `cellMemberBase`)
|
|
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
|
-
|
|
110
|
+
memberBase !== undefined
|
|
107
111
|
) {
|
|
108
112
|
const sourceText = sourceArg.getText(root)
|
|
109
|
-
|
|
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
|
|
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'
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|