@abide/abide 0.56.0 → 0.57.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.
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +7 -0
- package/src/lib/shared/cache.ts +139 -34
- 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/ui/compile/compileShadow.ts +91 -11
- package/src/lib/ui/createScope.ts +8 -1
- package/src/lib/ui/linked.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.57.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d09e821: `errorTtl` — opt a failed RPC load into the negative cache
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3ce8963: Fix an async-cell warm-seed ordering bug where a `linked` cell reading a blocking dependency could desync SSR and client
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
- e8fb019: Type a bare no-`await` promise/stream `state.computed` seed as pending (`T | undefined`)
|
|
18
|
+
|
|
19
|
+
`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.
|
|
20
|
+
|
|
21
|
+
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 `?.`/`??`.
|
|
22
|
+
|
|
3
23
|
## 0.56.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
}
|
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
|
|
@@ -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
|
}
|
|
@@ -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/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)
|