@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.
Files changed (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/lib/server/error.ts +48 -53
  6. package/src/lib/server/json.ts +4 -3
  7. package/src/lib/server/jsonl.ts +1 -1
  8. package/src/lib/server/rpc/defineRpc.ts +2 -8
  9. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  10. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  11. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  12. package/src/lib/server/rpc/validationError.ts +3 -3
  13. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  14. package/src/lib/server/runtime/createServer.ts +7 -0
  15. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  16. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  17. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  18. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  19. package/src/lib/server/sse.ts +1 -1
  20. package/src/lib/shared/HttpError.ts +1 -1
  21. package/src/lib/shared/cache.ts +26 -4
  22. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  23. package/src/lib/shared/cacheKeyOf.ts +7 -0
  24. package/src/lib/shared/cacheKeyStore.ts +8 -0
  25. package/src/lib/shared/httpErrorFor.ts +1 -1
  26. package/src/lib/shared/recordCacheKey.ts +6 -0
  27. package/src/lib/shared/toTagSet.ts +3 -3
  28. package/src/lib/shared/types/CacheEntry.ts +15 -0
  29. package/src/lib/shared/types/CacheOptions.ts +5 -5
  30. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  31. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  32. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  33. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  34. package/src/lib/ui/compile/generateSSR.ts +10 -3
  35. package/src/lib/ui/createScope.ts +11 -4
  36. package/src/lib/ui/deferResume.ts +29 -0
  37. package/src/lib/ui/dom/appendText.ts +19 -4
  38. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  39. package/src/lib/ui/dom/eachAsync.ts +11 -18
  40. package/src/lib/ui/installHotBridge.ts +2 -0
  41. package/src/lib/ui/matchRoute.ts +18 -1
  42. package/src/lib/ui/remoteProxy.ts +11 -1
  43. package/src/lib/ui/renderToStream.ts +13 -8
  44. package/src/lib/ui/resumeSeedScript.ts +2 -2
  45. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  46. package/src/lib/ui/runtime/RESUME.ts +6 -0
  47. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  48. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  49. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  50. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  51. package/src/lib/ui/runtime/runNode.ts +9 -1
  52. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  53. package/src/lib/ui/scope.ts +6 -8
  54. package/src/lib/ui/tryEncodeResume.ts +5 -2
  55. package/src/lib/ui/types/Scope.ts +3 -4
  56. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  57. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  58. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
@@ -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 (!isRaw && existing?.value !== undefined) {
202
- return Promise.resolve(cloneWarmValue(existing.value)) as Promise<Return>
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
- return isRaw ? responsePromise : (responsePromise.then(decodeResponse) as Promise<Return>)
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 | string[]): Set<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(errors.x(...))` and validation 422)
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
@@ -0,0 +1,6 @@
1
+ import { cacheKeyStore } from './cacheKeyStore.ts'
2
+
3
+ /* Tags a cache() read's returned promise with its store key (see cacheKeyStore). */
4
+ export function recordCacheKey(promise: Promise<unknown>, key: string): void {
5
+ cacheKeyStore.set(promise, key)
6
+ }
@@ -1,4 +1,4 @@
1
- /* Normalizes a tags option (one tag or many) to a Set for O(1) membership. */
2
- export function toTagSet(tags: string | string[]): Set<string> {
3
- return new Set(typeof tags === 'string' ? [tags] : tags)
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 one or more free-form labels grouping unrelated calls so one
10
- `cache.invalidate({ tags })` drops every entry sharing any of them — pass an
11
- array when a call belongs to multiple invalidation groups. A unique tag (e.g. a
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 | 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 declared error set, keyed by error NAME (not status, so two errors can
5
- share a status). Each entry names its HTTP `status` and an optional `data`
6
- schema whose inferred input the error constructor requires. Passed as the rpc's
7
- `errors` opt; the client derives a typed `Result` union from it (see
8
- `RpcErrorUnion`), and the handler receives matching constructors (see
9
- `ErrorConstructors`).
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
- /* Present only on a durable (`outbox`) RPC's client proxy: callable for the reactive
60
- list of undelivered entries, with `retry()` to drain the queue. Undefined otherwise. */
61
- readonly outbox?: Outbox<Args>
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' },
@@ -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 ${resolved} = await (${lowerExpression(node.promise)});\n`
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}] = { ok: true, value: ${plan.resolvedAs} };\n`
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,
@@ -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
- const self: Scope = {
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 cell/scope), not from the share. */
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
- for (const created of children) {
113
- created.dispose()
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,29 @@
1
+ import { activeCacheStore } from '../shared/activeCacheStore.ts'
2
+ import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
3
+ import { snapshotShippable } from '../shared/snapshotShippable.ts'
4
+ import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
5
+
6
+ /*
7
+ The resume-manifest entry for a blocking `{#await expr then value}`. When the awaited value
8
+ is a shippable cache-backed read, DEFER it: flag the store entry so its SSR snapshot seeds
9
+ lazily (the body is decoded on first read, not at hydration) and return a `{ defer, key }`
10
+ marker in place of the value. Hydration then adopts the server branch inert and decodes
11
+ neither copy — the blocking form's contract: "render it on the server, keep the page, refetch
12
+ only when I read or invalidate it." A non-cache value (a plain promise, a computation) or a
13
+ cache read whose entry can't ship inlines its value as before. Server-only in practice — the
14
+ client codegen never calls it. */
15
+ // @documentation plumbing
16
+ export function deferResume(promise: unknown, value: unknown): ResumeEntry | DeferMarker {
17
+ const isPromise =
18
+ promise !== null && typeof (promise as { then?: unknown })?.then === 'function'
19
+ const key = isPromise ? cacheKeyOf(promise as Promise<unknown>) : undefined
20
+ if (key === undefined) {
21
+ return { ok: true, value }
22
+ }
23
+ const entry = activeCacheStore().entries.get(key)
24
+ if (entry === undefined || !snapshotShippable(entry)) {
25
+ return { ok: true, value }
26
+ }
27
+ entry.deferred = true
28
+ return { defer: true, key }
29
+ }
@@ -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 node = claimChild(hydration, parent) as unknown as Text
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
- node !== null &&
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
- hydration.next.set(parent, node === null ? null : node.nextSibling)
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
  })
@@ -1,9 +1,11 @@
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'
8
10
  import { scope } from '../runtime/scope.ts'
9
11
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -56,8 +58,11 @@ export function awaitBlock(
56
58
  let active: { start: Comment; end: Comment; dispose: () => void } | undefined
57
59
  let anchor: Node | undefined
58
60
  let first = true
59
- /* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
60
- let generation = 0
61
+ /* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
62
+ owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
63
+ `{#for}`/component tears this block out is abandoned — otherwise its settle runs
64
+ `place` on the block's now-detached anchor and `insertBefore` throws NotFoundError. */
65
+ const guard = generationGuard()
61
66
  /* The resolved value, held as a reactive cell so the then-branch reads it through its
62
67
  own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
63
68
  the branch — the branch (and any keyed `each` inside it) survives and updates in place,
@@ -120,7 +125,7 @@ export function awaitBlock(
120
125
 
121
126
  /* Render a settled-or-pending result into the current generation. */
122
127
  const render = (result: unknown): void => {
123
- const gen = generation
128
+ const gen = guard.token()
124
129
  if (!isThenable(result)) {
125
130
  settleThen(result) // warm-sync → resolved now, no flash
126
131
  return
@@ -140,12 +145,12 @@ export function awaitBlock(
140
145
  }
141
146
  result.then(
142
147
  (value) => {
143
- if (gen === generation) {
148
+ if (guard.live(gen)) {
144
149
  settleThen(value)
145
150
  }
146
151
  },
147
152
  (error) => {
148
- if (gen === generation) {
153
+ if (guard.live(gen)) {
149
154
  settleError(error)
150
155
  }
151
156
  },
@@ -176,6 +181,42 @@ export function awaitBlock(
176
181
  active = { start, end, dispose }
177
182
  }
178
183
 
184
+ /* Inert adoption — a deferred `{#await cache()}` (large payload, shipped as a `{defer,key}`
185
+ resume marker). Adopt the SSR then-branch WITHOUT building its bindings or materializing
186
+ the awaited value: the server markup is already correct, so keep it verbatim and skip the
187
+ payload decode entirely (the boot-path cost deferral targets). Only subscribe THIS block's
188
+ effect to the cache key — reads, decodes and fetches all wait. A later cache.invalidate
189
+ re-runs the effect, which then reads the value for real and builds a fresh branch. Sound
190
+ because a display-first read replaces the whole branch on re-read anyway, so there is no
191
+ extra flash beyond the swap the re-read already performs. */
192
+ const adoptInert = (open: Node | null, key: string): void => {
193
+ const cursor = hydration as NonNullable<typeof hydration>
194
+ const firstKept = open?.nextSibling ?? null
195
+ /* Scan to THIS block's close marker, leaving every server node in place. Nested
196
+ blocks carry different ids, so the first data match is our own close. */
197
+ let node: Node | null = firstKept
198
+ while (node !== null && (node as { data?: string }).data !== `/abide:await:${id}`) {
199
+ node = node.nextSibling
200
+ }
201
+ const close = node
202
+ cursor.next.set(parent, close?.nextSibling ?? null)
203
+ /* Bracket the kept nodes as a `[`…`]` range + park an anchor, identical to `adopt`,
204
+ so the first re-run's `place`/`detach` evicts them like any other branch. */
205
+ const start = document.createComment(RANGE_OPEN)
206
+ parent.insertBefore(start, firstKept ?? close)
207
+ const end = document.createComment(RANGE_CLOSE)
208
+ parent.insertBefore(end, close)
209
+ anchor = document.createTextNode('')
210
+ parent.insertBefore(anchor, close)
211
+ /* No scope/effects were built for the kept nodes, so disposal only evicts the range. */
212
+ active = { start, end, dispose: () => undefined }
213
+ /* Not 'then' — the first re-run rebuilds the branch fresh via `place`. */
214
+ activeKind = 'pending'
215
+ /* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
216
+ cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
217
+ activeCacheStore().subscribe(key)
218
+ }
219
+
179
220
  /* Discard the SSR boundary and (re)build the block from the live promise, fresh
180
221
  (hydration off) — the recovery path when adoption can't use the server markup. */
181
222
  const rebuildCold = (open: Node | null): void => {
@@ -205,21 +246,34 @@ export function awaitBlock(
205
246
  didn't round-trip (e.g. a non-serializable Response) throws while building the
206
247
  branch — fall back to the live promise, which reads the properly-reconstructed
207
248
  warm cache (or re-fetches) instead of crashing hydration. */
208
- const firstHydrate = (result: unknown): void => {
249
+ const firstHydrate = (): void => {
209
250
  const cursor = hydration as NonNullable<typeof hydration>
210
251
  const open = claimChild(cursor, parent)
211
252
  /* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
212
253
  lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
213
254
  through to the live promise rather than crash hydration. */
214
255
  const raw = RESUME[id]
215
- let entry: ResumeEntry | undefined
256
+ let decoded: ResumeEntry | DeferMarker | undefined
216
257
  if (raw !== undefined) {
217
258
  try {
218
- entry = decodeRefJson(raw) as ResumeEntry
259
+ decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
219
260
  } catch {
220
- entry = undefined
261
+ decoded = undefined
221
262
  }
222
263
  }
264
+ /* A deferred marker ships the cache key, not the value — adopt the server branch inert
265
+ and skip the decode. Intercept before the ok/catch logic. Object-guard the `in`: a
266
+ corrupt manifest decoding to a primitive/null must read as "no resume" (fall through),
267
+ not throw past the decode try/catch and crash hydration. */
268
+ if (typeof decoded === 'object' && decoded !== null && 'defer' in decoded) {
269
+ adoptInert(open, decoded.key)
270
+ return
271
+ }
272
+ /* Non-deferred: read the promise now so the block subscribes to its reactive source
273
+ (a cache key) — warm on resume, so no round-trip — then adopt the resume value /
274
+ warm-sync result below, or discard and build the pending branch fresh. */
275
+ const result = promiseThunk()
276
+ const entry = decoded as ResumeEntry | undefined
223
277
  if (entry !== undefined) {
224
278
  /* Build the adopted branch around a value CELL (then) so a later re-run updates
225
279
  it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
@@ -278,30 +332,24 @@ export function awaitBlock(
278
332
  }
279
333
 
280
334
  effect(() => {
281
- generation += 1
282
- /* Read the promise EVERY run, including the first hydrate run, so the block
283
- subscribes to its reactive source (a cache key). A cache-remote read is warm
284
- on resume — it serves the snapshot without a network round-trip, so adoption
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()
335
+ guard.renew()
295
336
  if (first) {
296
337
  first = false
297
338
  if (hydration !== undefined) {
298
- firstHydrate(result)
339
+ /* firstHydrate reads the promise ITSELF, after checking the resume marker: a
340
+ deferred block must not invoke promiseThunk (it would materialize/fetch the
341
+ value we're deferring) and subscribes by key instead; every other path reads
342
+ it so the block subscribes to its reactive source (a cache key). */
343
+ firstHydrate()
299
344
  return
300
345
  }
301
346
  anchor = document.createTextNode('')
302
347
  parent.insertBefore(anchor, before)
303
348
  }
304
- render(result)
349
+ /* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
350
+ read is tracked (the branch builds untracked via `scope`), so the block re-runs only
351
+ when its promise source does, not on any branch-state change. */
352
+ render(promiseThunk())
305
353
  })
306
354
  }
307
355