@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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The text a reactive `{expr}` binding writes: the stringified value, or `''` while a blocking
|
|
5
|
+
`await` read is pending — a `SuspenseSignal` (ADR-0042). Nullish stringifies to `''` (never the
|
|
6
|
+
literal `"undefined"`), so a pending streaming read and a suspended blocking read both show nothing.
|
|
7
|
+
A suspending read still tracked its cell before throwing, so the enclosing bind effect re-runs and
|
|
8
|
+
fills in once the value resolves. Rethrows any non-suspense error unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export function readTextOrSuspend(read: () => unknown): string {
|
|
11
|
+
let next: unknown
|
|
12
|
+
try {
|
|
13
|
+
next = read()
|
|
14
|
+
} catch (signal) {
|
|
15
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
16
|
+
throw signal
|
|
17
|
+
}
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
return next == null ? '' : String(next)
|
|
21
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { effect } from '../effect.ts'
|
|
2
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
1
3
|
import { attr } from './attr.ts'
|
|
2
4
|
import { on } from './on.ts'
|
|
3
5
|
|
|
@@ -19,7 +21,51 @@ export function spreadAttrs(
|
|
|
19
21
|
exclude: string[] = [],
|
|
20
22
|
): void {
|
|
21
23
|
const skip = new Set(exclude)
|
|
22
|
-
|
|
24
|
+
let object: Record<string, unknown>
|
|
25
|
+
try {
|
|
26
|
+
object = source()
|
|
27
|
+
} catch (signal) {
|
|
28
|
+
/* A spread over a PENDING blocking `await` read throws a `SuspenseSignal` here (ADR-0042):
|
|
29
|
+
its keys can't be enumerated yet. Binding the empty object now would create no `attr`/`on`
|
|
30
|
+
effects, so the attributes would never appear after the cell resolves — instead defer the
|
|
31
|
+
enumeration into a one-shot effect that re-runs on settle (the read tracked its cell). The
|
|
32
|
+
effect is pinned to this build's scope, so the `attr` binds it creates own their lifetime
|
|
33
|
+
correctly; a `spread` flag makes the enumeration fire exactly once (later value changes
|
|
34
|
+
re-read per key via `attr`, never re-enumerate). */
|
|
35
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
36
|
+
throw signal
|
|
37
|
+
}
|
|
38
|
+
let spread = false
|
|
39
|
+
effect(() => {
|
|
40
|
+
let resolved: Record<string, unknown>
|
|
41
|
+
try {
|
|
42
|
+
resolved = source()
|
|
43
|
+
} catch (retry) {
|
|
44
|
+
if (!(retry instanceof SuspenseSignal)) {
|
|
45
|
+
throw retry
|
|
46
|
+
}
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (spread) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
spread = true
|
|
53
|
+
bindKeys(element, source, resolved, skip)
|
|
54
|
+
})
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
bindKeys(element, source, object, skip)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Binds each own key of `object` onto `element`: an `on<event>` function attaches as a listener,
|
|
61
|
+
any other non-function value binds as a reactive attribute re-reading `source()[key]` per change.
|
|
62
|
+
A key in `skip` is left to its explicit binding. */
|
|
63
|
+
function bindKeys(
|
|
64
|
+
element: Element,
|
|
65
|
+
source: () => Record<string, unknown>,
|
|
66
|
+
object: Record<string, unknown>,
|
|
67
|
+
skip: Set<string>,
|
|
68
|
+
): void {
|
|
23
69
|
for (const key in object) {
|
|
24
70
|
if (skip.has(key)) {
|
|
25
71
|
continue
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
1
2
|
import { mountSwappableRange } from './mountSwappableRange.ts'
|
|
2
3
|
import type { SwitchCase } from './types/SwitchCase.ts'
|
|
3
4
|
|
|
@@ -47,8 +48,24 @@ export function switchBlock(
|
|
|
47
48
|
parent,
|
|
48
49
|
/* A pending bare async subject (compiler-supplied `isPending`) selects no case — not
|
|
49
50
|
even the default — so the block renders nothing until the cell settles, rather than
|
|
50
|
-
conflating "still loading" with a subject that matched the default.
|
|
51
|
-
|
|
51
|
+
conflating "still loading" with a subject that matched the default. A blocking `await`
|
|
52
|
+
cell embedded in the subject or a `match` (a member access / compound the `isPending`
|
|
53
|
+
gate doesn't cover) throws a `SuspenseSignal` while pending (ADR-0042); select no case
|
|
54
|
+
until it settles, exactly as a bare async subject holds — the read tracked its cell, so
|
|
55
|
+
the swap effect re-runs on resolve. */
|
|
56
|
+
() => {
|
|
57
|
+
if (isPending?.() === true) {
|
|
58
|
+
return -1
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return select(subject())
|
|
62
|
+
} catch (signal) {
|
|
63
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
64
|
+
throw signal
|
|
65
|
+
}
|
|
66
|
+
return -1
|
|
67
|
+
}
|
|
68
|
+
},
|
|
52
69
|
(index) => {
|
|
53
70
|
const chosen = index === -1 ? undefined : cases[index]
|
|
54
71
|
return chosen && ((p) => chosen.render(p))
|
|
@@ -5,6 +5,7 @@ import { claimExpected } from '../runtime/claimExpected.ts'
|
|
|
5
5
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
6
6
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
7
7
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
8
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
8
9
|
import type { Boundary } from '../runtime/types/Boundary.ts'
|
|
9
10
|
import type { State } from '../runtime/types/State.ts'
|
|
10
11
|
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
@@ -104,6 +105,15 @@ export function tryBlock(
|
|
|
104
105
|
activeKind = 'try'
|
|
105
106
|
errorCell = undefined
|
|
106
107
|
} catch (error) {
|
|
108
|
+
/* A `SuspenseSignal` is "value pending", not an error, and must NEVER render `{:catch}`
|
|
109
|
+
(ADR-0042 D3, mirroring `flushEffects`, which refuses to route it to a boundary) — that
|
|
110
|
+
would flash the author's catch during loading and, since `rewatch` only arms on an
|
|
111
|
+
`AsyncCellError`, stick there forever. Every reading region withholds its own suspend
|
|
112
|
+
locally (post-ADR-0042 the `{#await}`/`{#each await}` subjects included), so this is
|
|
113
|
+
unreachable in a compiled template; rethrow defensively rather than mislabel it. */
|
|
114
|
+
if (error instanceof SuspenseSignal) {
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
107
117
|
if (!hasCatch) {
|
|
108
118
|
throw error
|
|
109
119
|
}
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
1
2
|
import { mountSwappableRange } from './mountSwappableRange.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
@@ -26,7 +27,24 @@ export function when(
|
|
|
26
27
|
): void {
|
|
27
28
|
mountSwappableRange(
|
|
28
29
|
parent,
|
|
29
|
-
() =>
|
|
30
|
+
() => {
|
|
31
|
+
if (isPending?.() === true) {
|
|
32
|
+
return 'pending'
|
|
33
|
+
}
|
|
34
|
+
/* A blocking `await` cell embedded in the condition — a member access or compound the
|
|
35
|
+
whole-subject `isPending` gate doesn't cover (`{#if !sources.length}`, `{#if user &&
|
|
36
|
+
await load()}`) — throws a `SuspenseSignal` while pending (ADR-0042). Withhold the
|
|
37
|
+
block (render neither branch) until it settles, exactly as a bare async subject holds;
|
|
38
|
+
the read tracked its cell, so the swap effect re-runs on resolve. */
|
|
39
|
+
try {
|
|
40
|
+
return condition() ? 'then' : 'else'
|
|
41
|
+
} catch (signal) {
|
|
42
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
43
|
+
throw signal
|
|
44
|
+
}
|
|
45
|
+
return 'pending'
|
|
46
|
+
}
|
|
47
|
+
},
|
|
30
48
|
(branch) => (branch === 'then' ? render : branch === 'else' ? renderElse : undefined),
|
|
31
49
|
before,
|
|
32
50
|
/* An async subject (compiler supplies `isPending`) can render a different branch on the
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Runs `read`, returning `fallback` when it SUSPENDS — a pending blocking `await` read throws a
|
|
5
|
+
`SuspenseSignal` (ADR-0042). The reading region shows `fallback` until the value resolves; the
|
|
6
|
+
throwing read subscribed the enclosing effect to the cell, so the effect re-runs and fills in on
|
|
7
|
+
settle. Any non-suspense error propagates unchanged (a real rejection still reaches `{#try}`). The
|
|
8
|
+
single place the `instanceof SuspenseSignal` swallow lives for a value-returning bind, so the read
|
|
9
|
+
sites (`each`, `watch`, …) share one definition rather than copying the try/catch. Text binds use
|
|
10
|
+
the sibling `readTextOrSuspend` (fallback `''` with String coercion); an attribute leaves itself
|
|
11
|
+
unset inline (a side effect, not a fallback value).
|
|
12
|
+
*/
|
|
13
|
+
export function withSuspense<T>(read: () => T, fallback: T): T {
|
|
14
|
+
try {
|
|
15
|
+
return read()
|
|
16
|
+
} catch (signal) {
|
|
17
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
18
|
+
throw signal
|
|
19
|
+
}
|
|
20
|
+
return fallback
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isAsyncCell } from '../../shared/isAsyncCell.ts'
|
|
2
|
+
import type { AsyncState } from '../../shared/types/AsyncState.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Unified write for a `linked` reference the compiler lowers to `$$writeCell(NAME, value)`
|
|
6
|
+
from an author assignment (`draft = x`, `draft += 1`, `draft++`). The read side is
|
|
7
|
+
`$$readCell` — a call, so not an assignable target — which is why an assignment to a
|
|
8
|
+
`linked` binding needs its own lowering. A `linked` cell is a writable `State` for a
|
|
9
|
+
sync seed (write `.value`) or a writable `AsyncState` for an async/stream seed (write
|
|
10
|
+
`.set(value)`, which latches until the next reseed); one write shape covers both, so
|
|
11
|
+
codegen never branches on the seed kind. Returns `value` so the lowered assignment still
|
|
12
|
+
evaluates to the written value in expression position (`a = b = draft = x`).
|
|
13
|
+
*/
|
|
14
|
+
// @documentation plumbing
|
|
15
|
+
export function writeCell(cell: unknown, value: unknown): unknown {
|
|
16
|
+
if (isAsyncCell(cell)) {
|
|
17
|
+
;(cell as AsyncState<unknown>).set(value)
|
|
18
|
+
} else {
|
|
19
|
+
;(cell as { value: unknown }).value = value
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* The doc-state warm-seed manifest: an SSR-captured reactive-document snapshot, keyed by its
|
|
2
|
+
scope's serialization-stable render-path id, as a ref-json-encoded STRING (decoded at the read
|
|
3
|
+
site in `createScope`). `startClient` drains `__SSR__.docs` into here before mount; a hydrating
|
|
4
|
+
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. Backed by
|
|
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 ??= {}
|
|
10
|
+
|
|
11
|
+
export const DOC_SEED: Record<string, string> = globalScope.__abideDocs
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The sentinel a compiled blocking read (`$$readCellBlocking`, an `await`-marked cell) raises
|
|
5
|
+
when the cell has no value *yet* — "not resolved," not an error (ADR-0042 D3). It is a sibling
|
|
6
|
+
of `AsyncCellError` but a DISTINCT class so the two are told apart by `instanceof`: a suspend is
|
|
7
|
+
caught LOCALLY by the reading region — each DOM primitive (`appendText`, `attr`, `each`,
|
|
8
|
+
`spreadAttrs`, `watch`, and the `when`/`switchBlock` condition) swallows it and withholds to an
|
|
9
|
+
empty fallback, re-running on settle because the throwing read already subscribed the region's
|
|
10
|
+
effect to the cell. It must never be handled as an error: `flushEffects` refuses to route a
|
|
11
|
+
`SuspenseSignal` to a reactive `{#try}` (that would flash the author's `{:catch}` during loading),
|
|
12
|
+
while an `AsyncCellError` still passes through. It carries the originating cell for diagnostics.
|
|
13
|
+
Extends `Error` only to satisfy throw-an-Error lint and match `AsyncCellError`; it carries no
|
|
14
|
+
`cause` — there is nothing wrong, the value is simply pending.
|
|
15
|
+
*/
|
|
16
|
+
export class SuspenseSignal extends Error {
|
|
17
|
+
readonly cell: AsyncComputed<unknown>
|
|
18
|
+
|
|
19
|
+
constructor(cell: AsyncComputed<unknown>) {
|
|
20
|
+
super('abide: suspense (value pending)')
|
|
21
|
+
this.name = 'SuspenseSignal'
|
|
22
|
+
this.cell = cell
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { CURRENT_PATH } from './CURRENT_PATH.ts'
|
|
1
2
|
import { claimChild } from './claimChild.ts'
|
|
2
3
|
import type { RENDER } from './RENDER.ts'
|
|
4
|
+
import { reportHydrationDivergence } from './reportHydrationDivergence.ts'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
7
|
A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
|
|
@@ -18,8 +20,11 @@ export function claimExpected(
|
|
|
18
20
|
): Node {
|
|
19
21
|
const node = claimChild(hydration, parent)
|
|
20
22
|
if (node === null) {
|
|
23
|
+
/* Name where first on the `hydrate` channel (return ignored — a missing structural node
|
|
24
|
+
desyncs the claim cursor, so unlike a text miss this can't safely continue) then throw. */
|
|
25
|
+
reportHydrationDivergence('structure desync', { expected })
|
|
21
26
|
throw new Error(
|
|
22
|
-
`[abide] hydration desync: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
|
|
27
|
+
`[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
|
|
23
28
|
)
|
|
24
29
|
}
|
|
25
30
|
return node
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { boundaryFor } from './boundaryFor.ts'
|
|
2
2
|
import { NODE_STATE } from './NODE_STATE.ts'
|
|
3
3
|
import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
|
|
4
|
+
import { SuspenseSignal } from './SuspenseSignal.ts'
|
|
4
5
|
import type { ReactiveNode } from './types/ReactiveNode.ts'
|
|
5
6
|
import { updateIfNecessary } from './updateIfNecessary.ts'
|
|
6
7
|
|
|
@@ -50,16 +51,23 @@ function drain(): void {
|
|
|
50
51
|
try {
|
|
51
52
|
updateIfNecessary(node)
|
|
52
53
|
} catch (error) {
|
|
53
|
-
/* A
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
it
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
/* A `SuspenseSignal` is "no value yet", not an error, and must NEVER route to a
|
|
55
|
+
reactive `{#try}` (that would flash the author's `{:catch}` during loading,
|
|
56
|
+
ADR-0042 D3). Every reading region catches its own suspend locally and re-runs on
|
|
57
|
+
settle (the throwing read subscribed its cell), so a `SuspenseSignal` reaches here
|
|
58
|
+
only if it escaped an unguarded read — surface it via the error collector below
|
|
59
|
+
rather than leaking it to a boundary. A normal error routes to the node's nearest
|
|
60
|
+
`{#try}` boundary: swap the guarded content to the catch branch instead of the
|
|
61
|
+
rethrow fallback — the "later re-run throw" the sync boundary could not catch.
|
|
62
|
+
Reset it CLEAN either way so a later write to its deps can re-queue it (otherwise
|
|
63
|
+
`mark`'s CLEAN→dirty gate leaves it permanently inert). */
|
|
64
|
+
if (!(error instanceof SuspenseSignal)) {
|
|
65
|
+
const boundary = boundaryFor.get(node)
|
|
66
|
+
if (boundary !== undefined) {
|
|
67
|
+
boundary.handle(error)
|
|
68
|
+
node.status = NODE_STATE.CLEAN
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
63
71
|
}
|
|
64
72
|
/* No boundary — one effect throwing must not strand the effects queued behind
|
|
65
73
|
it (they live in this same `batch`, unreachable once we swap
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { log } from '../../shared/log.ts'
|
|
2
|
+
import type { ChannelLog } from '../../shared/types/ChannelLog.ts'
|
|
3
|
+
import { CURRENT_PATH } from './CURRENT_PATH.ts'
|
|
4
|
+
|
|
5
|
+
/* The `hydrate` diagnostic channel. `createChannelLog` attaches a live `enabled()` (re-read per
|
|
6
|
+
call, so toggling `abide-debug` in devtools takes effect without reload) that the public
|
|
7
|
+
`ChannelLog` type narrows away — reach it here in the one place that needs it. The channel is
|
|
8
|
+
silent unless DEBUG/`abide-debug` matches `hydrate`. */
|
|
9
|
+
const hydrateChannel = log.channel('hydrate') as ChannelLog & { enabled(): boolean }
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Reports an SSR↔client hydration divergence on the DEBUG-gated `hydrate` channel, tagged with the
|
|
13
|
+
ambient render-path of the enclosing component/branch/row (`CURRENT_PATH` is coarse — it does not
|
|
14
|
+
descend to the individual node/attribute, so the path locates the component, the detail names the
|
|
15
|
+
value). Returns whether the caller should STILL THROW: with the channel off it returns `true`, so
|
|
16
|
+
the caller's hard throw stays the default and a real desync fails loudly; with the channel on it
|
|
17
|
+
warns and returns `false`, so the caller can keep hydrating and one reload surfaces EVERY
|
|
18
|
+
divergence instead of aborting at the first. Structural callers ignore the return (continuing past
|
|
19
|
+
a missing node desyncs the claim cursor) and throw regardless — the warn just names where first.
|
|
20
|
+
*/
|
|
21
|
+
export function reportHydrationDivergence(
|
|
22
|
+
summary: string,
|
|
23
|
+
detail: Record<string, unknown>,
|
|
24
|
+
): boolean {
|
|
25
|
+
if (!hydrateChannel.enabled()) {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
hydrateChannel.warn(`${summary} at ${CURRENT_PATH.current || '(root)'}`, detail)
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { applyCacheStalenessLocally } from '../shared/applyCacheStalenessLocally.ts'
|
|
2
|
+
import { cacheStalenessSlot } from '../shared/cacheStalenessSlot.ts'
|
|
1
3
|
import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
|
|
2
4
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
3
5
|
import { pageSlot } from '../shared/pageSlot.ts'
|
|
@@ -8,9 +10,11 @@ import { probeNavigation } from './probeNavigation.ts'
|
|
|
8
10
|
import { router } from './router.ts'
|
|
9
11
|
import { CELL_SEED } from './runtime/CELL_SEED.ts'
|
|
10
12
|
import { clientPage } from './runtime/clientPage.ts'
|
|
13
|
+
import { DOC_SEED } from './runtime/DOC_SEED.ts'
|
|
11
14
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
12
15
|
import { seedBootState } from './seedBootState.ts'
|
|
13
16
|
import { seedStreamedResolution } from './seedStreamedResolution.ts'
|
|
17
|
+
import { subscribeCacheStaleness } from './subscribeCacheStaleness.ts'
|
|
14
18
|
|
|
15
19
|
/*
|
|
16
20
|
The official abide-ui client entry. Reads the server's `window.__SSR__` payload,
|
|
@@ -49,6 +53,10 @@ export function startClient(
|
|
|
49
53
|
cacheStoreSlot.resolver = () => store
|
|
50
54
|
/* One tab store: cache(fn, { shared: true }) shares it, so shared is a no-op here. */
|
|
51
55
|
sharedCacheStoreSlot.resolver = () => store
|
|
56
|
+
/* invalidate()/refresh() apply to THIS tab's cache (the server entry installs a
|
|
57
|
+
broadcaster instead — the ADR-0041 side-swap). Same function as the slot fallback so
|
|
58
|
+
the local-apply path can't diverge. */
|
|
59
|
+
cacheStalenessSlot.resolver = () => applyCacheStalenessLocally
|
|
52
60
|
/* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
|
|
53
61
|
(inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
|
|
54
62
|
{#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
|
|
@@ -66,6 +74,12 @@ export function startClient(
|
|
|
66
74
|
if (ssr.cells !== undefined) {
|
|
67
75
|
Object.assign(CELL_SEED, ssr.cells)
|
|
68
76
|
}
|
|
77
|
+
/* Seed the doc-state warm partition into DOC_SEED before mount: a hydrating `createScope` reads
|
|
78
|
+
its render-path key to adopt the SSR doc snapshot, so a plain `state(initial)` keeps the server
|
|
79
|
+
value (the consume-once swap in `scope.replace`) instead of re-running a divergent init. */
|
|
80
|
+
if (ssr.docs !== undefined) {
|
|
81
|
+
Object.assign(DOC_SEED, ssr.docs)
|
|
82
|
+
}
|
|
69
83
|
/* Keep the cache channel live past boot: replace the head's buffering collector with
|
|
70
84
|
the store-connected sink so a post-boot resolution — the inline doc-stream cache
|
|
71
85
|
script — seeds the store through `seedStreamedResolution` directly instead of pushing
|
|
@@ -74,5 +88,12 @@ export function startClient(
|
|
|
74
88
|
;(globalThis as { __abideResolve?: (resolution: StreamedResolution) => void }).__abideResolve =
|
|
75
89
|
seedStreamedResolution
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
/* Subscribe to the reserved cache-staleness pipe (ADR-0041) AFTER seeding, so a server
|
|
92
|
+
broadcast drops/refetches this tab's freshly-hydrated cache — live-only, never replay. */
|
|
93
|
+
const disposeStaleness = subscribeCacheStaleness()
|
|
94
|
+
const disposeRouter = router(target, routes, layoutRoutes, probeNavigation)
|
|
95
|
+
return () => {
|
|
96
|
+
disposeStaleness()
|
|
97
|
+
disposeRouter()
|
|
98
|
+
}
|
|
78
99
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { abideLog } from '../shared/abideLog.ts'
|
|
2
|
+
import { CACHE_STALENESS_SOCKET } from '../shared/CACHE_STALENESS_SOCKET.ts'
|
|
3
|
+
import { cache } from '../shared/cache.ts'
|
|
4
|
+
import { matcherFromEnvelope } from '../shared/matcherFromEnvelope.ts'
|
|
5
|
+
import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
|
|
6
|
+
import type { CacheStalenessFrame } from '../shared/types/CacheStalenessFrame.ts'
|
|
7
|
+
import type { Socket } from '../shared/types/Socket.ts'
|
|
8
|
+
import { socketProxy } from './socketProxy.ts'
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
The client half of the cross-client staleness broadcast (ADR-0041): every tab
|
|
12
|
+
subscribes to the reserved __abide/cache socket at boot and applies each frame to
|
|
13
|
+
its own cache. A frame rebuilds the entry predicate via matcherFromEnvelope and
|
|
14
|
+
drives the SAME store loop a local invalidate()/refresh() does
|
|
15
|
+
(cache.invalidateMatching / cache.refreshMatching), so wire-driven and local applies
|
|
16
|
+
can't diverge.
|
|
17
|
+
|
|
18
|
+
LIVE-ONLY, never replay: bare iteration (replay 0) on both boot and reconnect — a
|
|
19
|
+
client applies only frames published while it is connected. The reserved topic keeps
|
|
20
|
+
no tail, so there is nothing to catch up on; a client offline when a frame was
|
|
21
|
+
published misses it and falls back to SWR staleness. Applies are idempotent, so
|
|
22
|
+
correctness has no dependency on frame ordering.
|
|
23
|
+
|
|
24
|
+
No-op on the server (no window): the broadcast is the server's job, not something it
|
|
25
|
+
consumes. Returns a disposer that stops the loop and closes the subscription.
|
|
26
|
+
*/
|
|
27
|
+
export function subscribeCacheStaleness(injectedSocket?: Socket<CacheStalenessFrame>): () => void {
|
|
28
|
+
if (typeof window === 'undefined') {
|
|
29
|
+
return () => undefined
|
|
30
|
+
}
|
|
31
|
+
/* Default to the browser channel proxy; tests inject a socket over their own channel. */
|
|
32
|
+
const socket = injectedSocket ?? socketProxy<CacheStalenessFrame>(CACHE_STALENESS_SOCKET)
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
/* `let`: a reconnect swaps in a fresh live iterator after a transport loss. */
|
|
35
|
+
let iterator = socket[Symbol.asyncIterator]()
|
|
36
|
+
;(async () => {
|
|
37
|
+
while (!controller.signal.aborted) {
|
|
38
|
+
let next: IteratorResult<CacheStalenessFrame>
|
|
39
|
+
try {
|
|
40
|
+
next = await iterator.next()
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (controller.signal.aborted) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
/* Transport loss: re-open a fresh LIVE subscription (no replay) and keep going. */
|
|
46
|
+
if (error instanceof SocketDisconnectedError) {
|
|
47
|
+
iterator = socket[Symbol.asyncIterator]()
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
abideLog.error(error)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (controller.signal.aborted || next.done === true) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
/* One malformed frame must not tear down the live pipe: a throw from decode/apply
|
|
57
|
+
is logged and swallowed so every subsequent frame still delivers. Frames come
|
|
58
|
+
only from the trusted server (clientPublish is false), so this is defense in
|
|
59
|
+
depth, not an expected path. */
|
|
60
|
+
try {
|
|
61
|
+
applyFrame(next.value)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
abideLog.error(error)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})()
|
|
67
|
+
return () => {
|
|
68
|
+
controller.abort()
|
|
69
|
+
iterator.return?.(undefined)?.catch(() => undefined)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Applies one decoded frame to this tab's cache via the shared apply-by-matcher seam. */
|
|
74
|
+
function applyFrame(frame: CacheStalenessFrame): void {
|
|
75
|
+
const matches = matcherFromEnvelope(frame)
|
|
76
|
+
/* The tripwire label + the rpc-error-registry prefix, mirroring what a local selector
|
|
77
|
+
derives: a tag frame has no key prefix to clear. */
|
|
78
|
+
const label = frame.mode === 'tags' ? `tags: ${frame.tags.join(', ')}` : frame.match
|
|
79
|
+
const prefix = frame.mode === 'tags' ? undefined : frame.match
|
|
80
|
+
if (frame.op === 'invalidate') {
|
|
81
|
+
cache.invalidateMatching(matches, label, prefix)
|
|
82
|
+
} else {
|
|
83
|
+
cache.refreshMatching(matches, label, prefix)
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/lib/ui/watch.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { REMOTE_FUNCTION } from '../shared/REMOTE_FUNCTION.ts'
|
|
|
3
3
|
import type { CacheOnContext } from '../shared/types/CacheOnContext.ts'
|
|
4
4
|
import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
|
|
5
5
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
6
|
+
import { withSuspense } from './dom/withSuspense.ts'
|
|
6
7
|
import { effect } from './effect.ts'
|
|
7
8
|
import { generationGuard } from './runtime/generationGuard.ts'
|
|
8
9
|
import type { EffectResult } from './runtime/types/EffectResult.ts'
|
|
@@ -61,9 +62,13 @@ export function watch(
|
|
|
61
62
|
also silence the compiler's generated bindings, which build on the client where the test
|
|
62
63
|
DOM sets no global `window`). SSR-safety comes from the compiler stripping author
|
|
63
64
|
`watch(...)` calls, and the subscribable branch's own server guard (cache.on). */
|
|
64
|
-
/* Compiler binding form watch(thunk): a bare auto-tracked effect (== today's $$effect).
|
|
65
|
+
/* Compiler binding form watch(thunk): a bare auto-tracked effect (== today's $$effect). A
|
|
66
|
+
pending blocking `await` read throws a `SuspenseSignal` (ADR-0042) — withhold this reaction
|
|
67
|
+
(e.g. leave a `class:`/`style:` binding untouched) until the value resolves; the read tracked
|
|
68
|
+
its cell, so the effect re-runs on settle. Rethrows any real error. */
|
|
65
69
|
if (argsOrHandler === undefined) {
|
|
66
|
-
|
|
70
|
+
const thunk = source as () => EffectResult
|
|
71
|
+
return effect(() => withSuspense<EffectResult>(thunk, undefined))
|
|
67
72
|
}
|
|
68
73
|
/* watch(fn, args, handler): an rpc selector with explicit args. */
|
|
69
74
|
if (maybeHandler !== undefined) {
|
package/src/serverEntry.ts
CHANGED
|
@@ -26,11 +26,14 @@ import { shell } from './_virtual/shell.ts'
|
|
|
26
26
|
import { sockets } from './_virtual/sockets.ts'
|
|
27
27
|
import { exitWithParent } from './lib/bundle/exitWithParent.ts'
|
|
28
28
|
import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
|
|
29
|
+
import { broadcastCacheStaleness } from './lib/server/runtime/cacheStalenessBroadcaster.ts'
|
|
29
30
|
import { createServer } from './lib/server/runtime/createServer.ts'
|
|
30
31
|
import { requestContext } from './lib/server/runtime/requestContext.ts'
|
|
31
32
|
import { resolvePageSnapshot } from './lib/server/runtime/resolvePageSnapshot.ts'
|
|
33
|
+
import { cacheStalenessSlot } from './lib/shared/cacheStalenessSlot.ts'
|
|
32
34
|
import { cacheStoreSlot } from './lib/shared/cacheStoreSlot.ts'
|
|
33
35
|
import { createCacheStore } from './lib/shared/createCacheStore.ts'
|
|
36
|
+
import { docSnapshotsSlot } from './lib/shared/docSnapshotsSlot.ts'
|
|
34
37
|
import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
|
|
35
38
|
import { pageSlot } from './lib/shared/pageSlot.ts'
|
|
36
39
|
import { pendingAsyncCellsSlot } from './lib/shared/pendingAsyncCellsSlot.ts'
|
|
@@ -79,6 +82,13 @@ sharedCacheStoreSlot.resolver = () => sharedCacheStore
|
|
|
79
82
|
|
|
80
83
|
cacheStoreSlot.resolver = () => requestContext.getStore()?.cache ?? sharedCacheStore
|
|
81
84
|
|
|
85
|
+
/*
|
|
86
|
+
Server-side invalidate()/refresh() broadcast to every connected client instead of
|
|
87
|
+
mutating a throwaway request store (ADR-0041). The broadcaster is imported ONLY here
|
|
88
|
+
so its server socket code never enters the client reachability graph.
|
|
89
|
+
*/
|
|
90
|
+
cacheStalenessSlot.resolver = () => broadcastCacheStaleness
|
|
91
|
+
|
|
82
92
|
/* One process-wide fallback list for reads with no request in flight (boot/cron/socket) —
|
|
83
93
|
real requests each carry their own `pendingAsyncCells`, so the SSR barrier drains a
|
|
84
94
|
per-request list and concurrent renders never cross-contaminate. */
|
|
@@ -89,6 +99,8 @@ const sharedResolvedCells = { entries: [] }
|
|
|
89
99
|
resolvedCellsSlot.resolver = () => requestContext.getStore()?.resolvedCells ?? sharedResolvedCells
|
|
90
100
|
const sharedStreamedCells = { entries: [] }
|
|
91
101
|
streamedCellsSlot.resolver = () => requestContext.getStore()?.streamedCells ?? sharedStreamedCells
|
|
102
|
+
const sharedDocSnapshots = { entries: [] }
|
|
103
|
+
docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
|
|
92
104
|
|
|
93
105
|
pageSlot.resolver = resolvePageSnapshot
|
|
94
106
|
|