@abide/abide 0.53.0 → 0.55.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 -494
- package/CHANGELOG.md +106 -0
- package/README.md +166 -146
- package/package.json +3 -4
- package/src/checkAbide.ts +1 -1
- 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/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
- 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/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +12 -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 +203 -84
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- 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 +4 -7
- 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/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 +39 -0
- package/src/lib/ui/compile/compileComponent.ts +1 -3
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +65 -36
- 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/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- 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 +31 -45
- 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 +27 -39
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +14 -0
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -16
- 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 +56 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- 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/Cell.ts +4 -2
- package/src/lib/ui/runtime/types/Doc.ts +3 -1
- 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/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +23 -0
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +21 -16
- 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
- package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
import { SEEDS } from '../../shared/SEEDS.ts'
|
|
2
|
+
|
|
1
3
|
/* The doc-state warm-seed manifest: an SSR-captured reactive-document snapshot, keyed by its
|
|
2
4
|
scope's serialization-stable render-path id, as a ref-json-encoded STRING (decoded at the read
|
|
3
5
|
site in `createScope`). `startClient` drains `__SSR__.docs` into here before mount; a hydrating
|
|
4
6
|
scope reads its key to seed a plain `state(initial)` to the SERVER value instead of the fresh
|
|
5
|
-
init — a uuid/timestamp/random otherwise diverges from the SSR HTML.
|
|
6
|
-
`globalThis.__abideDocs` so an inline pre-bundle script and the framework share one store —
|
|
7
|
-
whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
|
|
8
|
-
const globalScope = globalThis as { __abideDocs?: Record<string, string> }
|
|
9
|
-
globalScope.__abideDocs ??= {}
|
|
7
|
+
init — a uuid/timestamp/random otherwise diverges from the SSR HTML.
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
The `docs` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
|
|
10
|
+
export const DOC_SEED: Record<string, string> = SEEDS.docs
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
2
|
+
|
|
1
3
|
/*
|
|
2
4
|
Render mode shared by the dom helpers. In the default (create) mode `hydration`
|
|
3
|
-
is undefined and helpers build fresh nodes. During `hydrate` it holds
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
is undefined and helpers build fresh nodes. During `hydrate` it holds the pass's
|
|
6
|
+
claim cursor (see `HydrationCursor`): helpers claim in build order, which matches
|
|
7
|
+
the SSR order, advancing through the claim verbs (`claimMarker`/`claimRun`/
|
|
8
|
+
`claimText`) or parking explicitly (`parkCursor`).
|
|
7
9
|
|
|
8
10
|
`blockCounters`/`depth` drive the render-pass block-id counters: every `await`/`try`
|
|
9
11
|
block draws a path-namespaced id via `nextBlockId` (ADR-0037) — `blockCounters` maps
|
|
@@ -21,7 +23,7 @@ elements built there get the right namespace — the fragment itself carries non
|
|
|
21
23
|
undefined outside foreign content. See `enterNamespace`/`effectiveChildNamespace`.
|
|
22
24
|
*/
|
|
23
25
|
export const RENDER: {
|
|
24
|
-
hydration:
|
|
26
|
+
hydration: HydrationCursor | undefined
|
|
25
27
|
blockCounters: Map<string, number>
|
|
26
28
|
depth: number
|
|
27
29
|
namespace: string | undefined
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SEEDS } from '../../shared/SEEDS.ts'
|
|
2
|
+
|
|
1
3
|
/* The await-resume manifest: the resolved value (or error) of each streamed
|
|
2
4
|
`await` block, keyed by its boundary id. The SSR stream serializes each entry
|
|
3
5
|
alongside its fragment; the client registers it (via the inline swap script),
|
|
@@ -11,13 +13,9 @@
|
|
|
11
13
|
stream-swap script — vanilla, running before the bundle and the codec load — able
|
|
12
14
|
to register an entry without the decoder.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
same reference. */
|
|
16
|
+
The `resume` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS) —
|
|
17
|
+
the inline scripts and the framework share the same reference. */
|
|
17
18
|
export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
|
|
18
19
|
|
|
19
|
-
const globalScope = globalThis as { __abideResume?: Record<string, string> }
|
|
20
|
-
globalScope.__abideResume ??= {}
|
|
21
|
-
|
|
22
20
|
// @documentation plumbing
|
|
23
|
-
export const RESUME: Record<string, string> =
|
|
21
|
+
export const RESUME: Record<string, string> = SEEDS.resume
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* The active hydration pass's consumed-seed marks (ADR-0048 two-phase consume): each seed a
|
|
2
|
+
pass adopts is MARKED here rather than deleted, so a THROWING pass leaves every value in
|
|
3
|
+
place for the cold rebuild to re-adopt (no shadow backup), while the OUTERMOST clean pass
|
|
4
|
+
exit deletes the marked keys — preserving the one-shot contract (an SPA re-nav to the same
|
|
5
|
+
render-path re-inits fresh). Keyed per store so a mark is an O(1) Set add on the hydrate
|
|
6
|
+
hot path. Owned by `runHydrationPass`: save/restore for nesting, and a nested clean exit
|
|
7
|
+
hands its marks to the enclosing pass instead of committing — only the outermost owner
|
|
8
|
+
spends seeds, so an outer-pass throw can still hand every value to the recovery rebuild.
|
|
9
|
+
undefined outside a pass, where `consumeSeed` falls back to delete-on-read. Deliberately
|
|
10
|
+
NOT cleared by `withoutHydration` — a fresh build inside a live pass (a block's cold
|
|
11
|
+
rebuild) still marks and re-adopts. */
|
|
12
|
+
export const SEED_MARKS: {
|
|
13
|
+
current: Map<Record<string, string>, Set<string>> | undefined
|
|
14
|
+
} = { current: undefined }
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
|
-
The sentinel a
|
|
5
|
-
|
|
4
|
+
The sentinel `readCell` raises when a BLOCKING cell (`cell.blocking` — an `await`-marked cell)
|
|
5
|
+
has no value *yet* — "not resolved," not an error (ADR-0042 D3). It is a sibling
|
|
6
6
|
of `AsyncCellError` but a DISTINCT class so the two are told apart by `instanceof`: a suspend is
|
|
7
7
|
caught LOCALLY by the reading region — each DOM primitive (`appendText`, `attr`, `each`,
|
|
8
8
|
`spreadAttrs`, `watch`, and the `when`/`switchBlock` condition) swallows it and withholds to an
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
2
2
|
|
|
3
|
-
/* The next server-rendered node to claim under `parent` during hydration
|
|
4
|
-
defaulting to
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
parent: Node,
|
|
8
|
-
): Node | null {
|
|
3
|
+
/* The next server-rendered node to claim under `parent` during hydration — a PROBE
|
|
4
|
+
(the cursor doesn't move), defaulting to the first child when the pointer hasn't
|
|
5
|
+
been set yet. The claim verbs read through this and advance via `parkCursor`. */
|
|
6
|
+
export function claimChild(hydration: HydrationCursor, parent: Node): Node | null {
|
|
9
7
|
return hydration.next.has(parent) ? (hydration.next.get(parent) ?? null) : parent.firstChild
|
|
10
8
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CURRENT_PATH } from './CURRENT_PATH.ts'
|
|
2
2
|
import { claimChild } from './claimChild.ts'
|
|
3
|
-
import type { RENDER } from './RENDER.ts'
|
|
4
3
|
import { reportHydrationDivergence } from './reportHydrationDivergence.ts'
|
|
4
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
|
|
@@ -13,11 +13,7 @@ invariant from a compile-time-only parity test into a runtime guard. Use only wh
|
|
|
13
13
|
compiled skeleton GUARANTEES a node — control-flow range markers, keyed-row boundaries —
|
|
14
14
|
never where an end-of-list null is legitimate (e.g. a trailing reactive text binding).
|
|
15
15
|
*/
|
|
16
|
-
export function claimExpected(
|
|
17
|
-
hydration: NonNullable<(typeof RENDER)['hydration']>,
|
|
18
|
-
parent: Node,
|
|
19
|
-
expected: string,
|
|
20
|
-
): Node {
|
|
16
|
+
export function claimExpected(hydration: HydrationCursor, parent: Node, expected: string): Node {
|
|
21
17
|
const node = claimChild(hydration, parent)
|
|
22
18
|
if (node === null) {
|
|
23
19
|
/* Name where first on the `hydrate` channel (return ignored — a missing structural node
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { claimExpected } from './claimExpected.ts'
|
|
2
|
+
import { parkCursor } from './parkCursor.ts'
|
|
3
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
4
|
+
|
|
5
|
+
/* Claims a compiler-guaranteed marker node (a range bracket, a block/boundary comment,
|
|
6
|
+
a keyed-row bracket) at the parent's cursor and advances past it. Throws a legible
|
|
7
|
+
structural desync when no node is there (`claimExpected`) — a missing marker means
|
|
8
|
+
SSR markup and the client build disagree on structure at this position. */
|
|
9
|
+
export function claimMarker(hydration: HydrationCursor, parent: Node, expected: string): Comment {
|
|
10
|
+
const node = claimExpected(hydration, parent, expected)
|
|
11
|
+
parkCursor(hydration, parent, node.nextSibling)
|
|
12
|
+
return node as Comment
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { claimChild } from './claimChild.ts'
|
|
2
|
+
import { parkCursor } from './parkCursor.ts'
|
|
3
|
+
import type { HydrationCursor } from './types/HydrationCursor.ts'
|
|
4
|
+
|
|
5
|
+
/* Claims a run of `count` top-level server-rendered siblings at the parent's cursor and
|
|
6
|
+
parks it past them (a short run — the server DOM ending early — parks on null). The
|
|
7
|
+
static-clone paths advance through this: the markup is byte-identical to the server's,
|
|
8
|
+
so nothing inside needs claiming individually. `into` collects the claimed nodes when
|
|
9
|
+
the caller resolves holes against them (`skeleton`); omitted, the run is only skipped. */
|
|
10
|
+
export function claimRun(
|
|
11
|
+
hydration: HydrationCursor,
|
|
12
|
+
parent: Node,
|
|
13
|
+
count: number,
|
|
14
|
+
into?: Node[],
|
|
15
|
+
): void {
|
|
16
|
+
let node = claimChild(hydration, parent)
|
|
17
|
+
for (let index = 0; index < count && node !== null; index += 1) {
|
|
18
|
+
into?.push(node)
|
|
19
|
+
node = node.nextSibling
|
|
20
|
+
}
|
|
21
|
+
parkCursor(hydration, parent, node)
|
|
22
|
+
}
|
|
@@ -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,35 @@ 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
|
+
if (myRun === runId) {
|
|
214
|
+
inFlight = undefined
|
|
215
|
+
writeNode(errorNode, undefined)
|
|
216
|
+
}
|
|
217
|
+
return
|
|
218
|
+
}
|
|
184
219
|
settleError(myRun, error)
|
|
185
220
|
return
|
|
186
221
|
}
|
|
187
222
|
|
|
188
223
|
if (isAsyncIterable(produced)) {
|
|
224
|
+
/* A stream never settles — it PEEKS `undefined` until its first frame, never pauses. */
|
|
225
|
+
blockingSource = false
|
|
189
226
|
consumeStream(myRun, produced as NamedAsyncIterable<unknown>)
|
|
190
227
|
return
|
|
191
228
|
}
|
|
192
229
|
if (isThenable(produced)) {
|
|
230
|
+
/* A settling promise the cell would join the SSR barrier for (author `await`, or a
|
|
231
|
+
fail-open bare-call) PAUSES a pending read; a `streaming` promise peeks. */
|
|
232
|
+
blockingSource = options.streaming !== true
|
|
193
233
|
inFlight = produced as Promise<unknown>
|
|
194
234
|
/* Server-only: register the in-flight promise on the request-scoped pending list so
|
|
195
235
|
the SSR barrier (`$$settleAsyncCells`) awaits it before the template peeks the
|
|
@@ -222,15 +262,15 @@ export function createAsyncCell(
|
|
|
222
262
|
only in effect (the store is only ever populated by `startClient`, so it's empty on the
|
|
223
263
|
server — no `window` sniff needed); a decode failure falls through to a cold run. */
|
|
224
264
|
if (warmKey !== undefined) {
|
|
225
|
-
|
|
265
|
+
/* One-shot via `consumeSeed` (two-phase, ADR-0048): a LATER fresh mount at the same
|
|
266
|
+
render-path can't warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN
|
|
267
|
+
(params-independent), so an SPA navigation `/products/42`→`/products/99` — or a
|
|
268
|
+
back-nav that rebuilds the SSR page — recomputes the identical warmKey; unconsumed,
|
|
269
|
+
the new cell would render `/products/42`'s stale value until revalidation. During a
|
|
270
|
+
hydration pass the consume only MARKS, so a desync throw hands the seed to the cold
|
|
271
|
+
rebuild instead of losing it. */
|
|
272
|
+
const seeded = consumeSeed(CELL_SEED, warmKey)
|
|
226
273
|
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
274
|
try {
|
|
235
275
|
acceptValue(decodeRefJson(seeded))
|
|
236
276
|
} catch {
|
|
@@ -279,6 +319,14 @@ export function createAsyncCell(
|
|
|
279
319
|
/* The current in-flight promise (undefined once settled / not-a-promise) — an internal
|
|
280
320
|
runtime affordance the SSR barrier reads structurally; not on the public cell types. */
|
|
281
321
|
settled: () => inFlight,
|
|
322
|
+
/* Whether a pending read of this cell PAUSES its reader (ADR-0046) — true only for a
|
|
323
|
+
blocking source (a barrier-joining settling promise), resolved from what the seed
|
|
324
|
+
produced (`blockingSource`) so a stream (which shares the default `streaming` flag)
|
|
325
|
+
is correctly NON-blocking. A getter, since the seed's eager run sets it. `readCell`
|
|
326
|
+
consults it; an internal affordance, not on the public cell type. */
|
|
327
|
+
get blocking(): boolean {
|
|
328
|
+
return blockingSource
|
|
329
|
+
},
|
|
282
330
|
} as AsyncComputed<unknown>
|
|
283
331
|
if (!options.writable) {
|
|
284
332
|
return readOnly
|
|
@@ -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,
|
|
@@ -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. */
|
|
@@ -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,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
|
}
|