@abide/abide 0.56.0 → 0.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +6 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +23 -2
- package/src/lib/shared/SSR_SCRIPT_ID.ts +11 -0
- package/src/lib/shared/cache.ts +139 -34
- package/src/lib/shared/cacheEntryFromSnapshot.ts +15 -6
- package/src/lib/shared/types/CacheEntry.ts +9 -0
- package/src/lib/shared/types/CacheOptions.ts +12 -0
- package/src/lib/shared/types/CachePolicy.ts +10 -4
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -1
- package/src/lib/ui/compile/compileShadow.ts +91 -11
- package/src/lib/ui/createScope.ts +8 -1
- package/src/lib/ui/dom/each.ts +23 -9
- package/src/lib/ui/dom/spreadAttrs.ts +16 -1
- package/src/lib/ui/linked.ts +15 -0
- package/src/lib/ui/startClient.ts +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.57.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 866c96d: degrade a mangled **SSR** payload to a cold boot (ADR-0051) ([`adfee02`](https://github.com/briancray/abide/commit/adfee024d570e8bf46230fac1fa2521fcaae056f))
|
|
8
|
+
- 1abc943: Reuse the keyed-list (`{#for}`) reconcile scratch across passes so a steady-state reconcile allocates nothing
|
|
9
|
+
|
|
10
|
+
Each `each` reconcile built two fresh arrays per list change — `list.map(keyOf)` for the desired keys and `new Array(n)` for the resolved rows — then threw them away. On a list re-reconciled per keystroke that churn showed up as avoidable minor-GC pressure mid-interaction. The two buffers are now hoisted into persistent per-`each` scratch, reused every pass and cleared down to the live length on shrink (so no disposed-row references are retained). The `keyOf` pass is fused into the resolve loop — still exactly one `keyOf` call per item. Behaviour is unchanged: keyed diffing, in-place row updates, duplicate-key collapse, and prune-before-place all resolve identically; only the per-pass allocation is gone. The `[...source]` materialization for non-array iterables (generators must be drained) is unchanged.
|
|
11
|
+
|
|
12
|
+
- e137242: `spreadAttrs` — swallow a per-key suspend on a `{...rest}` spread
|
|
13
|
+
|
|
14
|
+
A `{...rest}` spread guards its enumeration against a pending blocking `await` read, but not the per-key value read. When `source()` is a restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying prop thunk, so a key whose expression reads a still-pending blocking cell suspends inside `bindKeys` — after the enumeration guard has already passed — and the `SuspenseSignal` escaped the render, killing the mount (seen as a crash on a cold client remount, e.g. navigating back to a page whose top-level blocking cells re-fetch and are briefly `pending()`). The per-key read now catches the suspend and binds the key as a deferred attribute (its `attr` effect swallows the signal and fills the value in on settle), matching `attr` and the deferred-enumeration branch; event handlers, whose thunks return a function without reading a cell, never suspend and stay on the listener path.
|
|
15
|
+
|
|
16
|
+
- 7367a23: SSR hydration payload ships as inert JSON instead of executable JavaScript (ADR-0051)
|
|
17
|
+
|
|
18
|
+
The `window.__SSR__` boot payload now ships as `<script type="application/json">` parsed once by the deferred client bundle with `JSON.parse`, rather than a `window.__SSR__ = {…}` statement the browser had to compile and evaluate as a multi-MB program on the critical path. The global still exists for debugging — the bundle republishes it after parsing. Additionally, a json cache body is now seeded pre-parsed (`data`) rather than as a re-escaped JSON string nested in the payload, so it's single-encoded (roughly half the wire bytes and gzip for a json-heavy grid) and read without a second parse. Warm-seed semantics — zero refetch, zero hydration divergence — are unchanged.
|
|
19
|
+
|
|
20
|
+
## 0.57.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- d09e821: `errorTtl` — opt a failed RPC load into the negative cache
|
|
25
|
+
|
|
26
|
+
By default a failed load evicts immediately so the next read retries. Setting `errorTtl` on an endpoint's `cache` policy (or a producer's `cache()` options) retains the failure for that many milliseconds instead: reads within the window re-surface the same rejection with no network round-trip — backing off a failing or rate-limited backend rather than hammering it — then the entry hard-evicts and the next read retries. The function form `errorTtl: (status) => number | undefined` returns a per-status window (`status` is `0` for a network-level fault), or `undefined` to keep the immediate-retry default for that status (e.g. back off a 429/503, retry a 500 at once). A `Retry-After` response header overrides the configured window as the authoritative delay. A negative-cached error is never shipped in the SSR snapshot, so it can't warm-hydrate a poisoned client entry.
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 3ce8963: Fix an async-cell warm-seed ordering bug where a `linked` cell reading a blocking dependency could desync SSR and client
|
|
31
|
+
|
|
32
|
+
An async cell's warm-seed key is `${scope.id}:${index}`, where `index` is a per-scope counter drawn in construction order. Only a cell that actually becomes async draws one — and a `linked` seed becomes async (drawing an index) only when it _suspends_ by reading a still-pending blocking dependency, otherwise it resolves synchronously to a plain `state` and drew none. Because a blocking dependency is typically in flight on the server (the `linked` suspends → index drawn) but already warm-adopted on the client (the `linked` resolves synchronously → no index), every async cell declared _after_ the `linked` in the same scope keyed off-by-one across the handoff and warm-adopted the wrong cell's value (e.g. a grid cell rendering the layout string it followed). `linked` now reserves its ordinal on the synchronous path too, so it always occupies exactly one index — the same invariant `computed` already has from its static `async`-function routing — and downstream keys line up on both sides.
|
|
33
|
+
|
|
34
|
+
- e8fb019: Type a bare no-`await` promise/stream `state.computed` seed as pending (`T | undefined`)
|
|
35
|
+
|
|
36
|
+
`state.computed(getRates())` (no `await`) routes to a STREAMING cell at runtime — it peeks `undefined` while pending — but the type-check shadow projected it as the resolved `T`, so an unguarded read (`rates.map(...)`) type-checked yet could hit `undefined` at first paint. It now projects through `$$cellValuePending` (`T | undefined`), matching the runtime and the semantically identical `state.computed(async () => getRates())` form. A blocking `state.computed(await getRates())` (suspends until resolved) and a genuinely sync seed are unchanged.
|
|
37
|
+
|
|
38
|
+
Consuming such a cell with `{#await rates}{:then data}` still binds `data` as the RESOLVED value: the shadow's `{#await}` lowering now strips the pending `undefined` off a bare pending-cell subject (mirroring the runtime's `$$awaitSubject`, ADR-0047), so the idiomatic await/then path stays free of `| undefined` noise. Direct reads should guard with `?.`/`??`.
|
|
39
|
+
|
|
3
40
|
## 0.56.0
|
|
4
41
|
|
|
5
42
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
|
6
6
|
import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
|
|
7
7
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
8
8
|
import { resolvedCellsSlot } from '../../shared/resolvedCellsSlot.ts'
|
|
9
|
+
import { SSR_SCRIPT_ID } from '../../shared/SSR_SCRIPT_ID.ts'
|
|
9
10
|
import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
|
|
10
11
|
import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
|
|
11
12
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
@@ -213,7 +214,11 @@ export function createUiPageRenderer({
|
|
|
213
214
|
health,
|
|
214
215
|
clientTimeout,
|
|
215
216
|
} satisfies SsrPayload)
|
|
216
|
-
|
|
217
|
+
/* Inert JSON data, NOT a `window.__SSR__ = {…}` statement (ADR-0051): the browser never
|
|
218
|
+
compiles this multi-MB payload as JavaScript on the critical path — the deferred bundle
|
|
219
|
+
reads it with one `JSON.parse`. `safeJsonForScript` has already `<`-escaped every
|
|
220
|
+
`<`, so an embedded `</script>` can't close the tag early. */
|
|
221
|
+
return `<script type="application/json" id="${SSR_SCRIPT_ID}">${payload}</script>`
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
/* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
|
|
@@ -41,6 +41,13 @@ export async function snapshotEntryFromCache(
|
|
|
41
41
|
if (!response) {
|
|
42
42
|
return undefined
|
|
43
43
|
}
|
|
44
|
+
/* A failed load never seeds the client: negative caching (errorTtl) can leave an
|
|
45
|
+
error-status entry in the store at snapshot time, but shipping it would warm-hydrate
|
|
46
|
+
a poisoned entry. Skip it — the client live-fetches on the miss and runs its own
|
|
47
|
+
negative cache. (Before errorTtl, errors evicted at settle and never reached here.) */
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
44
51
|
if (store.entries.get(entry.key) !== entry) {
|
|
45
52
|
return undefined
|
|
46
53
|
}
|
|
@@ -80,15 +87,29 @@ export async function snapshotEntryFromCache(
|
|
|
80
87
|
headers: response.headers,
|
|
81
88
|
}),
|
|
82
89
|
)
|
|
83
|
-
|
|
90
|
+
const wire: CacheSnapshotEntry = {
|
|
84
91
|
key: entry.key,
|
|
85
92
|
url: entry.request.url,
|
|
86
93
|
method,
|
|
87
94
|
status: response.status,
|
|
88
95
|
statusText: response.statusText,
|
|
89
96
|
headers: Array.from(response.headers.entries()),
|
|
90
|
-
body,
|
|
91
97
|
}
|
|
98
|
+
/* Ship a json body PARSED so it single-encodes in the payload rather than nesting a
|
|
99
|
+
re-escaped JSON string (ADR-0051 — the double-encoding that ~2.35×'d the wire). On a
|
|
100
|
+
parse failure ship the raw string under `body`: the client decodes it async exactly as
|
|
101
|
+
before (warmValueFromSnapshot's own try/catch), so a malformed-but-json-typed body still
|
|
102
|
+
round-trips rather than throwing here. A text body always ships raw. */
|
|
103
|
+
if (kind === 'json') {
|
|
104
|
+
try {
|
|
105
|
+
wire.data = JSON.parse(body)
|
|
106
|
+
return wire
|
|
107
|
+
} catch {
|
|
108
|
+
/* fall through to the raw-string form */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
wire.body = body
|
|
112
|
+
return wire
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
async function readSettled(promise: Promise<Response>): Promise<Response | undefined> {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The id of the `<script type="application/json">` element the server emits the
|
|
3
|
+
`window.__SSR__` payload into and the client (`startClient`) parses back out
|
|
4
|
+
(ADR-0051). Shipping the payload as inert JSON data — not a `window.__SSR__ = {…}`
|
|
5
|
+
executable statement — means the browser never COMPILES a multi-MB object literal
|
|
6
|
+
as JavaScript on the critical path; the deferred client bundle reads it with one
|
|
7
|
+
`JSON.parse` (a restricted grammar parsed several times faster than equivalent JS
|
|
8
|
+
source). Both sides import this constant so the write id and read id can't drift.
|
|
9
|
+
*/
|
|
10
|
+
// @documentation plumbing
|
|
11
|
+
export const SSR_SCRIPT_ID = 'abide-ssr'
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -280,8 +280,28 @@ function readThrough<Args, Return>(
|
|
|
280
280
|
warnEphemeralTtl(key)
|
|
281
281
|
}
|
|
282
282
|
store.subscribe(key)
|
|
283
|
-
|
|
283
|
+
let existing = store.entries.get(key)
|
|
284
284
|
recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
|
|
285
|
+
/*
|
|
286
|
+
Negative-cache short-circuit: a prior failed load retained under errorTtl
|
|
287
|
+
re-surfaces the same failure with no network until its window elapses — a
|
|
288
|
+
Response is re-served (raw) or re-decoded to throw the same HttpError, a
|
|
289
|
+
network Error is re-rejected. Past the deadline (the sweep may not have fired
|
|
290
|
+
yet) evict it here so the read below is a clean miss that retries.
|
|
291
|
+
*/
|
|
292
|
+
if (existing?.errorResult !== undefined) {
|
|
293
|
+
if (existing.expiresAt !== undefined && existing.expiresAt > Date.now()) {
|
|
294
|
+
const failure = existing.errorResult
|
|
295
|
+
if (failure instanceof Response) {
|
|
296
|
+
return isRaw
|
|
297
|
+
? (Promise.resolve(failure.clone()) as Promise<Return | Response>)
|
|
298
|
+
: (decodeResponse(failure.clone()) as Promise<Return | Response>)
|
|
299
|
+
}
|
|
300
|
+
return Promise.reject(failure)
|
|
301
|
+
}
|
|
302
|
+
evictIfCurrent(store, existing)
|
|
303
|
+
existing = undefined
|
|
304
|
+
}
|
|
285
305
|
if (existing) {
|
|
286
306
|
tagEntry(existing, effectiveOptions?.tags)
|
|
287
307
|
attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
|
|
@@ -380,6 +400,7 @@ function resolveEndpointCache<Args, Return>(
|
|
|
380
400
|
shared: policy.shared,
|
|
381
401
|
throttle: policy.throttle,
|
|
382
402
|
debounce: policy.debounce,
|
|
403
|
+
errorTtl: policy.errorTtl,
|
|
383
404
|
}
|
|
384
405
|
}
|
|
385
406
|
|
|
@@ -565,61 +586,76 @@ function registerEntry(
|
|
|
565
586
|
function deleteIfCurrent() {
|
|
566
587
|
evictIfCurrent(store, entry)
|
|
567
588
|
}
|
|
568
|
-
promise.then(
|
|
569
|
-
|
|
589
|
+
promise.then(
|
|
590
|
+
(result) => {
|
|
591
|
+
/*
|
|
570
592
|
Mark settled so SSR snapshot serialization can tell awaited entries
|
|
571
593
|
(resolved by the time render() returns → inline) from {#await} ones
|
|
572
594
|
(still pending → stream). Set before the ttl branches below since a
|
|
573
595
|
ttl=0 server entry stays in the store for the snapshot.
|
|
574
596
|
*/
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
597
|
+
entry.settled = true
|
|
598
|
+
/* The reload finished — this entry now holds fresh data, no longer refreshing. */
|
|
599
|
+
entry.refreshing = false
|
|
600
|
+
store.markLifecycle(key)
|
|
601
|
+
/*
|
|
580
602
|
An error-status Response is a failed load, not a value to retain: fetch
|
|
581
603
|
resolves (it only rejects on a network fault) on a 4xx/5xx, so without
|
|
582
604
|
this the entry would be served as a hit for the whole ttl, even after the
|
|
583
|
-
backend recovers. Evict it so the next read retries —
|
|
584
|
-
|
|
605
|
+
backend recovers. Evict it so the next read retries — unless the endpoint
|
|
606
|
+
opted into the negative cache (errorTtl), which retains the failure for a
|
|
607
|
+
bounded window (see retainErroredEntry). Mirrors fireRefetch, which guards
|
|
608
|
+
revalidation results with the same `!result.ok` check.
|
|
585
609
|
*/
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
610
|
+
if (result instanceof Response && !result.ok) {
|
|
611
|
+
retainErroredEntry(store, entry, options, result.status, result)
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
/*
|
|
591
615
|
A post-invalidate reload registers with refreshing=true; an invalidation
|
|
592
616
|
firing while it was in flight parked its refetch on policy.pending
|
|
593
617
|
(fireRefetch bails when refreshing). This settle path — not fireRefetch —
|
|
594
618
|
cleared the flag, so drain the parked refetch here or it's lost and the
|
|
595
619
|
entry keeps data that predates the invalidation.
|
|
596
620
|
*/
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
621
|
+
if (entry.invalidation?.pending) {
|
|
622
|
+
reschedulePendingRefetch(store, entry, entry.invalidation)
|
|
623
|
+
}
|
|
624
|
+
/*
|
|
601
625
|
Smart retained read: the display value is kept unconditionally — never
|
|
602
626
|
hard-evicted on settle. ttl marks a staleness deadline (the next read past
|
|
603
627
|
it revalidates in the background, stale stays visible, refreshing() true)
|
|
604
628
|
instead of eviction; ttl 0/undefined retain with no staleness clock.
|
|
605
629
|
*/
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
630
|
+
if (entry.retain) {
|
|
631
|
+
materializeRetained(store, entry, result)
|
|
632
|
+
if (ttl !== undefined && ttl > 0) {
|
|
633
|
+
stampStaleDeadline(entry, ttl)
|
|
634
|
+
}
|
|
635
|
+
return
|
|
610
636
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
637
|
+
if (ttl === 0) {
|
|
638
|
+
if (!keepZeroTtlForRequest) {
|
|
639
|
+
deleteIfCurrent()
|
|
640
|
+
}
|
|
641
|
+
return
|
|
616
642
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
643
|
+
if (ttl !== undefined) {
|
|
644
|
+
armTtlExpiry(store, entry, ttl)
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
(error) => {
|
|
648
|
+
/* A network-level fault (fetch rejects — no Response, so no status/Retry-After):
|
|
649
|
+
negative-cache it under the status-0 window if opted in, else evict-and-retry. */
|
|
650
|
+
retainErroredEntry(
|
|
651
|
+
store,
|
|
652
|
+
entry,
|
|
653
|
+
options,
|
|
654
|
+
0,
|
|
655
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
656
|
+
)
|
|
657
|
+
},
|
|
658
|
+
)
|
|
623
659
|
return entry
|
|
624
660
|
}
|
|
625
661
|
|
|
@@ -636,6 +672,75 @@ function evictIfCurrent(store: CacheStore, entry: CacheEntry): void {
|
|
|
636
672
|
}
|
|
637
673
|
}
|
|
638
674
|
|
|
675
|
+
/*
|
|
676
|
+
A failed load is retained (negative cache), not evicted, only when the endpoint
|
|
677
|
+
opted in with `errorTtl` for this status — otherwise it evicts so the next read
|
|
678
|
+
retries (the default). With a window the failure is buffered so reads within it
|
|
679
|
+
re-surface it with no network: a Response is stored as a pristine clone the read
|
|
680
|
+
path re-clones per serve (never consuming errorResult itself), a network Error is
|
|
681
|
+
stored as-is and re-rejected. `armTtlExpiry` hard-evicts at the deadline, so the
|
|
682
|
+
next read retries. A `Retry-After` header overrides the configured window as the
|
|
683
|
+
authoritative delay.
|
|
684
|
+
*/
|
|
685
|
+
function retainErroredEntry(
|
|
686
|
+
store: CacheStore,
|
|
687
|
+
entry: CacheEntry,
|
|
688
|
+
options: CacheOptions | undefined,
|
|
689
|
+
status: number,
|
|
690
|
+
failure: Response | Error,
|
|
691
|
+
): void {
|
|
692
|
+
const configured = errorTtlFor(options, status)
|
|
693
|
+
/* Not opted in for this status → default evict-and-retry (Retry-After is honoured
|
|
694
|
+
only under an explicit errorTtl, so no existing endpoint's behaviour shifts). */
|
|
695
|
+
if (configured === undefined) {
|
|
696
|
+
evictIfCurrent(store, entry)
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
const window =
|
|
700
|
+
failure instanceof Response ? (retryAfterMs(failure.headers) ?? configured) : configured
|
|
701
|
+
if (window <= 0) {
|
|
702
|
+
evictIfCurrent(store, entry)
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
if (store.entries.get(entry.key) !== entry) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
entry.errorResult = failure instanceof Response ? failure.clone() : failure
|
|
709
|
+
entry.settled = true
|
|
710
|
+
armTtlExpiry(store, entry, window)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* Resolves the endpoint's `errorTtl` against a failed status: a number applies to
|
|
714
|
+
any status, a function returns a per-status window (or undefined to keep the
|
|
715
|
+
immediate-retry default for that status). `status` is 0 for a network fault. */
|
|
716
|
+
function errorTtlFor(options: CacheOptions | undefined, status: number): number | undefined {
|
|
717
|
+
const policy = options?.errorTtl
|
|
718
|
+
if (typeof policy === 'function') {
|
|
719
|
+
return policy(status)
|
|
720
|
+
}
|
|
721
|
+
return policy
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* Parses a `Retry-After` header to ms — a delta-seconds integer or an HTTP-date —
|
|
725
|
+
clamped to >= 0. Returns undefined when absent or unparseable so the caller falls
|
|
726
|
+
back to the configured errorTtl. */
|
|
727
|
+
function retryAfterMs(headers: Headers): number | undefined {
|
|
728
|
+
const value = headers.get('retry-after')
|
|
729
|
+
if (value === null) {
|
|
730
|
+
return undefined
|
|
731
|
+
}
|
|
732
|
+
const seconds = Number(value)
|
|
733
|
+
if (Number.isFinite(seconds)) {
|
|
734
|
+
return seconds > 0 ? seconds * 1000 : 0
|
|
735
|
+
}
|
|
736
|
+
const when = Date.parse(value)
|
|
737
|
+
if (Number.isNaN(when)) {
|
|
738
|
+
return undefined
|
|
739
|
+
}
|
|
740
|
+
const delta = when - Date.now()
|
|
741
|
+
return delta > 0 ? delta : 0
|
|
742
|
+
}
|
|
743
|
+
|
|
639
744
|
/* Arms the ttl > 0 expiry sweep; `expiresAt` re-checks at fire time so a refreshed deadline survives. */
|
|
640
745
|
function armTtlExpiry(store: CacheStore, entry: CacheEntry, ttl: number): void {
|
|
641
746
|
entry.expiresAt = Date.now() + ttl
|
|
@@ -15,7 +15,12 @@ the first cache() read adopts its call site's ttl (see CacheEntry).
|
|
|
15
15
|
*/
|
|
16
16
|
export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
|
|
17
17
|
const headers = new Headers(entry.headers)
|
|
18
|
-
|
|
18
|
+
/* Body text for async readers / shareable clones. A json body arrives PARSED as `data`
|
|
19
|
+
(ADR-0051): re-stringify it (compact JSON — value-identical to the server body, whose
|
|
20
|
+
`json()` also emits compact JSON) so a later `.json()`/`.text()`/`.clone()` still works.
|
|
21
|
+
A text (or parse-failed json) body arrives raw as `body`. */
|
|
22
|
+
const bodyText = 'data' in entry ? JSON.stringify(entry.data) : (entry.body ?? '')
|
|
23
|
+
const response = new Response(bodyText, {
|
|
19
24
|
status: entry.status,
|
|
20
25
|
statusText: entry.statusText,
|
|
21
26
|
headers,
|
|
@@ -26,7 +31,7 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
|
|
|
26
31
|
request: new Request(entry.url, { method: entry.method }),
|
|
27
32
|
ttl: undefined,
|
|
28
33
|
expiresAt: undefined,
|
|
29
|
-
value: warmValueFromSnapshot(entry
|
|
34
|
+
value: warmValueFromSnapshot(entry, headers, bodyText),
|
|
30
35
|
settled: true,
|
|
31
36
|
hydrated: true,
|
|
32
37
|
}
|
|
@@ -42,19 +47,23 @@ non-2xx, 204 (the status gate below), streaming, binary (the DEFER sentinel) —
|
|
|
42
47
|
yields no warm value and defers to the async path, which throws HttpError /
|
|
43
48
|
returns the streaming error / blobs exactly as a live call would.
|
|
44
49
|
*/
|
|
45
|
-
function warmValueFromSnapshot(
|
|
50
|
+
function warmValueFromSnapshot(entry: CacheSnapshotEntry, headers: Headers, body: string): unknown {
|
|
46
51
|
/* Status gate is side-specific: an error/204 has no warm value here, but the live
|
|
47
52
|
read throws HttpError / returns undefined for the same status — so it stays out of
|
|
48
53
|
the shared kind mapping. */
|
|
49
|
-
if (status === 204 || status < 200 || status >= 300) {
|
|
54
|
+
if (entry.status === 204 || entry.status < 200 || entry.status >= 300) {
|
|
50
55
|
return undefined
|
|
51
56
|
}
|
|
52
57
|
const kind = contentBodyKind(contentTypeOf(headers))
|
|
53
58
|
const value = bodyValueForKind(
|
|
54
59
|
kind,
|
|
55
|
-
/*
|
|
56
|
-
|
|
60
|
+
/* Pre-parsed json body (`data`, ADR-0051) IS the value — no second parse. A raw json
|
|
61
|
+
body may still be malformed; fall back to the async path rather than throwing a
|
|
62
|
+
SyntaxError synchronously during hydration. */
|
|
57
63
|
() => {
|
|
64
|
+
if ('data' in entry) {
|
|
65
|
+
return entry.data
|
|
66
|
+
}
|
|
58
67
|
try {
|
|
59
68
|
return JSON.parse(body)
|
|
60
69
|
} catch {
|
|
@@ -73,6 +73,15 @@ export type CacheEntry = {
|
|
|
73
73
|
its drop-on-ttl / drop-on-invalidate behaviour.
|
|
74
74
|
*/
|
|
75
75
|
retain?: boolean
|
|
76
|
+
/*
|
|
77
|
+
A retained failed load (negative cache — opt in with `errorTtl`). Holds a
|
|
78
|
+
pristine Response clone (re-served per read by cloning it, never consumed) or
|
|
79
|
+
the network Error, so reads within the window re-surface the same failure with
|
|
80
|
+
no network round-trip. `expiresAt` (armed via the ttl sweep) hard-evicts it when
|
|
81
|
+
the window elapses, so the next read retries. Unset on a successful entry; its
|
|
82
|
+
presence is what the read path checks to short-circuit to the retained error.
|
|
83
|
+
*/
|
|
84
|
+
errorResult?: Response | Error
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
/* Per-key invalidate coalescing: the throttle/debounce policy plus the timer/in-flight state. */
|
|
@@ -31,6 +31,17 @@ and coalesces a background refetch (leading-edge-then-coalesce, or
|
|
|
31
31
|
fire-after-quiet respectively) instead of dropping the entry. Set one, not both.
|
|
32
32
|
SWR is otherwise unconditional for replayable remote reads; there is no `swr`
|
|
33
33
|
toggle.
|
|
34
|
+
|
|
35
|
+
`errorTtl` opts a failed load into the negative cache: without it a failure
|
|
36
|
+
evicts immediately so the next read retries (the default); with it, the failure
|
|
37
|
+
is retained for that many ms — reads within the window re-surface the same
|
|
38
|
+
rejection with no network round-trip, backing off a failing/rate-limited backend
|
|
39
|
+
instead of hammering it — then hard-evicts so the next read retries. A function
|
|
40
|
+
form receives the failed response's HTTP status (0 for a network-level fault with
|
|
41
|
+
no response) and returns a per-status window, or `undefined` to fall back to the
|
|
42
|
+
immediate-retry default for that status (e.g. back off a 429/503, retry a 500 at
|
|
43
|
+
once). A `Retry-After` header on the response overrides the number as the
|
|
44
|
+
authoritative delay. Off (evict-and-retry) when unset.
|
|
34
45
|
*/
|
|
35
46
|
export type CacheOptions = {
|
|
36
47
|
ttl?: number
|
|
@@ -38,4 +49,5 @@ export type CacheOptions = {
|
|
|
38
49
|
shared?: boolean
|
|
39
50
|
throttle?: number
|
|
40
51
|
debounce?: number
|
|
52
|
+
errorTtl?: number | ((status: number) => number | undefined)
|
|
41
53
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Endpoint cache policy, declared once on the rpc definition (ADR-0020). Same value
|
|
3
3
|
shape as the producer-side `CacheOptions` (`ttl` / `tags` / `throttle` / `debounce`
|
|
4
|
-
/ `shared`, no `swr` — SWR is unconditional for replayable reads),
|
|
5
|
-
also accepts an arg-derived function so the group can vary by call.
|
|
6
|
-
type generic over the rpc's `Args`: `(args) => ['rates:' + args.base]`
|
|
7
|
-
against the endpoint's own argument shape.
|
|
4
|
+
/ `shared` / `errorTtl`, no `swr` — SWR is unconditional for replayable reads),
|
|
5
|
+
except `tags` also accepts an arg-derived function so the group can vary by call.
|
|
6
|
+
That makes the type generic over the rpc's `Args`: `(args) => ['rates:' + args.base]`
|
|
7
|
+
is typed against the endpoint's own argument shape.
|
|
8
8
|
|
|
9
9
|
- `ttl` — retention/staleness in ms. Default Infinity: the entry is retained for its
|
|
10
10
|
store's lifetime — the request on the server (a non-shared read dies with the request,
|
|
@@ -15,6 +15,11 @@ against the endpoint's own argument shape.
|
|
|
15
15
|
- `shared` — opt the entry into the process-level store (never per-user data — the store
|
|
16
16
|
is keyed by method+url+args, not by user). With the default Infinity ttl this memoises
|
|
17
17
|
across requests; an explicit ttl bounds it. A client no-op (one tab store).
|
|
18
|
+
- `errorTtl` — negative-cache window in ms: retain a failed load for that long (reads
|
|
19
|
+
within it re-surface the rejection with no network) instead of the default
|
|
20
|
+
evict-and-retry, then hard-evict. A function of the failed status (0 = network fault)
|
|
21
|
+
returns a per-status window or `undefined` to keep the immediate-retry default for that
|
|
22
|
+
status; a `Retry-After` header overrides the number. Off when unset.
|
|
18
23
|
*/
|
|
19
24
|
export type CachePolicy<Args> = {
|
|
20
25
|
ttl?: number
|
|
@@ -22,4 +27,5 @@ export type CachePolicy<Args> = {
|
|
|
22
27
|
throttle?: number
|
|
23
28
|
debounce?: number
|
|
24
29
|
shared?: boolean
|
|
30
|
+
errorTtl?: number | ((status: number) => number | undefined)
|
|
25
31
|
}
|
|
@@ -6,6 +6,15 @@ Any entry called inline during render (any method) with a textual Content-Type i
|
|
|
6
6
|
emitted, so the client hydrates warm instead of re-firing the call after render;
|
|
7
7
|
binary bodies don't survive a JSON round-trip. The seeded value is read warm once
|
|
8
8
|
— it is never auto-replayed (that stays GET-only, see REPLAYABLE_METHODS).
|
|
9
|
+
|
|
10
|
+
The body travels in ONE of two shapes, never both (ADR-0051): a json-kind body
|
|
11
|
+
ships PARSED as `data` — a live JSON value nested directly in the payload — so the
|
|
12
|
+
whole snapshot serialises once (the payload's own `JSON.stringify`) instead of the
|
|
13
|
+
body being pre-serialised to a string and then re-escaped as a nested string
|
|
14
|
+
literal (the double-encoding that ~2.35×'d the wire and forced the client to parse
|
|
15
|
+
the body a second time). A text-kind body — or a json body the server couldn't
|
|
16
|
+
parse — ships raw as `body`. The client re-derives which from the presence of
|
|
17
|
+
`data` (`cacheEntryFromSnapshot`).
|
|
9
18
|
*/
|
|
10
19
|
export type CacheSnapshotEntry = {
|
|
11
20
|
key: string
|
|
@@ -14,5 +23,10 @@ export type CacheSnapshotEntry = {
|
|
|
14
23
|
status: number
|
|
15
24
|
statusText: string
|
|
16
25
|
headers: Array<[string, string]>
|
|
17
|
-
body
|
|
26
|
+
/* Raw body text — present for a text-kind body, or a json body that failed to parse
|
|
27
|
+
server-side (shipped raw, decoded async client-side as before). Absent when `data` is. */
|
|
28
|
+
body?: string
|
|
29
|
+
/* Pre-parsed json-kind body, single-encoded. Present iff the body was json and parsed;
|
|
30
|
+
`'data' in entry` is the discriminator (a `null` json body still keys present). */
|
|
31
|
+
data?: unknown
|
|
18
32
|
}
|
|
@@ -82,7 +82,6 @@ export function compileShadow(
|
|
|
82
82
|
propsType = 'Record<string, any>',
|
|
83
83
|
classify?: InterpolationClassifier,
|
|
84
84
|
): CompiledShadow {
|
|
85
|
-
const builder = createBuilder(classify)
|
|
86
85
|
const leadingScript = source.match(LEADING_SCRIPT)
|
|
87
86
|
const scriptBody = leadingScript?.[1] ?? ''
|
|
88
87
|
/* Body starts just past the opening `<script …>`; template just past `</script>`. */
|
|
@@ -98,7 +97,12 @@ export function compileShadow(
|
|
|
98
97
|
importedReactives,
|
|
99
98
|
propsLocalName,
|
|
100
99
|
watchLocalName,
|
|
100
|
+
pendingCellNames,
|
|
101
101
|
} = analyzeScript(scriptBody, scriptStart, classify)
|
|
102
|
+
/* Built AFTER the script analysis so the `pendingCellNames` set (the `$$cellValuePending`
|
|
103
|
+
cells) can ride onto the builder — the `{#await}` emit reads it to strip a bare
|
|
104
|
+
pending-cell subject's pending `undefined` off its resolved binding (ADR-0047 fidelity). */
|
|
105
|
+
const builder = createBuilder(classify, pendingCellNames)
|
|
102
106
|
builder.raw(shadowPreamble(importedReactives))
|
|
103
107
|
/* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
|
|
104
108
|
unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
|
|
@@ -117,6 +121,14 @@ export function compileShadow(
|
|
|
117
121
|
builder.raw(
|
|
118
122
|
'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
|
|
119
123
|
)
|
|
124
|
+
/* `{#await <pendingCell>}` awaits the CELL (ADR-0047 `$$awaitSubject`) — it shows pending then
|
|
125
|
+
binds the RESOLVED value, never the pending `undefined`. So a bare pending-cell subject's
|
|
126
|
+
resolved/`:then` binding must drop the `| undefined` the `$$cellValuePending` peek carries;
|
|
127
|
+
`$$awaitCell` strips exactly `undefined` (`Exclude`, so a genuine `null` in the value survives).
|
|
128
|
+
Emitted only when a pending cell exists (else the `{#await}` emit never references it). */
|
|
129
|
+
if (pendingCellNames.size > 0) {
|
|
130
|
+
builder.raw('declare function $$awaitCell<V>(v: V): Exclude<V, undefined>\n')
|
|
131
|
+
}
|
|
120
132
|
/* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
|
|
121
133
|
sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
|
|
122
134
|
async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
|
|
@@ -245,6 +257,9 @@ type Builder = {
|
|
|
245
257
|
/* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
|
|
246
258
|
like an await's resolved value, kept distinct so nested blocks never collide. */
|
|
247
259
|
unique: (base: string) => string
|
|
260
|
+
/* The `$$cellValuePending` (STREAMING) cells declared in the leading script — a bare
|
|
261
|
+
`{#await <name>}` over one strips the pending `undefined` off its resolved binding. */
|
|
262
|
+
pendingCellNames: ReadonlySet<string>
|
|
248
263
|
result: () => CompiledShadow
|
|
249
264
|
}
|
|
250
265
|
|
|
@@ -252,7 +267,10 @@ type Builder = {
|
|
|
252
267
|
emitted verbatim from the original script), offset-relative to the line start. */
|
|
253
268
|
type ScopeLine = { text: string; segments: ShadowMapping[] }
|
|
254
269
|
|
|
255
|
-
function createBuilder(
|
|
270
|
+
function createBuilder(
|
|
271
|
+
classify?: InterpolationClassifier,
|
|
272
|
+
pendingCellNames: ReadonlySet<string> = new Set(),
|
|
273
|
+
): Builder {
|
|
256
274
|
let code = ''
|
|
257
275
|
let uniqueCounter = 0
|
|
258
276
|
const mappings: ShadowMapping[] = []
|
|
@@ -302,6 +320,7 @@ function createBuilder(classify?: InterpolationClassifier): Builder {
|
|
|
302
320
|
}
|
|
303
321
|
}
|
|
304
322
|
const builder: Builder = {
|
|
323
|
+
pendingCellNames,
|
|
305
324
|
unique(base) {
|
|
306
325
|
return `__${base}_${uniqueCounter++}`
|
|
307
326
|
},
|
|
@@ -379,6 +398,9 @@ type ScriptAnalysis = {
|
|
|
379
398
|
overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
|
|
380
399
|
value, so the real State/socket/rpc overloads never match it). */
|
|
381
400
|
watchLocalName: string | undefined
|
|
401
|
+
/* The STREAMING (`$$cellValuePending`) cell names — a bare `{#await <name>}` over one awaits
|
|
402
|
+
the cell and strips its pending `undefined` for the resolved binding (ADR-0047). */
|
|
403
|
+
pendingCellNames: Set<string>
|
|
382
404
|
}
|
|
383
405
|
|
|
384
406
|
/* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
|
|
@@ -506,6 +528,7 @@ function analyzeScript(
|
|
|
506
528
|
const scope: ScopeLine[] = []
|
|
507
529
|
const propsShapes: string[] = []
|
|
508
530
|
const diagnostics: ShadowDiagnostic[] = []
|
|
531
|
+
const pendingCellNames = new Set<string>()
|
|
509
532
|
if (scriptBody.trim() === '') {
|
|
510
533
|
return {
|
|
511
534
|
imports,
|
|
@@ -516,6 +539,7 @@ function analyzeScript(
|
|
|
516
539
|
importedReactives: new Set(),
|
|
517
540
|
propsLocalName: undefined,
|
|
518
541
|
watchLocalName: undefined,
|
|
542
|
+
pendingCellNames,
|
|
519
543
|
}
|
|
520
544
|
}
|
|
521
545
|
const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
@@ -572,7 +596,17 @@ function analyzeScript(
|
|
|
572
596
|
continue
|
|
573
597
|
}
|
|
574
598
|
for (const declaration of reactive) {
|
|
575
|
-
scope.push(
|
|
599
|
+
scope.push(
|
|
600
|
+
scopeLineFor(
|
|
601
|
+
declaration,
|
|
602
|
+
propsShapes,
|
|
603
|
+
verbatim,
|
|
604
|
+
span,
|
|
605
|
+
bindings,
|
|
606
|
+
classify,
|
|
607
|
+
pendingCellNames,
|
|
608
|
+
),
|
|
609
|
+
)
|
|
576
610
|
}
|
|
577
611
|
}
|
|
578
612
|
return {
|
|
@@ -584,6 +618,7 @@ function analyzeScript(
|
|
|
584
618
|
importedReactives,
|
|
585
619
|
propsLocalName,
|
|
586
620
|
watchLocalName,
|
|
621
|
+
pendingCellNames,
|
|
587
622
|
}
|
|
588
623
|
}
|
|
589
624
|
|
|
@@ -710,6 +745,9 @@ function scopeLineFor(
|
|
|
710
745
|
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
711
746
|
bindings: ReactiveImportBindings,
|
|
712
747
|
classify?: InterpolationClassifier,
|
|
748
|
+
/* Collects the names that project through `$$cellValuePending` (STREAMING cells) so the
|
|
749
|
+
`{#await}` emit can strip their pending `undefined`. Absent for nested scripts. */
|
|
750
|
+
pendingCellNames?: Set<string>,
|
|
713
751
|
): ScopeLine {
|
|
714
752
|
const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
|
|
715
753
|
const call = declaration.initializer as ts.CallExpression
|
|
@@ -807,12 +845,21 @@ function scopeLineFor(
|
|
|
807
845
|
const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
|
|
808
846
|
const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
|
|
809
847
|
const wrapSuffix = fnIsThunk ? '' : ')'
|
|
810
|
-
/* ADR-0042: a STREAMING
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
848
|
+
/* ADR-0042/0043: a STREAMING cell reads `undefined`-while-pending → `$$cellValuePending`
|
|
849
|
+
(resolved `T | undefined`). Two seed shapes stream: a no-`await` async thunk (`async () =>
|
|
850
|
+
x`), and a bare call/identifier whose type resolves to a promise/stream — the latter is what
|
|
851
|
+
desugarSignals routes to `trackedComputed(…, streaming)` (`isPromiseComputed` /
|
|
852
|
+
`isEagerStreamComputed`), so the shadow must agree or a `state.computed(getFoo())` reads as
|
|
853
|
+
non-optional `T` while the runtime peeks `undefined`. A BLOCKING (`await`) cell suspends its
|
|
854
|
+
region so it is always resolved, and a genuinely sync seed is never pending → `$$cellValue`
|
|
855
|
+
(resolved `T`). The bare-seed arm needs the warm classifier; fail-open keeps `$$cellValue`. */
|
|
856
|
+
const streamingSeed =
|
|
857
|
+
(seedIsAsync(fn) && !seedIsBlocking(fn)) || bareSeedStreams(fn, span, verbatim, classify)
|
|
858
|
+
const helper = streamingSeed ? '$$cellValuePending' : '$$cellValue'
|
|
859
|
+
/* Record the pending cell so `{#await <name>}` strips its pending `undefined` (ADR-0047). */
|
|
860
|
+
if (streamingSeed && ts.isIdentifier(declaration.name)) {
|
|
861
|
+
pendingCellNames?.add(name)
|
|
862
|
+
}
|
|
816
863
|
const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
|
|
817
864
|
/* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
|
|
818
865
|
`getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
|
|
@@ -870,6 +917,31 @@ function seedIsBlocking(seed: ts.Expression): boolean {
|
|
|
870
917
|
return hasTopLevelAwait(seed)
|
|
871
918
|
}
|
|
872
919
|
|
|
920
|
+
/* True for a bare (non-thunk, no-`await`) call/identifier seed whose checker type resolves to a
|
|
921
|
+
promise or async iterable — the STREAMING cell desugarSignals routes to `trackedComputed(…,
|
|
922
|
+
streaming)` (`isPromiseComputed` / `isEagerStreamComputed`). Such a cell peeks `undefined` while
|
|
923
|
+
pending, so its shadow type must be `$$cellValuePending` (resolved `T | undefined`), matching the
|
|
924
|
+
`async () => x` streaming form. Restricted to a bare CALL/IDENTIFIER seed — the whole-seed shape,
|
|
925
|
+
mirroring desugarSignals' bare-seed routing; a compound seed's buried async sub-expression is
|
|
926
|
+
peek-wrapped separately. Needs the warm classifier — absent (or on a resolution throw) it returns
|
|
927
|
+
false, keeping today's `$$cellValue`. */
|
|
928
|
+
function bareSeedStreams(
|
|
929
|
+
seed: ts.Expression,
|
|
930
|
+
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
931
|
+
verbatim: (node: ts.Node) => string,
|
|
932
|
+
classify: InterpolationClassifier | undefined,
|
|
933
|
+
): boolean {
|
|
934
|
+
if (classify === undefined || (!ts.isCallExpression(seed) && !ts.isIdentifier(seed))) {
|
|
935
|
+
return false
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
const kind = classify(span(seed, 0).sourceStart, verbatim(seed))
|
|
939
|
+
return kind === 'promise' || kind === 'asyncIterable'
|
|
940
|
+
} catch {
|
|
941
|
+
return false
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
873
945
|
/* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
|
|
874
946
|
attachment aliases. All nested content (block bodies, await/switch branches, snippet
|
|
875
947
|
bodies) routes through `children`, so a recursive `children` walk is complete. */
|
|
@@ -1123,9 +1195,17 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
1123
1195
|
they're the pending content, checked without the resolved value. */
|
|
1124
1196
|
const resolved = builder.unique('awaited')
|
|
1125
1197
|
builder.raw('{\n')
|
|
1126
|
-
|
|
1198
|
+
/* A bare `{#await <pendingCell>}` awaits the CELL (ADR-0047 `$$awaitSubject`): it shows
|
|
1199
|
+
pending then binds the RESOLVED value, never the pending `undefined` the
|
|
1200
|
+
`$$cellValuePending` peek carries. So strip that `undefined` with `$$awaitCell` — the
|
|
1201
|
+
resolved/`:then` bindings all derive from `resolved`, so one strip covers every one.
|
|
1202
|
+
Only a whole-subject bare pending-cell reference qualifies (mirrors the runtime
|
|
1203
|
+
`awaitSubjectExpr` classification); any other subject (a plain promise, a blocking
|
|
1204
|
+
cell, a compound expression) stays a plain `await`. */
|
|
1205
|
+
const subjectIsPendingCell = builder.pendingCellNames.has(node.promise.trim())
|
|
1206
|
+
builder.raw(`const ${resolved} = ${subjectIsPendingCell ? '$$awaitCell(' : 'await '}`)
|
|
1127
1207
|
builder.expr(node.promise, node.loc)
|
|
1128
|
-
builder.raw(
|
|
1208
|
+
builder.raw(`${subjectIsPendingCell ? ')' : ''};\nvoid ${resolved};\n`)
|
|
1129
1209
|
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
1130
1210
|
const branches = node.children.filter((child) => child.kind === 'branch')
|
|
1131
1211
|
if (node.blocking && node.as !== undefined) {
|
|
@@ -85,7 +85,14 @@ export function createScope(
|
|
|
85
85
|
declaration order — the local half of a cell's serialization-stable warm-seed key
|
|
86
86
|
(`${scope.id}:${index}`, see `createAsyncCell`). Per-scope (not global) so a client-only
|
|
87
87
|
sibling component can't shift another component's cell keys: divergence stays local. Both
|
|
88
|
-
SSR and client construct a component's cells in the same order, so the indices agree
|
|
88
|
+
SSR and client construct a component's cells in the same order, so the indices agree — but
|
|
89
|
+
ONLY because every cell that CAN become async draws exactly one index per construction,
|
|
90
|
+
independent of whether it materializes as async on a given render. `computed` gets this from
|
|
91
|
+
its static `isAsyncFunction` routing; `linked` decides at runtime (it draws in
|
|
92
|
+
`createAsyncCell` when its seed suspends, else RESERVES the index in its plain-`state`
|
|
93
|
+
fall-through), so a `linked` whose blocking dep is in flight on the server but warm on the
|
|
94
|
+
client still occupies its one slot on both sides. Break that "exactly one per construction"
|
|
95
|
+
rule and every downstream cell keys off-by-one across the handoff. */
|
|
89
96
|
let cellIndex = 0
|
|
90
97
|
|
|
91
98
|
/* `cell` and `nextCellIndex` are not on the public `Scope` type — `cell` is the compiler-only
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -62,6 +62,12 @@ export function each<T>(
|
|
|
62
62
|
/* Each row's scope, registered with the owner so every live row disposes on owner
|
|
63
63
|
teardown (the effect's own disposer only unsubscribes it from `items()`). */
|
|
64
64
|
const group = scopeGroup()
|
|
65
|
+
/* Persistent reconcile scratch, reused across passes so a steady-state reconcile
|
|
66
|
+
allocates nothing: `keys` holds each desired key (one `keyOf` call per item), `resolved`
|
|
67
|
+
its surviving row (or undefined → build). Both are sized to the current list each pass
|
|
68
|
+
and their stale tail is cleared, so a shrinking list retains no disposed-row refs. */
|
|
69
|
+
const keys: string[] = []
|
|
70
|
+
const resolved: (EachRow | undefined)[] = []
|
|
65
71
|
|
|
66
72
|
/* Build a row's range. Hydrate mode (only while the claim cursor is active —
|
|
67
73
|
read fresh, since a row built by a post-hydration reconcile must create, not
|
|
@@ -177,22 +183,30 @@ export function each<T>(
|
|
|
177
183
|
withoutHydration(() => {
|
|
178
184
|
/* Undefined source → empty list (see the hydration loop, ADR-0032 D3). */
|
|
179
185
|
const list = source == null ? [] : Array.isArray(source) ? source : [...source]
|
|
180
|
-
const
|
|
186
|
+
const count = list.length
|
|
181
187
|
generation += 1
|
|
182
188
|
const pass = generation
|
|
183
|
-
/*
|
|
184
|
-
this pass — so prune below is an int compare (no `Set`)
|
|
185
|
-
walk reuses the resolved row instead of a second `rows.get`.
|
|
186
|
-
lookups per reconcile is a real win once the harness's O(n)
|
|
187
|
-
longer swamps it. `undefined` marks a key with no surviving row
|
|
188
|
-
|
|
189
|
-
for (let index = 0; index <
|
|
190
|
-
const
|
|
189
|
+
/* Fill the reused scratch and resolve each desired key to its surviving row once,
|
|
190
|
+
here, stamping it with this pass — so prune below is an int compare (no `Set`)
|
|
191
|
+
and the placement walk reuses the resolved row instead of a second `rows.get`.
|
|
192
|
+
Halving the map lookups per reconcile is a real win once the harness's O(n)
|
|
193
|
+
`nextSibling` no longer swamps it. `undefined` marks a key with no surviving row
|
|
194
|
+
— a build. One `keyOf` call per item, cached in `keys` for the placement walk. */
|
|
195
|
+
for (let index = 0; index < count; index += 1) {
|
|
196
|
+
const key = keyOf(list[index] as T)
|
|
197
|
+
keys[index] = key
|
|
198
|
+
const row = rows.get(key)
|
|
191
199
|
if (row !== undefined) {
|
|
192
200
|
row.gen = pass
|
|
193
201
|
}
|
|
194
202
|
resolved[index] = row
|
|
195
203
|
}
|
|
204
|
+
/* Drop the previous pass's longer tail so a shrunk list retains no disposed-row
|
|
205
|
+
refs and the scratch stays sized to the live list. */
|
|
206
|
+
if (keys.length > count) {
|
|
207
|
+
keys.length = count
|
|
208
|
+
resolved.length = count
|
|
209
|
+
}
|
|
196
210
|
/* Prune departed rows first (those the resolve loop didn't reach this pass) so
|
|
197
211
|
their ranges don't sit between survivors and throw off the in-place sibling
|
|
198
212
|
checks below. */
|
|
@@ -70,7 +70,22 @@ function bindKeys(
|
|
|
70
70
|
if (skip.has(key)) {
|
|
71
71
|
continue
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
/* Reading the value can itself throw a `SuspenseSignal`: when `source()` is a
|
|
74
|
+
restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying
|
|
75
|
+
prop thunk, so a key whose expression reads a still-pending blocking `await` suspends
|
|
76
|
+
HERE — after the `source()` enumeration guard already passed. Classify it as an
|
|
77
|
+
attribute; `attr`'s own effect swallows the suspend and fills the value in on settle.
|
|
78
|
+
An event handler never suspends — its thunk returns a function without reading a cell. */
|
|
79
|
+
let value: unknown
|
|
80
|
+
try {
|
|
81
|
+
value = object[key]
|
|
82
|
+
} catch (signal) {
|
|
83
|
+
if (!(signal instanceof SuspenseSignal)) {
|
|
84
|
+
throw signal
|
|
85
|
+
}
|
|
86
|
+
attr(element, key, () => source()[key])
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
74
89
|
if (key.startsWith('on') && typeof value === 'function') {
|
|
75
90
|
on(element, key.slice(2), value as EventListener)
|
|
76
91
|
} else if (typeof value !== 'function') {
|
package/src/lib/ui/linked.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAsyncIterable } from '../shared/isAsyncIterable.ts'
|
|
2
2
|
import type { AsyncState } from '../shared/types/AsyncState.ts'
|
|
3
3
|
import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
|
|
4
|
+
import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
|
|
4
5
|
import { createAsyncCell } from './runtime/createAsyncCell.ts'
|
|
5
6
|
import { createEffectNode } from './runtime/createEffectNode.ts'
|
|
6
7
|
import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
|
|
@@ -8,6 +9,7 @@ import { routeBareSeed } from './runtime/routeBareSeed.ts'
|
|
|
8
9
|
import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
|
|
9
10
|
import type { State } from './runtime/types/State.ts'
|
|
10
11
|
import { state } from './state.ts'
|
|
12
|
+
import type { Scope } from './types/Scope.ts'
|
|
11
13
|
|
|
12
14
|
/*
|
|
13
15
|
A writable cell seeded reactively from upstream — the abide form of Angular's
|
|
@@ -95,6 +97,19 @@ export function linked<T>(
|
|
|
95
97
|
transform: coerce,
|
|
96
98
|
}) as AsyncState<T>
|
|
97
99
|
}
|
|
100
|
+
/* Reserve this cell's async-cell ordinal even though the seed resolved synchronously to a
|
|
101
|
+
plain `state`. Whether a `linked` seed suspends (→ `createAsyncCell`, which draws a
|
|
102
|
+
`nextCellIndex()` for its warm-seed key) or resolves synchronously (→ this plain `state`,
|
|
103
|
+
drawing none) is DATA-dependent, and that data can differ across the SSR→client handoff: a
|
|
104
|
+
blocking dependency still in flight on the server (→ suspend → async cell → index drawn) is
|
|
105
|
+
already warm-adopted on the warm client (→ sync resolve → plain state → no index). Without
|
|
106
|
+
this reserve, every async cell declared AFTER a `linked` in the same scope would key
|
|
107
|
+
off-by-one between sides and adopt the wrong warm seed. Drawing the index here makes a
|
|
108
|
+
`linked` ALWAYS occupy exactly one ordinal — the invariant `computed` gets for free from its
|
|
109
|
+
static `isAsyncFunction` routing — so downstream keys align regardless of which path ran. A
|
|
110
|
+
detached cell (no scope) never warm-seeds, so it draws nothing, mirroring `createAsyncCell`. */
|
|
111
|
+
const scope = CURRENT_SCOPE.current as (Scope & { nextCellIndex: () => number }) | undefined
|
|
112
|
+
scope?.nextCellIndex()
|
|
98
113
|
/* The cell is a plain `state` — same store, same write path, so `transform` gates
|
|
99
114
|
reseeds and explicit writes identically. */
|
|
100
115
|
const cell = state<T>(undefined as T, transform)
|
|
@@ -7,6 +7,7 @@ import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
|
|
|
7
7
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
8
8
|
import { pageSlot } from '../shared/pageSlot.ts'
|
|
9
9
|
import { SOCKET_SEED } from '../shared/SOCKET_SEED.ts'
|
|
10
|
+
import { SSR_SCRIPT_ID } from '../shared/SSR_SCRIPT_ID.ts'
|
|
10
11
|
import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
|
|
11
12
|
import type { SsrPayload } from '../shared/types/SsrPayload.ts'
|
|
12
13
|
import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
|
|
@@ -32,6 +33,39 @@ navigation — importing each further page's chunk on first visit and probing th
|
|
|
32
33
|
destination through the server's app.handle so auth/redirect gating still applies.
|
|
33
34
|
Returns a disposer. `target` defaults to `#app`; pass one explicitly in tests.
|
|
34
35
|
*/
|
|
36
|
+
/*
|
|
37
|
+
Reads the server's SSR payload. Production ships it as an inert `<script
|
|
38
|
+
type="application/json">` (ADR-0051) parsed once here in the deferred bundle — off the
|
|
39
|
+
critical path, and through the fast JSON grammar rather than a compiled multi-MB JS object
|
|
40
|
+
literal. A pre-set `globalThis.__SSR__` wins first: the uiStartClient tests stamp it
|
|
41
|
+
directly, and it preserves any pre-bundle inline stamp. The parsed payload is republished
|
|
42
|
+
onto `globalThis.__SSR__` for devtools / inspector visibility.
|
|
43
|
+
*/
|
|
44
|
+
function readSsrPayload(): Partial<SsrPayload> {
|
|
45
|
+
const globalScope = globalThis as { __SSR__?: Partial<SsrPayload> }
|
|
46
|
+
const preset = globalScope.__SSR__
|
|
47
|
+
if (preset !== undefined) {
|
|
48
|
+
return preset
|
|
49
|
+
}
|
|
50
|
+
const element = typeof document !== 'undefined' ? document.getElementById(SSR_SCRIPT_ID) : null
|
|
51
|
+
const text = element?.textContent
|
|
52
|
+
if (!text) {
|
|
53
|
+
return {}
|
|
54
|
+
}
|
|
55
|
+
/* Degrade a mangled payload to a cold boot rather than throwing out of startClient (a
|
|
56
|
+
proxy/extension could corrupt the script text). The old inline `window.__SSR__ = {…}`
|
|
57
|
+
statement failed the same way — its throw left the global undefined and the `?? {}`
|
|
58
|
+
fallback booted cold — so keep that resilience instead of failing the whole mount. */
|
|
59
|
+
let parsed: Partial<SsrPayload>
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(text) as Partial<SsrPayload>
|
|
62
|
+
} catch {
|
|
63
|
+
return {}
|
|
64
|
+
}
|
|
65
|
+
globalScope.__SSR__ = parsed
|
|
66
|
+
return parsed
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
// @documentation plumbing
|
|
36
70
|
export function startClient(
|
|
37
71
|
routes: Record<string, RouteLoader>,
|
|
@@ -43,7 +77,7 @@ export function startClient(
|
|
|
43
77
|
if (target === null) {
|
|
44
78
|
throw new Error('[abide] startClient: missing #app target')
|
|
45
79
|
}
|
|
46
|
-
const ssr = (
|
|
80
|
+
const ssr = readSsrPayload()
|
|
47
81
|
/* Seed the per-page __SSR__ stamps into their shared slots before mount: the mount
|
|
48
82
|
base, app name (default log channel), health payload (so health()'s first probe is
|
|
49
83
|
warm), and the env-configured RPC timeout (ABIDE_CLIENT_TIMEOUT, shipped per
|