@abide/abide 0.34.2 → 0.36.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 (46) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +75 -69
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/shared/cache.ts +10 -3
  14. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  15. package/src/lib/shared/withCacheManaged.ts +18 -0
  16. package/src/lib/ui/README.md +1 -1
  17. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  18. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  19. package/src/lib/ui/compile/compileShadow.ts +60 -55
  20. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  21. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  22. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  23. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  24. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  25. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  26. package/src/lib/ui/dom/applyResolved.ts +27 -7
  27. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  28. package/src/lib/ui/dom/each.ts +8 -15
  29. package/src/lib/ui/dom/eachAsync.ts +8 -9
  30. package/src/lib/ui/dom/switchBlock.ts +7 -3
  31. package/src/lib/ui/dom/tryBlock.ts +16 -5
  32. package/src/lib/ui/dom/when.ts +7 -3
  33. package/src/lib/ui/installHotBridge.ts +2 -0
  34. package/src/lib/ui/remoteProxy.ts +32 -6
  35. package/src/lib/ui/router.ts +26 -10
  36. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  37. package/src/lib/ui/runtime/abortNode.ts +22 -0
  38. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  39. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  40. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  41. package/src/lib/ui/runtime/runNode.ts +9 -0
  42. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  43. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  44. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  45. package/src/lib/ui/startClient.ts +17 -3
  46. package/src/lib/shared/types/CacheSnapshot.ts +0 -16
@@ -1,14 +1,20 @@
1
1
  import { appNameSlot } from '../../shared/appNameSlot.ts'
2
2
  import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
3
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
4
+ import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
4
5
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
6
+ import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
7
+ import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
8
+ import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
5
9
  import { renderChain } from '../../ui/renderChain.ts'
6
10
  import { renderToStream } from '../../ui/renderToStream.ts'
7
11
  import type { UiComponent } from '../../ui/runtime/types/UiComponent.ts'
8
12
  import { pageUrlFromStore } from './pageUrlFromStore.ts'
9
13
  import { SSR_SWAP_SCRIPT } from './SSR_SWAP_SCRIPT.ts'
14
+ import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
10
15
  import { safeJsonForScript } from './safeJsonForScript.ts'
11
16
  import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
17
+ import { streamCacheResolutions } from './streamCacheResolutions.ts'
12
18
  import type { RequestStore } from './types/RequestStore.ts'
13
19
 
14
20
  /* A abide-ui page module: its default export is the compiled component. */
@@ -21,6 +27,19 @@ function wantsJson(request: Request): boolean {
21
27
  return (request.headers.get('accept') ?? '').includes('application/json')
22
28
  }
23
29
 
30
+ /* Defines `window.__abideResolve` ahead of the body: the vanilla collector each
31
+ streamed cache resolution calls, buffering payloads for startClient to drain into
32
+ the store before hydration (the bundle is deferred, so it runs after every chunk). */
33
+ const CACHE_RESOLVE_SCRIPT =
34
+ 'window.__abideResolve=function(r){(window.__abideResumeCache=window.__abideResumeCache||[]).push(r)}'
35
+
36
+ /* One streamed cache resolution as an inline `__abideResolve(...)` call. `<` is escaped
37
+ (as in encodeResume) so a body value can't close the script early. */
38
+ function resolveChunk(resolution: StreamedResolution): string {
39
+ const payload = JSON.stringify(resolution).replace(/</g, '\\u003c')
40
+ return `<script>__abideResolve(${payload})</script>`
41
+ }
42
+
24
43
  /*
25
44
  The abide-ui SSR document renderer. A matched route + params in, a finished HTML
26
45
  Response out.
@@ -30,9 +49,16 @@ await blocks STREAMS: the pending shell flushes first, then each resolved fragme
30
49
  (`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
31
50
  by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
32
51
  manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
52
+ Then, once the stream has run every `{#await}` thunk (creating and settling its cache
53
+ entry mid-stream, after the render-return snapshot), each such entry streams an inline
54
+ `__abideResolve(...)` chunk — a warm snapshot, or a `{ key, miss }` marker for an
55
+ unshippable body (→ live refetch) — seeding the client store before the deferred bundle
56
+ so the block's subscription read is warm (no refetch).
33
57
 
34
58
  `__SSR__` carries the route/params, mount base, trace, app name, client timeout,
35
- and the settled cache snapshot (the client seeds its tab store from it). A route's
59
+ and the render-return cache snapshot (top-level `await` reads; the client seeds its tab
60
+ store from it). `{#await}` reads aren't settled yet at render-return — they arrive over
61
+ the stream as above. A route's
36
62
  `layout.abide` files wrap the page outermost-first (every ancestor directory's
37
63
  layout applies); the chain server-renders as one document via `renderChain`, the
38
64
  page folded into each layout's `<slot/>` outlet. The client router re-resolves the
@@ -44,6 +70,7 @@ export function createUiPageRenderer({
44
70
  clientTimeout,
45
71
  pages,
46
72
  layouts,
73
+ routePreloads = {},
47
74
  healthPayload,
48
75
  }: {
49
76
  shell: string
@@ -51,6 +78,7 @@ export function createUiPageRenderer({
51
78
  clientTimeout: number | undefined
52
79
  pages: Record<string, LoadPage>
53
80
  layouts: Record<string, LoadPage>
81
+ routePreloads?: Record<string, string[]>
54
82
  healthPayload: (request: Request) => Promise<Record<string, unknown>>
55
83
  }): {
56
84
  renderPage: (
@@ -65,13 +93,15 @@ export function createUiPageRenderer({
65
93
  stack?: string,
66
94
  ) => Promise<Response | undefined>
67
95
  } {
68
- /* Build the __SSR__ <script> the client (startClient) reads on boot. */
96
+ /* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
97
+ (settled) cache partition is computed once by the caller and threaded in, so the
98
+ streaming branch can also drain the pending partition over the same render. */
69
99
  async function stateTag(
70
100
  routeUrl: string,
71
101
  params: Record<string, string>,
72
102
  store: RequestStore,
103
+ inline: CacheSnapshotEntry[],
73
104
  ): Promise<string> {
74
- const { inline } = await serializeCacheSnapshot(store.cache)
75
105
  const health = store.healthRead ? await healthPayload(store.req) : undefined
76
106
  const payload = safeJsonForScript({
77
107
  route: routeUrl,
@@ -86,6 +116,32 @@ export function createUiPageRenderer({
86
116
  return `<script>window.__SSR__ = ${payload};</script>`
87
117
  }
88
118
 
119
+ /* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
120
+ and their route-only static runtime deps (the shell already preloads the entry's
121
+ shared runtime). Those chunks are dynamically imported by the entry, so the browser
122
+ discovers them only after the entry runs at parse-end ≈ stream-close; preloading them
123
+ in <head> overlaps their transfer with the stream. Rebased onto the mount base like
124
+ the shell's own `/_app/` refs. Cached per route — the set is render-invariant. */
125
+ const preloadLinkCache = new Map<string, string>()
126
+ function routePreloadLinks(routeUrl: string): string {
127
+ const cached = preloadLinkCache.get(routeUrl)
128
+ if (cached !== undefined) {
129
+ return cached
130
+ }
131
+ const links = (routePreloads[routeUrl] ?? [])
132
+ .map((chunk) => `<link rel="modulepreload" href="${base}/_app/${chunk}" />`)
133
+ .join('')
134
+ preloadLinkCache.set(routeUrl, links)
135
+ return links
136
+ }
137
+
138
+ /* Splices the route preloads in before the shell's </head> (case-insensitive, like the
139
+ build-time injector). A no-op when there are none or the shell carries no </head>. */
140
+ function injectRoutePreloads(html: string, routeUrl: string): string {
141
+ const links = routePreloadLinks(routeUrl)
142
+ return links === '' ? html : html.replace(/<\/head>/i, `${links}</head>`)
143
+ }
144
+
89
145
  async function renderPage(
90
146
  routeUrl: string,
91
147
  params: Record<string, string>,
@@ -117,14 +173,24 @@ export function createUiPageRenderer({
117
173
  params,
118
174
  )
119
175
 
176
+ /* Snapshot the cache settled by render-return — top-level `await` reads (render
177
+ blocked on them) — into __SSR__. A `{#await}` read is NOT here: its expression
178
+ is a thunk `renderToStream` runs lazily, so its entry is created mid-stream and
179
+ seeded after the drain (see below). */
180
+ const inline = await serializeCacheSnapshot(store.cache)
181
+ const inlinedKeys = new Set(inline.map((entry) => entry.key))
182
+
120
183
  /* No await blocks → render synchronously, ship buffered. */
121
184
  if (ssr.awaits.length === 0) {
122
- const html = shell.replace(SSR_MARKER, (_match, key: string) =>
123
- key === 'body' ? ssr.html : key === 'state' ? '' : '',
185
+ const html = injectRoutePreloads(
186
+ shell.replace(SSR_MARKER, (_match, key: string) =>
187
+ key === 'body' ? ssr.html : key === 'state' ? '' : '',
188
+ ),
189
+ routeUrl,
124
190
  )
125
191
  const withState = html.replace(
126
192
  '</body>',
127
- `${await stateTag(routeUrl, params, store)}</body>`,
193
+ `${await stateTag(routeUrl, params, store, inline)}</body>`,
128
194
  )
129
195
  return new Response(withState, {
130
196
  headers: {
@@ -139,9 +205,14 @@ export function createUiPageRenderer({
139
205
  Fill head/state but LEAVE the body marker intact — it's the split point for
140
206
  streaming the page body into `#app`; consuming it here would append the body
141
207
  after the whole shell (outside `#app`), breaking hydration. */
142
- const head = `<script>${SSR_SWAP_SCRIPT}</script>${await stateTag(routeUrl, params, store)}`
143
- const filled = shell.replace(/<!--ssr:(head|state)-->/g, (_match, key: string) =>
144
- key === 'head' ? head : '',
208
+ const head =
209
+ `<script>${SSR_SWAP_SCRIPT}${CACHE_RESOLVE_SCRIPT}</script>` +
210
+ `${await stateTag(routeUrl, params, store, inline)}`
211
+ const filled = injectRoutePreloads(
212
+ shell.replace(/<!--ssr:(head|state)-->/g, (_match, key: string) =>
213
+ key === 'head' ? head : '',
214
+ ),
215
+ routeUrl,
145
216
  )
146
217
  const [before, after] = filled.split(BODY_MARKER)
147
218
  const encoder = new TextEncoder()
@@ -158,6 +229,26 @@ export function createUiPageRenderer({
158
229
  )
159
230
  first = false
160
231
  }
232
+ /* The {#await} reads created (and settled) their cache entries DURING the
233
+ stream — the await expression is a thunk `renderToStream` ran lazily —
234
+ so they missed the render-return snapshot. Drain them now: each lands an
235
+ inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
236
+ marker for an unshippable body → live refetch) before the deferred
237
+ bundle, so startClient seeds the store and the block's subscription read
238
+ is warm. Skip keys already shipped inline in __SSR__. */
239
+ const streamedEntries = Array.from(store.cache.entries.values()).filter(
240
+ (entry: CacheEntry) =>
241
+ entry.settled === true &&
242
+ entry.request !== undefined &&
243
+ isReplayableMethod(entry.request.method.toUpperCase()) &&
244
+ !inlinedKeys.has(entry.key),
245
+ )
246
+ for await (const resolution of streamCacheResolutions(
247
+ store.cache,
248
+ streamedEntries,
249
+ )) {
250
+ controller.enqueue(encoder.encode(resolveChunk(resolution)))
251
+ }
161
252
  controller.enqueue(encoder.encode(after ?? ''))
162
253
  controller.close()
163
254
  },
@@ -166,6 +257,10 @@ export function createUiPageRenderer({
166
257
  headers: {
167
258
  'Content-Type': 'text/html; charset=utf-8',
168
259
  'Cache-Control': SSR_CACHE_CONTROL,
260
+ /* Mark the progressively-flushed document so gzipResponse compresses it
261
+ with a per-chunk-flushing gzip (the plain CompressionStream buffers the
262
+ head and defeats streaming); the marker is stripped before send. */
263
+ [STREAMED_HTML_HEADER]: '1',
169
264
  },
170
265
  },
171
266
  )
@@ -0,0 +1,62 @@
1
+ import { constants, createGzip } from 'node:zlib'
2
+
3
+ /*
4
+ A gzip TransformStream that emits a decodable block after every input chunk via
5
+ Z_SYNC_FLUSH, instead of buffering until its deflate window fills like the web
6
+ CompressionStream. Used for the streamed SSR document so the head reaches the
7
+ browser compressed-but-decodable immediately — the preload scanner sees the
8
+ entry/css links and the pending shell paints — rather than only at stream close.
9
+ node:zlib is required here: the web CompressionStream exposes no per-chunk flush.
10
+ */
11
+ export function flushingGzipStream(): TransformStream<Uint8Array, Uint8Array> {
12
+ const gzip = createGzip()
13
+ let sink: TransformStreamDefaultController<Uint8Array>
14
+ /* `closed` gates enqueue: once the consumer cancels (client disconnect), the node
15
+ gzip can still emit a trailing `data` that would throw on the closed controller. */
16
+ let closed = false
17
+ gzip.on('data', (chunk: Buffer) => {
18
+ if (closed) {
19
+ return
20
+ }
21
+ /* enqueue can still race a just-closed controller (cancel is async); treat the
22
+ throw as the consumer having gone and tear down. */
23
+ try {
24
+ sink.enqueue(new Uint8Array(chunk))
25
+ } catch {
26
+ closed = true
27
+ gzip.destroy()
28
+ }
29
+ })
30
+ /* The spec'd Transformer.cancel hook (consumer-cancellation) postdates this TS lib's
31
+ Transformer type; declare it locally so the literal type-checks while the platform
32
+ (Bun) still invokes it. Typing the const sidesteps the fresh-literal excess-property
33
+ check without weakening start/transform/flush. */
34
+ const transformer: Transformer<Uint8Array, Uint8Array> & {
35
+ cancel(reason?: unknown): void
36
+ } = {
37
+ start(controller) {
38
+ sink = controller
39
+ gzip.on('error', (error) => controller.error(error))
40
+ },
41
+ /* Resolve only once the chunk is compressed AND sync-flushed, so its bytes are
42
+ on the wire before the stream pulls the next (possibly delayed) chunk. */
43
+ transform(chunk) {
44
+ return new Promise((resolve) => {
45
+ gzip.write(chunk, () => gzip.flush(constants.Z_SYNC_FLUSH, () => resolve()))
46
+ })
47
+ },
48
+ /* End the deflate stream; its trailing bytes arrive as `data` before `end`. */
49
+ flush() {
50
+ return new Promise((resolve) => {
51
+ gzip.on('end', () => resolve())
52
+ gzip.end()
53
+ })
54
+ },
55
+ /* Consumer went away — stop enqueueing and tear down the node stream. */
56
+ cancel() {
57
+ closed = true
58
+ gzip.destroy()
59
+ },
60
+ }
61
+ return new TransformStream(transformer)
62
+ }
@@ -1,5 +1,7 @@
1
1
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
2
2
  import { acceptsGzip } from './acceptsGzip.ts'
3
+ import { flushingGzipStream } from './flushingGzipStream.ts'
4
+ import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
3
5
 
4
6
  /*
5
7
  Compressible Content-Types — text and structured-text payloads. Binary or
@@ -11,18 +13,27 @@ const COMPRESSIBLE_TYPE =
11
13
 
12
14
  /*
13
15
  Gzips a dynamic response (SSR HTML, rpc/json replies, the plain 404) when the
14
- client accepts it, piping the body through a CompressionStream so a buffered
15
- body and a streamed SSR document take the identical path. Static assets never
16
- reach here — they already carry a precompressed Content-Encoding, which
17
- short-circuits the first guard. Skipped for: already-encoded bodies, bodiless
18
- responses (204/304/HEAD), non-compressible types (images, fonts, archives),
19
- and frame-delimited streams (SSE/jsonl, where gzip buffering would stall
20
- per-frame flush). No byte-size floor: Bun doesn't expose a string body's length
21
- before send, and measuring would mean buffering the streamed SSR document —
22
- the framing overhead on the rare tiny body is negligible against compressing
23
- every page and rpc payload.
16
+ client accepts it. Static assets never reach here they already carry a
17
+ precompressed Content-Encoding, which short-circuits the first guard. Skipped
18
+ for: already-encoded bodies, bodiless responses (204/304/HEAD), non-compressible
19
+ types (images, fonts, archives), and frame-delimited streams (SSE/jsonl, where
20
+ gzip buffering would stall per-frame flush). No byte-size floor: Bun doesn't
21
+ expose a string body's length before send, and measuring would mean buffering the
22
+ body the framing overhead on the rare tiny body is negligible against
23
+ compressing every page and rpc payload.
24
+
25
+ Buffered bodies take the web CompressionStream (best ratio, one flush at close).
26
+ The streamed SSR document self-marks (STREAMED_HTML_HEADER) and takes a
27
+ per-chunk-flushing gzip instead: the plain CompressionStream buffers the head
28
+ until its deflate window fills, which defeats streaming (the browser can't
29
+ preload-scan the head or paint the pending shell until the stream nearly closes).
30
+ The marker is stripped so it never reaches the client.
24
31
  */
25
32
  export function gzipResponse(req: Request, response: Response): Response {
33
+ const streamedHtml = response.headers.has(STREAMED_HTML_HEADER)
34
+ if (streamedHtml) {
35
+ response.headers.delete(STREAMED_HTML_HEADER)
36
+ }
26
37
  if (!response.body || response.headers.has('Content-Encoding')) {
27
38
  return response
28
39
  }
@@ -38,7 +49,8 @@ export function gzipResponse(req: Request, response: Response): Response {
38
49
  headers.append('Vary', 'Accept-Encoding')
39
50
  /* The stored length no longer matches the compressed body (and is unknown for a stream). */
40
51
  headers.delete('Content-Length')
41
- return new Response(response.body.pipeThrough(new CompressionStream('gzip')), {
52
+ const compressor = streamedHtml ? flushingGzipStream() : new CompressionStream('gzip')
53
+ return new Response(response.body.pipeThrough(compressor), {
42
54
  status: response.status,
43
55
  statusText: response.statusText,
44
56
  headers,
@@ -1,45 +1,34 @@
1
1
  import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
2
- import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
3
- import type { CacheSnapshot } from '../../shared/types/CacheSnapshot.ts'
4
2
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
5
3
  import type { CacheStore } from '../../shared/types/CacheStore.ts'
6
4
  import { snapshotEntryFromCache } from './snapshotEntryFromCache.ts'
7
5
 
8
6
  /*
9
- Partitions the request-scoped cache for SSR. Entries settled by the time
10
- `render()` returns were consumed via `await` (render blocked on them) and ship
11
- inline in the document's `__SSR__` blob. Entries still pending were consumed
12
- via `{#await}` render emitted their pending branch without blocking so they
13
- go to `pending` for the response streamer to drain and resolve over the wire.
7
+ Snapshots the request-scoped cache for SSR at a single instant: every replayable
8
+ (GET/DELETE) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
9
+ client seeds its store from. Unsettled and non-replayable entries are skipped; a body
10
+ that can't ship (binary / streaming / rejected) drops out via snapshotEntryFromCache.
14
11
 
15
- Unlike the old buffer-everything path, this never awaits the pending promises:
16
- that's the whole point of streaming. Settled entries are read concurrently (the
17
- awaits are immediate since they're already resolved, but their body reads run in
18
- parallel); pending entries are handed back as-is for the streamer to await one
19
- chunk at a time.
12
+ Snapshots concurrently — the awaits are immediate (entries are already settled), but
13
+ their body reads run in parallel. Never blocks on an unsettled entry.
14
+
15
+ WHEN it's called decides what it sees. createUiPageRenderer calls it at render-return
16
+ for `__SSR__` that catches only top-level `await` reads (render blocked on them). A
17
+ `{#await cache()}` read does NOT appear: its expression is a thunk renderToStream runs
18
+ lazily, so its entry is created mid-stream, after this snapshot. The renderer snapshots
19
+ AGAIN once the stream has drained (entries then exist and are settled) and seeds those
20
+ over the wire — see its post-stream `__abideResolve` pass. So for a streaming page the
21
+ render-return snapshot is typically empty; the warm cache arrives over the stream.
20
22
  */
21
- export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshot> {
22
- const settled: CacheEntry[] = []
23
- const pending: CacheEntry[] = []
24
- for (const entry of store.entries.values()) {
25
- /* Producer entries carry no wire request nothing to rehydrate against, skip. */
26
- if (!entry.request) {
27
- continue
28
- }
29
- if (!isReplayableMethod(entry.request.method.toUpperCase())) {
30
- continue
31
- }
32
- if (entry.settled) {
33
- settled.push(entry)
34
- } else {
35
- pending.push(entry)
36
- }
37
- }
23
+ export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
24
+ const settled = Array.from(store.entries.values()).filter(
25
+ (entry) =>
26
+ entry.settled === true &&
27
+ entry.request !== undefined &&
28
+ isReplayableMethod(entry.request.method.toUpperCase()),
29
+ )
38
30
  const snapshots = await Promise.all(
39
31
  settled.map((entry) => snapshotEntryFromCache(store, entry)),
40
32
  )
41
- const inline = snapshots.filter(
42
- (snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined,
43
- )
44
- return { inline, pending }
33
+ return snapshots.filter((snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined)
45
34
  }
@@ -24,6 +24,7 @@ import type { CacheStore } from './types/CacheStore.ts'
24
24
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
25
25
  import type { RemoteFunction } from './types/RemoteFunction.ts'
26
26
  import type { Subscribable } from './types/Subscribable.ts'
27
+ import { withCacheManaged } from './withCacheManaged.ts'
27
28
 
28
29
  type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
29
30
  type Producer<Args, Return> = (args?: Args) => Promise<Return>
@@ -309,8 +310,12 @@ function invokeProducer<Args, Return>(
309
310
  }
310
311
  /* Miss: time the producer run — where a request's time actually goes (an
311
312
  external fetch, a computation). Producer path only; the remote path must
312
- keep its own promise so getRemoteMeta can read the recorded Request. */
313
- const promise = cacheLog.trace<Return>(`cache ${key}`, () => producer(args))
313
+ keep its own promise so getRemoteMeta can read the recorded Request. The
314
+ producer runs cache-managed so a bare RPC inside it isn't scope-bound — the
315
+ cache coalesces and owns this flight. */
316
+ const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
317
+ withCacheManaged(() => producer(args)),
318
+ )
314
319
  registerEntry(store, key, promise, options, undefined, () => producer(args))
315
320
  return promise
316
321
  }
@@ -326,7 +331,9 @@ function invokeRemote<Args>(
326
331
  if (existing) {
327
332
  return shareable(existing.promise as Promise<Response>)
328
333
  }
329
- const promise = rawFn(args as Args)
334
+ /* Cache-managed: the shared flight isn't bound to the reader that triggered the
335
+ miss, so its scope disposing can't abort a request other readers still join. */
336
+ const promise = withCacheManaged(() => rawFn(args as Args))
330
337
  const request = getRemoteMeta(promise)
331
338
  if (!request) {
332
339
  throw new Error(
@@ -0,0 +1,10 @@
1
+ /*
2
+ Set while cache() synchronously invokes the underlying remote/producer, so the
3
+ client's currentAbortSignal skips scope-binding for cache-managed calls. The cache
4
+ coalesces one in-flight request across every reader and owns its lifetime (it
5
+ evicts the entry when the request rejects), so a single reader navigating away must
6
+ not abort a flight the others still depend on. A plain boolean — cache invokes the
7
+ underlying call synchronously and never re-enters across an await. Harmless on the
8
+ server, where there is no reactive observer to bind anyway.
9
+ */
10
+ export const cacheManagedSlot: { active: boolean } = { active: false }
@@ -0,0 +1,18 @@
1
+ import { cacheManagedSlot } from './cacheManagedSlot.ts'
2
+
3
+ /*
4
+ Runs `invoke` with cacheManagedSlot flagged, so any RPC fired synchronously inside
5
+ it (the cache's underlying remote/producer call) skips reactive scope-binding — the
6
+ cache, not the calling reader, owns the shared flight's lifetime. Save/restore keeps
7
+ it correct under nesting (a producer that itself reads cache). The fetch is fired
8
+ synchronously and the flag clears before the await, so it never spans the network.
9
+ */
10
+ export function withCacheManaged<T>(invoke: () => T): T {
11
+ const previous = cacheManagedSlot.active
12
+ cacheManagedSlot.active = true
13
+ try {
14
+ return invoke()
15
+ } finally {
16
+ cacheManagedSlot.active = previous
17
+ }
18
+ }
@@ -36,7 +36,7 @@ every page, and `.abide` files are the only component format.
36
36
 
37
37
  ## Idioms
38
38
 
39
- - **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `prop(name)`.
39
+ - **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `props()`.
40
40
  You write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers
41
41
  it. Templates auto-read (`{count}`); ordering and cross-references compose.
42
42
  - **Everything dynamic lives in `{ }`** — `{expr}` text, `name={expr}`,
@@ -1,12 +1,12 @@
1
1
  /* The callee names the `.abide` compiler recognises as reactive declarations
2
- (`let x = state(...)`, `linked(...)`, `computed(...)`, `prop(...)`): the shared
3
- "is this a reactive binding" allowlist read by the desugarer, the nested-script
4
- scoper, and the type-checking shadow. How each lowers — a serializable doc slot
5
- vs a `.value` cell — is decided per-site; this is only the membership set, so a
6
- new primitive is a single edit here. */
2
+ (`let x = state(...)`, `linked(...)`, `computed(...)`, and the destructuring
3
+ `const {…} = props()`): the shared "is this a reactive binding" allowlist read by
4
+ the desugarer, the nested-script scoper, and the type-checking shadow. How each
5
+ lowers — a serializable doc slot vs a `.value` cell — is decided per-site; this is
6
+ only the membership set, so a new primitive is a single edit here. */
7
7
  export const REACTIVE_CALLEES: ReadonlySet<string> = new Set([
8
8
  'state',
9
9
  'linked',
10
10
  'computed',
11
- 'prop',
11
+ 'props',
12
12
  ])
@@ -32,6 +32,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
33
  { name: 'mountChild', specifier: 'ui/dom/mountChild' },
34
34
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
35
+ { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
35
36
  { name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId' },
36
37
  { name: 'enterRenderPass', specifier: 'ui/runtime/enterRenderPass' },
37
38
  { name: 'exitRenderPass', specifier: 'ui/runtime/exitRenderPass' },