@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.
Files changed (68) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +56 -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 +1 -6
  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/DEFER_MIN_ARRAY_LENGTH.ts +14 -0
  21. package/src/lib/shared/HttpError.ts +1 -1
  22. package/src/lib/shared/cache.ts +26 -4
  23. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  24. package/src/lib/shared/cacheKeyOf.ts +7 -0
  25. package/src/lib/shared/cacheKeyStore.ts +8 -0
  26. package/src/lib/shared/httpErrorFor.ts +1 -1
  27. package/src/lib/shared/recordCacheKey.ts +6 -0
  28. package/src/lib/shared/toTagSet.ts +3 -3
  29. package/src/lib/shared/types/CacheEntry.ts +15 -0
  30. package/src/lib/shared/types/CacheOptions.ts +5 -5
  31. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  32. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  33. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  34. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  35. package/src/lib/ui/compile/generateBuild.ts +5 -1
  36. package/src/lib/ui/compile/generateSSR.ts +10 -3
  37. package/src/lib/ui/compile/parseTemplate.ts +14 -1
  38. package/src/lib/ui/compile/types/TemplateNode.ts +5 -0
  39. package/src/lib/ui/createScope.ts +11 -4
  40. package/src/lib/ui/deferResume.ts +36 -0
  41. package/src/lib/ui/dom/appendText.ts +19 -4
  42. package/src/lib/ui/dom/awaitBlock.ts +99 -26
  43. package/src/lib/ui/dom/eachAsync.ts +11 -18
  44. package/src/lib/ui/dom/firstElementBetween.ts +14 -0
  45. package/src/lib/ui/dom/mountChild.ts +5 -2
  46. package/src/lib/ui/dom/mountRange.ts +75 -0
  47. package/src/lib/ui/installHotBridge.ts +2 -0
  48. package/src/lib/ui/matchRoute.ts +18 -1
  49. package/src/lib/ui/remoteProxy.ts +11 -1
  50. package/src/lib/ui/renderToStream.ts +13 -8
  51. package/src/lib/ui/resumeSeedScript.ts +2 -2
  52. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  53. package/src/lib/ui/runtime/RESUME.ts +6 -0
  54. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  55. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  56. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  57. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  58. package/src/lib/ui/runtime/runNode.ts +9 -1
  59. package/src/lib/ui/runtime/scheduleWake.ts +28 -0
  60. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  61. package/src/lib/ui/runtime/whenIdle.ts +21 -0
  62. package/src/lib/ui/runtime/whenVisible.ts +105 -0
  63. package/src/lib/ui/scope.ts +6 -8
  64. package/src/lib/ui/tryEncodeResume.ts +5 -2
  65. package/src/lib/ui/types/Scope.ts +3 -4
  66. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  67. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  68. 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
- /* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
60
- let generation = 0
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 = generation
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 === generation) {
159
+ if (guard.live(gen)) {
144
160
  settleThen(value)
145
161
  }
146
162
  },
147
163
  (error) => {
148
- if (gen === generation) {
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 = (result: unknown): void => {
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 entry: ResumeEntry | undefined
278
+ let decoded: ResumeEntry | DeferMarker | undefined
216
279
  if (raw !== undefined) {
217
280
  try {
218
- entry = decodeRefJson(raw) as ResumeEntry
281
+ decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
219
282
  } catch {
220
- entry = undefined
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
- 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()
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(result)
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
- render(result)
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 { OWNER } from '../runtime/OWNER.ts'
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
- generation += 1
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 !== generation) {
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 !== generation) {
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,
@@ -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
- params[segment.name] = value
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
- return Promise.resolve(block.promise()).then(
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: { ok: true, value },
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 } = { current: 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 = {