@abide/abide 0.44.1 → 0.45.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 +8 -6
- package/CHANGELOG.md +40 -0
- package/README.md +171 -181
- package/package.json +2 -1
- package/src/lib/server/error.ts +48 -53
- package/src/lib/server/json.ts +4 -3
- package/src/lib/server/jsonl.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +2 -8
- package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
- package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
- package/src/lib/server/rpc/types/TypedError.ts +18 -0
- package/src/lib/server/rpc/validationError.ts +3 -3
- package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
- package/src/lib/server/runtime/createServer.ts +7 -0
- package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
- package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sse.ts +1 -1
- package/src/lib/shared/HttpError.ts +1 -1
- package/src/lib/shared/cache.ts +26 -4
- package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
- package/src/lib/shared/cacheKeyOf.ts +7 -0
- package/src/lib/shared/cacheKeyStore.ts +8 -0
- package/src/lib/shared/httpErrorFor.ts +1 -1
- package/src/lib/shared/recordCacheKey.ts +6 -0
- package/src/lib/shared/toTagSet.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +15 -0
- package/src/lib/shared/types/CacheOptions.ts +5 -5
- package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
- package/src/lib/shared/types/ErrorSpec.ts +6 -6
- package/src/lib/shared/types/RemoteFunction.ts +8 -4
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/generateSSR.ts +10 -3
- package/src/lib/ui/createScope.ts +11 -4
- package/src/lib/ui/deferResume.ts +29 -0
- package/src/lib/ui/dom/appendText.ts +19 -4
- package/src/lib/ui/dom/awaitBlock.ts +74 -26
- package/src/lib/ui/dom/eachAsync.ts +11 -18
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/matchRoute.ts +18 -1
- package/src/lib/ui/remoteProxy.ts +11 -1
- package/src/lib/ui/renderToStream.ts +13 -8
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
- package/src/lib/ui/runtime/RESUME.ts +6 -0
- package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
- package/src/lib/ui/runtime/createEffectNode.ts +7 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -0
- package/src/lib/ui/runtime/generationGuard.ts +40 -0
- package/src/lib/ui/runtime/runNode.ts +9 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
- package/src/lib/ui/scope.ts +6 -8
- package/src/lib/ui/tryEncodeResume.ts +5 -2
- package/src/lib/ui/types/Scope.ts +3 -4
- package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
- package/src/lib/shared/types/ErrorConstructors.ts +0 -17
- package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { effect } from '../effect.ts'
|
|
2
2
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
|
-
import {
|
|
3
|
+
import { generationGuard } from '../runtime/generationGuard.ts'
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
6
|
import type { State } from '../runtime/types/State.ts'
|
|
@@ -74,12 +74,16 @@ export function eachAsync<T>(
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/* Bumped each run so a superseded drain stops appending and pruning. */
|
|
78
|
-
let generation = 0
|
|
79
77
|
let iterator: AsyncIterator<T> | undefined
|
|
78
|
+
/* Bumped each run so a superseded drain stops appending and pruning, and on owner teardown
|
|
79
|
+
— which also `return()`s the live iterator to release the source (rows and the error
|
|
80
|
+
branch are disposed by the group, whose scopes were tracked). */
|
|
81
|
+
const guard = generationGuard(() => {
|
|
82
|
+
iterator?.return?.(undefined)?.catch(() => undefined)
|
|
83
|
+
iterator = undefined
|
|
84
|
+
})
|
|
80
85
|
effect(() => {
|
|
81
|
-
|
|
82
|
-
const generationAtStart = generation
|
|
86
|
+
const generationAtStart = guard.renew()
|
|
83
87
|
iterator?.return?.(undefined)?.catch(() => undefined) // close the superseded run's iterator before re-streaming
|
|
84
88
|
iterator = undefined
|
|
85
89
|
clearError() // a fresh run drops a prior error branch
|
|
@@ -92,7 +96,7 @@ export function eachAsync<T>(
|
|
|
92
96
|
while (true) {
|
|
93
97
|
const result = await active.next()
|
|
94
98
|
/* A re-run or teardown bumped the generation while we awaited. */
|
|
95
|
-
if (generationAtStart
|
|
99
|
+
if (!guard.live(generationAtStart)) {
|
|
96
100
|
return
|
|
97
101
|
}
|
|
98
102
|
if (result.done === true) {
|
|
@@ -126,7 +130,7 @@ export function eachAsync<T>(
|
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
drain().catch((error: unknown) => {
|
|
129
|
-
if (generationAtStart
|
|
133
|
+
if (!guard.live(generationAtStart)) {
|
|
130
134
|
return
|
|
131
135
|
}
|
|
132
136
|
/* No catch branch → surface the rejection (mirrors `<template await>`). */
|
|
@@ -137,15 +141,4 @@ export function eachAsync<T>(
|
|
|
137
141
|
errorRange = insertRange((host) => renderCatch(host, error))
|
|
138
142
|
})
|
|
139
143
|
})
|
|
140
|
-
|
|
141
|
-
/* Stop the live stream when the enclosing scope tears down: bump the generation so
|
|
142
|
-
the drain abandons its loop and `return()` the iterator to release the source. The
|
|
143
|
-
rows and error branch are disposed by the group (their scopes were tracked). */
|
|
144
|
-
if (OWNER.current !== undefined) {
|
|
145
|
-
OWNER.current.push(() => {
|
|
146
|
-
generation += 1
|
|
147
|
-
iterator?.return?.(undefined)?.catch(() => undefined)
|
|
148
|
-
iterator = undefined
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
144
|
}
|
|
@@ -25,6 +25,7 @@ import { spreadProps } from './dom/spreadProps.ts'
|
|
|
25
25
|
import { switchBlock } from './dom/switchBlock.ts'
|
|
26
26
|
import { tryBlock } from './dom/tryBlock.ts'
|
|
27
27
|
import { when } from './dom/when.ts'
|
|
28
|
+
import { deferResume } from './deferResume.ts'
|
|
28
29
|
import { effect } from './effect.ts'
|
|
29
30
|
import { enterScope } from './enterScope.ts'
|
|
30
31
|
import { exitScope } from './exitScope.ts'
|
|
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
|
|
|
68
69
|
eachAsync,
|
|
69
70
|
when,
|
|
70
71
|
awaitBlock,
|
|
72
|
+
deferResume,
|
|
71
73
|
tryBlock,
|
|
72
74
|
switchBlock,
|
|
73
75
|
mountSlot,
|
package/src/lib/ui/matchRoute.ts
CHANGED
|
@@ -52,6 +52,18 @@ export function matchRoute(
|
|
|
52
52
|
return best === undefined ? undefined : { route: best.route, params: best.params }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/* Percent-decodes a captured `[name]` value. Bun's `req.params` decoding is
|
|
56
|
+
lenient (malformed sequences pass through), so mirror that by falling back to
|
|
57
|
+
the raw value rather than throwing on a malformed `%` a page navigation would
|
|
58
|
+
otherwise crash on. */
|
|
59
|
+
function decodeParam(value: string): string {
|
|
60
|
+
try {
|
|
61
|
+
return decodeURIComponent(value)
|
|
62
|
+
} catch {
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
/* Matches one parsed pattern against the path's segments, capturing params;
|
|
56
68
|
undefined on mismatch. A catch-all consumes every remaining segment. */
|
|
57
69
|
function matchSegments(
|
|
@@ -81,7 +93,12 @@ function matchSegments(
|
|
|
81
93
|
if (value === '') {
|
|
82
94
|
return undefined
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
/* `url()` encodes a `[name]` value whole, and Bun decodes `req.params`
|
|
97
|
+
server-side, so decode here to hand the page the same value SSR does
|
|
98
|
+
(e.g. `The%20Daily%20Show` → `The Daily Show`). The catch-all above
|
|
99
|
+
stays raw to match the server, which reconstructs it from the raw
|
|
100
|
+
pathname. */
|
|
101
|
+
params[segment.name] = decodeParam(value)
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
/* No catch-all consumed the tail, so the path must have no extra segments. */
|
|
@@ -53,11 +53,21 @@ HttpError; and once a backlog exists, a fresh call parks straight to the TAIL
|
|
|
53
53
|
the app owns when to replay. `rpc.outbox()` exposes the queue.
|
|
54
54
|
*/
|
|
55
55
|
// @documentation plumbing
|
|
56
|
+
export function remoteProxy<Args, Return>(
|
|
57
|
+
method: HttpMethod,
|
|
58
|
+
url: string,
|
|
59
|
+
durable: DurableOptions & { outbox: true },
|
|
60
|
+
): RemoteFunction<Args, Return, Record<never, never>, true>
|
|
61
|
+
export function remoteProxy<Args, Return>(
|
|
62
|
+
method: HttpMethod,
|
|
63
|
+
url: string,
|
|
64
|
+
durable?: DurableOptions,
|
|
65
|
+
): RemoteFunction<Args, Return>
|
|
56
66
|
export function remoteProxy<Args, Return>(
|
|
57
67
|
method: HttpMethod,
|
|
58
68
|
url: string,
|
|
59
69
|
durable?: DurableOptions,
|
|
60
|
-
): RemoteFunction<Args, Return> {
|
|
70
|
+
): RemoteFunction<Args, Return, Record<never, never>, boolean> {
|
|
61
71
|
/* Assigned after `createRemoteFunction` so the invoke closure (which runs later, per
|
|
62
72
|
call) parks through the shared queue; undefined leaves the plain fetch path. */
|
|
63
73
|
let queue: OutboxQueue<Args> | undefined
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { deferResume } from './deferResume.ts'
|
|
1
2
|
import { resumeSeedScript } from './resumeSeedScript.ts'
|
|
2
|
-
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
|
+
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
3
4
|
import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
|
|
4
5
|
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
5
6
|
|
|
@@ -39,8 +40,8 @@ export async function* renderToStream(
|
|
|
39
40
|
instead of refetching. (`resume` is the render body's live object, so late writes
|
|
40
41
|
appear here.) */
|
|
41
42
|
const seededResume = new Set<number>(Object.keys(resume).map(Number))
|
|
42
|
-
const resumeDelta = (): Record<number, ResumeEntry> => {
|
|
43
|
-
const delta: Record<number, ResumeEntry> = {}
|
|
43
|
+
const resumeDelta = (): Record<number, ResumeEntry | DeferMarker> => {
|
|
44
|
+
const delta: Record<number, ResumeEntry | DeferMarker> = {}
|
|
44
45
|
for (const [key, entry] of Object.entries(resume)) {
|
|
45
46
|
const id = Number(key)
|
|
46
47
|
if (!seededResume.has(id)) {
|
|
@@ -83,18 +84,22 @@ export async function* renderToStream(
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
type Settled = { id: number; html: string; resume: ResumeEntry }
|
|
87
|
+
type Settled = { id: number; html: string; resume: ResumeEntry | DeferMarker }
|
|
87
88
|
|
|
88
89
|
/* Awaits one streaming block's promise and renders the resolved or error branch to
|
|
89
90
|
HTML (the renderers are async so a nested `await` block composes), capturing the
|
|
90
91
|
value (serializable) for the resume manifest. Errors serialize as their message —
|
|
91
|
-
enough for the catch branch, without leaking a stack.
|
|
92
|
+
enough for the catch branch, without leaking a stack. A cache-backed value defers via
|
|
93
|
+
`deferResume` (a `{defer,key}` marker + lazy body seed) — the client adopts the streamed
|
|
94
|
+
branch inert instead of decoding the value; a non-cache value ships inline as before. */
|
|
92
95
|
function settle(block: SsrAwait): Promise<Settled> {
|
|
93
|
-
|
|
96
|
+
/* Keep the promise: cache() tagged it with its key, which `deferResume` reads to defer. */
|
|
97
|
+
const pending = block.promise()
|
|
98
|
+
return Promise.resolve(pending).then(
|
|
94
99
|
async (value) => ({
|
|
95
100
|
id: block.id,
|
|
96
101
|
html: await block.then(value),
|
|
97
|
-
resume:
|
|
102
|
+
resume: deferResume(pending, value),
|
|
98
103
|
}),
|
|
99
104
|
async (error) => {
|
|
100
105
|
/* No catch branch → surface the rejection (500 before the first flush,
|
|
@@ -118,6 +123,6 @@ function settle(block: SsrAwait): Promise<Settled> {
|
|
|
118
123
|
strings. `tryEncodeResume` handles the serialize-or-refetch policy (undefined → no
|
|
119
124
|
script → the swap consumers skip registration → hydration re-runs that one promise).
|
|
120
125
|
`applyResolved`/the inline swap script store it via `.textContent`; `awaitBlock` decodes it. */
|
|
121
|
-
function encodeStreamResume(resume: ResumeEntry, id: number): string | undefined {
|
|
126
|
+
function encodeStreamResume(resume: ResumeEntry | DeferMarker, id: number): string | undefined {
|
|
122
127
|
return tryEncodeResume(resume, id)?.replace(/</g, '\\u003c')
|
|
123
128
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
|
|
2
|
-
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
2
|
+
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
3
3
|
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
4
4
|
|
|
5
5
|
/* A self-contained `<script>` seeding the await-resume manifest with the blocking
|
|
@@ -11,7 +11,7 @@ import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
|
11
11
|
early or parse as a line terminator. Shared by the buffered (`createUiPageRenderer`)
|
|
12
12
|
and streaming (`renderToStream`) paths. */
|
|
13
13
|
// @documentation plumbing
|
|
14
|
-
export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
|
|
14
|
+
export function resumeSeedScript(resume: Record<number, ResumeEntry | DeferMarker>): string {
|
|
15
15
|
/* ref-json (not JSON) so a value carrying cycles or shared back-references — a
|
|
16
16
|
media tree with parent↔child links — seeds instead of being dropped. `tryEncodeResume`
|
|
17
17
|
drops just an unserializable entry (the client re-runs that one branch's promise),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Scope } from '../types/Scope.ts'
|
|
2
|
+
import { ambientScopeBacking } from './ambientScopeBacking.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
The ambient lexical scope. The compiler establishes one per lexical level (a
|
|
@@ -6,5 +7,17 @@ component, a control-flow branch) by setting `current` around the build, so the
|
|
|
6
7
|
bare `scope()` accessor and the scope-bound primitives resolve "where they are"
|
|
7
8
|
with no handle threaded. Undefined outside any scope, where `scope()` mints a
|
|
8
9
|
detached root on first use.
|
|
10
|
+
|
|
11
|
+
`current` reads/writes through a SWAPPABLE backing (`ambientScopeBacking`) rather
|
|
12
|
+
than a raw field: the default is a module variable, but the server installs an
|
|
13
|
+
AsyncLocalStorage-backed holder so concurrent async SSR renders don't clobber one
|
|
14
|
+
shared global across the inline `await`s they suspend on. See `ambientScopeBacking`.
|
|
9
15
|
*/
|
|
10
|
-
export const CURRENT_SCOPE: { current: Scope | undefined } = {
|
|
16
|
+
export const CURRENT_SCOPE: { current: Scope | undefined } = {
|
|
17
|
+
get current(): Scope | undefined {
|
|
18
|
+
return ambientScopeBacking.active.get()
|
|
19
|
+
},
|
|
20
|
+
set current(value: Scope | undefined) {
|
|
21
|
+
ambientScopeBacking.active.set(value)
|
|
22
|
+
},
|
|
23
|
+
}
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
same reference. */
|
|
17
17
|
export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
|
|
18
18
|
|
|
19
|
+
/* Deferred-resume marker (Tier 2): a large cache-backed `{#await cache()}` ships this in
|
|
20
|
+
place of its value — just the cache key, so hydration adopts the server branch inert and
|
|
21
|
+
pays no value decode. The client seeds that key lazily and materializes it only on a later
|
|
22
|
+
re-read. Discriminated from a ResumeEntry by the `defer` field. */
|
|
23
|
+
export type DeferMarker = { defer: true; key: string }
|
|
24
|
+
|
|
19
25
|
const globalScope = globalThis as { __abideResume?: Record<number, string> }
|
|
20
26
|
globalScope.__abideResume ??= {}
|
|
21
27
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Scope } from '../types/Scope.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The swappable backing behind `CURRENT_SCOPE.current`. The default is a module
|
|
5
|
+
variable — correct on the client (one render tree) and on the server outside any
|
|
6
|
+
request. The server swaps in an AsyncLocalStorage-backed holder at boot
|
|
7
|
+
(`installAmbientScopeStore`), because SSR is partly async: a render brackets itself
|
|
8
|
+
with `enterScope`/`exitScope` and `await`s inline between them (blocking `{#await}`,
|
|
9
|
+
child renders, slots, a top-level `await`). A module global held across those awaits
|
|
10
|
+
interleaves across concurrent requests — one render resumes to read another's scope.
|
|
11
|
+
Keying the ambient off the per-request store (which the async context propagates
|
|
12
|
+
correctly) isolates it. Mirrors `requestScopeResolver`'s server-installed slot; the
|
|
13
|
+
indirection cost lands at build/effect-creation time, never on the signal hot path.
|
|
14
|
+
*/
|
|
15
|
+
type ScopeBacking = { get(): Scope | undefined; set(value: Scope | undefined): void }
|
|
16
|
+
|
|
17
|
+
let moduleCurrent: Scope | undefined
|
|
18
|
+
|
|
19
|
+
export const ambientScopeBacking: { active: ScopeBacking } = {
|
|
20
|
+
active: {
|
|
21
|
+
get: () => moduleCurrent,
|
|
22
|
+
set: (value) => {
|
|
23
|
+
moduleCurrent = value
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CURRENT_SCOPE } from './CURRENT_SCOPE.ts'
|
|
2
|
+
import { inScope } from './inScope.ts'
|
|
1
3
|
import { NODE_STATE } from './NODE_STATE.ts'
|
|
2
4
|
import { OWNER } from './OWNER.ts'
|
|
3
5
|
import { runNode } from './runNode.ts'
|
|
@@ -23,13 +25,17 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
23
25
|
ReactiveNode so signals and computeds — which share the node shape and the
|
|
24
26
|
read/write hot path — pay nothing for a feature only effects use. */
|
|
25
27
|
let cleanup: Teardown | undefined
|
|
28
|
+
/* The teardown fires deferred (before a re-run, on dispose) when the ambient scope
|
|
29
|
+
has moved on; pin the one current at creation so an ambient `scope()` inside it
|
|
30
|
+
resolves the owning component, matching how `attach` pins its teardown. */
|
|
31
|
+
const captured = CURRENT_SCOPE.current
|
|
26
32
|
/* Runs the previous run's teardown before re-running and on dispose, exactly
|
|
27
33
|
once each. */
|
|
28
34
|
const runCleanup = (): void => {
|
|
29
35
|
if (cleanup !== undefined) {
|
|
30
36
|
const teardown = cleanup
|
|
31
37
|
cleanup = undefined
|
|
32
|
-
teardown
|
|
38
|
+
inScope(captured, teardown)
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
const node: ReactiveNode = {
|
|
@@ -11,6 +11,12 @@ drains the captured one — so an effect that dirties further effects re-queues
|
|
|
11
11
|
for the next pass rather than mutating the array mid-iteration; loops until the
|
|
12
12
|
graph settles. The swap reuses the drained array as the next spare, so a steady
|
|
13
13
|
flush allocates nothing.
|
|
14
|
+
|
|
15
|
+
Raises `batchDepth` for the whole drain so a write inside an effect body queues
|
|
16
|
+
rather than re-entering the flush: `trigger` gates its flush on `batchDepth === 0`,
|
|
17
|
+
and re-entry would run a just-dirtied effect nested — ahead of effects already in
|
|
18
|
+
this pass and on a JS stack that grows with the write chain. Suppressed, the
|
|
19
|
+
newly-dirtied effect falls to the `do…while` and runs in queue (creation) order.
|
|
14
20
|
*/
|
|
15
21
|
export function flushEffects(): void {
|
|
16
22
|
/* Empty-queue fast path allocates nothing — only the length check, matching the
|
|
@@ -20,6 +26,18 @@ export function flushEffects(): void {
|
|
|
20
26
|
if (REACTIVE_CONTEXT.pendingEffects.length === 0) {
|
|
21
27
|
return
|
|
22
28
|
}
|
|
29
|
+
/* Always entered at depth 0 (trigger/batch-exit gate on it); the bump makes the
|
|
30
|
+
drain non-reentrant, restored in `finally` so a throwing effect body can't strand
|
|
31
|
+
the graph batched. */
|
|
32
|
+
REACTIVE_CONTEXT.batchDepth += 1
|
|
33
|
+
try {
|
|
34
|
+
drain()
|
|
35
|
+
} finally {
|
|
36
|
+
REACTIVE_CONTEXT.batchDepth -= 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function drain(): void {
|
|
23
41
|
let spare: ReactiveNode[] = []
|
|
24
42
|
do {
|
|
25
43
|
const batch = REACTIVE_CONTEXT.pendingEffects
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { OWNER } from './OWNER.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A monotonic generation an async block checks before a late continuation touches the DOM. It
|
|
5
|
+
answers one question — "is the work I started still the current work?" — and is bumped on the
|
|
6
|
+
two events that both invalidate in-flight work: a reactive `renew()` (a re-run supersedes the
|
|
7
|
+
prior promise/drain) and the enclosing OWNER's teardown (a settle after disposal must be
|
|
8
|
+
DROPPED, not run `place`/`insertBefore` on a now-detached anchor → NotFoundError). `onTeardown`
|
|
9
|
+
releases extra resources at teardown (e.g. an async iterator's `return()`).
|
|
10
|
+
|
|
11
|
+
Extracted from the byte-identical guard hand-rolled in `awaitBlock` and `eachAsync`. The
|
|
12
|
+
teardown half is the load-bearing one: a re-run bump alone leaves a promise/drain that settles
|
|
13
|
+
AFTER disposal to still pass the liveness check and crash on the dead anchor — the bug both
|
|
14
|
+
sites independently had to grow.
|
|
15
|
+
*/
|
|
16
|
+
export function generationGuard(onTeardown?: () => void): {
|
|
17
|
+
renew: () => number
|
|
18
|
+
token: () => number
|
|
19
|
+
live: (captured: number) => boolean
|
|
20
|
+
} {
|
|
21
|
+
let generation = 0
|
|
22
|
+
/* The teardown bump is load-bearing: without it a promise/drain settling after the owner
|
|
23
|
+
disposes still passes `live(captured)` and touches a detached anchor. */
|
|
24
|
+
if (OWNER.current !== undefined) {
|
|
25
|
+
OWNER.current.push(() => {
|
|
26
|
+
generation += 1
|
|
27
|
+
onTeardown?.()
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
/* Supersede any in-flight generation; call at the start of each (re-)run. Returns the
|
|
32
|
+
new token so a runner can capture it in the same statement. */
|
|
33
|
+
renew: () => (generation += 1),
|
|
34
|
+
/* The live generation to capture before an await and compare after — for when the
|
|
35
|
+
renew and the capture happen in different functions (as in awaitBlock's render). */
|
|
36
|
+
token: () => generation,
|
|
37
|
+
/* Whether `captured` is still the live generation — safe to touch the DOM. */
|
|
38
|
+
live: (captured: number) => captured === generation,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -34,7 +34,15 @@ export function runNode(node: ReactiveNode): unknown {
|
|
|
34
34
|
on read without re-running, never waking downstream. An effect has no value
|
|
35
35
|
worth comparing and no subscribers, so this is a no-op for it (its body ran
|
|
36
36
|
inside compute above). The subscriber list isn't re-linked here — only its
|
|
37
|
-
members' `status` is bumped — so walking it live is safe.
|
|
37
|
+
members' `status` is bumped — so walking it live is safe.
|
|
38
|
+
|
|
39
|
+
This bumps status directly, bypassing `mark` — it neither enqueues effects nor
|
|
40
|
+
propagates CHECK onward, yet nothing is missed: this recompute only happens
|
|
41
|
+
while settling a subscriber that was itself reached from the originating
|
|
42
|
+
write's `mark` propagation, which already CHECK-marked (and so enqueued) every
|
|
43
|
+
effect in this cone on their CLEAN→CHECK edge. So every subscriber here is
|
|
44
|
+
already CHECK/queued; the direct write only UPGRADES it CHECK→DIRTY so its own
|
|
45
|
+
settle recomputes instead of memoising back to CLEAN. */
|
|
38
46
|
if (!Object.is(node.value, next)) {
|
|
39
47
|
node.value = next
|
|
40
48
|
let link = node.subsHead
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResumeEntry } from '../RESUME.ts'
|
|
1
|
+
import type { DeferMarker, ResumeEntry } from '../RESUME.ts'
|
|
2
2
|
|
|
3
3
|
/* One STREAMING await block captured during SSR (no `then` on the `await` tag): its
|
|
4
4
|
boundary id, the promise to await, and the async string-renderers for the resolved
|
|
@@ -18,10 +18,11 @@ export type SsrAwait = {
|
|
|
18
18
|
/* The result of a component's server `render()`: the pending-shell HTML, the
|
|
19
19
|
serializable document snapshot for client resume, the STREAMING await blocks to
|
|
20
20
|
flush out of order, and `resume` — the inline-rendered BLOCKING await values keyed
|
|
21
|
-
by boundary id, seeded into the manifest so hydration adopts them without a refetch.
|
|
21
|
+
by boundary id, seeded into the manifest so hydration adopts them without a refetch. A
|
|
22
|
+
deferred (cache-backed) blocking value is a `DeferMarker` in place of the value. */
|
|
22
23
|
export type SsrRender = {
|
|
23
24
|
html: string
|
|
24
25
|
state: unknown
|
|
25
26
|
awaits: SsrAwait[]
|
|
26
|
-
resume: Record<number, ResumeEntry>
|
|
27
|
+
resume: Record<number, ResumeEntry | DeferMarker>
|
|
27
28
|
}
|
package/src/lib/ui/scope.ts
CHANGED
|
@@ -3,17 +3,15 @@ import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
|
|
|
3
3
|
import type { Scope } from './types/Scope.ts'
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
|
-
Resolves
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
passable: hand it to a child or a helper and it can read/extend/undo that scope.
|
|
6
|
+
Resolves the current lexical scope — established per lexical level by the compiler,
|
|
7
|
+
so it reads "where you are" with no handle. Outside any scope (boot, a script) it
|
|
8
|
+
mints a detached root once and reuses it. The returned value is passable: hand it to
|
|
9
|
+
a child or a helper and it can read/extend/undo that scope (walk up via `.root()`).
|
|
11
10
|
*/
|
|
12
11
|
// @documentation reactive-state
|
|
13
|
-
export function scope(
|
|
12
|
+
export function scope(): Scope {
|
|
14
13
|
if (!CURRENT_SCOPE.current) {
|
|
15
14
|
CURRENT_SCOPE.current = createScope()
|
|
16
15
|
}
|
|
17
|
-
|
|
18
|
-
return address === '/' ? current.root() : current
|
|
16
|
+
return CURRENT_SCOPE.current
|
|
19
17
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { encodeRefJson } from '../shared/encodeRefJson.ts'
|
|
2
|
-
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
2
|
+
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
3
3
|
|
|
4
4
|
/* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized.
|
|
5
5
|
encodeRefJson is total (cycles become back-references, functions fold to undefined),
|
|
@@ -7,7 +7,10 @@ import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
|
7
7
|
entry and warn, so the client re-runs that one branch's promise while every other
|
|
8
8
|
branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
|
|
9
9
|
(`resumeSeedScript`) paths so the serialize-or-refetch policy lives in one place. */
|
|
10
|
-
export function tryEncodeResume(
|
|
10
|
+
export function tryEncodeResume(
|
|
11
|
+
entry: ResumeEntry | DeferMarker,
|
|
12
|
+
id: number | string,
|
|
13
|
+
): string | undefined {
|
|
11
14
|
try {
|
|
12
15
|
return encodeRefJson(entry)
|
|
13
16
|
} catch (cause) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { computed } from '../computed.ts'
|
|
2
2
|
import type { effect } from '../effect.ts'
|
|
3
3
|
import type { linked } from '../linked.ts'
|
|
4
|
-
import type { Cell } from '../runtime/types/Cell.ts'
|
|
5
4
|
import type { Patch } from '../runtime/types/Patch.ts'
|
|
6
5
|
import type { state } from '../state.ts'
|
|
7
6
|
import type { SyncTransport } from './SyncTransport.ts'
|
|
@@ -9,7 +8,7 @@ import type { SyncTransport } from './SyncTransport.ts'
|
|
|
9
8
|
/*
|
|
10
9
|
A lexical scope: the unit that owns a region's reactive data, its lifetime, and
|
|
11
10
|
the capabilities applied to it. Its data surface MIRRORS `Doc` (read/replace/add/
|
|
12
|
-
remove/
|
|
11
|
+
remove/derive/apply/snapshot) so the compiler can target a scope as a
|
|
13
12
|
component's data binding directly. It nests (`child`/`root`), passes values
|
|
14
13
|
down the tree as context (`share`/`shared`), and carries the capability surface as
|
|
15
14
|
methods so a scope is a passable value: `<Child parentScope={scope} />`.
|
|
@@ -33,7 +32,6 @@ export type Scope = {
|
|
|
33
32
|
add: (path: string, value: unknown) => void
|
|
34
33
|
remove: (path: string) => void
|
|
35
34
|
apply: (patch: Patch) => void
|
|
36
|
-
cell: <T>(path: string) => Cell<T>
|
|
37
35
|
derive: <T>(path: string, compute: () => T) => () => T
|
|
38
36
|
snapshot: () => unknown
|
|
39
37
|
/* The reactive primitives — namespaced under the scope, but AMBIENT-bound, not
|
|
@@ -64,7 +62,8 @@ export type Scope = {
|
|
|
64
62
|
/* context — values shared DOWN the tree (not in the reactive doc, which doesn't
|
|
65
63
|
inherit): `share` puts a named value on this scope; `shared` reads the closest
|
|
66
64
|
ancestor (self included) that has the key, undefined if none. The value is held
|
|
67
|
-
by reference, so reactive context = share a
|
|
65
|
+
by reference, so reactive context = share a scope (its doc is reactive), not a
|
|
66
|
+
plain object snapshot. */
|
|
68
67
|
share: (key: string, value: unknown) => void
|
|
69
68
|
shared: <T>(key: string) => T | undefined
|
|
70
69
|
/* capabilities — enable where the scope's changes go */
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { ErrorConstructors } from '../../shared/types/ErrorConstructors.ts'
|
|
2
|
-
import type { ErrorSpec } from '../../shared/types/ErrorSpec.ts'
|
|
3
|
-
|
|
4
|
-
/*
|
|
5
|
-
Turns a rpc's declared `ErrorSpec` into the constructor object handed to the
|
|
6
|
-
handler (`(args, { errors })`). Each constructor stamps its name + status onto an
|
|
7
|
-
`ErrorDescriptor` carrying the call's `data`, which `error()` serializes as the
|
|
8
|
-
`{ $abideError, data }` body. Receiver-agnostic on data: a nullary error ignores
|
|
9
|
-
the (absent) argument.
|
|
10
|
-
*/
|
|
11
|
-
export function buildErrorConstructors<Spec extends ErrorSpec>(
|
|
12
|
-
spec: Spec,
|
|
13
|
-
): ErrorConstructors<Spec> {
|
|
14
|
-
const entries = Object.entries(spec).map(([name, { status }]) => [
|
|
15
|
-
name,
|
|
16
|
-
(data: unknown) => ({ $abideError: name, status, data }),
|
|
17
|
-
])
|
|
18
|
-
return Object.fromEntries(entries) as ErrorConstructors<Spec>
|
|
19
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { ErrorDescriptor } from './ErrorDescriptor.ts'
|
|
2
|
-
import type { ErrorSpec } from './ErrorSpec.ts'
|
|
3
|
-
import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
4
|
-
|
|
5
|
-
/*
|
|
6
|
-
The constructors handed to the handler via its second arg (`(args, { errors })`),
|
|
7
|
-
derived from the rpc's `ErrorSpec`. An entry with a `data` schema makes its
|
|
8
|
-
constructor require that schema's inferred input; an entry without one is nullary.
|
|
9
|
-
Each returns a typed `ErrorDescriptor` to pass to `error()`.
|
|
10
|
-
*/
|
|
11
|
-
export type ErrorConstructors<Spec extends ErrorSpec> = {
|
|
12
|
-
[Name in keyof Spec & string]: Spec[Name]['data'] extends StandardSchemaV1
|
|
13
|
-
? (
|
|
14
|
-
data: StandardSchemaV1.InferInput<Spec[Name]['data']>,
|
|
15
|
-
) => ErrorDescriptor<Name, StandardSchemaV1.InferInput<Spec[Name]['data']>>
|
|
16
|
-
: () => ErrorDescriptor<Name, undefined>
|
|
17
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
What a rpc error constructor returns: a plain descriptor (NOT a Response), so it
|
|
3
|
-
flows through the single `error()` funnel — `return error(errors.invalidCoupon({…}))`.
|
|
4
|
-
`error()` reads `status` off it and serializes `{ $abideError: name, data }` as the body.
|
|
5
|
-
*/
|
|
6
|
-
export type ErrorDescriptor<Name extends string = string, Data = unknown> = {
|
|
7
|
-
readonly $abideError: Name
|
|
8
|
-
readonly status: number
|
|
9
|
-
readonly data: Data
|
|
10
|
-
}
|