@abide/abide 0.51.0 → 0.53.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 +518 -525
- package/CHANGELOG.md +54 -0
- package/README.md +130 -157
- package/package.json +5 -1
- package/src/abideResolverPlugin.ts +12 -0
- package/src/checkAbide.ts +60 -7
- package/src/lib/cli/completeCli.ts +38 -0
- package/src/lib/cli/printTopLevelHelp.ts +1 -0
- package/src/lib/cli/renderCliCompletions.ts +56 -0
- package/src/lib/cli/runCli.ts +27 -0
- package/src/lib/server/render.ts +70 -0
- package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
- package/src/lib/server/runtime/createServer.ts +43 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
- package/src/lib/server/runtime/pageRenderSlot.ts +15 -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/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/registerSocket.ts +12 -0
- package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
- package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
- package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
- package/src/lib/shared/cache.ts +32 -4
- package/src/lib/shared/cacheStalenessSlot.ts +21 -0
- package/src/lib/shared/createRpcServerProgram.ts +187 -0
- package/src/lib/shared/docSnapshotsSlot.ts +13 -0
- package/src/lib/shared/invalidate.ts +39 -0
- package/src/lib/shared/matcherFromEnvelope.ts +31 -0
- package/src/lib/shared/prepareRpcModule.ts +22 -3
- package/src/lib/shared/refresh.ts +9 -2
- package/src/lib/shared/selectorMatcher.ts +2 -15
- package/src/lib/shared/serializeSelector.ts +69 -0
- package/src/lib/shared/setsIntersect.ts +15 -0
- package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
- package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
- package/src/lib/shared/types/DocSnapshots.ts +12 -0
- package/src/lib/shared/types/RemoteFunction.ts +11 -8
- package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
- package/src/lib/shared/types/SsrPayload.ts +5 -0
- package/src/lib/test/createTestApp.ts +15 -0
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +2 -0
- package/src/lib/ui/compile/analyzeComponent.ts +2 -0
- package/src/lib/ui/compile/compileComponent.ts +3 -1
- package/src/lib/ui/compile/compileSSR.ts +12 -0
- package/src/lib/ui/compile/compileShadow.ts +97 -16
- package/src/lib/ui/compile/desugarSignals.ts +60 -32
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
- package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
- package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
- package/src/lib/ui/compile/lowerContext.ts +4 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
- package/src/lib/ui/compile/lowerScript.ts +17 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +83 -0
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
- package/src/lib/ui/createScope.ts +42 -1
- package/src/lib/ui/dom/appendText.ts +29 -8
- package/src/lib/ui/dom/appendTextAt.ts +21 -4
- package/src/lib/ui/dom/assertClaimedText.ts +14 -4
- package/src/lib/ui/dom/attr.ts +41 -3
- package/src/lib/ui/dom/awaitBlock.ts +34 -13
- package/src/lib/ui/dom/each.ts +10 -2
- package/src/lib/ui/dom/eachAsync.ts +9 -1
- package/src/lib/ui/dom/readCellBlocking.ts +29 -0
- package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
- package/src/lib/ui/dom/spreadAttrs.ts +47 -1
- package/src/lib/ui/dom/switchBlock.ts +19 -2
- package/src/lib/ui/dom/tryBlock.ts +10 -0
- package/src/lib/ui/dom/when.ts +19 -1
- package/src/lib/ui/dom/withSuspense.ts +22 -0
- package/src/lib/ui/dom/writeCell.ts +22 -0
- package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
- package/src/lib/ui/runtime/claimExpected.ts +6 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -10
- package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
- package/src/lib/ui/startClient.ts +22 -1
- package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
- package/src/lib/ui/watch.ts +7 -2
- package/src/serverEntry.ts +12 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Rewrites references to a component's signal bindings into the document form the
|
|
@@ -21,6 +22,21 @@ Exposed as a `ts.TransformerFactory`, so the script pipeline can chain it with
|
|
|
21
22
|
`docAccessTransformer` over a SINGLE parsed tree (see `lowerScript`) instead of
|
|
22
23
|
print-then-reparse between passes.
|
|
23
24
|
*/
|
|
25
|
+
/* `$$readCell(name)` — the unified cell read (peek async / `.value` sync). */
|
|
26
|
+
function cellReadCall(name: string): ts.Expression {
|
|
27
|
+
return ts.factory.createCallExpression(ts.factory.createIdentifier('$$readCell'), undefined, [
|
|
28
|
+
ts.factory.createIdentifier(name),
|
|
29
|
+
])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* `$$writeCell(name, value)` — the unified cell write (`.value =` sync / `.set(...)` async). */
|
|
33
|
+
function writeCellCall(name: string, value: ts.Expression): ts.Expression {
|
|
34
|
+
return ts.factory.createCallExpression(ts.factory.createIdentifier('$$writeCell'), undefined, [
|
|
35
|
+
ts.factory.createIdentifier(name),
|
|
36
|
+
value,
|
|
37
|
+
])
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
export function signalRefsTransformer(
|
|
25
41
|
stateNames: ReadonlySet<string>,
|
|
26
42
|
derivedNames: ReadonlySet<string>,
|
|
@@ -39,6 +55,10 @@ export function signalRefsTransformer(
|
|
|
39
55
|
`computed` (see `desugarSignals`). A nearer lexical binding of the same name shadows
|
|
40
56
|
them like any other signal. */
|
|
41
57
|
cellReadNames: ReadonlySet<string> = new Set(),
|
|
58
|
+
/* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042), read through
|
|
59
|
+
`$$readCellBlocking(name)` (suspend-on-pending) instead of `$$readCell`. Non-empty only on
|
|
60
|
+
the CLIENT template lowering; the script pass and SSR keep it empty (`$$readCell`). */
|
|
61
|
+
blockingCellNames: ReadonlySet<string> = new Set(),
|
|
42
62
|
): ts.TransformerFactory<ts.SourceFile> {
|
|
43
63
|
/* The signal names that a nested binding can shadow — only these matter for
|
|
44
64
|
scope tracking, so we ignore every other local binding. */
|
|
@@ -87,6 +107,55 @@ export function signalRefsTransformer(
|
|
|
87
107
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
88
108
|
return node
|
|
89
109
|
}
|
|
110
|
+
/* An assignment to a `linked` cell (a `cellReadNames` name) — its read form
|
|
111
|
+
is `$$readCell(name)`, a call, so it can't sit on an assignment's left.
|
|
112
|
+
Lower the write to `$$writeCell(name, value)`, which dispatches `.value =`
|
|
113
|
+
(sync `State`) vs `.set(...)` (async `AsyncState`). A compound/logical
|
|
114
|
+
assignment folds the current value in through the read form. `state` and
|
|
115
|
+
`derived` writes stay on their own paths (`$$model.replace` / `.value =`). */
|
|
116
|
+
if (
|
|
117
|
+
ts.isBinaryExpression(node) &&
|
|
118
|
+
ts.isIdentifier(node.left) &&
|
|
119
|
+
cellReadNames.has(node.left.text) &&
|
|
120
|
+
!shadowed.has(node.left.text)
|
|
121
|
+
) {
|
|
122
|
+
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
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/* `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). */
|
|
138
|
+
if (
|
|
139
|
+
(ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
|
|
140
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
141
|
+
node.operator === ts.SyntaxKind.MinusMinusToken) &&
|
|
142
|
+
ts.isIdentifier(node.operand) &&
|
|
143
|
+
cellReadNames.has(node.operand.text) &&
|
|
144
|
+
!shadowed.has(node.operand.text)
|
|
145
|
+
) {
|
|
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
|
+
),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
90
159
|
/* Shorthand `{ count }` → `{ count: $$model.count }` / `{ total: total.value }`,
|
|
91
160
|
unless a nearer scope shadows the name. */
|
|
92
161
|
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
@@ -97,6 +166,7 @@ export function signalRefsTransformer(
|
|
|
97
166
|
computedNames,
|
|
98
167
|
blockLocal,
|
|
99
168
|
cellReadNames,
|
|
169
|
+
blockingCellNames,
|
|
100
170
|
)
|
|
101
171
|
if (replacement !== undefined) {
|
|
102
172
|
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
@@ -110,6 +180,7 @@ export function signalRefsTransformer(
|
|
|
110
180
|
computedNames,
|
|
111
181
|
blockLocal,
|
|
112
182
|
cellReadNames,
|
|
183
|
+
blockingCellNames,
|
|
113
184
|
)
|
|
114
185
|
if (replacement !== undefined) {
|
|
115
186
|
return replacement
|
|
@@ -328,6 +399,7 @@ function referenceFor(
|
|
|
328
399
|
computedNames: ReadonlySet<string>,
|
|
329
400
|
blockLocal: ReadonlySet<string> = new Set(),
|
|
330
401
|
cellReadNames: ReadonlySet<string> = new Set(),
|
|
402
|
+
blockingCellNames: ReadonlySet<string> = new Set(),
|
|
331
403
|
): ts.Expression | undefined {
|
|
332
404
|
if (blockLocal.has(name)) {
|
|
333
405
|
return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
|
|
@@ -337,6 +409,17 @@ function referenceFor(
|
|
|
337
409
|
if (name === 'scope') {
|
|
338
410
|
return ts.factory.createIdentifier('$$scope')
|
|
339
411
|
}
|
|
412
|
+
/* A BLOCKING `await` cell → `$$readCellBlocking(name)` (ADR-0042): the throwing peek that
|
|
413
|
+
SUSPENDS the render region while pending, so an `await` binding reads as a resolved value
|
|
414
|
+
and its region withholds until it settles. Client template lowering only — `blockingCellNames`
|
|
415
|
+
is empty in the script pass and SSR, which keep the plain `$$readCell` peek below. */
|
|
416
|
+
if (blockingCellNames.has(name)) {
|
|
417
|
+
return ts.factory.createCallExpression(
|
|
418
|
+
ts.factory.createIdentifier('$$readCellBlocking'),
|
|
419
|
+
undefined,
|
|
420
|
+
[ts.factory.createIdentifier(name)],
|
|
421
|
+
)
|
|
422
|
+
}
|
|
340
423
|
/* A `linked` / async `computed` cell → `$$readCell(name)`: one read shape that peeks an
|
|
341
424
|
async cell and reads `.value` off a sync one. */
|
|
342
425
|
if (cellReadNames.has(name)) {
|
|
@@ -28,6 +28,10 @@ export type AnalyzedComponent = {
|
|
|
28
28
|
`computed`. Threaded to both back-ends so a template reference (`{draft}`) lowers to
|
|
29
29
|
the unified cell read consistently on client and server. */
|
|
30
30
|
cellReadNames: Set<string>
|
|
31
|
+
/* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042): a template-injected
|
|
32
|
+
`{await X}` cell or a script `computed(await …)`. The CLIENT back-end reads these via
|
|
33
|
+
`$$readCellBlocking` (suspend-on-pending) rather than `$$readCell`. */
|
|
34
|
+
blockingCellNames: Set<string>
|
|
31
35
|
nodes: TemplateNode[]
|
|
32
36
|
/* One entry per non-empty `<style>` in the template (in source order): the scope
|
|
33
37
|
attribute its covered elements carry (annotated onto `nodes`) and the scoped
|
|
@@ -46,6 +46,23 @@ export function wrapReactionCellSources(
|
|
|
46
46
|
return false
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/* The inert footgun: a member access (`s.foo`, `s['foo']`) whose base is a known cell.
|
|
50
|
+
abide has no per-property cells — the whole object lives in the one cell — so a member
|
|
51
|
+
expression is read ONCE as a plain value (outside any tracking scope) and handed to the
|
|
52
|
+
runtime as a dead value: nothing subscribes, the reaction never fires. It can't be folded
|
|
53
|
+
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
|
+
function cellMemberBase(source: ts.Expression): string | undefined {
|
|
56
|
+
if (
|
|
57
|
+
(ts.isPropertyAccessExpression(source) || ts.isElementAccessExpression(source)) &&
|
|
58
|
+
ts.isIdentifier(source.expression) &&
|
|
59
|
+
cellNames.has(source.expression.text)
|
|
60
|
+
) {
|
|
61
|
+
return source.expression.text
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
return (context) => (root) => {
|
|
50
67
|
function visit(node: ts.Node): ts.Node {
|
|
51
68
|
const [sourceArg, handlerArg] = ts.isCallExpression(node) ? node.arguments : []
|
|
@@ -78,6 +95,21 @@ export function wrapReactionCellSources(
|
|
|
78
95
|
)
|
|
79
96
|
return ts.factory.createCallExpression(node.expression, undefined, [thunk])
|
|
80
97
|
}
|
|
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. */
|
|
100
|
+
if (
|
|
101
|
+
ts.isCallExpression(node) &&
|
|
102
|
+
ts.isIdentifier(node.expression) &&
|
|
103
|
+
watchLocalNames.has(node.expression.text) &&
|
|
104
|
+
node.arguments.length === 2 &&
|
|
105
|
+
sourceArg !== undefined &&
|
|
106
|
+
cellMemberBase(sourceArg) !== undefined
|
|
107
|
+
) {
|
|
108
|
+
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 => …)\`).`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
81
113
|
return ts.visitEachChild(node, visit, context)
|
|
82
114
|
}
|
|
83
115
|
return ts.visitNode(root, visit) as ts.SourceFile
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { decodeRefJson } from '../shared/decodeRefJson.ts'
|
|
2
|
+
import { docSnapshotsSlot } from '../shared/docSnapshotsSlot.ts'
|
|
1
3
|
import { computed } from './computed.ts'
|
|
2
4
|
import { effect } from './effect.ts'
|
|
3
5
|
import { linked } from './linked.ts'
|
|
4
6
|
import { CURRENT_PATH } from './runtime/CURRENT_PATH.ts'
|
|
5
7
|
import { createDoc } from './runtime/createDoc.ts'
|
|
8
|
+
import { DOC_SEED } from './runtime/DOC_SEED.ts'
|
|
6
9
|
import type { Cell } from './runtime/types/Cell.ts'
|
|
7
10
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
8
11
|
import { state } from './state.ts'
|
|
@@ -43,6 +46,33 @@ export function createScope(
|
|
|
43
46
|
: parent === undefined
|
|
44
47
|
? `scope-${nextId++}`
|
|
45
48
|
: `${parent.id}.${nextId++}`
|
|
49
|
+
/* Doc-state warm-seed (client hydration only): a plain `state(initial)` re-runs its initializer
|
|
50
|
+
on the client, so a uuid/timestamp/random would diverge from the SSR HTML. The server snapshot
|
|
51
|
+
for this scope's render-path id is decoded here and consumed by `replace` on each slot's FIRST
|
|
52
|
+
write (the eager init), keeping the server value while the throwaway init is discarded. One-shot
|
|
53
|
+
— deleted on read so an SPA re-nav to the same path re-inits fresh; empty on the server. */
|
|
54
|
+
let pendingSeed: Map<string, unknown> | undefined
|
|
55
|
+
/* `DOC_SEED` is populated only on the client by `startClient` (empty on the server), so the read
|
|
56
|
+
needs no window guard — it mirrors the async-cell warm read. */
|
|
57
|
+
const encodedSeed = DOC_SEED[id]
|
|
58
|
+
if (encodedSeed !== undefined) {
|
|
59
|
+
delete DOC_SEED[id]
|
|
60
|
+
try {
|
|
61
|
+
pendingSeed = new Map(
|
|
62
|
+
Object.entries(decodeRefJson(encodedSeed) as Record<string, unknown>),
|
|
63
|
+
)
|
|
64
|
+
} catch {
|
|
65
|
+
/* A corrupt seed falls back to a cold init — the same failure mode as a warm cell. */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/* Server: register this scope's doc snapshot for the `__SSR__.docs` warm-seed, keyed by its
|
|
69
|
+
render-path id. Lazy — taken at render-return, after the synchronous state inits have run.
|
|
70
|
+
Only a rendered scope (stable render-path id) registers; a detached scope (counter id) and the
|
|
71
|
+
client never do. An empty/unused doc is dropped at the stamp, so a stateless scope costs only
|
|
72
|
+
the push. */
|
|
73
|
+
if (typeof window === 'undefined' && renderPath !== '') {
|
|
74
|
+
docSnapshotsSlot.get()?.entries.push({ id, take: () => document?.snapshot() })
|
|
75
|
+
}
|
|
46
76
|
/* Adopted build teardowns (the reactivity stopper from the mount core). Disposed
|
|
47
77
|
first and in reverse on teardown — so the one `dispose` runs the order the call sites
|
|
48
78
|
hand-composed as `stop(); lexical.dispose()`. */
|
|
@@ -65,7 +95,18 @@ export function createScope(
|
|
|
65
95
|
nextCellIndex: () => cellIndex++,
|
|
66
96
|
parent,
|
|
67
97
|
read: (path) => data().read(path),
|
|
68
|
-
|
|
98
|
+
/* Consume-once seed adoption: on the client's hydrating build, the FIRST write to each seeded
|
|
99
|
+
slot is the eager `state(initial)` init — swap in the server value and drop the seed, so a
|
|
100
|
+
`state(uuid())` keeps the SSR value while its throwaway fresh init is discarded. Every later
|
|
101
|
+
write (a real reassignment) and every non-seeded path passes straight through. */
|
|
102
|
+
replace: (path, value) => {
|
|
103
|
+
if (pendingSeed?.has(path)) {
|
|
104
|
+
const seeded = pendingSeed.get(path)
|
|
105
|
+
pendingSeed.delete(path)
|
|
106
|
+
return data().replace(path, seeded)
|
|
107
|
+
}
|
|
108
|
+
return data().replace(path, value)
|
|
109
|
+
},
|
|
69
110
|
add: (path, value) => data().add(path, value),
|
|
70
111
|
remove: (path) => data().remove(path),
|
|
71
112
|
apply: (patch) => data().apply(patch),
|
|
@@ -3,10 +3,12 @@ import { snippetPayload } from '../../shared/snippet.ts'
|
|
|
3
3
|
import { effect } from '../effect.ts'
|
|
4
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
6
7
|
import { appendSnippet } from './appendSnippet.ts'
|
|
7
8
|
import { assertClaimedText } from './assertClaimedText.ts'
|
|
8
9
|
import { isComment } from './isComment.ts'
|
|
9
10
|
import { parseRawNodes } from './parseRawNodes.ts'
|
|
11
|
+
import { readTextOrSuspend } from './readTextOrSuspend.ts'
|
|
10
12
|
|
|
11
13
|
const CLOSE = '/abide:html'
|
|
12
14
|
|
|
@@ -22,16 +24,36 @@ its parsed nodes go between an anchor (create), or the server-rendered nodes
|
|
|
22
24
|
between `<!--abide:html-->`/`<!--/abide:html-->` markers are adopted (hydrate), and
|
|
23
25
|
a change re-parses and swaps. A binding is text or raw for its lifetime (decided by
|
|
24
26
|
its first value), so plain text — the common case — stays a cheap single node.
|
|
27
|
+
|
|
28
|
+
A blocking `await` read that is pending throws a `SuspenseSignal` (ADR-0042): the
|
|
29
|
+
interpolation SUSPENDS — it renders empty and withholds until the value resolves,
|
|
30
|
+
never evaluating the surrounding expression against a pending `undefined`. The read
|
|
31
|
+
tracked the cell, so the bind effect re-runs on settle. A suspend can only occur on a
|
|
32
|
+
cold client render (on hydrate the warm-seed makes the cell `refreshing()`, not
|
|
33
|
+
`pending()`, D4), and a suspended value is treated as text — its snippet/html shape is
|
|
34
|
+
decided from the first resolved value.
|
|
25
35
|
*/
|
|
26
36
|
// @documentation plumbing
|
|
27
37
|
export function appendText(parent: Node, read: () => unknown, splitAlways = false): void {
|
|
38
|
+
/* Probe the first value once, tolerating a suspend (a pending blocking read) — a suspended
|
|
39
|
+
interpolation skips snippet/html detection and takes the text path, starting empty. */
|
|
40
|
+
let probe: unknown
|
|
41
|
+
let suspended = false
|
|
42
|
+
try {
|
|
43
|
+
probe = read()
|
|
44
|
+
} catch (signal) {
|
|
45
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
46
|
+
throw signal
|
|
47
|
+
}
|
|
48
|
+
suspended = true
|
|
49
|
+
}
|
|
28
50
|
/* A snippet call (`{row(args)}`) mounts its builder; a `html\`\`` value inserts
|
|
29
51
|
raw markup; everything else is escaped text — decided by the first value. */
|
|
30
|
-
if (typeof snippetPayload(
|
|
52
|
+
if (!suspended && typeof snippetPayload(probe) === 'function') {
|
|
31
53
|
appendSnippet(parent, read)
|
|
32
54
|
return
|
|
33
55
|
}
|
|
34
|
-
if (rawHtmlString(
|
|
56
|
+
if (!suspended && rawHtmlString(probe) !== undefined) {
|
|
35
57
|
appendRawHtml(parent, read)
|
|
36
58
|
return
|
|
37
59
|
}
|
|
@@ -39,8 +61,9 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
|
|
|
39
61
|
if (hydration !== undefined) {
|
|
40
62
|
const claimed = claimChild(hydration, parent)
|
|
41
63
|
/* Nullish reads render as empty text, never the literal `"undefined"` — so a
|
|
42
|
-
pending async read (undefined-while-pending, ADR-0032 D3) shows nothing.
|
|
43
|
-
|
|
64
|
+
pending async read (undefined-while-pending, ADR-0032 D3) shows nothing. A blocking
|
|
65
|
+
read never suspends here (warm-seeded → `refreshing()`), so `probe` holds its value. */
|
|
66
|
+
const firstValue = probe
|
|
44
67
|
const value = firstValue == null ? '' : String(firstValue)
|
|
45
68
|
/* A value that first rendered empty produced NO server text node, so the cursor
|
|
46
69
|
points at the following node (an element/comment) or past the end (null) — not a
|
|
@@ -77,16 +100,14 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
|
|
|
77
100
|
at the end). */
|
|
78
101
|
hydration.next.set(parent, isText ? node.nextSibling : claimed)
|
|
79
102
|
effect(() => {
|
|
80
|
-
|
|
81
|
-
node.data = next == null ? '' : String(next)
|
|
103
|
+
node.data = readTextOrSuspend(read)
|
|
82
104
|
})
|
|
83
105
|
return
|
|
84
106
|
}
|
|
85
107
|
const node = document.createTextNode('')
|
|
86
108
|
parent.appendChild(node)
|
|
87
109
|
effect(() => {
|
|
88
|
-
|
|
89
|
-
node.data = next == null ? '' : String(next)
|
|
110
|
+
node.data = readTextOrSuspend(read)
|
|
90
111
|
})
|
|
91
112
|
}
|
|
92
113
|
|
|
@@ -2,9 +2,11 @@ import { rawHtmlString } from '../../shared/html.ts'
|
|
|
2
2
|
import { snippetPayload } from '../../shared/snippet.ts'
|
|
3
3
|
import { effect } from '../effect.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
5
6
|
import { appendSnippet } from './appendSnippet.ts'
|
|
6
7
|
import { appendText } from './appendText.ts'
|
|
7
8
|
import { parseRawNodes } from './parseRawNodes.ts'
|
|
9
|
+
import { readTextOrSuspend } from './readTextOrSuspend.ts'
|
|
8
10
|
|
|
9
11
|
/*
|
|
10
12
|
A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
|
|
@@ -39,14 +41,25 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
|
|
|
39
41
|
return
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
/* Probe once, tolerating a suspend (a pending blocking read): a suspended interpolation skips
|
|
45
|
+
snippet/html detection and takes the text path, starting empty until the value resolves. */
|
|
46
|
+
let first: unknown
|
|
47
|
+
let suspended = false
|
|
48
|
+
try {
|
|
49
|
+
first = read()
|
|
50
|
+
} catch (signal) {
|
|
51
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
52
|
+
throw signal
|
|
53
|
+
}
|
|
54
|
+
suspended = true
|
|
55
|
+
}
|
|
56
|
+
if (!suspended && typeof snippetPayload(first) === 'function') {
|
|
44
57
|
const fragment = document.createDocumentFragment()
|
|
45
58
|
appendSnippet(fragment, read)
|
|
46
59
|
parent.insertBefore(fragment, anchor.nextSibling)
|
|
47
60
|
return
|
|
48
61
|
}
|
|
49
|
-
if (rawHtmlString(first) !== undefined) {
|
|
62
|
+
if (!suspended && rawHtmlString(first) !== undefined) {
|
|
50
63
|
let nodes: Node[] = []
|
|
51
64
|
effect(() => {
|
|
52
65
|
for (const node of nodes) {
|
|
@@ -63,7 +76,11 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
|
|
|
63
76
|
}
|
|
64
77
|
const node = document.createTextNode('')
|
|
65
78
|
parent.insertBefore(node, anchor.nextSibling)
|
|
79
|
+
/* Text/suspend handling shared with `appendText`'s bind via `readTextOrSuspend`: stringify the
|
|
80
|
+
value, show '' while a blocking read is pending (re-running on settle, since the read tracked
|
|
81
|
+
its cell), and coerce nullish to '' (never the literal "undefined") — keeping this create path
|
|
82
|
+
congruent with the hydrate path above, which delegates to `appendText`. */
|
|
66
83
|
effect(() => {
|
|
67
|
-
node.data =
|
|
84
|
+
node.data = readTextOrSuspend(read)
|
|
68
85
|
})
|
|
69
86
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
|
|
2
|
+
import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
|
|
3
|
+
|
|
1
4
|
/*
|
|
2
5
|
A text claim that MUST match the server's rendered text. On hydrate,
|
|
3
6
|
appendStatic/appendText adopt a merged SSR text node and split it at the CLIENT
|
|
@@ -12,9 +15,16 @@ the following siblings' text AFTER it, so the value is exactly the leading slice
|
|
|
12
15
|
mismatch means SSR and the client build disagree on the text here.
|
|
13
16
|
*/
|
|
14
17
|
export function assertClaimedText(node: Text, value: string): void {
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
if (node.data.startsWith(value)) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
/* Report on the `hydrate` channel with the render-path. With the channel on, keep hydrating
|
|
22
|
+
(re-split proceeds below on the client length) so one reload surfaces every text divergence;
|
|
23
|
+
off, the hard throw stays the default. A text miss is cursor-safe to continue past. */
|
|
24
|
+
if (!reportHydrationDivergence('text desync', { expected: value, server: node.data })) {
|
|
25
|
+
return
|
|
19
26
|
}
|
|
27
|
+
throw new Error(
|
|
28
|
+
`[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
|
|
29
|
+
)
|
|
20
30
|
}
|
package/src/lib/ui/dom/attr.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
|
+
import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
|
|
4
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
5
|
+
|
|
6
|
+
/* The attribute string an element carries for `value`, or `null` when absent — the present/absent +
|
|
7
|
+
stringify semantics below, factored so the hydration compare and the write agree exactly. */
|
|
8
|
+
function attributeText(value: unknown): string | null {
|
|
9
|
+
if (value === false || value === null || value === undefined) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
return value === true ? '' : String(value)
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
/*
|
|
4
16
|
Binds an element attribute to `read()`. A boolean true sets the bare attribute,
|
|
@@ -8,12 +20,38 @@ changed attribute touches the DOM.
|
|
|
8
20
|
*/
|
|
9
21
|
// @documentation plumbing
|
|
10
22
|
export function attr(element: Element, name: string, read: () => unknown): void {
|
|
23
|
+
/* Captured at bind time (inside the hydrating build) so the first effect run can compare the
|
|
24
|
+
server-rendered attribute before overwriting it — an attribute divergence is otherwise
|
|
25
|
+
invisible, since the effect always overwrites. */
|
|
26
|
+
let hydrating = RENDER.hydration !== undefined
|
|
11
27
|
effect(() => {
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
let value: unknown
|
|
29
|
+
try {
|
|
30
|
+
value = read()
|
|
31
|
+
} catch (signal) {
|
|
32
|
+
/* A pending blocking `await` read (ADR-0042) suspends this attribute: leave it
|
|
33
|
+
unset until the value resolves. The read tracked its cell, so this effect
|
|
34
|
+
re-runs on settle. A suspend can only occur on a cold client render (warm-seed
|
|
35
|
+
keeps a hydrating read `refreshing()`, never `pending()`), so the hydration
|
|
36
|
+
compare below is never reached while suspended. */
|
|
37
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
38
|
+
throw signal
|
|
39
|
+
}
|
|
40
|
+
element.removeAttribute(name)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const next = attributeText(value)
|
|
44
|
+
if (hydrating) {
|
|
45
|
+
hydrating = false
|
|
46
|
+
const server = element.getAttribute(name)
|
|
47
|
+
if (server !== next) {
|
|
48
|
+
reportHydrationDivergence(`attr "${name}" desync`, { server, client: next })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (next === null) {
|
|
14
52
|
element.removeAttribute(name)
|
|
15
53
|
} else {
|
|
16
|
-
element.setAttribute(name,
|
|
54
|
+
element.setAttribute(name, next)
|
|
17
55
|
}
|
|
18
56
|
})
|
|
19
57
|
}
|
|
@@ -8,6 +8,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
10
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
11
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
11
12
|
import type { State } from '../runtime/types/State.ts'
|
|
12
13
|
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
13
14
|
import { state } from '../state.ts'
|
|
@@ -109,17 +110,10 @@ export function awaitBlock(
|
|
|
109
110
|
activeKind = 'catch'
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
/*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
settleThen(result) // warm-sync → resolved now, no flash
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
/* A then-branch is already mounted (a revalidation): keep it visible and update in
|
|
120
|
-
place when the new value settles, instead of blanking to pending and rebuilding —
|
|
121
|
-
this is the no-flash live-update path. A first load (or a prior pending/catch)
|
|
122
|
-
shows the pending branch (or detaches) while the promise is in flight. */
|
|
113
|
+
/* Show the pending branch (or detach when there is none), UNLESS a then-branch is already
|
|
114
|
+
mounted — a revalidation keeps it visible and updates in place when the new value settles
|
|
115
|
+
(the no-flash live-update path). Shared by an in-flight promise and a suspended subject read. */
|
|
116
|
+
const showPending = (): void => {
|
|
123
117
|
if (activeKind !== 'then') {
|
|
124
118
|
if (renderPending !== undefined) {
|
|
125
119
|
branch.place((host) => renderPending(host))
|
|
@@ -129,6 +123,18 @@ export function awaitBlock(
|
|
|
129
123
|
activeKind = undefined
|
|
130
124
|
}
|
|
131
125
|
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Render a settled-or-pending result into the current generation. */
|
|
129
|
+
const render = (result: unknown): void => {
|
|
130
|
+
const gen = guard.token()
|
|
131
|
+
if (!isThenable(result)) {
|
|
132
|
+
settleThen(result) // warm-sync → resolved now, no flash
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
/* A first load (or a prior pending/catch) shows the pending branch while the promise is in
|
|
136
|
+
flight; a live then-branch stays put (see `showPending`). */
|
|
137
|
+
showPending()
|
|
132
138
|
result.then(
|
|
133
139
|
(value) => {
|
|
134
140
|
if (guard.live(gen)) {
|
|
@@ -296,8 +302,23 @@ export function awaitBlock(
|
|
|
296
302
|
}
|
|
297
303
|
/* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
|
|
298
304
|
read is tracked (the branch builds untracked via `scope`), so the block re-runs only
|
|
299
|
-
when its promise source does, not on any branch-state change.
|
|
300
|
-
|
|
305
|
+
when its promise source does, not on any branch-state change. A blocking `await` cell
|
|
306
|
+
embedded in the subject (`{#await loadMore(user.id)}` where `user` is an `await` cell)
|
|
307
|
+
throws a `SuspenseSignal` while pending (ADR-0042) — on a COLD client render the read
|
|
308
|
+
runs synchronously here, so withhold to the pending branch until it settles (mirroring
|
|
309
|
+
the local suspense withhold every other reading region does), rather than letting the
|
|
310
|
+
throw escape the build. The read tracked its cell, so this effect re-runs on resolve. */
|
|
311
|
+
let result: unknown
|
|
312
|
+
try {
|
|
313
|
+
result = promiseThunk()
|
|
314
|
+
} catch (signal) {
|
|
315
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
316
|
+
throw signal
|
|
317
|
+
}
|
|
318
|
+
showPending()
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
render(result)
|
|
301
322
|
})
|
|
302
323
|
}
|
|
303
324
|
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
|
13
13
|
import { moveRange } from './moveRange.ts'
|
|
14
14
|
import { removeRange } from './removeRange.ts'
|
|
15
15
|
import type { EachRow } from './types/EachRow.ts'
|
|
16
|
+
import { withSuspense } from './withSuspense.ts'
|
|
16
17
|
|
|
17
18
|
/*
|
|
18
19
|
Keyed list binding — the runtime for `<template each key=>`. Each row is a content
|
|
@@ -119,6 +120,13 @@ export function each<T>(
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/* Read the source, tolerating a pending blocking `await` read (a SuspenseSignal): treat a
|
|
124
|
+
suspend as an empty list — identical to the undefined-while-pending peek (ADR-0032 D3) — so
|
|
125
|
+
the each renders no rows until the source resolves. The read still tracked its cell, so the
|
|
126
|
+
driving effect re-runs on settle. */
|
|
127
|
+
const readSource = (): Iterable<T> | undefined =>
|
|
128
|
+
withSuspense<Iterable<T> | undefined>(items, undefined)
|
|
129
|
+
|
|
122
130
|
let anchor: Node
|
|
123
131
|
/* When hydrating, the first effect run must NOT reconcile — the rows it would
|
|
124
132
|
build are already adopted in place below. */
|
|
@@ -128,7 +136,7 @@ export function each<T>(
|
|
|
128
136
|
let position = 0
|
|
129
137
|
/* An undefined source renders an empty list, not a throw — a `{#for x in promise}`
|
|
130
138
|
whose lifted source peeks undefined while pending (ADR-0032 D3). */
|
|
131
|
-
for (const item of
|
|
139
|
+
for (const item of readSource() ?? []) {
|
|
132
140
|
const key = keyOf(item)
|
|
133
141
|
const row = buildRow(item, position) // claims the SSR row where it sits
|
|
134
142
|
if (rows.has(key)) {
|
|
@@ -156,7 +164,7 @@ export function each<T>(
|
|
|
156
164
|
effect(() => {
|
|
157
165
|
/* Read (subscribe) every run, including the adopting one. Materialize a
|
|
158
166
|
non-array iterable to an array so a generator yields fresh each run. */
|
|
159
|
-
const source =
|
|
167
|
+
const source = readSource()
|
|
160
168
|
if (adopting) {
|
|
161
169
|
adopting = false // rows already adopted in document order; nothing to move
|
|
162
170
|
return
|
|
@@ -11,6 +11,7 @@ import { state } from '../state.ts'
|
|
|
11
11
|
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
12
12
|
import { removeRange } from './removeRange.ts'
|
|
13
13
|
import type { EachRow } from './types/EachRow.ts'
|
|
14
|
+
import { withSuspense } from './withSuspense.ts'
|
|
14
15
|
|
|
15
16
|
/*
|
|
16
17
|
Async keyed list — the runtime for `<template each await key=>`. Like `each`, but
|
|
@@ -113,7 +114,14 @@ export function eachAsync<T>(
|
|
|
113
114
|
removeRange(row.start, row.end)
|
|
114
115
|
}
|
|
115
116
|
rows.clear()
|
|
116
|
-
|
|
117
|
+
/* Read (subscribe) the source synchronously, tolerating a pending blocking `await` read
|
|
118
|
+
embedded in it (a SuspenseSignal, ADR-0042): withhold — render no rows until it resolves,
|
|
119
|
+
exactly as sync `each` does. On a cold client render the read runs here at build; the
|
|
120
|
+
read tracked its cell, so this effect re-runs on settle. */
|
|
121
|
+
const iterable = withSuspense<AsyncIterable<T> | undefined>(items, undefined)
|
|
122
|
+
if (iterable === undefined) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
117
125
|
const present = new Set<string>()
|
|
118
126
|
let arrivals = 0 // stream arrival ordinal → each row's index
|
|
119
127
|
const drain = async (): Promise<void> => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isAsyncCell } from '../../shared/isAsyncCell.ts'
|
|
2
|
+
import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
|
|
3
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
4
|
+
import { readCell } from './readCell.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The blocking-cell read the compiler lowers to `$$readCellBlocking(NAME)` for an `await`-marked
|
|
8
|
+
cell (ADR-0042 D5.3). Exactly `readCell`, with one added rule ahead of it: a cell with no value
|
|
9
|
+
*yet* throws a `SuspenseSignal` so the enclosing region SUSPENDS until the value resolves rather
|
|
10
|
+
than rendering against `undefined`. This is what makes an `await` binding read as a resolved `T`
|
|
11
|
+
(never `undefined`-while-pending) on both sides — the client mirrors the server flush barrier.
|
|
12
|
+
|
|
13
|
+
The suspend is keyed on `cell.pending()` (in-flight AND no value), NOT `peek() === undefined`:
|
|
14
|
+
- a blocking cell that legitimately resolves to `undefined` is not pending, so it does not
|
|
15
|
+
suspend forever;
|
|
16
|
+
- a warm-seeded cell on hydrate is `refreshing()` (has value, in flight), not `pending()`, so it
|
|
17
|
+
reads its held value and adopts the SSR markup with no suspend and no flash (ADR-0042 D4).
|
|
18
|
+
Everything past the pending gate — the throwing peek (error-with-no-retained-value → `AsyncCellError`
|
|
19
|
+
to `{#try}`, a retained value winning over pending/error via SWR), a function call, or a `.value`
|
|
20
|
+
read — is `readCell` verbatim, so the two reads can never drift.
|
|
21
|
+
*/
|
|
22
|
+
// @documentation plumbing
|
|
23
|
+
export function readCellBlocking(cell: unknown): unknown {
|
|
24
|
+
/* No value yet → suspend the reading region (distinct from an error, which `readCell` routes). */
|
|
25
|
+
if (isAsyncCell(cell) && (cell as AsyncComputed<unknown>).pending()) {
|
|
26
|
+
throw new SuspenseSignal(cell as AsyncComputed<unknown>)
|
|
27
|
+
}
|
|
28
|
+
return readCell(cell)
|
|
29
|
+
}
|