@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
package/src/lib/shared/cache.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { keyForRemoteCall } from './keyForRemoteCall.ts'
|
|
|
12
12
|
import { producerKey } from './producerKey.ts'
|
|
13
13
|
import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
|
|
14
14
|
import { REPLAYABLE_METHODS } from './REPLAYABLE_METHODS.ts'
|
|
15
|
+
import { recordCacheKey } from './recordCacheKey.ts'
|
|
15
16
|
import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
|
|
16
17
|
import { selectorMatcher } from './selectorMatcher.ts'
|
|
17
18
|
import { selectorPrefix } from './selectorPrefix.ts'
|
|
@@ -198,8 +199,22 @@ export function cache<Args, Return>(
|
|
|
198
199
|
reader of the key, so one mutating it would corrupt the others. A live
|
|
199
200
|
fetch hands each reader a fresh object; cloning keeps warm reads the same.
|
|
200
201
|
*/
|
|
201
|
-
if (
|
|
202
|
-
|
|
202
|
+
if (
|
|
203
|
+
!isRaw &&
|
|
204
|
+
existing !== undefined &&
|
|
205
|
+
(existing.value !== undefined || existing.warm !== undefined)
|
|
206
|
+
) {
|
|
207
|
+
/* A deferred seed carries a lazy materializer instead of a decoded value: decode
|
|
208
|
+
it here, on the first read that needs it (off the hydration path), and cache the
|
|
209
|
+
result onto the entry so later reads skip the decode. Drop the materializer once
|
|
210
|
+
used — its closure pins the raw body string, so keeping it alongside the decoded
|
|
211
|
+
value would double memory for exactly the large payloads deferral targets. */
|
|
212
|
+
const warmValue = existing.value !== undefined ? existing.value : existing.warm?.()
|
|
213
|
+
existing.value = warmValue
|
|
214
|
+
existing.warm = undefined
|
|
215
|
+
const warmed = Promise.resolve(cloneWarmValue(warmValue)) as Promise<Return>
|
|
216
|
+
recordCacheKey(warmed, key)
|
|
217
|
+
return warmed
|
|
203
218
|
}
|
|
204
219
|
const responsePromise = invokeRemote(
|
|
205
220
|
store,
|
|
@@ -209,7 +224,14 @@ export function cache<Args, Return>(
|
|
|
209
224
|
args,
|
|
210
225
|
options,
|
|
211
226
|
)
|
|
212
|
-
|
|
227
|
+
if (isRaw) {
|
|
228
|
+
return responsePromise
|
|
229
|
+
}
|
|
230
|
+
/* Tag the decoded value promise with its key so the SSR resume path can recognise a
|
|
231
|
+
cache-backed await value and defer a large one to a `{ defer, key }` marker. */
|
|
232
|
+
const decoded = responsePromise.then(decodeResponse) as Promise<Return>
|
|
233
|
+
recordCacheKey(decoded, key)
|
|
234
|
+
return decoded
|
|
213
235
|
}
|
|
214
236
|
/* Non-enumerable brand; selectorMatcher and the re-wrap guard read it. */
|
|
215
237
|
Object.defineProperty(read, CACHE_WRAPPED, { value: fn })
|
|
@@ -812,7 +834,7 @@ function settleRefetchFailure(store: CacheStore, entry: CacheEntry, status?: num
|
|
|
812
834
|
}
|
|
813
835
|
|
|
814
836
|
/* Folds new tags into an entry's existing set without duplicating them. */
|
|
815
|
-
function mergeTags(existing: Set<string> | undefined, incoming: string
|
|
837
|
+
function mergeTags(existing: Set<string> | undefined, incoming: string[]): Set<string> {
|
|
816
838
|
return new Set([...(existing ?? []), ...toTagSet(incoming)])
|
|
817
839
|
}
|
|
818
840
|
|
|
@@ -19,18 +19,38 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
|
|
|
19
19
|
statusText: entry.statusText,
|
|
20
20
|
headers,
|
|
21
21
|
})
|
|
22
|
+
/* Deferred seed: decode nothing now — hand back a memoized materializer the first read
|
|
23
|
+
invokes, so hydration pays no payload decode. Eager seed: decode up front as before. */
|
|
24
|
+
const warm = entry.lazy
|
|
25
|
+
? memoizeWarm(() => warmValueFromSnapshot(entry.status, headers, entry.body))
|
|
26
|
+
: undefined
|
|
22
27
|
return {
|
|
23
28
|
key: entry.key,
|
|
24
29
|
promise: Promise.resolve(response),
|
|
25
30
|
request: new Request(entry.url, { method: entry.method }),
|
|
26
31
|
ttl: undefined,
|
|
27
32
|
expiresAt: undefined,
|
|
28
|
-
value: warmValueFromSnapshot(entry.status, headers, entry.body),
|
|
33
|
+
value: entry.lazy ? undefined : warmValueFromSnapshot(entry.status, headers, entry.body),
|
|
34
|
+
warm,
|
|
29
35
|
settled: true,
|
|
30
36
|
hydrated: true,
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/* Wraps a warm decode so it runs at most once — the materialized value (including a
|
|
41
|
+
legitimate undefined for a non-warmable status) is cached after the first call. */
|
|
42
|
+
function memoizeWarm(decode: () => unknown): () => unknown {
|
|
43
|
+
let materialized = false
|
|
44
|
+
let value: unknown
|
|
45
|
+
return () => {
|
|
46
|
+
if (!materialized) {
|
|
47
|
+
value = decode()
|
|
48
|
+
materialized = true
|
|
49
|
+
}
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
/*
|
|
35
55
|
Synchronously decodes a snapshot body so the warm entry reads without a
|
|
36
56
|
microtask hop on first render. The json/text branches go through the shared
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { cacheKeyStore } from './cacheKeyStore.ts'
|
|
2
|
+
|
|
3
|
+
/* The store key behind a cache() read's promise, or undefined if it carries none
|
|
4
|
+
(a producer read, a raw Response read, or a non-cache promise). */
|
|
5
|
+
export function cacheKeyOf(promise: Promise<unknown>): string | undefined {
|
|
6
|
+
return cacheKeyStore.get(promise)
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
WeakMap recording the cache key behind a `cache()` read's returned promise, so a
|
|
3
|
+
consumer holding only the promise can recover its key. Mirrors remoteMetaStore (which
|
|
4
|
+
records the synthesized Request the same way). The SSR resume path uses it to decide
|
|
5
|
+
whether a `{#await cache()}` value is a large cache-backed read it can defer — shipping a
|
|
6
|
+
`{ defer, key }` marker instead of the value. Collected with the promise.
|
|
7
|
+
*/
|
|
8
|
+
export const cacheKeyStore = new WeakMap<Promise<unknown>, string>()
|
|
@@ -4,7 +4,7 @@ import { HttpError } from './HttpError.ts'
|
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
Builds the HttpError for a non-2xx response, parsing a typed-error body
|
|
7
|
-
(`{ $abideError, data }`, emitted by `error
|
|
7
|
+
(`{ $abideError, data }`, emitted by an `error.typed(...)` constructor and validation 422)
|
|
8
8
|
onto `.kind` / `.data`. Reads a clone so the original `response.body` stays
|
|
9
9
|
unread for callers that inspect it. A non-JSON or malformed body leaves
|
|
10
10
|
`.kind` / `.data` undefined (a plain `error(status, text)`). Shared by the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Normalizes a tags option
|
|
2
|
-
export function toTagSet(tags: string
|
|
3
|
-
return new Set(
|
|
1
|
+
/* Normalizes a tags option to a Set for O(1) membership. */
|
|
2
|
+
export function toTagSet(tags: string[]): Set<string> {
|
|
3
|
+
return new Set(tags)
|
|
4
4
|
}
|
|
@@ -59,9 +59,24 @@ export type CacheEntry = {
|
|
|
59
59
|
ttl: number | undefined
|
|
60
60
|
expiresAt: number | undefined
|
|
61
61
|
value?: unknown
|
|
62
|
+
/*
|
|
63
|
+
Lazy warm materializer (Tier 2 deferred seed): a memoized thunk that decodes the
|
|
64
|
+
shipped body into the warm value on first call, so a deferred snapshot pays no decode
|
|
65
|
+
at hydration — only when a read actually needs the value. `value` stays undefined until
|
|
66
|
+
then; the read gate treats an entry with `warm` set as warm without invoking it. Eager
|
|
67
|
+
snapshots set `value` and leave this undefined.
|
|
68
|
+
*/
|
|
69
|
+
warm?: () => unknown
|
|
62
70
|
tags?: Set<string>
|
|
63
71
|
settled?: boolean
|
|
64
72
|
hydrated?: boolean
|
|
73
|
+
/*
|
|
74
|
+
Marks that the SSR resume path deferred this entry's `{#await cache()}` value — shipped a
|
|
75
|
+
`{ defer, key }` marker instead of inlining it. The post-stream snapshot drain reads it to
|
|
76
|
+
ship the body as a LAZY seed (decoded on first read, not at hydration). Set server-side
|
|
77
|
+
only, on the request-scoped entry, during the stream.
|
|
78
|
+
*/
|
|
79
|
+
deferred?: boolean
|
|
65
80
|
refreshing?: boolean
|
|
66
81
|
invalidation?: InvalidationState
|
|
67
82
|
}
|
|
@@ -6,10 +6,10 @@ milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
|
|
|
6
6
|
dedupe only (entry dropped once the promise settles — the mutation idiom:
|
|
7
7
|
in-flight coalescing and pending() visibility, nothing retained), any other
|
|
8
8
|
number = TTL.
|
|
9
|
-
`tags` is
|
|
10
|
-
`cache.invalidate({ tags })` drops every entry sharing any of them —
|
|
11
|
-
|
|
12
|
-
uuid) shared by a set of calls gives them their own private invalidation group.
|
|
9
|
+
`tags` is an array of free-form labels grouping unrelated calls so one
|
|
10
|
+
`cache.invalidate({ tags })` drops every entry sharing any of them — list
|
|
11
|
+
multiple when a call belongs to multiple invalidation groups. A unique tag (e.g.
|
|
12
|
+
a uuid) shared by a set of calls gives them their own private invalidation group.
|
|
13
13
|
|
|
14
14
|
`global` opts the entry into the process-level store instead of the default
|
|
15
15
|
request-scoped one (server) — so a value computed in one request is reused by
|
|
@@ -37,7 +37,7 @@ uncheckable — set `swr` only on a producer that is a pure read.
|
|
|
37
37
|
*/
|
|
38
38
|
export type CacheOptions = {
|
|
39
39
|
ttl?: number
|
|
40
|
-
tags?: string
|
|
40
|
+
tags?: string[]
|
|
41
41
|
global?: boolean
|
|
42
42
|
swr?: boolean | { throttle?: number; debounce?: number }
|
|
43
43
|
}
|
|
@@ -14,4 +14,9 @@ export type CacheSnapshotEntry = {
|
|
|
14
14
|
statusText: string
|
|
15
15
|
headers: Array<[string, string]>
|
|
16
16
|
body: string
|
|
17
|
+
/* Deferred seed (Tier 2): the client stores the body but does NOT decode it at boot —
|
|
18
|
+
the warm value is materialized lazily on the first read, off the hydration path. Set
|
|
19
|
+
by the server for a deferred `{#await cache()}` whose value ships via a `{defer,key}`
|
|
20
|
+
resume marker instead of inline, so hydration touches neither copy's decode. */
|
|
21
|
+
lazy?: boolean
|
|
17
22
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
|
-
A rpc's
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`
|
|
8
|
-
`
|
|
9
|
-
`
|
|
4
|
+
A rpc's error set, keyed by error NAME (not status, so two errors can share a
|
|
5
|
+
status). Each entry names its HTTP `status` and an optional `data` schema whose
|
|
6
|
+
inferred input the error constructor requires. Built at the type level from the
|
|
7
|
+
`error.typed(...)` constructors a handler RETURNS (see `TypedError` +
|
|
8
|
+
`InferredErrors` in RpcHelper); the client's `rpc.isError` narrows `.kind` /
|
|
9
|
+
`.data` off it (see `RpcErrorGuard`).
|
|
10
10
|
*/
|
|
11
11
|
export type ErrorSpec = Record<string, { status: number; data?: StandardSchemaV1 }>
|
|
@@ -43,6 +43,7 @@ export type RemoteFunction<
|
|
|
43
43
|
Args,
|
|
44
44
|
Return,
|
|
45
45
|
Errors extends ErrorSpec = Record<never, never>,
|
|
46
|
+
Durable extends boolean = false,
|
|
46
47
|
> = RemoteCallable<Args, Return> & {
|
|
47
48
|
readonly method: HttpMethod
|
|
48
49
|
readonly url: string
|
|
@@ -56,7 +57,10 @@ export type RemoteFunction<
|
|
|
56
57
|
per-rpc replacement for a global guard, since the error name → data type mapping
|
|
57
58
|
lives in the rpc's own spec. */
|
|
58
59
|
readonly isError: RpcErrorGuard<Errors>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
} /* `outbox` presence follows the `outbox: true` opt: the mutating helper threads `Durable`
|
|
61
|
+
into the return type so a DURABLE rpc's `.outbox` is the required queue face (no optional
|
|
62
|
+
chain). A non-durable rpc keeps it optional — assignable to a durable one everywhere a
|
|
63
|
+
bare `RemoteFunction<Args, Return>` slot (cache selectors, registries) is expected, since
|
|
64
|
+
required→optional widens cleanly and no call site had to learn the `Durable` bit. */ & (Durable extends true
|
|
65
|
+
? { readonly outbox: Outbox<Args> }
|
|
66
|
+
: { readonly outbox?: Outbox<Args> })
|
|
@@ -35,6 +35,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
|
|
|
35
35
|
{ name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
|
|
36
36
|
{ name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
|
|
37
37
|
{ name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
|
|
38
|
+
{ name: 'deferResume', specifier: 'ui/deferResume', alias: '$$deferResume' },
|
|
38
39
|
{ name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
|
|
39
40
|
{ name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
|
|
40
41
|
{ name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
|
|
@@ -558,7 +558,11 @@ export function generateBuild(
|
|
|
558
558
|
parentVar: string,
|
|
559
559
|
before: string,
|
|
560
560
|
): string {
|
|
561
|
-
|
|
561
|
+
/* `client:idle`/`client:visible` → the island trigger, the 6th mountChild arg. Omitted
|
|
562
|
+
(the common case) leaves the call byte-identical to before — eager hydration. */
|
|
563
|
+
const trigger =
|
|
564
|
+
node.clientTrigger !== undefined ? `, ${JSON.stringify(node.clientTrigger)}` : ''
|
|
565
|
+
return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)}${trigger});\n`
|
|
562
566
|
}
|
|
563
567
|
|
|
564
568
|
/* An await block: pending → resolved(value) / error branches. Each branch is a
|
|
@@ -527,10 +527,15 @@ export function generateSSR(
|
|
|
527
527
|
`withBindings` shadow models, so a reference to the binding reads the plain
|
|
528
528
|
local rather than the (unresolved) component signal it shadows. */
|
|
529
529
|
const resolved = nextVar('$av')
|
|
530
|
+
/* Keep the promise object: a cache() read tagged it with its store key, which
|
|
531
|
+
`$$deferResume` reads to defer a cache-backed value (ship a `{defer,key}` marker +
|
|
532
|
+
lazy seed) instead of inlining it — the blocking form's contract. */
|
|
533
|
+
const promiseVar = nextVar('$ap')
|
|
530
534
|
let code = `const ${id} = $ctx.next++;\n`
|
|
531
535
|
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
532
536
|
code += `try {\n`
|
|
533
|
-
code += `const ${
|
|
537
|
+
code += `const ${promiseVar} = (${lowerExpression(node.promise)});\n`
|
|
538
|
+
code += `const ${resolved} = await ${promiseVar};\n`
|
|
534
539
|
code += `{\n`
|
|
535
540
|
code += `const ${plan.resolvedAs} = ${resolved};\n`
|
|
536
541
|
code += withBindings(withShadow, plan.resolvedBindings, ssrBindingKind, () =>
|
|
@@ -540,7 +545,7 @@ export function generateSSR(
|
|
|
540
545
|
shadow — matching the catch branch below, so a finally expression naming the
|
|
541
546
|
same identifier as the `then` binding reads the component signal, not the local. */
|
|
542
547
|
code += branchContent(plan.finallyChildren, target)
|
|
543
|
-
code += `$resume[${id}] = {
|
|
548
|
+
code += `$resume[${id}] = $$deferResume(${promiseVar}, ${plan.resolvedAs});\n`
|
|
544
549
|
code += `}\n`
|
|
545
550
|
if (plan.surfaceRejection) {
|
|
546
551
|
/* No catch/finally → let the rejection surface instead of an empty branch. */
|
|
@@ -564,7 +569,9 @@ export function generateSSR(
|
|
|
564
569
|
`await` block inside the branch composes. `finally` appends after the outcome,
|
|
565
570
|
matching the client's concatenated node range. Neither catch nor finally → omit
|
|
566
571
|
`catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
|
|
567
|
-
keeps a catch renderer that renders just finally.
|
|
572
|
+
keeps a catch renderer that renders just finally. A cache-backed streaming value is
|
|
573
|
+
DEFERRED at settle time (renderToStream ships a `{defer,key}` marker + lazy body, the
|
|
574
|
+
client adopts the streamed branch inert) — the machinery here is unchanged. */
|
|
568
575
|
function generateStreamingAwait(
|
|
569
576
|
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
570
577
|
target: string,
|
|
@@ -640,12 +640,25 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
640
640
|
its children become slot content (rendered where the child puts <slot>). */
|
|
641
641
|
if (UPPERCASE_START.test(tag)) {
|
|
642
642
|
const slotted = selfClosing ? [] : readChildren(tag)
|
|
643
|
+
/* `client:idle` / `client:visible` are island directives (bare) — pull them out of
|
|
644
|
+
the attributes so they never round-trip as props; only the build back-end reads
|
|
645
|
+
the trigger. Any other `client:*` falls through as a prop (a prop-check error). */
|
|
646
|
+
const clientAttr = attrs.find(
|
|
647
|
+
(attr) =>
|
|
648
|
+
attr.kind === 'static' &&
|
|
649
|
+
(attr.name === 'client:idle' || attr.name === 'client:visible'),
|
|
650
|
+
)
|
|
651
|
+
const clientTrigger =
|
|
652
|
+
clientAttr?.kind === 'static'
|
|
653
|
+
? (clientAttr.name.slice('client:'.length) as 'idle' | 'visible')
|
|
654
|
+
: undefined
|
|
643
655
|
return {
|
|
644
656
|
kind: 'component',
|
|
645
657
|
name: tag,
|
|
646
658
|
loc: baseOffset + tagStart,
|
|
647
|
-
props: toProps(attrs),
|
|
659
|
+
props: toProps(attrs.filter((attr) => attr !== clientAttr)),
|
|
648
660
|
children: slotted,
|
|
661
|
+
clientTrigger,
|
|
649
662
|
}
|
|
650
663
|
}
|
|
651
664
|
/* `{...expr}` spreads onto a component (its props) or a native element (its
|
|
@@ -80,6 +80,11 @@ export type TemplateNode =
|
|
|
80
80
|
the prop name's source offset — the anchor for an excess-prop diagnostic. */
|
|
81
81
|
props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
|
|
82
82
|
children: TemplateNode[]
|
|
83
|
+
/* `client:idle` / `client:visible` on the tag → hydrate this child as an island
|
|
84
|
+
(server markup kept, build deferred to the trigger). Extracted from the attributes
|
|
85
|
+
so it never becomes a prop; the build back-end passes it to `mountChild`, SSR and
|
|
86
|
+
the prop check ignore it (an island renders fully on the server). */
|
|
87
|
+
clientTrigger?: 'idle' | 'visible'
|
|
83
88
|
}
|
|
84
89
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
85
90
|
/* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
|
|
@@ -5,6 +5,7 @@ import { linked } from './linked.ts'
|
|
|
5
5
|
import { persist as persistDoc } from './persist.ts'
|
|
6
6
|
import { createDoc } from './runtime/createDoc.ts'
|
|
7
7
|
import { liveScopes } from './runtime/liveScopes.ts'
|
|
8
|
+
import type { Cell } from './runtime/types/Cell.ts'
|
|
8
9
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
9
10
|
import { state } from './state.ts'
|
|
10
11
|
import { sync } from './sync.ts'
|
|
@@ -49,7 +50,11 @@ export function createScope(
|
|
|
49
50
|
let persistence: PersistHandle | undefined
|
|
50
51
|
let unsync: (() => void) | undefined
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
/* `cell` is not on the public `Scope` type — it is the compiler-only leaf the
|
|
54
|
+
cell-hoisting lowering targets (`const _cell0 = $$scope().cell("path")`, see
|
|
55
|
+
`hoistCells`). It stays on the runtime object but off the documented surface,
|
|
56
|
+
so authors reach data through `read`/`replace`/`derive`, not a raw cell handle. */
|
|
57
|
+
const self: Scope & { cell: <T>(path: string) => Cell<T> } = {
|
|
53
58
|
id,
|
|
54
59
|
label,
|
|
55
60
|
parent,
|
|
@@ -79,7 +84,7 @@ export function createScope(
|
|
|
79
84
|
},
|
|
80
85
|
root: () => (parent === undefined ? self : parent.root()),
|
|
81
86
|
/* Reference store — no tracking, so a lookup never subscribes; reactivity comes
|
|
82
|
-
from what is shared (a
|
|
87
|
+
from what is shared (a scope, whose doc is reactive), not from the share. */
|
|
83
88
|
share: (key, value) => {
|
|
84
89
|
shared.set(key, value)
|
|
85
90
|
},
|
|
@@ -109,8 +114,10 @@ export function createScope(
|
|
|
109
114
|
owned[index]?.()
|
|
110
115
|
}
|
|
111
116
|
owned.length = 0
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
/* Children reverse too (last created first), so a later child that captured an
|
|
118
|
+
earlier sibling tears down before the sibling it depends on — LIFO like `owned`. */
|
|
119
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
120
|
+
children[index]?.dispose()
|
|
114
121
|
}
|
|
115
122
|
children.length = 0
|
|
116
123
|
shared.clear()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { activeCacheStore } from '../shared/activeCacheStore.ts'
|
|
2
|
+
import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
|
|
3
|
+
import { DEFER_MIN_ARRAY_LENGTH } from '../shared/DEFER_MIN_ARRAY_LENGTH.ts'
|
|
4
|
+
import { snapshotShippable } from '../shared/snapshotShippable.ts'
|
|
5
|
+
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The resume-manifest entry for a blocking `{#await expr then value}`. When the awaited value
|
|
9
|
+
is a shippable cache-backed read, DEFER it: flag the store entry so its SSR snapshot seeds
|
|
10
|
+
lazily (the body is decoded on first read, not at hydration) and return a `{ defer, key }`
|
|
11
|
+
marker in place of the value. Hydration then adopts the server branch inert and decodes
|
|
12
|
+
neither copy — the blocking form's contract: "render it on the server, keep the page, refetch
|
|
13
|
+
only when I read or invalidate it." A non-cache value (a plain promise, a computation) or a
|
|
14
|
+
cache read whose entry can't ship inlines its value as before. Server-only in practice — the
|
|
15
|
+
client codegen never calls it. */
|
|
16
|
+
// @documentation plumbing
|
|
17
|
+
export function deferResume(promise: unknown, value: unknown): ResumeEntry | DeferMarker {
|
|
18
|
+
const isPromise =
|
|
19
|
+
promise !== null && typeof (promise as { then?: unknown })?.then === 'function'
|
|
20
|
+
const key = isPromise ? cacheKeyOf(promise as Promise<unknown>) : undefined
|
|
21
|
+
if (key === undefined) {
|
|
22
|
+
return { ok: true, value }
|
|
23
|
+
}
|
|
24
|
+
const entry = activeCacheStore().entries.get(key)
|
|
25
|
+
if (entry === undefined || !snapshotShippable(entry)) {
|
|
26
|
+
return { ok: true, value }
|
|
27
|
+
}
|
|
28
|
+
/* Only defer a genuinely large grid: below the threshold, inline the value and hydrate
|
|
29
|
+
eagerly — the block is interactive from the first frame, no inert phase, no wake. This is
|
|
30
|
+
what keeps a modest searchable list live instead of frozen-until-idle (the reported gap). */
|
|
31
|
+
if (!Array.isArray(value) || value.length < DEFER_MIN_ARRAY_LENGTH) {
|
|
32
|
+
return { ok: true, value }
|
|
33
|
+
}
|
|
34
|
+
entry.deferred = true
|
|
35
|
+
return { defer: true, key }
|
|
36
|
+
}
|
|
@@ -36,21 +36,36 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
|
|
|
36
36
|
}
|
|
37
37
|
const hydration = RENDER.hydration
|
|
38
38
|
if (hydration !== undefined) {
|
|
39
|
-
const
|
|
39
|
+
const claimed = claimChild(hydration, parent)
|
|
40
40
|
const value = String(read())
|
|
41
|
+
/* A value that first rendered empty produced NO server text node, so the cursor
|
|
42
|
+
points at the following node (an element/comment) or past the end (null) — not a
|
|
43
|
+
text node to claim. Bind to a Text node either way: claim the merged SSR node when
|
|
44
|
+
one is here, else synthesize an empty one at the cursor and leave the claimed node
|
|
45
|
+
for the next consumer (a following element hole, a sibling binding, or nothing).
|
|
46
|
+
Without this the bind effect below derefs a null/element `node`. A text node is
|
|
47
|
+
detected by `splitText` (not `nodeType`), so the test mini-dom is covered too. */
|
|
48
|
+
const isText = claimed !== null && typeof (claimed as Text).splitText === 'function'
|
|
49
|
+
const node = (
|
|
50
|
+
isText ? claimed : parent.insertBefore(document.createTextNode(''), claimed)
|
|
51
|
+
) as Text
|
|
41
52
|
/* Peel this binding's text off the merged SSR node. A non-final binding in a
|
|
42
53
|
run (`splitAlways`) splits even when it consumes the whole node, leaving an
|
|
43
54
|
empty node for the next binding — otherwise an interpolation that renders to
|
|
44
55
|
empty string (or whose followers do) has no node and the next claim grabs the
|
|
45
56
|
wrong sibling. The final binding keeps `<` so it doesn't leave a stray node a
|
|
46
|
-
following element would claim.
|
|
57
|
+
following element would claim. A synthesized node is already this binding's own,
|
|
58
|
+
so it never splits. */
|
|
47
59
|
if (
|
|
48
|
-
|
|
60
|
+
isText &&
|
|
49
61
|
(splitAlways ? value.length <= node.data.length : value.length < node.data.length)
|
|
50
62
|
) {
|
|
51
63
|
node.splitText(value.length)
|
|
52
64
|
}
|
|
53
|
-
|
|
65
|
+
/* Advance past the claimed text node; for a synthesized node leave the cursor on the
|
|
66
|
+
still-unclaimed `claimed` node it was inserted before (an element/comment, or null
|
|
67
|
+
at the end). */
|
|
68
|
+
hydration.next.set(parent, isText ? node.nextSibling : claimed)
|
|
54
69
|
effect(() => {
|
|
55
70
|
node.data = String(read())
|
|
56
71
|
})
|