@abide/abide 0.44.0 → 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 (119) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +6 -1
  6. package/src/devEntry.ts +43 -9
  7. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  8. package/src/lib/cli/runCli.ts +9 -24
  9. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  10. package/src/lib/server/error.ts +48 -53
  11. package/src/lib/server/json.ts +4 -3
  12. package/src/lib/server/jsonl.ts +1 -1
  13. package/src/lib/server/rpc/defineRpc.ts +11 -9
  14. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  15. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  16. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  17. package/src/lib/server/rpc/validationError.ts +3 -3
  18. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  19. package/src/lib/server/runtime/createServer.ts +36 -12
  20. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  21. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  22. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  23. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  24. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  25. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  26. package/src/lib/server/runtime/types/RequestStore.ts +25 -0
  27. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  28. package/src/lib/server/sockets/defineSocket.ts +5 -1
  29. package/src/lib/server/sse.ts +1 -1
  30. package/src/lib/shared/HttpError.ts +1 -1
  31. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  32. package/src/lib/shared/activeCacheStore.ts +5 -14
  33. package/src/lib/shared/activePage.ts +5 -19
  34. package/src/lib/shared/augmentModule.ts +18 -0
  35. package/src/lib/shared/basePath.ts +6 -5
  36. package/src/lib/shared/baseResolver.ts +10 -0
  37. package/src/lib/shared/baseSlot.ts +6 -12
  38. package/src/lib/shared/cache.ts +46 -4
  39. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  40. package/src/lib/shared/cacheKeyOf.ts +7 -0
  41. package/src/lib/shared/cacheKeyStore.ts +8 -0
  42. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  43. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  44. package/src/lib/shared/changeAffectsClient.ts +35 -0
  45. package/src/lib/shared/createResolverSlot.ts +37 -0
  46. package/src/lib/shared/debugGate.ts +90 -0
  47. package/src/lib/shared/emitLogRecord.ts +18 -4
  48. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  49. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  50. package/src/lib/shared/httpErrorFor.ts +1 -1
  51. package/src/lib/shared/isDebugEnabled.ts +3 -10
  52. package/src/lib/shared/isDebugNegated.ts +4 -6
  53. package/src/lib/shared/pageResolver.ts +16 -0
  54. package/src/lib/shared/pageSlot.ts +5 -14
  55. package/src/lib/shared/recordCacheKey.ts +6 -0
  56. package/src/lib/shared/requestScopeResolver.ts +12 -0
  57. package/src/lib/shared/requestScopeSlot.ts +7 -12
  58. package/src/lib/shared/responseBodyKind.ts +35 -0
  59. package/src/lib/shared/setBaseResolver.ts +2 -4
  60. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  61. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  62. package/src/lib/shared/setPageResolver.ts +2 -5
  63. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  64. package/src/lib/shared/toTagSet.ts +3 -3
  65. package/src/lib/shared/types/CacheEntry.ts +15 -0
  66. package/src/lib/shared/types/CacheOptions.ts +5 -5
  67. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  68. package/src/lib/shared/types/DebugGate.ts +10 -0
  69. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  70. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  71. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  72. package/src/lib/shared/writeDts.ts +8 -1
  73. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  74. package/src/lib/shared/writeRoutesDts.ts +12 -16
  75. package/src/lib/shared/writeRpcDts.ts +13 -12
  76. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  77. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  78. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  79. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  80. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  81. package/src/lib/ui/compile/compileModule.ts +37 -16
  82. package/src/lib/ui/compile/compileShadow.ts +75 -1
  83. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  84. package/src/lib/ui/compile/generateBuild.ts +7 -1
  85. package/src/lib/ui/compile/generateSSR.ts +10 -3
  86. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  87. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  88. package/src/lib/ui/createScope.ts +11 -4
  89. package/src/lib/ui/deferResume.ts +29 -0
  90. package/src/lib/ui/dom/appendText.ts +19 -4
  91. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  92. package/src/lib/ui/dom/eachAsync.ts +11 -18
  93. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  94. package/src/lib/ui/dom/skeleton.ts +10 -1
  95. package/src/lib/ui/dom/switchBlock.ts +15 -63
  96. package/src/lib/ui/dom/when.ts +11 -64
  97. package/src/lib/ui/installHotBridge.ts +2 -0
  98. package/src/lib/ui/matchRoute.ts +18 -1
  99. package/src/lib/ui/remoteProxy.ts +11 -1
  100. package/src/lib/ui/renderToStream.ts +13 -8
  101. package/src/lib/ui/resumeSeedScript.ts +2 -2
  102. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  103. package/src/lib/ui/runtime/RESUME.ts +6 -0
  104. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  105. package/src/lib/ui/runtime/createDoc.ts +102 -12
  106. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  107. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  108. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  109. package/src/lib/ui/runtime/runNode.ts +9 -1
  110. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  111. package/src/lib/ui/scope.ts +6 -8
  112. package/src/lib/ui/tryEncodeResume.ts +5 -2
  113. package/src/lib/ui/types/Scope.ts +3 -4
  114. package/src/zodCjsPlugin.ts +16 -1
  115. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  116. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  117. package/src/lib/shared/parseDebugPatterns.ts +0 -21
  118. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  119. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
@@ -1,6 +1,6 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { claimChild } from '../runtime/claimChild.ts'
3
- import { 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,79 @@
1
+ import { effect } from '../effect.ts'
2
+ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
+ import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scope } from '../runtime/scope.ts'
5
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
+ import { fillBefore } from './fillBefore.ts'
7
+ import { openMarker } from './openMarker.ts'
8
+ import { replaceRange } from './replaceRange.ts'
9
+
10
+ /*
11
+ The shared lifecycle of every single-slot control-flow block (`when`, `switchBlock`):
12
+ a marker-bounded RANGE that holds at most one piece of content at a time, swapped as a
13
+ unit when a `key` changes. The block supplies a `key()` thunk (the identity of the
14
+ chosen branch — `'then'|'else'` for `when`, the case index for `switchBlock`) and a
15
+ `contentFor(key)` resolver returning that branch's builder, or `undefined` for an
16
+ empty branch. This module owns the marker setup, the hydrate-vs-create fork, and the
17
+ swap effect with its reentrancy/teardown dance — the structure both blocks copy-pasted.
18
+
19
+ The chosen branch builds through `scope` (directly on hydrate, via `fillBefore` on a
20
+ swap), which builds untracked — so a raw reactive read in the branch content doesn't
21
+ subscribe the swap effect; only `key()` (which reads the block's source) drives the
22
+ toggle. The branch's own interpolations still track, each through its own effect.
23
+
24
+ On hydrate it adopts the server-rendered range: claim the start marker, run the chosen
25
+ branch in place (its content claims the existing nodes), then claim the end marker. The
26
+ effect's first run sees the same key and is a no-op; later changes clear the range and
27
+ build fresh into a fragment.
28
+
29
+ `before` (a static node located by the skeleton) places the range among siblings on
30
+ create, so the block sits before a static suffix rather than at the parent's end.
31
+ Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement.
32
+ */
33
+ export function mountSwappableRange<Key>(
34
+ parent: Node,
35
+ key: () => Key,
36
+ contentFor: (key: Key) => ((parent: Node) => void) | undefined,
37
+ before: Node | null = null,
38
+ ): void {
39
+ const hydration = RENDER.hydration
40
+ /* The live branch's scope, registered with the owner so it disposes on owner
41
+ teardown — not only on a branch swap via replaceRange. */
42
+ const group = scopeGroup()
43
+ let dispose: (() => void) | undefined
44
+ let activeKey: Key
45
+ let end: Comment
46
+
47
+ const start = openMarker(parent, RANGE_OPEN, before)
48
+ if (hydration !== undefined) {
49
+ activeKey = key()
50
+ const chosen = contentFor(activeKey)
51
+ if (chosen !== undefined) {
52
+ dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
53
+ }
54
+ end = openMarker(parent, RANGE_CLOSE)
55
+ } else {
56
+ end = openMarker(parent, RANGE_CLOSE, before)
57
+ activeKey = key()
58
+ const chosen = contentFor(activeKey)
59
+ if (chosen !== undefined) {
60
+ dispose = group.track(fillBefore(end, chosen))
61
+ }
62
+ }
63
+
64
+ effect(() => {
65
+ const next = key()
66
+ if (next === activeKey) {
67
+ return
68
+ }
69
+ activeKey = next
70
+ const chosen = contentFor(next)
71
+ /* Null `dispose` before `replaceRange` builds the new branch: a reentrant swap
72
+ during that build (an effect in the new content writing the source) would
73
+ otherwise re-enter with the already-disposed disposer and clear it twice. */
74
+ const prior = dispose
75
+ dispose = undefined
76
+ const built = replaceRange(start, end, prior, chosen)
77
+ dispose = built !== undefined ? group.track(built) : undefined
78
+ })
79
+ }
@@ -129,10 +129,19 @@ export function skeleton(parent: Node, html: string): SkeletonHoles {
129
129
  hydration.next.set(parent, node)
130
130
  } else {
131
131
  const children = source.childNodes
132
+ /* Stage clones in a fragment ONLY for a live (connected) parent, where one
133
+ append reflows once instead of per clone. A detached parent triggers no
134
+ reflow on append, so the fragment is pure overhead there — skip it and
135
+ append direct. `topLevel` collects each clone either way for anchor/
136
+ element-hole resolution. */
137
+ const target: Node = parent.isConnected ? document.createDocumentFragment() : parent
132
138
  for (let index = 0; index < children.length; index += 1) {
133
139
  const clone = (children[index] as Node).cloneNode(true)
134
140
  topLevel.push(clone)
135
- parent.appendChild(clone)
141
+ target.appendChild(clone)
142
+ }
143
+ if (target !== parent) {
144
+ parent.appendChild(target)
136
145
  }
137
146
  }
138
147
  /* Anchor holes via the ONE shared ordering rule (`walkAnchorOrder`) — the same traversal
@@ -1,26 +1,16 @@
1
- import { effect } from '../effect.ts'
2
- import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
- import { RENDER } from '../runtime/RENDER.ts'
4
- import { scope } from '../runtime/scope.ts'
5
- import { scopeGroup } from '../runtime/scopeGroup.ts'
6
- import { fillBefore } from './fillBefore.ts'
7
- import { openMarker } from './openMarker.ts'
8
- import { replaceRange } from './replaceRange.ts'
1
+ import { mountSwappableRange } from './mountSwappableRange.ts'
9
2
  import type { SwitchCase } from './types/SwitchCase.ts'
10
3
 
11
4
  /*
12
5
  Multi-branch binding — the runtime for `{#switch}` blocks and for `{#if}` chains that
13
6
  include `{:elseif}` branches (compiled as a switch over `true` with Boolean-coerced
14
- match thunks so the first truthy branch wins). An effect evaluates the
7
+ match thunks so the first truthy branch wins). A swappable range evaluates the
15
8
  subject, picks the first case whose `match` equals it (strict `===`), falling back
16
9
  to the default (`match` undefined); the chosen case's content lives in a RANGE
17
10
  bounded by two comment markers, so a case holds any content. Staying on the same
18
11
  case across a subject change leaves it mounted; switching clears the range and
19
- builds the new case fresh.
20
-
21
- On hydrate it adopts the case the server rendered: claim the start marker, run the
22
- matching case in place, claim the end marker. The effect's first run picks the same
23
- case and is a no-op; later changes swap the range.
12
+ builds the new case fresh. See `mountSwappableRange` for the shared
13
+ hydrate/swap/teardown semantics — the case index is the swap key.
24
14
  */
25
15
  // @documentation plumbing
26
16
  export function switchBlock(
@@ -29,59 +19,21 @@ export function switchBlock(
29
19
  cases: SwitchCase[],
30
20
  before: Node | null = null,
31
21
  ): void {
32
- const hydration = RENDER.hydration
33
- /* The live case's scope, registered with the owner so it disposes on owner
34
- teardown — not only when the subject switches cases via replaceRange. */
35
- const group = scopeGroup()
36
- let dispose: (() => void) | undefined
37
- let activeIndex: number
38
- let end: Comment
39
-
22
+ /* Pick the first case matching the subject (`===`), else the default (`match`
23
+ undefined), else -1 for no match. */
40
24
  const select = (value: unknown): number => {
41
25
  const matched = cases.findIndex(
42
26
  (entry) => entry.match !== undefined && entry.match() === value,
43
27
  )
44
28
  return matched === -1 ? cases.findIndex((entry) => entry.match === undefined) : matched
45
29
  }
46
- const caseAt = (index: number): SwitchCase | undefined =>
47
- index === -1 ? undefined : cases[index]
48
- /* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
49
- swap), which builds untracked — so a raw reactive read in the case content doesn't
50
- subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
51
- the swap. The case's own interpolations still track, each through its own effect. */
52
-
53
- /* `before` places the range among static siblings on create (block before a suffix);
54
- hydrate ignores it and uses the parked claim cursor. */
55
- const start = openMarker(parent, RANGE_OPEN, before)
56
- if (hydration !== undefined) {
57
- activeIndex = select(subject())
58
- const chosen = caseAt(activeIndex)
59
- if (chosen !== undefined) {
60
- dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
61
- }
62
- end = openMarker(parent, RANGE_CLOSE)
63
- } else {
64
- end = openMarker(parent, RANGE_CLOSE, before)
65
- activeIndex = select(subject())
66
- const chosen = caseAt(activeIndex)
67
- if (chosen !== undefined) {
68
- dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
69
- }
70
- }
71
-
72
- effect(() => {
73
- const index = select(subject())
74
- if (index === activeIndex) {
75
- return
76
- }
77
- activeIndex = index
78
- const chosen = caseAt(index)
79
- /* Null `dispose` before `replaceRange` builds the new case: a reentrant switch
80
- during that build (an effect in the new content writing the subject) would
81
- otherwise re-enter with the already-disposed disposer and clear it twice. */
82
- const prior = dispose
83
- dispose = undefined
84
- const next = replaceRange(start, end, prior, chosen && ((p) => chosen.render(p)))
85
- dispose = next !== undefined ? group.track(next) : undefined
86
- })
30
+ mountSwappableRange(
31
+ parent,
32
+ () => select(subject()),
33
+ (index) => {
34
+ const chosen = index === -1 ? undefined : cases[index]
35
+ return chosen && ((p) => chosen.render(p))
36
+ },
37
+ before,
38
+ )
87
39
  }
@@ -1,24 +1,13 @@
1
- import { effect } from '../effect.ts'
2
- import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
- import { RENDER } from '../runtime/RENDER.ts'
4
- import { scope } from '../runtime/scope.ts'
5
- import { scopeGroup } from '../runtime/scopeGroup.ts'
6
- import { fillBefore } from './fillBefore.ts'
7
- import { openMarker } from './openMarker.ts'
8
- import { replaceRange } from './replaceRange.ts'
1
+ import { mountSwappableRange } from './mountSwappableRange.ts'
9
2
 
10
3
  /*
11
4
  Conditional binding — the runtime for `<template if>` (with optional `else`). The
12
5
  branch's content lives in a RANGE bounded by two comment markers, so a branch may
13
6
  hold anything — elements, components, text, nested control-flow, snippets — not
14
- just element roots. An effect tracks `condition()` and swaps the range's content
15
- on a truthy↔falsy flip (`render` truthy, `renderElse` falsy); an unchanged
16
- condition is a no-op.
17
-
18
- On hydrate it adopts the server-rendered range: claim the start marker, run the
19
- matching render in place (its content claims the existing nodes), then claim the
20
- end marker. The effect's first run sees the same branch and is a no-op; later
21
- toggles clear the range and build fresh into a fragment.
7
+ just element roots. A 2-case swappable range tracks `condition()` and swaps the
8
+ range's content on a truthy↔falsy flip (`render` truthy, `renderElse` falsy); an
9
+ unchanged condition is a no-op. See `mountSwappableRange` for the shared
10
+ hydrate/swap/teardown semantics.
22
11
  */
23
12
  // @documentation plumbing
24
13
  export function when(
@@ -28,52 +17,10 @@ export function when(
28
17
  renderElse?: (parent: Node) => void,
29
18
  before: Node | null = null,
30
19
  ): void {
31
- const hydration = RENDER.hydration
32
- /* The chosen branch builds through `scope` (directly on hydrate, via `fillBefore`
33
- on a swap), which builds untracked — so a raw reactive read in the branch content
34
- doesn't subscribe the swap effect below; only `condition()` drives the toggle. The
35
- branch's own interpolations still track, each through its own effect. */
36
- const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
37
- /* The live branch's scope, registered with the owner so it disposes on owner
38
- teardown — not only on a branch flip via replaceRange. */
39
- const group = scopeGroup()
40
- let dispose: (() => void) | undefined
41
- let activeBranch: 'then' | 'else'
42
- let end: Comment
43
-
44
- /* `before` (a static node located by the skeleton) places the range among siblings on
45
- create, so the block sits before a static suffix rather than at the parent's end.
46
- Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement. */
47
- const start = openMarker(parent, RANGE_OPEN, before)
48
- if (hydration !== undefined) {
49
- activeBranch = condition() ? 'then' : 'else'
50
- const chosen = chosenFor(activeBranch)
51
- if (chosen !== undefined) {
52
- dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
53
- }
54
- end = openMarker(parent, RANGE_CLOSE)
55
- } else {
56
- end = openMarker(parent, RANGE_CLOSE, before)
57
- activeBranch = condition() ? 'then' : 'else'
58
- const chosen = chosenFor(activeBranch)
59
- if (chosen !== undefined) {
60
- dispose = group.track(fillBefore(end, chosen))
61
- }
62
- }
63
-
64
- effect(() => {
65
- const branch = condition() ? 'then' : 'else'
66
- if (branch === activeBranch) {
67
- return
68
- }
69
- activeBranch = branch
70
- const chosen = chosenFor(branch)
71
- /* Null `dispose` before `replaceRange` builds the new branch: a reentrant flip
72
- during that build (an effect in the new content writing the condition) would
73
- otherwise re-enter with the already-disposed disposer and clear it twice. */
74
- const prior = dispose
75
- dispose = undefined
76
- const next = replaceRange(start, end, prior, chosen)
77
- dispose = next !== undefined ? group.track(next) : undefined
78
- })
20
+ mountSwappableRange(
21
+ parent,
22
+ () => (condition() ? 'then' : 'else'),
23
+ (branch) => (branch === 'then' ? render : renderElse),
24
+ before,
25
+ )
79
26
  }
@@ -25,6 +25,7 @@ import { spreadProps } from './dom/spreadProps.ts'
25
25
  import { switchBlock } from './dom/switchBlock.ts'
26
26
  import { tryBlock } from './dom/tryBlock.ts'
27
27
  import { when } from './dom/when.ts'
28
+ import { deferResume } from './deferResume.ts'
28
29
  import { effect } from './effect.ts'
29
30
  import { enterScope } from './enterScope.ts'
30
31
  import { exitScope } from './exitScope.ts'
@@ -68,6 +69,7 @@ export function installHotBridge(): void {
68
69
  eachAsync,
69
70
  when,
70
71
  awaitBlock,
72
+ deferResume,
71
73
  tryBlock,
72
74
  switchBlock,
73
75
  mountSlot,
@@ -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
+ }