@abide/abide 0.44.1 → 0.46.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 +56 -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 +1 -6
- 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/DEFER_MIN_ARRAY_LENGTH.ts +14 -0
- 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/generateBuild.ts +5 -1
- package/src/lib/ui/compile/generateSSR.ts +10 -3
- package/src/lib/ui/compile/parseTemplate.ts +14 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +5 -0
- package/src/lib/ui/createScope.ts +11 -4
- package/src/lib/ui/deferResume.ts +36 -0
- package/src/lib/ui/dom/appendText.ts +19 -4
- package/src/lib/ui/dom/awaitBlock.ts +99 -26
- package/src/lib/ui/dom/eachAsync.ts +11 -18
- package/src/lib/ui/dom/firstElementBetween.ts +14 -0
- package/src/lib/ui/dom/mountChild.ts +5 -2
- package/src/lib/ui/dom/mountRange.ts +75 -0
- 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/scheduleWake.ts +28 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
- package/src/lib/ui/runtime/whenIdle.ts +21 -0
- package/src/lib/ui/runtime/whenVisible.ts +105 -0
- 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,16 +1,20 @@
|
|
|
1
|
+
import { activeCacheStore } from '../../shared/activeCacheStore.ts'
|
|
1
2
|
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
3
|
import { effect } from '../effect.ts'
|
|
3
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
|
+
import { generationGuard } from '../runtime/generationGuard.ts'
|
|
4
6
|
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
5
7
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
|
-
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
8
|
+
import type { DeferMarker, ResumeEntry } from '../runtime/RESUME.ts'
|
|
7
9
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
10
|
+
import { scheduleWake } from '../runtime/scheduleWake.ts'
|
|
8
11
|
import { scope } from '../runtime/scope.ts'
|
|
9
12
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
10
13
|
import type { State } from '../runtime/types/State.ts'
|
|
11
14
|
import { state } from '../state.ts'
|
|
12
15
|
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
13
16
|
import { discardBoundary } from './discardBoundary.ts'
|
|
17
|
+
import { firstElementBetween } from './firstElementBetween.ts'
|
|
14
18
|
import { removeRange } from './removeRange.ts'
|
|
15
19
|
|
|
16
20
|
/*
|
|
@@ -56,8 +60,20 @@ export function awaitBlock(
|
|
|
56
60
|
let active: { start: Comment; end: Comment; dispose: () => void } | undefined
|
|
57
61
|
let anchor: Node | undefined
|
|
58
62
|
let first = true
|
|
59
|
-
/*
|
|
60
|
-
|
|
63
|
+
/* A deferred (inert-adopted) block schedules a wake (idle or visible); cancel it if the
|
|
64
|
+
owner tears the block out before it fires, so no late re-run touches a detached anchor. */
|
|
65
|
+
let cancelWake: (() => void) | undefined
|
|
66
|
+
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
|
|
67
|
+
owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
|
|
68
|
+
`{#for}`/component tears this block out is abandoned — otherwise its settle runs
|
|
69
|
+
`place` on the block's now-detached anchor and `insertBefore` throws NotFoundError.
|
|
70
|
+
The teardown also cancels a pending wake (above). */
|
|
71
|
+
const guard = generationGuard(() => cancelWake?.())
|
|
72
|
+
/* Flipped by a deferred block's wake. Read at the top of the effect so the block subscribes
|
|
73
|
+
to it and re-runs when it flips — the same re-run path an invalidate takes, so wake needs
|
|
74
|
+
no bespoke build logic. Never flips for a non-deferred block, so it costs one inert read
|
|
75
|
+
per run there. */
|
|
76
|
+
const woken = state(false)
|
|
61
77
|
/* The resolved value, held as a reactive cell so the then-branch reads it through its
|
|
62
78
|
own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
|
|
63
79
|
the branch — the branch (and any keyed `each` inside it) survives and updates in place,
|
|
@@ -120,7 +136,7 @@ export function awaitBlock(
|
|
|
120
136
|
|
|
121
137
|
/* Render a settled-or-pending result into the current generation. */
|
|
122
138
|
const render = (result: unknown): void => {
|
|
123
|
-
const gen =
|
|
139
|
+
const gen = guard.token()
|
|
124
140
|
if (!isThenable(result)) {
|
|
125
141
|
settleThen(result) // warm-sync → resolved now, no flash
|
|
126
142
|
return
|
|
@@ -140,12 +156,12 @@ export function awaitBlock(
|
|
|
140
156
|
}
|
|
141
157
|
result.then(
|
|
142
158
|
(value) => {
|
|
143
|
-
if (gen
|
|
159
|
+
if (guard.live(gen)) {
|
|
144
160
|
settleThen(value)
|
|
145
161
|
}
|
|
146
162
|
},
|
|
147
163
|
(error) => {
|
|
148
|
-
if (gen
|
|
164
|
+
if (guard.live(gen)) {
|
|
149
165
|
settleError(error)
|
|
150
166
|
}
|
|
151
167
|
},
|
|
@@ -176,6 +192,53 @@ export function awaitBlock(
|
|
|
176
192
|
active = { start, end, dispose }
|
|
177
193
|
}
|
|
178
194
|
|
|
195
|
+
/* Inert adoption — a deferred `{#await cache()}` (large payload, shipped as a `{defer,key}`
|
|
196
|
+
resume marker). Adopt the SSR then-branch WITHOUT building its bindings or materializing
|
|
197
|
+
the awaited value: the server markup is already correct, so keep it verbatim and skip the
|
|
198
|
+
payload decode entirely (the boot-path cost deferral targets). Only subscribe THIS block's
|
|
199
|
+
effect to the cache key — reads, decodes and fetches all wait. A later cache.invalidate
|
|
200
|
+
re-runs the effect, which then reads the value for real and builds a fresh branch. Sound
|
|
201
|
+
because a display-first read replaces the whole branch on re-read anyway, so there is no
|
|
202
|
+
extra flash beyond the swap the re-read already performs. */
|
|
203
|
+
const adoptInert = (open: Node | null, key: string): void => {
|
|
204
|
+
const cursor = hydration as NonNullable<typeof hydration>
|
|
205
|
+
const firstKept = open?.nextSibling ?? null
|
|
206
|
+
/* Scan to THIS block's close marker, leaving every server node in place. Nested
|
|
207
|
+
blocks carry different ids, so the first data match is our own close. */
|
|
208
|
+
let node: Node | null = firstKept
|
|
209
|
+
while (node !== null && (node as { data?: string }).data !== `/abide:await:${id}`) {
|
|
210
|
+
node = node.nextSibling
|
|
211
|
+
}
|
|
212
|
+
const close = node
|
|
213
|
+
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
214
|
+
/* Bracket the kept nodes as a `[`…`]` range + park an anchor, identical to `adopt`,
|
|
215
|
+
so the first re-run's `place`/`detach` evicts them like any other branch. */
|
|
216
|
+
const start = document.createComment(RANGE_OPEN)
|
|
217
|
+
parent.insertBefore(start, firstKept ?? close)
|
|
218
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
219
|
+
parent.insertBefore(end, close)
|
|
220
|
+
anchor = document.createTextNode('')
|
|
221
|
+
parent.insertBefore(anchor, close)
|
|
222
|
+
/* No scope/effects were built for the kept nodes, so disposal only evicts the range. */
|
|
223
|
+
active = { start, end, dispose: () => undefined }
|
|
224
|
+
/* Not 'then' — the first re-run rebuilds the branch fresh via `place`. */
|
|
225
|
+
activeKind = 'pending'
|
|
226
|
+
/* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
|
|
227
|
+
cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
|
|
228
|
+
activeCacheStore().subscribe(key)
|
|
229
|
+
/* Wake the inert branch — it's a boot-frame optimization, never a lasting mode. The
|
|
230
|
+
wake flips `woken`, re-running the effect through its normal `render` path: the
|
|
231
|
+
lazily-seeded cache reads warm, so the branch materializes live and interactive with
|
|
232
|
+
no fetch. Closes the "deferred grid stays inert until invalidate" gap.
|
|
233
|
+
|
|
234
|
+
Position picks the trigger ('auto'): a branch with an element wakes on VISIBLE (a
|
|
235
|
+
below-the-fold grid decodes only when scrolled to, one never reached costs nothing);
|
|
236
|
+
an empty branch or a DOM with no observer wakes on IDLE — never lingering inert. */
|
|
237
|
+
cancelWake = scheduleWake('auto', firstElementBetween(firstKept, close), () => {
|
|
238
|
+
woken.value = true
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
179
242
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|
|
180
243
|
(hydration off) — the recovery path when adoption can't use the server markup. */
|
|
181
244
|
const rebuildCold = (open: Node | null): void => {
|
|
@@ -205,21 +268,34 @@ export function awaitBlock(
|
|
|
205
268
|
didn't round-trip (e.g. a non-serializable Response) throws while building the
|
|
206
269
|
branch — fall back to the live promise, which reads the properly-reconstructed
|
|
207
270
|
warm cache (or re-fetches) instead of crashing hydration. */
|
|
208
|
-
const firstHydrate = (
|
|
271
|
+
const firstHydrate = (): void => {
|
|
209
272
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
210
273
|
const open = claimChild(cursor, parent)
|
|
211
274
|
/* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
|
|
212
275
|
lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
|
|
213
276
|
through to the live promise rather than crash hydration. */
|
|
214
277
|
const raw = RESUME[id]
|
|
215
|
-
let
|
|
278
|
+
let decoded: ResumeEntry | DeferMarker | undefined
|
|
216
279
|
if (raw !== undefined) {
|
|
217
280
|
try {
|
|
218
|
-
|
|
281
|
+
decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
|
|
219
282
|
} catch {
|
|
220
|
-
|
|
283
|
+
decoded = undefined
|
|
221
284
|
}
|
|
222
285
|
}
|
|
286
|
+
/* A deferred marker ships the cache key, not the value — adopt the server branch inert
|
|
287
|
+
and skip the decode. Intercept before the ok/catch logic. Object-guard the `in`: a
|
|
288
|
+
corrupt manifest decoding to a primitive/null must read as "no resume" (fall through),
|
|
289
|
+
not throw past the decode try/catch and crash hydration. */
|
|
290
|
+
if (typeof decoded === 'object' && decoded !== null && 'defer' in decoded) {
|
|
291
|
+
adoptInert(open, decoded.key)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
/* Non-deferred: read the promise now so the block subscribes to its reactive source
|
|
295
|
+
(a cache key) — warm on resume, so no round-trip — then adopt the resume value /
|
|
296
|
+
warm-sync result below, or discard and build the pending branch fresh. */
|
|
297
|
+
const result = promiseThunk()
|
|
298
|
+
const entry = decoded as ResumeEntry | undefined
|
|
223
299
|
if (entry !== undefined) {
|
|
224
300
|
/* Build the adopted branch around a value CELL (then) so a later re-run updates
|
|
225
301
|
it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
|
|
@@ -278,30 +354,27 @@ export function awaitBlock(
|
|
|
278
354
|
}
|
|
279
355
|
|
|
280
356
|
effect(() => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
stays no-flash AND a later cache.invalidate re-runs the block. Without this
|
|
286
|
-
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
287
|
-
|
|
288
|
-
ONLY the promise read is tracked. The warm-sync resolve, the hydration adopt,
|
|
289
|
-
and the pending render all BUILD the branch through `scope`, which builds
|
|
290
|
-
untracked — so the branch's own reactive reads don't subscribe THIS effect
|
|
291
|
-
(otherwise the whole block re-runs and re-suspends on any branch-state change,
|
|
292
|
-
e.g. a sibling route param updating in place). The branch's own child effects
|
|
293
|
-
still track normally; the block re-runs only when the promise source does. */
|
|
294
|
-
const result = promiseThunk()
|
|
357
|
+
/* Subscribe to the wake cell every run (including the first, before it can flip) so a
|
|
358
|
+
deferred block's idle wake re-runs this effect. Inert for non-deferred blocks. */
|
|
359
|
+
woken.value
|
|
360
|
+
guard.renew()
|
|
295
361
|
if (first) {
|
|
296
362
|
first = false
|
|
297
363
|
if (hydration !== undefined) {
|
|
298
|
-
firstHydrate
|
|
364
|
+
/* firstHydrate reads the promise ITSELF, after checking the resume marker: a
|
|
365
|
+
deferred block must not invoke promiseThunk (it would materialize/fetch the
|
|
366
|
+
value we're deferring) and subscribes by key instead; every other path reads
|
|
367
|
+
it so the block subscribes to its reactive source (a cache key). */
|
|
368
|
+
firstHydrate()
|
|
299
369
|
return
|
|
300
370
|
}
|
|
301
371
|
anchor = document.createTextNode('')
|
|
302
372
|
parent.insertBefore(anchor, before)
|
|
303
373
|
}
|
|
304
|
-
|
|
374
|
+
/* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
|
|
375
|
+
read is tracked (the branch builds untracked via `scope`), so the block re-runs only
|
|
376
|
+
when its promise source does, not on any branch-state change. */
|
|
377
|
+
render(promiseThunk())
|
|
305
378
|
})
|
|
306
379
|
}
|
|
307
380
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isElement } from './isElement.ts'
|
|
2
|
+
|
|
3
|
+
/* The first Element in the sibling run `[start, end)` — the node a visible-wake observes for a
|
|
4
|
+
deferred region (await branch or component island). Undefined when the run holds no element
|
|
5
|
+
(text/comment only), so the caller falls back to an idle wake. Element detection is
|
|
6
|
+
method-based (`isElement`), not `nodeType`, so the walk runs under the test mini-dom too. */
|
|
7
|
+
export function firstElementBetween(start: Node | null, end: Node | null): Element | undefined {
|
|
8
|
+
for (let node = start; node !== null && node !== end; node = node.nextSibling) {
|
|
9
|
+
if (isElement(node)) {
|
|
10
|
+
return node
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
@@ -29,16 +29,19 @@ export function mountChild(
|
|
|
29
29
|
props: Parameters<UiComponent>[1],
|
|
30
30
|
before: Node | null = null,
|
|
31
31
|
label?: string,
|
|
32
|
+
/* `client:idle` / `client:visible` on the tag → hydrate this child as an island (server
|
|
33
|
+
markup kept, build deferred to the trigger). Undefined = hydrate eagerly, as before. */
|
|
34
|
+
clientTrigger?: 'idle' | 'visible',
|
|
32
35
|
): void {
|
|
33
36
|
const moduleId = factory.__abideId
|
|
34
37
|
if (!__ABIDE_DEV__ || !hotReloadEnabled.current || moduleId === undefined) {
|
|
35
|
-
mountRange(parent, factory.build, props, before, label)
|
|
38
|
+
mountRange(parent, factory.build, props, before, label, clientTrigger)
|
|
36
39
|
return
|
|
37
40
|
}
|
|
38
41
|
/* Capture the component's model alongside its mount handle, so a later swap can
|
|
39
42
|
carry its state across (see `hotReplace`). */
|
|
40
43
|
const { value: handle, model } = captureModelDoc(() =>
|
|
41
|
-
mountRange(parent, factory.build, props, before, label),
|
|
44
|
+
mountRange(parent, factory.build, props, before, label, clientTrigger),
|
|
42
45
|
)
|
|
43
46
|
const instance = {
|
|
44
47
|
factory,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { claimChild } from '../runtime/claimChild.ts'
|
|
1
2
|
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { scheduleWake } from '../runtime/scheduleWake.ts'
|
|
3
5
|
import { scope } from '../runtime/scope.ts'
|
|
4
6
|
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
7
|
+
import { clearBetween } from './clearBetween.ts'
|
|
8
|
+
import { commentData } from './commentData.ts'
|
|
5
9
|
import { disposeRange } from './disposeRange.ts'
|
|
6
10
|
import { fillRange } from './fillRange.ts'
|
|
11
|
+
import { firstElementBetween } from './firstElementBetween.ts'
|
|
12
|
+
import { markerDepthDelta } from './markerDepthDelta.ts'
|
|
7
13
|
import { openMarker } from './openMarker.ts'
|
|
8
14
|
import { withScope } from './withScope.ts'
|
|
9
15
|
|
|
@@ -22,6 +28,12 @@ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the b
|
|
|
22
28
|
`anchorCursor`) places the range among static siblings on create; hydrate ignores it
|
|
23
29
|
(the claim cursor drives placement). Returns the markers + a disposer so the hot path
|
|
24
30
|
can rebuild in place.
|
|
31
|
+
|
|
32
|
+
`clientTrigger` marks the component an ISLAND: on hydrate its server markup is kept
|
|
33
|
+
verbatim and its build is SKIPPED, then run on the trigger (`'idle'`/`'visible'`) —
|
|
34
|
+
so a below-the-fold widget ships as HTML and wires no effects until scrolled to (or
|
|
35
|
+
the first idle gap), off the critical boot path. Create ignores it (there is no server
|
|
36
|
+
markup to keep — an island is a hydration-cost optimization). See `adoptIsland`.
|
|
25
37
|
*/
|
|
26
38
|
// @documentation plumbing
|
|
27
39
|
export function mountRange(
|
|
@@ -30,6 +42,7 @@ export function mountRange(
|
|
|
30
42
|
props: UiProps | undefined,
|
|
31
43
|
before: Node | null = null,
|
|
32
44
|
label: string | undefined = undefined,
|
|
45
|
+
clientTrigger: 'idle' | 'visible' | undefined = undefined,
|
|
33
46
|
): { start: Comment; end: Comment; dispose: () => void } {
|
|
34
47
|
const hydration = RENDER.hydration
|
|
35
48
|
const start = openMarker(parent, RANGE_OPEN, before)
|
|
@@ -37,6 +50,9 @@ export function mountRange(
|
|
|
37
50
|
const end = openMarker(parent, RANGE_CLOSE, before)
|
|
38
51
|
return fillRange(start, end, build, props, label)
|
|
39
52
|
}
|
|
53
|
+
if (clientTrigger !== undefined) {
|
|
54
|
+
return adoptIsland(parent, start, build, props, label, clientTrigger, hydration)
|
|
55
|
+
}
|
|
40
56
|
/* Hydrate: adopt the server range in place. Establish the child's lexical scope
|
|
41
57
|
and render pass (same as `fillRange`), build claiming the existing nodes, then
|
|
42
58
|
claim the end marker the build's content stops before. */
|
|
@@ -44,3 +60,62 @@ export function mountRange(
|
|
|
44
60
|
const end = openMarker(parent, RANGE_CLOSE)
|
|
45
61
|
return { start, end, dispose: disposeRange(scoped, start, end) }
|
|
46
62
|
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
Island hydration: keep the server markup, skip the build, wake it later on the trigger. Scan the
|
|
66
|
+
child's server nodes to THIS range's depth-matched close (`markerDepthDelta` balances nested
|
|
67
|
+
`[`…`]` / `abide:` ranges) — keeping them and advancing the claim cursor past them, so the rest
|
|
68
|
+
of the page hydrates around the skipped subtree — then claim the close marker. No scope is built,
|
|
69
|
+
so the inert disposer only clears the kept nodes; the wake clears them and builds the child fresh
|
|
70
|
+
(hydration off) between the same markers, exactly like a create-path mount.
|
|
71
|
+
*/
|
|
72
|
+
function adoptIsland(
|
|
73
|
+
parent: Node,
|
|
74
|
+
start: Comment,
|
|
75
|
+
build: (host: Node, props?: UiProps) => void,
|
|
76
|
+
props: UiProps | undefined,
|
|
77
|
+
label: string | undefined,
|
|
78
|
+
trigger: 'idle' | 'visible',
|
|
79
|
+
hydration: NonNullable<(typeof RENDER)['hydration']>,
|
|
80
|
+
): { start: Comment; end: Comment; dispose: () => void } {
|
|
81
|
+
const firstKept = claimChild(hydration, parent)
|
|
82
|
+
let node = firstKept
|
|
83
|
+
let depth = 0
|
|
84
|
+
while (node !== null) {
|
|
85
|
+
const data = commentData(node)
|
|
86
|
+
if (data !== undefined) {
|
|
87
|
+
const delta = markerDepthDelta(data)
|
|
88
|
+
/* A depth-0 close is THIS range's own; anything nested balances out first. */
|
|
89
|
+
if (delta === -1 && depth === 0) {
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
depth += delta
|
|
93
|
+
}
|
|
94
|
+
node = node.nextSibling
|
|
95
|
+
}
|
|
96
|
+
hydration.next.set(parent, node)
|
|
97
|
+
const end = openMarker(parent, RANGE_CLOSE)
|
|
98
|
+
|
|
99
|
+
/* Inert: no scope/effects for the kept nodes, so disposal only evicts the range. The wake
|
|
100
|
+
swaps in a real build whose own disposer replaces this one. */
|
|
101
|
+
let disposeContent = (): void => clearBetween(start, end)
|
|
102
|
+
const wake = (): void => {
|
|
103
|
+
disposeContent()
|
|
104
|
+
const previous = RENDER.hydration
|
|
105
|
+
RENDER.hydration = undefined
|
|
106
|
+
try {
|
|
107
|
+
disposeContent = fillRange(start, end, build, props, label).dispose
|
|
108
|
+
} finally {
|
|
109
|
+
RENDER.hydration = previous
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const cancelWake = scheduleWake(trigger, firstElementBetween(firstKept, end), wake)
|
|
113
|
+
return {
|
|
114
|
+
start,
|
|
115
|
+
end,
|
|
116
|
+
dispose: () => {
|
|
117
|
+
cancelWake()
|
|
118
|
+
disposeContent()
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { snippet } from '../shared/snippet.ts'
|
|
2
|
+
import { deferResume } from './deferResume.ts'
|
|
2
3
|
import { anchorCursor } from './dom/anchorCursor.ts'
|
|
3
4
|
import { appendSnippet } from './dom/appendSnippet.ts'
|
|
4
5
|
import { appendStatic } from './dom/appendStatic.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 = {
|