@abide/abide 0.54.0 → 0.56.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 +330 -498
- package/CHANGELOG.md +63 -0
- package/README.md +166 -146
- package/package.json +2 -3
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
- package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
- package/src/lib/shared/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +4 -5
- package/src/lib/shared/cache.ts +169 -75
- package/src/lib/shared/createRemoteFunction.ts +4 -1
- package/src/lib/shared/encodeSeedValue.ts +24 -0
- package/src/lib/shared/jsonSchemaForType.ts +3 -16
- package/src/lib/shared/peek.ts +3 -6
- package/src/lib/ui/amendReaderHook.ts +1 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
- package/src/lib/ui/compile/analyzeComponent.ts +0 -2
- package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
- package/src/lib/ui/compile/compileComponent.ts +1 -10
- package/src/lib/ui/compile/compileShadow.ts +113 -8
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +195 -44
- package/src/lib/ui/compile/generateBuild.ts +25 -24
- package/src/lib/ui/compile/generateSSR.ts +37 -24
- package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
- package/src/lib/ui/compile/wrapSeed.ts +28 -0
- package/src/lib/ui/computed.ts +16 -0
- package/src/lib/ui/createScope.ts +4 -3
- package/src/lib/ui/dom/anchorCursor.ts +2 -1
- package/src/lib/ui/dom/anchoredBranch.ts +14 -3
- package/src/lib/ui/dom/appendStatic.ts +11 -20
- package/src/lib/ui/dom/appendText.ts +41 -43
- package/src/lib/ui/dom/appendTextAt.ts +10 -57
- package/src/lib/ui/dom/awaitBlock.ts +30 -44
- package/src/lib/ui/dom/awaitSubject.ts +118 -0
- package/src/lib/ui/dom/claimText.ts +38 -0
- package/src/lib/ui/dom/cloneStatic.ts +2 -8
- package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
- package/src/lib/ui/dom/discardBoundary.ts +5 -4
- package/src/lib/ui/dom/each.ts +3 -5
- package/src/lib/ui/dom/fillBoundary.ts +2 -1
- package/src/lib/ui/dom/hydrate.ts +5 -12
- package/src/lib/ui/dom/mountChild.ts +73 -5
- package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
- package/src/lib/ui/dom/openMarker.ts +2 -4
- package/src/lib/ui/dom/outlet.ts +4 -3
- package/src/lib/ui/dom/readCell.ts +26 -11
- package/src/lib/ui/dom/skeleton.ts +2 -7
- package/src/lib/ui/dom/switchBlock.ts +2 -1
- package/src/lib/ui/dom/tryBlock.ts +26 -38
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +32 -1
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -24
- package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
- package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
- package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
- package/src/lib/ui/runtime/RENDER.ts +7 -5
- package/src/lib/ui/runtime/RESUME.ts +5 -7
- package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
- package/src/lib/ui/runtime/claimChild.ts +5 -7
- package/src/lib/ui/runtime/claimExpected.ts +2 -6
- package/src/lib/ui/runtime/claimMarker.ts +13 -0
- package/src/lib/ui/runtime/claimRun.ts +22 -0
- package/src/lib/ui/runtime/consumeSeed.ts +38 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +66 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createEffectNode.ts +1 -0
- package/src/lib/ui/runtime/createSignalNode.ts +1 -0
- package/src/lib/ui/runtime/parkCursor.ts +10 -0
- package/src/lib/ui/runtime/readNode.ts +21 -1
- package/src/lib/ui/runtime/renderPath.ts +2 -2
- package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
- package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
- package/src/lib/ui/runtime/runNode.ts +14 -0
- package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
- package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
- package/src/lib/ui/settleAsyncCells.ts +30 -3
- package/src/lib/ui/startClient.ts +0 -7
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/watch.ts +3 -11
- package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
- package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SEED_MARKS } from './SEED_MARKS.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Reads a warm-seed entry (`CELL_SEED` / `DOC_SEED`) under the one-shot contract: a seed
|
|
5
|
+
hydrates exactly one pass, so a LATER fresh mount at the same render-path can't
|
|
6
|
+
warm-adopt a stale boot-time snapshot (an SPA navigation recomputes the identical key).
|
|
7
|
+
|
|
8
|
+
Two-phase (ADR-0048): during a hydration pass the read MARKS the entry instead of deleting
|
|
9
|
+
it — a clean pass exit deletes the marked keys, and a THROWING pass leaves every value in
|
|
10
|
+
place, so the discard→cold-rebuild recovery re-adopts the SSR-resolved values instead of
|
|
11
|
+
refetching (a cold refetch would leave blocking `await` cells pending and escape as an
|
|
12
|
+
uncaught SuspenseSignal at mount). WITHIN a pass the read is idempotent — a repeat read
|
|
13
|
+
re-adopts the still-present value, because the repeat is a block-level cold rebuild
|
|
14
|
+
reconstructing the same render-path inside the live pass (its first construction marked
|
|
15
|
+
the key, then the adopt desynced); the one-shot half is enforced at pass exit, not per
|
|
16
|
+
read. Outside a pass — the recovery rebuild itself, a manual client-only mount — the
|
|
17
|
+
read deletes immediately, the plain one-shot.
|
|
18
|
+
*/
|
|
19
|
+
export function consumeSeed(store: Record<string, string>, key: string): string | undefined {
|
|
20
|
+
const marks = SEED_MARKS.current
|
|
21
|
+
if (marks === undefined) {
|
|
22
|
+
const value = store[key]
|
|
23
|
+
if (value !== undefined) {
|
|
24
|
+
delete store[key]
|
|
25
|
+
}
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
const value = store[key]
|
|
29
|
+
if (value !== undefined) {
|
|
30
|
+
let keys = marks.get(store)
|
|
31
|
+
if (keys === undefined) {
|
|
32
|
+
keys = new Set()
|
|
33
|
+
marks.set(store, keys)
|
|
34
|
+
}
|
|
35
|
+
keys.add(key)
|
|
36
|
+
}
|
|
37
|
+
return value
|
|
38
|
+
}
|
|
@@ -12,10 +12,12 @@ import { activePendingCells } from '../activePendingCells.ts'
|
|
|
12
12
|
import type { Scope } from '../types/Scope.ts'
|
|
13
13
|
import { CELL_SEED } from './CELL_SEED.ts'
|
|
14
14
|
import { CURRENT_SCOPE } from './CURRENT_SCOPE.ts'
|
|
15
|
+
import { consumeSeed } from './consumeSeed.ts'
|
|
15
16
|
import { createEffectNode } from './createEffectNode.ts'
|
|
16
17
|
import { createSignalNode } from './createSignalNode.ts'
|
|
17
18
|
import { readNode } from './readNode.ts'
|
|
18
19
|
import { registerStreamedCell } from './STREAMED_CELLS.ts'
|
|
20
|
+
import { SuspenseSignal } from './SuspenseSignal.ts'
|
|
19
21
|
import { writeNode } from './writeNode.ts'
|
|
20
22
|
|
|
21
23
|
/* The seed's transform gate (mirrors `state`/`linked`): coerces every value entering
|
|
@@ -83,6 +85,14 @@ export function createAsyncCell(
|
|
|
83
85
|
let inFlight: Promise<unknown> | undefined
|
|
84
86
|
/* Cancels the active stream subscription (a reseed, refresh, or dispose supersedes it). */
|
|
85
87
|
let cancelStream: (() => void) | undefined
|
|
88
|
+
/* Whether a pending read of this cell PAUSES its reader (ADR-0046). True only for a
|
|
89
|
+
BLOCKING source — a settling PROMISE the cell would join the SSR barrier for (author
|
|
90
|
+
`await`, or a fail-open bare-call that registers): `streaming !== true` AND the seed
|
|
91
|
+
produced a thenable. A STREAM (`isAsyncIterable`) never settles and always peeks, so it
|
|
92
|
+
is NOT blocking even though it carries the same default `streaming` flag; `run` clears
|
|
93
|
+
this when it sees a stream. Resolved from the produced source, not the flag alone, so
|
|
94
|
+
`streaming: false` cannot conflate "author `await`" with "stream". */
|
|
95
|
+
let blockingSource = false
|
|
86
96
|
|
|
87
97
|
/* Store a produced value, honouring the write latch and the transform gate. */
|
|
88
98
|
const acceptValue = (value: unknown): void => {
|
|
@@ -126,6 +136,16 @@ export function createAsyncCell(
|
|
|
126
136
|
return
|
|
127
137
|
}
|
|
128
138
|
inFlight = undefined
|
|
139
|
+
/* A `SuspenseSignal` is a PAUSE, not a failure — the same as the synchronous case in
|
|
140
|
+
`run`, but arriving through a REJECTION: an async seed (`computed(await f(dep))`) reads a
|
|
141
|
+
pending blocking `dep` in its synchronous prefix, and the throw becomes the thunk's
|
|
142
|
+
rejection rather than a sync throw. Stay pending (leave `inFlightNode` true, hold no
|
|
143
|
+
error); the throwing read subscribed this seed's effect to `dep` during that prefix, so
|
|
144
|
+
it re-runs and produces the real source once `dep` settles. */
|
|
145
|
+
if (error instanceof SuspenseSignal) {
|
|
146
|
+
writeNode(errorNode, undefined)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
129
149
|
writeNode(errorNode, error)
|
|
130
150
|
writeNode(inFlightNode, false)
|
|
131
151
|
}
|
|
@@ -181,15 +201,45 @@ export function createAsyncCell(
|
|
|
181
201
|
try {
|
|
182
202
|
produced = seed()
|
|
183
203
|
} catch (error) {
|
|
204
|
+
/* A `SuspenseSignal` is a PAUSE, not a failure: this seed read a blocking
|
|
205
|
+
dependency that has no value yet, so this cell's branch pauses too. Stay
|
|
206
|
+
pending — leave `inFlightNode` true, hold no value, carry no error — rather
|
|
207
|
+
than latching the pause into `error()`. The throwing read already subscribed
|
|
208
|
+
this seed's effect to the pending dependency, so when it settles the effect
|
|
209
|
+
re-runs the seed, which now reads a value and produces the real source. No
|
|
210
|
+
promise is registered (there is none), so the SSR barrier's fixpoint drain
|
|
211
|
+
picks this cell up on that re-run instead. */
|
|
212
|
+
if (error instanceof SuspenseSignal) {
|
|
213
|
+
/* This cell's pending reads must PAUSE too — the seed derives from a blocking
|
|
214
|
+
source, so a reader peeking `undefined` (the streaming default) would race the
|
|
215
|
+
dependency instead of waiting for it. Mark blocking from the SUSPEND itself,
|
|
216
|
+
since a synchronously-suspending seed produces no promise for the `isThenable`
|
|
217
|
+
branch to read it from; honour a `streaming` opt-out for symmetry with that
|
|
218
|
+
branch. Once the seed re-runs and settles a value this flag is moot (a retained
|
|
219
|
+
value is returned before `blocking` is ever consulted). */
|
|
220
|
+
if (options.streaming !== true) {
|
|
221
|
+
blockingSource = true
|
|
222
|
+
}
|
|
223
|
+
if (myRun === runId) {
|
|
224
|
+
inFlight = undefined
|
|
225
|
+
writeNode(errorNode, undefined)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
184
229
|
settleError(myRun, error)
|
|
185
230
|
return
|
|
186
231
|
}
|
|
187
232
|
|
|
188
233
|
if (isAsyncIterable(produced)) {
|
|
234
|
+
/* A stream never settles — it PEEKS `undefined` until its first frame, never pauses. */
|
|
235
|
+
blockingSource = false
|
|
189
236
|
consumeStream(myRun, produced as NamedAsyncIterable<unknown>)
|
|
190
237
|
return
|
|
191
238
|
}
|
|
192
239
|
if (isThenable(produced)) {
|
|
240
|
+
/* A settling promise the cell would join the SSR barrier for (author `await`, or a
|
|
241
|
+
fail-open bare-call) PAUSES a pending read; a `streaming` promise peeks. */
|
|
242
|
+
blockingSource = options.streaming !== true
|
|
193
243
|
inFlight = produced as Promise<unknown>
|
|
194
244
|
/* Server-only: register the in-flight promise on the request-scoped pending list so
|
|
195
245
|
the SSR barrier (`$$settleAsyncCells`) awaits it before the template peeks the
|
|
@@ -222,15 +272,15 @@ export function createAsyncCell(
|
|
|
222
272
|
only in effect (the store is only ever populated by `startClient`, so it's empty on the
|
|
223
273
|
server — no `window` sniff needed); a decode failure falls through to a cold run. */
|
|
224
274
|
if (warmKey !== undefined) {
|
|
225
|
-
|
|
275
|
+
/* One-shot via `consumeSeed` (two-phase, ADR-0048): a LATER fresh mount at the same
|
|
276
|
+
render-path can't warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN
|
|
277
|
+
(params-independent), so an SPA navigation `/products/42`→`/products/99` — or a
|
|
278
|
+
back-nav that rebuilds the SSR page — recomputes the identical warmKey; unconsumed,
|
|
279
|
+
the new cell would render `/products/42`'s stale value until revalidation. During a
|
|
280
|
+
hydration pass the consume only MARKS, so a desync throw hands the seed to the cold
|
|
281
|
+
rebuild instead of losing it. */
|
|
282
|
+
const seeded = consumeSeed(CELL_SEED, warmKey)
|
|
226
283
|
if (seeded !== undefined) {
|
|
227
|
-
/* One-shot: consume the seed so a LATER fresh mount at the same render-path can't
|
|
228
|
-
warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN (params-
|
|
229
|
-
independent), so an SPA navigation `/products/42`→`/products/99` — or a back-nav
|
|
230
|
-
that rebuilds the SSR page — recomputes the identical warmKey; without deleting,
|
|
231
|
-
the new cell would render `/products/42`'s stale value until revalidation. The seed
|
|
232
|
-
only ever hydrates the initial SSR render, where each cell is constructed once. */
|
|
233
|
-
delete CELL_SEED[warmKey]
|
|
234
284
|
try {
|
|
235
285
|
acceptValue(decodeRefJson(seeded))
|
|
236
286
|
} catch {
|
|
@@ -279,6 +329,14 @@ export function createAsyncCell(
|
|
|
279
329
|
/* The current in-flight promise (undefined once settled / not-a-promise) — an internal
|
|
280
330
|
runtime affordance the SSR barrier reads structurally; not on the public cell types. */
|
|
281
331
|
settled: () => inFlight,
|
|
332
|
+
/* Whether a pending read of this cell PAUSES its reader (ADR-0046) — true only for a
|
|
333
|
+
blocking source (a barrier-joining settling promise), resolved from what the seed
|
|
334
|
+
produced (`blockingSource`) so a stream (which shares the default `streaming` flag)
|
|
335
|
+
is correctly NON-blocking. A getter, since the seed's eager run sets it. `readCell`
|
|
336
|
+
consults it; an internal affordance, not on the public cell type. */
|
|
337
|
+
get blocking(): boolean {
|
|
338
|
+
return blockingSource
|
|
339
|
+
},
|
|
282
340
|
} as AsyncComputed<unknown>
|
|
283
341
|
if (!options.writable) {
|
|
284
342
|
return readOnly
|
|
@@ -52,6 +52,7 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
52
52
|
subsTail: undefined,
|
|
53
53
|
/* Born DIRTY; the immediate `runNode` below captures deps and settles it CLEAN. */
|
|
54
54
|
status: NODE_STATE.DIRTY,
|
|
55
|
+
thrown: undefined,
|
|
55
56
|
isEffect: true,
|
|
56
57
|
}
|
|
57
58
|
runNode(node)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
2
|
+
|
|
3
|
+
/* Repositions the parent's claim cursor at `reference` (null = past the last child). The
|
|
4
|
+
ONE write site of the cursor invariant: every claim must leave the cursor on the next
|
|
5
|
+
unclaimed sibling, or the following claim grabs the wrong node — a silent desync. The
|
|
6
|
+
claim verbs advance through here; a block parks explicitly when it skips or discards a
|
|
7
|
+
region (an anchor's content, a boundary it re-claims later, a discarded range). */
|
|
8
|
+
export function parkCursor(hydration: HydrationCursor, parent: Node, reference: Node | null): void {
|
|
9
|
+
hydration.next.set(parent, reference)
|
|
10
|
+
}
|
|
@@ -8,10 +8,30 @@ settles first (the lazy pull — `updateIfNecessary` refreshes only the deps tha
|
|
|
8
8
|
changed, recomputing if any did); a signal has no compute and returns its stored
|
|
9
9
|
value directly. Tracking happens after the settle so the reader links to the
|
|
10
10
|
computed itself, not its transitive deps.
|
|
11
|
+
|
|
12
|
+
The settle runs in a `try` whose `finally` still links the reader: an edge to a
|
|
13
|
+
node is an edge even when that node is currently PAUSED. A computed whose compute
|
|
14
|
+
throws — the common case being a `SuspenseSignal` from a pending blocking cell it
|
|
15
|
+
reads — must still leave its reader subscribed to it, or the reader would never
|
|
16
|
+
re-run when the paused branch resolves (the compute re-runs on settle via its own
|
|
17
|
+
retained deps, then this reader wakes off its edge to the computed). The signal
|
|
18
|
+
fast-path stays a plain track — it has no compute to throw and is the hot read.
|
|
11
19
|
*/
|
|
12
20
|
export function readNode(node: ReactiveNode): unknown {
|
|
13
21
|
if (node.compute !== undefined) {
|
|
14
|
-
|
|
22
|
+
try {
|
|
23
|
+
updateIfNecessary(node)
|
|
24
|
+
/* Throw memoisation: a paused/erroring compute settled CLEAN with its throw
|
|
25
|
+
retained (`runNode`'s catch) — rethrow it so EVERY reader observes the
|
|
26
|
+
pause/error, not just the one whose read ran the compute. Cleared by the
|
|
27
|
+
next successful run once a dep change re-dirties the node. */
|
|
28
|
+
if (node.thrown !== undefined) {
|
|
29
|
+
throw node.thrown
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
track(node)
|
|
33
|
+
}
|
|
34
|
+
return node.value
|
|
15
35
|
}
|
|
16
36
|
track(node)
|
|
17
37
|
return node.value
|
|
@@ -3,8 +3,8 @@ import { withPath } from './withPath.ts'
|
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
The render-path a `<Child/>` mounts under — the boundary id of a STREAMED child component (ADR-0039).
|
|
6
|
-
Composes the child's ordinal segment onto the ambient path exactly as the client's
|
|
7
|
-
`
|
|
6
|
+
Composes the child's ordinal segment onto the ambient path exactly as the client's dual-mode
|
|
7
|
+
`mountChild` does (`withPath(ordinal, …)`), so the server-written `abide:await:CHILDPATH`
|
|
8
8
|
boundary and the client adopter compute the SAME id with no counter to drift. It carries `/`
|
|
9
9
|
separators and never a `:`, so a child path (`products/0`) can't collide with an await-block id
|
|
10
10
|
(`products:0`). Server-emit-only (the streaming boundary is server codegen), so it tree-shakes out of
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isAsyncIterable } from '../../shared/isAsyncIterable.ts'
|
|
2
|
+
import { isThenable } from '../../shared/isThenable.ts'
|
|
3
|
+
import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
|
|
4
|
+
import type { AsyncState } from '../../shared/types/AsyncState.ts'
|
|
5
|
+
import { createAsyncCell } from './createAsyncCell.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The ONE statement of the ADR-0045 bare-seed contract for `computed`/`linked`: a bare
|
|
9
|
+
seed that reached the runtime literally (a branch-nested `<script>` keeps its
|
|
10
|
+
`state.computed(...)` calls literal, and a direct JS caller never compiles) routes BY
|
|
11
|
+
VALUE exactly as its thunk form would — a promise → a streaming unwrapped value cell, a
|
|
12
|
+
stream → a frame cell. Anything else returns `undefined`: not async, the caller wraps it
|
|
13
|
+
as a constant seed thunk. The classification order (thenable before async-iterable) and
|
|
14
|
+
the streaming flag are load-bearing and shared here so the two primitives cannot drift.
|
|
15
|
+
*/
|
|
16
|
+
export function routeBareSeed(
|
|
17
|
+
value: unknown,
|
|
18
|
+
writable: boolean,
|
|
19
|
+
transform?: (next: unknown, previous: unknown) => unknown,
|
|
20
|
+
): AsyncComputed<unknown> | AsyncState<unknown> | undefined {
|
|
21
|
+
if (isThenable(value)) {
|
|
22
|
+
return createAsyncCell(() => value, { writable, transform, streaming: true })
|
|
23
|
+
}
|
|
24
|
+
if (isAsyncIterable(value)) {
|
|
25
|
+
return createAsyncCell(() => value, { writable, transform })
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { hydrationWindow } from '../../shared/hydrationWindow.ts'
|
|
2
|
+
import { enterRenderPass } from './enterRenderPass.ts'
|
|
3
|
+
import { exitRenderPass } from './exitRenderPass.ts'
|
|
4
|
+
import { RENDER } from './RENDER.ts'
|
|
5
|
+
import { SEED_MARKS } from './SEED_MARKS.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The one owner of a hydration pass's lifecycle. A pass is four lifetimes that must open
|
|
9
|
+
and close together — the claim cursor (`RENDER.hydration`), the cache-withhold window
|
|
10
|
+
(`hydrationWindow`, which `cache.peek` reads), the block-id render pass
|
|
11
|
+
(`enterRenderPass`), and the two-phase seed consume (`SEED_MARKS`/`consumeSeed`,
|
|
12
|
+
ADR-0048): the seeds a pass adopts are only MARKED while it runs, deleted on the
|
|
13
|
+
OUTERMOST clean exit, and left in place on a throw — so the caller's
|
|
14
|
+
discard→cold-rebuild recovery re-adopts the SSR-resolved values instead of refetching
|
|
15
|
+
(a cold refetch would leave blocking `await` cells pending and escape as an uncaught
|
|
16
|
+
SuspenseSignal at mount).
|
|
17
|
+
|
|
18
|
+
Both entry points (`hydrate`, the router's hydrating first mount) run through here, so
|
|
19
|
+
the bracket order and the throw semantics can't drift between them. Nesting is safe:
|
|
20
|
+
the cursor saves/restores, the window/render-pass are depth-counted, and a nested
|
|
21
|
+
clean exit hands its marks to the enclosing pass rather than committing — if the outer
|
|
22
|
+
pass later throws, the nested pass's seeds are still in place for the recovery rebuild.
|
|
23
|
+
*/
|
|
24
|
+
export function runHydrationPass<T>(run: () => T): T {
|
|
25
|
+
const previous = RENDER.hydration
|
|
26
|
+
const previousMarks = SEED_MARKS.current
|
|
27
|
+
RENDER.hydration = { next: new Map() }
|
|
28
|
+
SEED_MARKS.current = new Map()
|
|
29
|
+
try {
|
|
30
|
+
hydrationWindow.enter()
|
|
31
|
+
enterRenderPass()
|
|
32
|
+
try {
|
|
33
|
+
const result = run()
|
|
34
|
+
const marks = SEED_MARKS.current
|
|
35
|
+
if (previousMarks === undefined) {
|
|
36
|
+
/* Outermost clean pass: spend the adopted seeds, preserving the one-shot
|
|
37
|
+
contract (a later mount at the same render-path re-inits fresh, never a
|
|
38
|
+
stale snapshot). */
|
|
39
|
+
for (const [store, keys] of marks) {
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
delete store[key]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
/* Nested clean pass: merge the marks upward — only the outermost owner
|
|
46
|
+
commits, so an outer throw still leaves every value in place. */
|
|
47
|
+
for (const [store, keys] of marks) {
|
|
48
|
+
let outerKeys = previousMarks.get(store)
|
|
49
|
+
if (outerKeys === undefined) {
|
|
50
|
+
outerKeys = new Set()
|
|
51
|
+
previousMarks.set(store, outerKeys)
|
|
52
|
+
}
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
outerKeys.add(key)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
} finally {
|
|
60
|
+
exitRenderPass()
|
|
61
|
+
RENDER.hydration = previous
|
|
62
|
+
/* Outermost exit clears the window and wakes the peeks this pass withheld for
|
|
63
|
+
SSR congruence, now that the warm value is congruent to show. */
|
|
64
|
+
hydrationWindow.exit()
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
SEED_MARKS.current = previousMarks
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -52,7 +52,21 @@ export function runNode(node: ReactiveNode): unknown {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
node.status = NODE_STATE.CLEAN
|
|
55
|
+
node.thrown = undefined
|
|
55
56
|
return node.value
|
|
57
|
+
} catch (error) {
|
|
58
|
+
/* The compute threw — commonly a `SuspenseSignal` from a pending blocking cell it read,
|
|
59
|
+
propagating the pause down this edge. Settle the node CLEAN anyway (its value is left
|
|
60
|
+
stale) so a later change to the deps it DID track before throwing re-marks it DIRTY and
|
|
61
|
+
re-propagates to its subscribers; left DIRTY, `mark`'s status gate would take the
|
|
62
|
+
settle's CLEAN→DIRTY edge as already-dirty and leave the node permanently inert — the
|
|
63
|
+
same reset `flushEffects` applies to a thrown effect. The throw is also RETAINED on the
|
|
64
|
+
node: a CLEAN settle memoises, so without it only this first reader would observe the
|
|
65
|
+
pause/error and every later reader would silently get the stale value — `readNode`
|
|
66
|
+
rethrows the retained throw until the next successful run clears it. */
|
|
67
|
+
node.status = NODE_STATE.CLEAN
|
|
68
|
+
node.thrown = error
|
|
69
|
+
throw error
|
|
56
70
|
} finally {
|
|
57
71
|
REACTIVE_CONTEXT.observer = previous
|
|
58
72
|
/* Drop the edges `track` didn't reuse — last run's now-stale dependencies. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The active hydration pass's claim cursor: per parent, the next server-rendered child
|
|
2
|
+
to claim — a node pointer (not an index) so it survives nodes a block inserts (anchors,
|
|
3
|
+
range brackets) mid-hydration. Read through `claimChild` (probe) / the claim verbs
|
|
4
|
+
(claim + advance); repositioned through `parkCursor`. The live instance hangs off
|
|
5
|
+
`RENDER.hydration` (undefined outside a pass). */
|
|
6
|
+
export type HydrationCursor = {
|
|
7
|
+
next: Map<Node, Node | null>
|
|
8
|
+
}
|
|
@@ -24,5 +24,10 @@ export type ReactiveNode = {
|
|
|
24
24
|
NODE_STATE). A signal is always CLEAN (no compute); a computed is born DIRTY and
|
|
25
25
|
cycles CLEAN→CHECK/DIRTY→CLEAN as deps change and reads settle it. */
|
|
26
26
|
status: number
|
|
27
|
+
/* Throw memoisation: what the last compute threw (commonly a `SuspenseSignal`
|
|
28
|
+
pause), retained while the node sits CLEAN so EVERY reader observes the pause or
|
|
29
|
+
error — not just the one whose read ran the compute. Cleared by the next
|
|
30
|
+
successful run; undefined on a signal (no compute to throw). */
|
|
31
|
+
thrown: unknown
|
|
27
32
|
isEffect: boolean
|
|
28
33
|
}
|
|
@@ -10,15 +10,42 @@ earlier settled layer's cells. `Promise.allSettled` never rejects the render: a
|
|
|
10
10
|
rejected cell settles into its `error()` state, surfaced at the read site (via
|
|
11
11
|
`$$readCell`), not here. On the client the list is always empty (cells register
|
|
12
12
|
server-side only), so the barrier is a no-op await.
|
|
13
|
+
|
|
14
|
+
It drains to a FIXPOINT — "await until this branch of the graph stops pausing" —
|
|
15
|
+
not a single snapshot. Pending propagates down dependency edges: a blocking cell
|
|
16
|
+
whose seed reads a still-pending blocking dependency stays pending and registers no
|
|
17
|
+
promise (its seed threw a `SuspenseSignal`, caught as pending, not error). When the
|
|
18
|
+
dependency settles inside this await, the reactive flush is synchronous, so the
|
|
19
|
+
dependent's seed re-runs and registers ITS promise before the loop re-checks — the
|
|
20
|
+
whole chain resolves in order. The per-render list isolation (`isolateCellBarrier`)
|
|
21
|
+
keeps the loop draining only this render's cascade, never a concurrent sibling's.
|
|
22
|
+
|
|
23
|
+
The round cap bounds a pathological feedback edge — a cell whose settle writes a
|
|
24
|
+
signal its own seed reads re-registers a fresh promise every round, and an
|
|
25
|
+
unbounded fixpoint would hang the SSR request forever. Legitimate chains resolve
|
|
26
|
+
one dependency level per round, so the cap is far above any real graph; hitting
|
|
27
|
+
it renders with whatever settled (the old snapshot-drain behavior) and warns.
|
|
13
28
|
*/
|
|
29
|
+
const MAX_SETTLE_ROUNDS = 1000
|
|
30
|
+
|
|
14
31
|
// @documentation plumbing
|
|
15
32
|
export async function settleAsyncCells(): Promise<void> {
|
|
16
33
|
const pending = activePendingCells()
|
|
17
34
|
if (pending === undefined) {
|
|
18
35
|
return
|
|
19
36
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
37
|
+
let rounds = 0
|
|
38
|
+
while (pending.promises.length > 0) {
|
|
39
|
+
if (rounds >= MAX_SETTLE_ROUNDS) {
|
|
40
|
+
console.warn(
|
|
41
|
+
'[abide] settleAsyncCells did not reach a fixpoint after ' +
|
|
42
|
+
`${MAX_SETTLE_ROUNDS} rounds — an async cell is re-registering ` +
|
|
43
|
+
'a promise on every settle (a seed that reads state its own ' +
|
|
44
|
+
'resolution writes). Rendering with the values settled so far.',
|
|
45
|
+
)
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
rounds += 1
|
|
49
|
+
await Promise.allSettled(pending.promises.splice(0))
|
|
23
50
|
}
|
|
24
51
|
}
|
|
@@ -17,7 +17,6 @@ import { CELL_SEED } from './runtime/CELL_SEED.ts'
|
|
|
17
17
|
import { clientPage } from './runtime/clientPage.ts'
|
|
18
18
|
import { DOC_SEED } from './runtime/DOC_SEED.ts'
|
|
19
19
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
20
|
-
import { warmSeedBackup } from './runtime/warmSeedBackup.ts'
|
|
21
20
|
import { seedBootState } from './seedBootState.ts'
|
|
22
21
|
import { seedStreamedResolution } from './seedStreamedResolution.ts'
|
|
23
22
|
import { subscribeCacheStaleness } from './subscribeCacheStaleness.ts'
|
|
@@ -99,12 +98,6 @@ export function startClient(
|
|
|
99
98
|
if (ssr.sockets !== undefined) {
|
|
100
99
|
Object.assign(SOCKET_SEED, ssr.sockets)
|
|
101
100
|
}
|
|
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
|
|
108
101
|
/* Keep the cache channel live past boot: replace the head's buffering collector with
|
|
109
102
|
the store-connected sink so a post-boot resolution — the inline doc-stream cache
|
|
110
103
|
script — seeds the store through `seedStreamedResolution` directly instead of pushing
|
|
@@ -1,20 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { encodeSeedValue } from '../shared/encodeSeedValue.ts'
|
|
2
2
|
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
3
|
|
|
4
|
-
/* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
entry and warn, so the client re-runs that one branch's promise while every other
|
|
4
|
+
/* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized — the
|
|
5
|
+
await-resume arm of the shared serialize-or-degrade policy (`encodeSeedValue`): drop
|
|
6
|
+
just this entry, so the client re-runs that one branch's promise while every other
|
|
8
7
|
branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
|
|
9
|
-
(`resumeSeedScript`) paths
|
|
8
|
+
(`resumeSeedScript`) paths. */
|
|
10
9
|
export function tryEncodeResume(entry: ResumeEntry, id: number | string): string | undefined {
|
|
11
|
-
|
|
12
|
-
return encodeRefJson(entry)
|
|
13
|
-
} catch (cause) {
|
|
14
|
-
console.warn(
|
|
15
|
-
`[abide] resume for await ${id} is not serializable; client will refetch it`,
|
|
16
|
-
cause,
|
|
17
|
-
)
|
|
18
|
-
return undefined
|
|
19
|
-
}
|
|
10
|
+
return encodeSeedValue(entry, `resume for await ${id}`, 'the client will refetch it')
|
|
20
11
|
}
|
package/src/lib/ui/watch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cache } from '../shared/cache.ts'
|
|
2
|
+
import { isAsyncIterable } from '../shared/isAsyncIterable.ts'
|
|
2
3
|
import { REMOTE_FUNCTION } from '../shared/REMOTE_FUNCTION.ts'
|
|
3
4
|
import type { CacheOnContext } from '../shared/types/CacheOnContext.ts'
|
|
4
5
|
import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
|
|
@@ -86,7 +87,7 @@ export function watch(
|
|
|
86
87
|
const handler = argsOrHandler as (value: unknown, context?: CacheOnContext) => void
|
|
87
88
|
/* A subscribable (socket / stream): per-frame delivery with reconnect-replay — the
|
|
88
89
|
existing cache.on loop is the single implementation this branch delegates to. */
|
|
89
|
-
if (
|
|
90
|
+
if (isAsyncIterable(source)) {
|
|
90
91
|
return cache.on(
|
|
91
92
|
source as NamedAsyncIterable<unknown>,
|
|
92
93
|
handler as (frame: unknown, context: CacheOnContext) => void | Promise<void>,
|
|
@@ -94,7 +95,7 @@ export function watch(
|
|
|
94
95
|
}
|
|
95
96
|
/* An rpc without args → run the smart read reactively, pipe its value to the handler. A
|
|
96
97
|
RemoteFunction is a callable, so accept `function` too — a state cell is a branded-free
|
|
97
|
-
object and never matches, and a streaming rpc was already caught by
|
|
98
|
+
object and never matches, and a streaming rpc was already caught by isAsyncIterable above. */
|
|
98
99
|
if (
|
|
99
100
|
source !== null &&
|
|
100
101
|
(typeof source === 'object' || typeof source === 'function') &&
|
|
@@ -121,15 +122,6 @@ export function watch(
|
|
|
121
122
|
})
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
/* True for a socket / rpc stream — anything async-iterable. */
|
|
125
|
-
function isSubscribable(source: unknown): boolean {
|
|
126
|
-
return (
|
|
127
|
-
source !== null &&
|
|
128
|
-
(typeof source === 'object' || typeof source === 'function') &&
|
|
129
|
-
typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
125
|
/*
|
|
134
126
|
Reacts to an rpc's cached value: an effect that runs the smart read (subscribing
|
|
135
127
|
its key synchronously, so the effect re-runs when the value changes — a refresh,
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
|
|
2
|
-
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
|
-
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
|
-
import { RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
5
|
-
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
|
-
import type { UiComponent } from '../runtime/types/UiComponent.ts'
|
|
7
|
-
import { withOptionalPath } from '../runtime/withOptionalPath.ts'
|
|
8
|
-
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
9
|
-
import { discardBoundary } from './discardBoundary.ts'
|
|
10
|
-
import { mountRange } from './mountRange.ts'
|
|
11
|
-
import { openMarker } from './openMarker.ts'
|
|
12
|
-
|
|
13
|
-
/*
|
|
14
|
-
The client mount for a HOISTABLE child (ADR-0039) — a DUAL-MODE adopter. The server decides per
|
|
15
|
-
render whether a hoistable child inlined (fast/settled) or streamed (still-pending), and the client
|
|
16
|
-
build can't know which, so this probes the hydration cursor:
|
|
17
|
-
|
|
18
|
-
- `<!--[-->` (RANGE_OPEN) next → the server INLINED the child; adopt its range exactly as
|
|
19
|
-
`mountChild` does (byte-identical to the pre-ADR-0039 path).
|
|
20
|
-
- `<!--abide:await:CHILDPATH-->` next → the server STREAMED it; `__abideSwap` has already spliced
|
|
21
|
-
the child's `<!--[-->…<!--]-->` between the boundary markers before the bundle ran, so claim the
|
|
22
|
-
boundary open, adopt the inner range in place, claim the boundary close.
|
|
23
|
-
- no hydration (a client-side navigation) → a plain create-mode mount, like `mountChild`.
|
|
24
|
-
|
|
25
|
-
CHILDPATH is the child's render-path — `withPath(ordinal, () => CURRENT_PATH.current)` — computed the
|
|
26
|
-
same way the server's `renderPath(ordinal)` wrote the boundary id, so the two agree with no counter.
|
|
27
|
-
A streamed child re-mounts (no RESUME value of its own — the boundary is html-only); nested `{#await}`
|
|
28
|
-
blocks and async cells inside it adopt through their own existing channels.
|
|
29
|
-
*/
|
|
30
|
-
// @documentation plumbing
|
|
31
|
-
export function mountStreamedChild(
|
|
32
|
-
parent: Node,
|
|
33
|
-
factory: UiComponent,
|
|
34
|
-
props: Parameters<UiComponent>[1],
|
|
35
|
-
before: Node | null = null,
|
|
36
|
-
ordinal?: number,
|
|
37
|
-
): void {
|
|
38
|
-
const run = <T>(build: () => T): T => withOptionalPath(ordinal, build)
|
|
39
|
-
const mount = (): { dispose: () => void } =>
|
|
40
|
-
run(() => mountRange(parent, factory.build, props, before))
|
|
41
|
-
|
|
42
|
-
const hydration = RENDER.hydration
|
|
43
|
-
if (hydration === undefined) {
|
|
44
|
-
/* Call mount() first, THEN register its dispose — `OWNER.current?.push(mount())` would
|
|
45
|
-
short-circuit the whole expression (never mounting) when there is no owner. */
|
|
46
|
-
const handle = mount()
|
|
47
|
-
OWNER.current?.push(handle.dispose)
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/* Probe the cursor without consuming it. Streamed iff the next node is this child's boundary. */
|
|
52
|
-
const childPath = run(() => CURRENT_PATH.current)
|
|
53
|
-
const cursor = claimChild(hydration, parent)
|
|
54
|
-
const streamed =
|
|
55
|
-
cursor !== null &&
|
|
56
|
-
cursor.nodeType === 8 &&
|
|
57
|
-
(cursor as Comment).data === `abide:await:${childPath}`
|
|
58
|
-
|
|
59
|
-
if (!streamed) {
|
|
60
|
-
/* INLINE — the server inlined a settled child; adopt its `[ … ]` range in place. */
|
|
61
|
-
const handle = mount()
|
|
62
|
-
OWNER.current?.push(handle.dispose)
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* STREAMED — claim the boundary open (advances the cursor to the swapped-in `[`). */
|
|
67
|
-
const open = openMarker(parent, `abide:await:${childPath}`)
|
|
68
|
-
const inner = claimChild(hydration, parent)
|
|
69
|
-
const warm = inner !== null && inner.nodeType === 8 && (inner as Comment).data === RANGE_OPEN
|
|
70
|
-
if (warm) {
|
|
71
|
-
const handle = mount()
|
|
72
|
-
openMarker(parent, `/abide:await:${childPath}`)
|
|
73
|
-
OWNER.current?.push(handle.dispose)
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
/* COLD (defensive — renderToStream drains every await before closing, so a warm boundary is the
|
|
77
|
-
deterministic path). The fragment never arrived: discard the empty boundary and create-mount
|
|
78
|
-
the child fresh at that position; its streamed cells resolve post-mount via receiveStreamedCell. */
|
|
79
|
-
const after = discardBoundary(parent, open, `/abide:await:${childPath}`, hydration)
|
|
80
|
-
const handle = withoutHydration(() =>
|
|
81
|
-
run(() => mountRange(parent, factory.build, props, after)),
|
|
82
|
-
)
|
|
83
|
-
OWNER.current?.push(handle.dispose)
|
|
84
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { isAsyncCell } from '../../shared/isAsyncCell.ts'
|
|
2
|
-
import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
|
|
3
|
-
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
4
|
-
import { readCell } from './readCell.ts'
|
|
5
|
-
|
|
6
|
-
/*
|
|
7
|
-
The blocking-cell read the compiler lowers to `$$readCellBlocking(NAME)` for an `await`-marked
|
|
8
|
-
cell (ADR-0042 D5.3). Exactly `readCell`, with one added rule ahead of it: a cell with no value
|
|
9
|
-
*yet* throws a `SuspenseSignal` so the enclosing region SUSPENDS until the value resolves rather
|
|
10
|
-
than rendering against `undefined`. This is what makes an `await` binding read as a resolved `T`
|
|
11
|
-
(never `undefined`-while-pending) on both sides — the client mirrors the server flush barrier.
|
|
12
|
-
|
|
13
|
-
The suspend is keyed on `cell.pending()` (in-flight AND no value), NOT `peek() === undefined`:
|
|
14
|
-
- a blocking cell that legitimately resolves to `undefined` is not pending, so it does not
|
|
15
|
-
suspend forever;
|
|
16
|
-
- a warm-seeded cell on hydrate is `refreshing()` (has value, in flight), not `pending()`, so it
|
|
17
|
-
reads its held value and adopts the SSR markup with no suspend and no flash (ADR-0042 D4).
|
|
18
|
-
Everything past the pending gate — the throwing peek (error-with-no-retained-value → `AsyncCellError`
|
|
19
|
-
to `{#try}`, a retained value winning over pending/error via SWR), a function call, or a `.value`
|
|
20
|
-
read — is `readCell` verbatim, so the two reads can never drift.
|
|
21
|
-
*/
|
|
22
|
-
// @documentation plumbing
|
|
23
|
-
export function readCellBlocking(cell: unknown): unknown {
|
|
24
|
-
/* No value yet → suspend the reading region (distinct from an error, which `readCell` routes). */
|
|
25
|
-
if (isAsyncCell(cell) && (cell as AsyncComputed<unknown>).pending()) {
|
|
26
|
-
throw new SuspenseSignal(cell as AsyncComputed<unknown>)
|
|
27
|
-
}
|
|
28
|
-
return readCell(cell)
|
|
29
|
-
}
|