@abide/abide 0.55.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 +34 -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 +203 -18
- package/src/lib/ui/compile/desugarSignals.ts +187 -0
- package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
- package/src/lib/ui/createScope.ts +8 -1
- package/src/lib/ui/linked.ts +33 -1
- package/src/lib/ui/runtime/createAsyncCell.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.56.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- lift buried async sub-expressions in state/linked/computed seeds (ADR-0032, script side) ([`6b269f4`](https://github.com/briancray/abide/commit/6b269f45d8ab2e117d62684620542cfd1199631e))
|
|
28
|
+
- a linked seed that reads a pending blocking cell becomes a blocking AsyncState ([`8329365`](https://github.com/briancray/abide/commit/83293652689d6a95c8cf4ffe66722e33be1aa63b))
|
|
29
|
+
- a034b2d: `linked` seed that reads a pending blocking cell becomes a blocking `AsyncState`
|
|
30
|
+
|
|
31
|
+
A `linked()` whose seed transitively reads a blocking (`await`-marked) async cell used to throw a `SuspenseSignal` out of its eager reseed effect at construction — the effect is a leaf with no reader to swallow the suspend, so it escaped to the caller. The classification probe now routes a seed that suspends to a writable async cell, and that cell marks itself blocking when its seed synchronously suspends, so its own reads pause too. A blocking read now behaves the same inside a `linked` seed as it does in a `computed` seed or a template binding.
|
|
32
|
+
|
|
33
|
+
- a034b2d: Lift buried async sub-expressions in `state` / `state.linked` / `state.computed` seeds
|
|
34
|
+
|
|
35
|
+
A script seed whose argument buries a promise/stream call — `state.linked(getSession()?.filteredSources ?? [])` — now lifts that call to an injected streaming peek-cell, exactly as a `{#if getSession()?.x}` template position already does. It reads `undefined` while pending (composing with `?.`/`??`) instead of type-erroring on `Promise.filteredSources`, and reseeds when the source resolves. A bare whole-seed call/identifier (`state.computed(getRates())`, `chat.done()`) is unchanged — only a compound seed that buries an async call is lifted. This closes the gap where the script side was less forgiving than the template side.
|
|
36
|
+
|
|
3
37
|
## 0.55.0
|
|
4
38
|
|
|
5
39
|
### 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
|
}
|
|
@@ -4,7 +4,11 @@ import { attrLiftPosition } from './attrLiftPosition.ts'
|
|
|
4
4
|
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
5
5
|
import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
|
|
6
6
|
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type LiftPosition,
|
|
9
|
+
type LiftSpan,
|
|
10
|
+
liftAsyncSubExpressions,
|
|
11
|
+
} from './liftAsyncSubExpressions.ts'
|
|
8
12
|
import { parseTemplate } from './parseTemplate.ts'
|
|
9
13
|
import {
|
|
10
14
|
NESTED_REACTIVE_BINDINGS,
|
|
@@ -78,7 +82,6 @@ export function compileShadow(
|
|
|
78
82
|
propsType = 'Record<string, any>',
|
|
79
83
|
classify?: InterpolationClassifier,
|
|
80
84
|
): CompiledShadow {
|
|
81
|
-
const builder = createBuilder(classify)
|
|
82
85
|
const leadingScript = source.match(LEADING_SCRIPT)
|
|
83
86
|
const scriptBody = leadingScript?.[1] ?? ''
|
|
84
87
|
/* Body starts just past the opening `<script …>`; template just past `</script>`. */
|
|
@@ -94,7 +97,12 @@ export function compileShadow(
|
|
|
94
97
|
importedReactives,
|
|
95
98
|
propsLocalName,
|
|
96
99
|
watchLocalName,
|
|
97
|
-
|
|
100
|
+
pendingCellNames,
|
|
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)
|
|
98
106
|
builder.raw(shadowPreamble(importedReactives))
|
|
99
107
|
/* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
|
|
100
108
|
unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
|
|
@@ -113,6 +121,14 @@ export function compileShadow(
|
|
|
113
121
|
builder.raw(
|
|
114
122
|
'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
|
|
115
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
|
+
}
|
|
116
132
|
/* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
|
|
117
133
|
sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
|
|
118
134
|
async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
|
|
@@ -241,6 +257,9 @@ type Builder = {
|
|
|
241
257
|
/* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
|
|
242
258
|
like an await's resolved value, kept distinct so nested blocks never collide. */
|
|
243
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>
|
|
244
263
|
result: () => CompiledShadow
|
|
245
264
|
}
|
|
246
265
|
|
|
@@ -248,7 +267,10 @@ type Builder = {
|
|
|
248
267
|
emitted verbatim from the original script), offset-relative to the line start. */
|
|
249
268
|
type ScopeLine = { text: string; segments: ShadowMapping[] }
|
|
250
269
|
|
|
251
|
-
function createBuilder(
|
|
270
|
+
function createBuilder(
|
|
271
|
+
classify?: InterpolationClassifier,
|
|
272
|
+
pendingCellNames: ReadonlySet<string> = new Set(),
|
|
273
|
+
): Builder {
|
|
252
274
|
let code = ''
|
|
253
275
|
let uniqueCounter = 0
|
|
254
276
|
const mappings: ShadowMapping[] = []
|
|
@@ -298,6 +320,7 @@ function createBuilder(classify?: InterpolationClassifier): Builder {
|
|
|
298
320
|
}
|
|
299
321
|
}
|
|
300
322
|
const builder: Builder = {
|
|
323
|
+
pendingCellNames,
|
|
301
324
|
unique(base) {
|
|
302
325
|
return `__${base}_${uniqueCounter++}`
|
|
303
326
|
},
|
|
@@ -375,6 +398,9 @@ type ScriptAnalysis = {
|
|
|
375
398
|
overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
|
|
376
399
|
value, so the real State/socket/rpc overloads never match it). */
|
|
377
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>
|
|
378
404
|
}
|
|
379
405
|
|
|
380
406
|
/* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
|
|
@@ -492,12 +518,17 @@ function collectNestedScriptAwaitDiagnostics(
|
|
|
492
518
|
module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
|
|
493
519
|
prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
|
|
494
520
|
spans map back exactly. */
|
|
495
|
-
function analyzeScript(
|
|
521
|
+
function analyzeScript(
|
|
522
|
+
scriptBody: string,
|
|
523
|
+
scriptStart: number,
|
|
524
|
+
classify?: InterpolationClassifier,
|
|
525
|
+
): ScriptAnalysis {
|
|
496
526
|
const imports: ScopeLine[] = []
|
|
497
527
|
const types: ScopeLine[] = []
|
|
498
528
|
const scope: ScopeLine[] = []
|
|
499
529
|
const propsShapes: string[] = []
|
|
500
530
|
const diagnostics: ShadowDiagnostic[] = []
|
|
531
|
+
const pendingCellNames = new Set<string>()
|
|
501
532
|
if (scriptBody.trim() === '') {
|
|
502
533
|
return {
|
|
503
534
|
imports,
|
|
@@ -508,6 +539,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
508
539
|
importedReactives: new Set(),
|
|
509
540
|
propsLocalName: undefined,
|
|
510
541
|
watchLocalName: undefined,
|
|
542
|
+
pendingCellNames,
|
|
511
543
|
}
|
|
512
544
|
}
|
|
513
545
|
const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
@@ -564,7 +596,17 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
564
596
|
continue
|
|
565
597
|
}
|
|
566
598
|
for (const declaration of reactive) {
|
|
567
|
-
scope.push(
|
|
599
|
+
scope.push(
|
|
600
|
+
scopeLineFor(
|
|
601
|
+
declaration,
|
|
602
|
+
propsShapes,
|
|
603
|
+
verbatim,
|
|
604
|
+
span,
|
|
605
|
+
bindings,
|
|
606
|
+
classify,
|
|
607
|
+
pendingCellNames,
|
|
608
|
+
),
|
|
609
|
+
)
|
|
568
610
|
}
|
|
569
611
|
}
|
|
570
612
|
return {
|
|
@@ -576,6 +618,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
576
618
|
importedReactives,
|
|
577
619
|
propsLocalName,
|
|
578
620
|
watchLocalName,
|
|
621
|
+
pendingCellNames,
|
|
579
622
|
}
|
|
580
623
|
}
|
|
581
624
|
|
|
@@ -623,12 +666,88 @@ function reactiveDeclarations(
|
|
|
623
666
|
|
|
624
667
|
/* Builds one scope line for a reactive declaration, projecting it to its value
|
|
625
668
|
type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
|
|
669
|
+
/* Splits a SEED's source text so each buried async sub-expression is wrapped in its shadow peek
|
|
670
|
+
helper (`$$peek`/`$$peekStream`) — the script-seed twin of the builder's `emitPeeked` (ADR-0032),
|
|
671
|
+
but returning a mapped `{ text, segments }` for a scope line rather than driving the builder. So a
|
|
672
|
+
bare `state.linked(getSession()?.x ?? [])` shadow-types via `$$peek(getSession())?.x ?? []` (the
|
|
673
|
+
RESOLVED value, `?.`/`??` compose) instead of erroring on `Promise.x` — mirroring exactly what the
|
|
674
|
+
`desugarSignals` lift builds. Fail-open: no classifier, a thunk seed (the walk stops at function
|
|
675
|
+
boundaries — matching the build, which also leaves a `() => …` seed unlifted), or an unparseable
|
|
676
|
+
span → the seed emits verbatim as one span. Every original char keeps its true source offset; the
|
|
677
|
+
inserted wrapper chars are unmapped, so diagnostics land precisely. */
|
|
678
|
+
function peekWrappedSeed(
|
|
679
|
+
seedText: string,
|
|
680
|
+
sourceStart: number,
|
|
681
|
+
prefixLength: number,
|
|
682
|
+
classify: InterpolationClassifier | undefined,
|
|
683
|
+
): { text: string; segments: ShadowMapping[] } {
|
|
684
|
+
const verbatimResult = {
|
|
685
|
+
text: seedText,
|
|
686
|
+
segments: [{ shadowStart: prefixLength, sourceStart, length: seedText.length }],
|
|
687
|
+
}
|
|
688
|
+
if (classify === undefined) {
|
|
689
|
+
return verbatimResult
|
|
690
|
+
}
|
|
691
|
+
let spans: LiftSpan[]
|
|
692
|
+
try {
|
|
693
|
+
let counter = 0
|
|
694
|
+
spans = liftAsyncSubExpressions(
|
|
695
|
+
seedText,
|
|
696
|
+
sourceStart,
|
|
697
|
+
classify,
|
|
698
|
+
() => `__v${counter++}`,
|
|
699
|
+
'content',
|
|
700
|
+
).spans.filter((span) => !span.blocking)
|
|
701
|
+
} catch {
|
|
702
|
+
return verbatimResult
|
|
703
|
+
}
|
|
704
|
+
if (spans.length === 0) {
|
|
705
|
+
return verbatimResult
|
|
706
|
+
}
|
|
707
|
+
/* A single span covering the WHOLE seed is the whole-seed case (`computed(getRates())`): the
|
|
708
|
+
`$$cellValue`/`$$cellValuePending` wrapper the caller already applies unwraps it, so peek-
|
|
709
|
+
wrapping it here would double-unwrap (`T` → `T | undefined`). Skip it — only PROPER sub-
|
|
710
|
+
expressions wrap, matching `liftScriptSeed`'s identical whole-seed skip on the build side. */
|
|
711
|
+
const only = spans.length === 1 ? spans[0] : undefined
|
|
712
|
+
if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
|
|
713
|
+
return verbatimResult
|
|
714
|
+
}
|
|
715
|
+
let text = ''
|
|
716
|
+
const segments: ShadowMapping[] = []
|
|
717
|
+
const pushMapped = (chunk: string, chunkSource: number): void => {
|
|
718
|
+
segments.push({
|
|
719
|
+
shadowStart: prefixLength + text.length,
|
|
720
|
+
sourceStart: chunkSource,
|
|
721
|
+
length: chunk.length,
|
|
722
|
+
})
|
|
723
|
+
text += chunk
|
|
724
|
+
}
|
|
725
|
+
let cursor = 0
|
|
726
|
+
for (const span of spans) {
|
|
727
|
+
if (span.start > cursor) {
|
|
728
|
+
pushMapped(seedText.slice(cursor, span.start), sourceStart + cursor)
|
|
729
|
+
}
|
|
730
|
+
text += span.kind === 'asyncIterable' ? '$$peekStream(' : '$$peek('
|
|
731
|
+
pushMapped(seedText.slice(span.start, span.end), sourceStart + span.start)
|
|
732
|
+
text += ')'
|
|
733
|
+
cursor = span.end
|
|
734
|
+
}
|
|
735
|
+
if (cursor < seedText.length) {
|
|
736
|
+
pushMapped(seedText.slice(cursor), sourceStart + cursor)
|
|
737
|
+
}
|
|
738
|
+
return { text, segments }
|
|
739
|
+
}
|
|
740
|
+
|
|
626
741
|
function scopeLineFor(
|
|
627
742
|
declaration: ts.VariableDeclaration,
|
|
628
743
|
propsShapes: string[],
|
|
629
744
|
verbatim: (node: ts.Node) => string,
|
|
630
745
|
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
631
746
|
bindings: ReactiveImportBindings,
|
|
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>,
|
|
632
751
|
): ScopeLine {
|
|
633
752
|
const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
|
|
634
753
|
const call = declaration.initializer as ts.CallExpression
|
|
@@ -689,9 +808,12 @@ function scopeLineFor(
|
|
|
689
808
|
})
|
|
690
809
|
}
|
|
691
810
|
const prefix = `let ${name}${annotation} = (`
|
|
811
|
+
/* Peek-wrap a buried async sub-expression in the initial too (the `desugarSignals` lift
|
|
812
|
+
runs on `state` seeds), so `state(getSession()?.x ?? [])` type-checks. */
|
|
813
|
+
const seed = seedPeekWrap(init, verbatim, span, prefix.length, classify)
|
|
692
814
|
return withCalleeRef({
|
|
693
|
-
text: `${prefix}${
|
|
694
|
-
segments: [span(declaration.name, 4),
|
|
815
|
+
text: `${prefix}${seed.text});`,
|
|
816
|
+
segments: [span(declaration.name, 4), ...seed.segments],
|
|
695
817
|
})
|
|
696
818
|
}
|
|
697
819
|
/* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
|
|
@@ -723,19 +845,49 @@ function scopeLineFor(
|
|
|
723
845
|
const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
|
|
724
846
|
const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
|
|
725
847
|
const wrapSuffix = fnIsThunk ? '' : ')'
|
|
726
|
-
/* ADR-0042: a STREAMING
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
+
}
|
|
732
863
|
const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
|
|
864
|
+
/* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
|
|
865
|
+
`getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
|
|
866
|
+
A whole-seed async call / a thunk seed yields no wrap (verbatim), exactly as the build. */
|
|
867
|
+
const seed = seedPeekWrap(fn, verbatim, span, prefix.length, classify)
|
|
733
868
|
return withCalleeRef({
|
|
734
|
-
text: `${prefix}${
|
|
735
|
-
segments: [span(declaration.name, keywordOffset),
|
|
869
|
+
text: `${prefix}${seed.text}${wrapSuffix});`,
|
|
870
|
+
segments: [span(declaration.name, keywordOffset), ...seed.segments],
|
|
736
871
|
})
|
|
737
872
|
}
|
|
738
873
|
|
|
874
|
+
/* The seed peek-wrap as a scope line contributes it: a bare CALL or IDENTIFIER seed (the whole-seed
|
|
875
|
+
/ probe case — `getRates()`, `chat.done()`) stays verbatim so the `$$cellValue` wrapper unwraps it
|
|
876
|
+
and no probe receiver is lifted, mirroring `desugarSignals`'s bare-seed skip; every other seed
|
|
877
|
+
descends into `peekWrappedSeed`. */
|
|
878
|
+
function seedPeekWrap(
|
|
879
|
+
seed: ts.Expression,
|
|
880
|
+
verbatim: (node: ts.Node) => string,
|
|
881
|
+
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
882
|
+
prefixLength: number,
|
|
883
|
+
classify: InterpolationClassifier | undefined,
|
|
884
|
+
): { text: string; segments: ShadowMapping[] } {
|
|
885
|
+
if (ts.isCallExpression(seed) || ts.isIdentifier(seed)) {
|
|
886
|
+
return { text: verbatim(seed), segments: [span(seed, prefixLength)] }
|
|
887
|
+
}
|
|
888
|
+
return peekWrappedSeed(verbatim(seed), span(seed, 0).sourceStart, prefixLength, classify)
|
|
889
|
+
}
|
|
890
|
+
|
|
739
891
|
/* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
|
|
740
892
|
a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
|
|
741
893
|
x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
|
|
@@ -765,6 +917,31 @@ function seedIsBlocking(seed: ts.Expression): boolean {
|
|
|
765
917
|
return hasTopLevelAwait(seed)
|
|
766
918
|
}
|
|
767
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
|
+
|
|
768
945
|
/* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
|
|
769
946
|
attachment aliases. All nested content (block bodies, await/switch branches, snippet
|
|
770
947
|
bodies) routes through `children`, so a recursive `children` walk is complete. */
|
|
@@ -1018,9 +1195,17 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
1018
1195
|
they're the pending content, checked without the resolved value. */
|
|
1019
1196
|
const resolved = builder.unique('awaited')
|
|
1020
1197
|
builder.raw('{\n')
|
|
1021
|
-
|
|
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 '}`)
|
|
1022
1207
|
builder.expr(node.promise, node.loc)
|
|
1023
|
-
builder.raw(
|
|
1208
|
+
builder.raw(`${subjectIsPendingCell ? ')' : ''};\nvoid ${resolved};\n`)
|
|
1024
1209
|
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
1025
1210
|
const branches = node.children.filter((child) => child.kind === 'branch')
|
|
1026
1211
|
if (node.blocking && node.as !== undefined) {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
2
|
import { assignmentTargetNames } from './assignmentTargetNames.ts'
|
|
3
3
|
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
4
|
+
import { liftScriptSeed, type ScriptSeedLift } from './liftScriptSeed.ts'
|
|
5
|
+
import type { InjectedCell } from './lowerAsyncInterpolations.ts'
|
|
4
6
|
import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
|
|
5
7
|
import { signalCallee } from './signalCallee.ts'
|
|
8
|
+
import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
|
|
6
9
|
import type { InterpolationKind } from './types/InterpolationKind.ts'
|
|
7
10
|
import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
|
|
8
11
|
import { wrapSeed } from './wrapSeed.ts'
|
|
@@ -279,6 +282,77 @@ export function desugarSignals(
|
|
|
279
282
|
}
|
|
280
283
|
return seedKind(argument) === 'promise'
|
|
281
284
|
}
|
|
285
|
+
/* ADR-0032 (script side): a `state`/`linked`/`computed` seed whose ARGUMENT buries a
|
|
286
|
+
promise/stream sub-expression (`getSession()?.filteredSources ?? []`) — not the whole seed
|
|
287
|
+
— gets that sub-expression lifted to an injected streaming peek-cell, exactly as a template
|
|
288
|
+
interpolation does, so it reads `undefined` while pending and composes with `?.`/`??`. The
|
|
289
|
+
lift runs on the seed's checker type via the SAME classifier the whole-seed routing uses,
|
|
290
|
+
bridged to the interpolation shape (its `undefined` failure sentinel folds to `sync`, safe
|
|
291
|
+
because the walk only branches on `promise`/`asyncIterable`). Cached per declaration so the
|
|
292
|
+
shared `__vs` mint stays consistent between the name-collection pass and the transform. */
|
|
293
|
+
const liftClassify: InterpolationClassifier | undefined =
|
|
294
|
+
seedClassify === undefined ? undefined : (loc, code) => seedClassify(loc, code) ?? 'sync'
|
|
295
|
+
let scriptSeedCounter = 0
|
|
296
|
+
const mintScriptSeed = (): string => `__vs${scriptSeedCounter++}`
|
|
297
|
+
const seedLifts = new Map<ts.VariableDeclaration, ScriptSeedLift | undefined>()
|
|
298
|
+
const seedLiftFor = (declaration: ts.VariableDeclaration): ScriptSeedLift | undefined => {
|
|
299
|
+
if (seedLifts.has(declaration)) {
|
|
300
|
+
return seedLifts.get(declaration)
|
|
301
|
+
}
|
|
302
|
+
const lift = computeSeedLift(declaration)
|
|
303
|
+
seedLifts.set(declaration, lift)
|
|
304
|
+
return lift
|
|
305
|
+
}
|
|
306
|
+
/* Applies the per-callee gate then runs the lift. A seed the WHOLE-seed classification already
|
|
307
|
+
claims — an author `await` (blocking), a bare promise/stream call — is left untouched so the
|
|
308
|
+
existing routing (or the runtime probe) owns it and nothing double-wraps; only a buried
|
|
309
|
+
sub-expression in an otherwise-sync seed lifts. */
|
|
310
|
+
function computeSeedLift(declaration: ts.VariableDeclaration): ScriptSeedLift | undefined {
|
|
311
|
+
if (liftClassify === undefined) {
|
|
312
|
+
return undefined
|
|
313
|
+
}
|
|
314
|
+
/* Only a single-declaration statement lifts: the injected peek-cells are emitted before the
|
|
315
|
+
owning statement, which can't split a `let a = …, b = …` list cleanly — and a
|
|
316
|
+
`rejectMixedDeclaration`-legal same-kind multi-declaration seed-lift is vanishingly rare.
|
|
317
|
+
Leaving it unlifted degrades to the pre-lift behavior (no injected orphan cell). */
|
|
318
|
+
const list = declaration.parent
|
|
319
|
+
if (!ts.isVariableDeclarationList(list) || list.declarations.length !== 1) {
|
|
320
|
+
return undefined
|
|
321
|
+
}
|
|
322
|
+
const callee = signalCallee(declaration, bindings)
|
|
323
|
+
if (callee === 'computed') {
|
|
324
|
+
if (
|
|
325
|
+
!isComputedSlot(declaration, bindings) ||
|
|
326
|
+
isAsyncComputed(declaration) ||
|
|
327
|
+
isEagerStreamComputed(declaration) ||
|
|
328
|
+
isPromiseComputed(declaration)
|
|
329
|
+
) {
|
|
330
|
+
return undefined
|
|
331
|
+
}
|
|
332
|
+
} else if (callee === 'linked' || callee === 'state') {
|
|
333
|
+
const argument = seedArgument(declaration)
|
|
334
|
+
if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
return undefined
|
|
339
|
+
}
|
|
340
|
+
const argument = seedArgument(declaration)
|
|
341
|
+
if (argument === undefined) {
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
/* A bare CALL or IDENTIFIER seed is the whole-seed case (`computed(getRates())`,
|
|
345
|
+
`linked(chat.done())`, `state.computed(getRates.refreshing({...}))`) — the existing
|
|
346
|
+
whole-seed routing / runtime probe owns it, and descending into it would lift a probe
|
|
347
|
+
RECEIVER (`chat` out of `chat.done()`), breaking the call. Only a COMPOUND seed — a
|
|
348
|
+
member/binary/etc. that BURIES an async call — lifts. Mirrors `isBareCallComputed`. */
|
|
349
|
+
if (ts.isCallExpression(argument) || ts.isIdentifier(argument)) {
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
352
|
+
const start = argument.getStart(source)
|
|
353
|
+
const seedText = source.text.slice(start, argument.getEnd())
|
|
354
|
+
return liftScriptSeed(seedText, scriptBase + start, liftClassify, mintScriptSeed)
|
|
355
|
+
}
|
|
282
356
|
for (const statement of source.statements) {
|
|
283
357
|
if (!ts.isVariableStatement(statement)) {
|
|
284
358
|
continue
|
|
@@ -317,6 +391,16 @@ export function desugarSignals(
|
|
|
317
391
|
cellReadNames.add(declaration.name.text)
|
|
318
392
|
continue
|
|
319
393
|
}
|
|
394
|
+
/* Register any injected `__vsN` peek-cell a buried async seed sub-expression lifts, so
|
|
395
|
+
the reference-lowering emits `$$readCell(__vsN)` for its reads. The owning
|
|
396
|
+
declaration keeps its own bucket (a lifted computed's seed is now SYNC — a lazy
|
|
397
|
+
`derive` — while a lifted `linked`/`state` stays its usual cell). */
|
|
398
|
+
const seedLift = seedLiftFor(declaration)
|
|
399
|
+
if (seedLift !== undefined) {
|
|
400
|
+
for (const cell of seedLift.cells) {
|
|
401
|
+
cellReadNames.add(cell.name)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
320
404
|
if (isPlainStateSlot(declaration, bindings)) {
|
|
321
405
|
/* Plain `state(initial)` → a serializable `model` doc slot. */
|
|
322
406
|
stateNames.add(declaration.name.text)
|
|
@@ -394,6 +478,7 @@ export function desugarSignals(
|
|
|
394
478
|
writtenNames,
|
|
395
479
|
isEagerStreamComputed,
|
|
396
480
|
isPromiseComputed,
|
|
481
|
+
seedLiftFor,
|
|
397
482
|
),
|
|
398
483
|
)
|
|
399
484
|
}
|
|
@@ -422,9 +507,11 @@ function loweredStatement(
|
|
|
422
507
|
writtenNames: ReadonlySet<string>,
|
|
423
508
|
isEagerStreamComputed: EagerStreamPredicate,
|
|
424
509
|
isPromiseComputed: EagerStreamPredicate,
|
|
510
|
+
seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
|
|
425
511
|
): ts.Statement[] {
|
|
426
512
|
rejectMixedDeclaration(statement, bindings)
|
|
427
513
|
return (
|
|
514
|
+
liftedSeedStatements(statement, bindings, seedLiftFor) ??
|
|
428
515
|
injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
|
|
429
516
|
stateAssignmentStatements(statement, bindings) ??
|
|
430
517
|
computedStatements(
|
|
@@ -439,6 +526,106 @@ function loweredStatement(
|
|
|
439
526
|
)
|
|
440
527
|
}
|
|
441
528
|
|
|
529
|
+
/* Parses a lifted seed fragment (`getSession()` / `__vs0?.filteredSources ?? []`) back into an
|
|
530
|
+
expression node. Wrapped in parens so an object-literal or comma seed parses as an expression;
|
|
531
|
+
the node is synthetic (mini-parse positions), safe because every downstream pass — reference
|
|
532
|
+
renaming, doc-access lowering — is position-independent. */
|
|
533
|
+
function parseSeedExpression(code: string): ts.Expression {
|
|
534
|
+
const file = ts.createSourceFile('__seed.ts', `(${code})`, ts.ScriptTarget.Latest, true)
|
|
535
|
+
const statement = file.statements[0]
|
|
536
|
+
if (statement !== undefined && ts.isExpressionStatement(statement)) {
|
|
537
|
+
const expression = statement.expression
|
|
538
|
+
return ts.isParenthesizedExpression(expression) ? expression.expression : expression
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`abide: could not parse lifted seed expression: ${code}`)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* `() => (<expr>)` — the bare thunk an injected STREAM peek-cell wraps its source in (byte-mirrors
|
|
544
|
+
an explicit `state.computed(getStream())`). */
|
|
545
|
+
function bareThunk(expression: ts.Expression): ts.Expression {
|
|
546
|
+
return factory.createArrowFunction(
|
|
547
|
+
undefined,
|
|
548
|
+
undefined,
|
|
549
|
+
[],
|
|
550
|
+
undefined,
|
|
551
|
+
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
552
|
+
factory.createParenthesizedExpression(expression),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* The injected `const __vsN = scope().trackedComputed(...)` cell for one lifted sub-expression —
|
|
557
|
+
the script-side twin of `injectedComputedStatements`: a PROMISE sub-expression becomes an async
|
|
558
|
+
unwrapping thunk carrying the streaming flag (`!blocking`), a STREAM stays a bare probe thunk. */
|
|
559
|
+
function injectedSeedCell(cell: InjectedCell): ts.Statement {
|
|
560
|
+
const source = parseSeedExpression(cell.code)
|
|
561
|
+
const args =
|
|
562
|
+
cell.kind === 'promise'
|
|
563
|
+
? [wrapAwaitSeed(source), cell.blocking ? factory.createFalse() : factory.createTrue()]
|
|
564
|
+
: [bareThunk(source)]
|
|
565
|
+
return constDeclaration(cell.name, scopeMethodCall('trackedComputed', args))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* If any declaration in `statement` carries a script-seed lift (ADR-0032), lowers the whole
|
|
569
|
+
statement HERE: each lifted declaration emits its injected `__vsN` peek-cells FIRST (declared
|
|
570
|
+
before the owning statement — the seed runs eagerly at construction, so an appended cell would
|
|
571
|
+
be a TDZ reference), then the owning declaration lowered over its now-SYNC rewritten seed (a
|
|
572
|
+
lifted `computed` is a lazy `derive`; a `linked`/`state` keeps its cell). A non-lifted sibling
|
|
573
|
+
in the same (same-kind) statement re-enters the normal lowering as its own single declaration.
|
|
574
|
+
Returns undefined when no declaration lifts, so the standard dispatch runs. */
|
|
575
|
+
function liftedSeedStatements(
|
|
576
|
+
statement: ts.Statement,
|
|
577
|
+
bindings: ReactiveImportBindings,
|
|
578
|
+
seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
|
|
579
|
+
): ts.Statement[] | undefined {
|
|
580
|
+
if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) {
|
|
581
|
+
return undefined
|
|
582
|
+
}
|
|
583
|
+
const declaration = statement.declarationList.declarations[0]!
|
|
584
|
+
const lift = seedLiftFor(declaration)
|
|
585
|
+
if (lift === undefined || !ts.isIdentifier(declaration.name)) {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
const output: ts.Statement[] = []
|
|
589
|
+
{
|
|
590
|
+
for (const cell of lift.cells) {
|
|
591
|
+
output.push(injectedSeedCell(cell))
|
|
592
|
+
}
|
|
593
|
+
const name = declaration.name.text
|
|
594
|
+
const seed = parseSeedExpression(lift.rewrittenSeedText)
|
|
595
|
+
const callee = signalCallee(declaration, bindings)
|
|
596
|
+
const rest = (declaration.initializer as ts.CallExpression).arguments.slice(1)
|
|
597
|
+
if (callee === 'computed') {
|
|
598
|
+
/* The rewritten seed is SYNC (it reads the peek-cells), so the computed is a lazy
|
|
599
|
+
`derive` doc slot read as `name()` — recomputes when a peek-cell reseeds. */
|
|
600
|
+
output.push(
|
|
601
|
+
constDeclaration(
|
|
602
|
+
name,
|
|
603
|
+
scopeMethodCall('derive', [factory.createStringLiteral(name), wrapSeed(seed)]),
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
} else if (callee === 'linked') {
|
|
607
|
+
output.push(
|
|
608
|
+
constDeclaration(name, scopeMethodCall('linked', [wrapSeed(seed), ...rest])),
|
|
609
|
+
)
|
|
610
|
+
} else if (isPlainStateSlot(declaration, bindings)) {
|
|
611
|
+
output.push(
|
|
612
|
+
factory.createExpressionStatement(
|
|
613
|
+
factory.createAssignment(
|
|
614
|
+
factory.createPropertyAccessExpression(
|
|
615
|
+
factory.createIdentifier('$$model'),
|
|
616
|
+
name,
|
|
617
|
+
),
|
|
618
|
+
seed,
|
|
619
|
+
),
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
} else {
|
|
623
|
+
output.push(constDeclaration(name, scopeMethodCall('state', [seed, ...rest])))
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return output
|
|
627
|
+
}
|
|
628
|
+
|
|
442
629
|
/* Lowers a synthetic `const __vN = computed(<seed>)` (an async (sub)expression peek-cell
|
|
443
630
|
`analyzeComponent` injected, ADR-0032) to a `scope().trackedComputed(...)` cell read via
|
|
444
631
|
`$$readCell`. Two shapes, by the seed:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
|
|
2
|
+
import type { InjectedCell } from './lowerAsyncInterpolations.ts'
|
|
3
|
+
import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
|
|
4
|
+
|
|
5
|
+
/* The pieces a script-seed lift yields: the async sub-expressions hoisted out of a
|
|
6
|
+
`state`/`linked`/`computed` seed argument (each an injected peek-cell) plus the seed
|
|
7
|
+
rewritten to reference them by bare `__vsN`. `desugarSignals` turns `cells` into
|
|
8
|
+
`scope().trackedComputed(...)` consts emitted BEFORE the owning declaration and parses
|
|
9
|
+
`rewrittenSeedText` back into the lowered seed. */
|
|
10
|
+
export type ScriptSeedLift = { cells: InjectedCell[]; rewrittenSeedText: string }
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
The script-side twin of the template `lowerAsyncInterpolations` walk (ADR-0032): runs
|
|
14
|
+
`liftAsyncSubExpressions` over a `state`/`state.linked`/`state.computed` seed ARGUMENT so a
|
|
15
|
+
buried promise/stream sub-expression (`getSession()?.filteredSources ?? []`) lifts to an
|
|
16
|
+
injected streaming peek-cell — reading `undefined` while pending so `?.`/`??` compose — exactly
|
|
17
|
+
as it would in a `{#if getSession()?.x}` template position. Only PROPER sub-expressions lift: a
|
|
18
|
+
seed that IS a single async expression (`computed(getSession())`, `linked(getStream())`) is left
|
|
19
|
+
whole for the existing whole-seed classification / runtime probe, so this never double-wraps.
|
|
20
|
+
|
|
21
|
+
`classify` absent (no warm program) ⇒ no lift — a buried bare-async sub-expression needs the
|
|
22
|
+
checker to know it is a promise, and a seed carrying a top-level `await` is already routed by the
|
|
23
|
+
caller's whole-seed classification, so fail-open here is a plain no-op. `seedLoc` is the seed's
|
|
24
|
+
absolute source offset, so each lifted span resolves against the shadow's source→shadow mappings.
|
|
25
|
+
*/
|
|
26
|
+
export function liftScriptSeed(
|
|
27
|
+
seedText: string,
|
|
28
|
+
seedLoc: number,
|
|
29
|
+
classify: InterpolationClassifier | undefined,
|
|
30
|
+
mint: () => string,
|
|
31
|
+
): ScriptSeedLift | undefined {
|
|
32
|
+
if (classify === undefined) {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
const result = liftAsyncSubExpressions(seedText, seedLoc, classify, mint, 'content')
|
|
36
|
+
if (result.lifts.length === 0) {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
/* A single span covering the whole seed is the whole-seed case (`computed(getSession())`) —
|
|
40
|
+
leave it to the existing classification (`isPromiseComputed`) / the runtime probe rather
|
|
41
|
+
than pointlessly wrapping the seed in its own cell. Only proper sub-expressions lift. */
|
|
42
|
+
const only = result.spans.length === 1 ? result.spans[0] : undefined
|
|
43
|
+
if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
return { cells: result.lifts, rewrittenSeedText: result.code }
|
|
47
|
+
}
|
|
@@ -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,12 +1,15 @@
|
|
|
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'
|
|
7
8
|
import { routeBareSeed } from './runtime/routeBareSeed.ts'
|
|
9
|
+
import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
|
|
8
10
|
import type { State } from './runtime/types/State.ts'
|
|
9
11
|
import { state } from './state.ts'
|
|
12
|
+
import type { Scope } from './types/Scope.ts'
|
|
10
13
|
|
|
11
14
|
/*
|
|
12
15
|
A writable cell seeded reactively from upstream — the abide form of Angular's
|
|
@@ -67,10 +70,26 @@ export function linked<T>(
|
|
|
67
70
|
non-stream value falls through to the plain synchronous cell. */
|
|
68
71
|
let probe: unknown
|
|
69
72
|
let threw = false
|
|
73
|
+
let suspended = false
|
|
70
74
|
try {
|
|
71
75
|
probe = seed()
|
|
72
|
-
} catch {
|
|
76
|
+
} catch (error) {
|
|
73
77
|
threw = true
|
|
78
|
+
suspended = error instanceof SuspenseSignal
|
|
79
|
+
}
|
|
80
|
+
/* The seed SUSPENDED — it read a pending BLOCKING cell (`await`-marked). A sync `state`
|
|
81
|
+
here would hold `undefined` while the reseed effect re-threw the suspend, letting it
|
|
82
|
+
escape at construction. Route to the eager async cell instead: `createAsyncCell` already
|
|
83
|
+
treats a synchronously-suspending seed as "pending" — it stays in flight, the throwing
|
|
84
|
+
read subscribed its effect to the blocking dependency, and it re-runs on settle — and it
|
|
85
|
+
marks the cell BLOCKING so its own reads pause too, matching a lazy `computed(() =>
|
|
86
|
+
blockingCell)`. This makes a blocking read behave the same in a `linked` seed as in a
|
|
87
|
+
`computed` seed or a template. A real (non-suspense) throw keeps the sync fall-through. */
|
|
88
|
+
if (suspended) {
|
|
89
|
+
return createAsyncCell(seed as () => unknown, {
|
|
90
|
+
writable: true,
|
|
91
|
+
transform: coerce,
|
|
92
|
+
}) as AsyncState<T>
|
|
74
93
|
}
|
|
75
94
|
if (!threw && isAsyncIterable(probe)) {
|
|
76
95
|
return createAsyncCell(seed as () => unknown, {
|
|
@@ -78,6 +97,19 @@ export function linked<T>(
|
|
|
78
97
|
transform: coerce,
|
|
79
98
|
}) as AsyncState<T>
|
|
80
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()
|
|
81
113
|
/* The cell is a plain `state` — same store, same write path, so `transform` gates
|
|
82
114
|
reseeds and explicit writes identically. */
|
|
83
115
|
const cell = state<T>(undefined as T, transform)
|
|
@@ -210,6 +210,16 @@ export function createAsyncCell(
|
|
|
210
210
|
promise is registered (there is none), so the SSR barrier's fixpoint drain
|
|
211
211
|
picks this cell up on that re-run instead. */
|
|
212
212
|
if (error instanceof SuspenseSignal) {
|
|
213
|
+
/* This cell's pending reads must PAUSE too — the seed derives from a blocking
|
|
214
|
+
source, so a reader peeking `undefined` (the streaming default) would race the
|
|
215
|
+
dependency instead of waiting for it. Mark blocking from the SUSPEND itself,
|
|
216
|
+
since a synchronously-suspending seed produces no promise for the `isThenable`
|
|
217
|
+
branch to read it from; honour a `streaming` opt-out for symmetry with that
|
|
218
|
+
branch. Once the seed re-runs and settles a value this flag is moot (a retained
|
|
219
|
+
value is returned before `blocking` is ever consulted). */
|
|
220
|
+
if (options.streaming !== true) {
|
|
221
|
+
blockingSource = true
|
|
222
|
+
}
|
|
213
223
|
if (myRun === runId) {
|
|
214
224
|
inFlight = undefined
|
|
215
225
|
writeNode(errorNode, undefined)
|