@rindle/react 0.7.12 → 0.8.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/README.md +28 -0
- package/dist/index.d.ts +81 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +140 -51
- package/dist/index.js.map +1 -1
- package/dist/stream.d.ts +59 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +156 -0
- package/dist/stream.js.map +1 -0
- package/package.json +8 -5
- package/src/index.ts +218 -47
- package/src/stream.ts +205 -0
package/README.md
CHANGED
|
@@ -71,3 +71,31 @@ runtime dependency of these base React bindings.
|
|
|
71
71
|
- `queryCacheKey(query)` — the stable string key (`@rindle/client`'s `stableKey(ast)`) under which
|
|
72
72
|
the cache stores that view. Same AST → same key → one shared entry, and it matches the SSR seed
|
|
73
73
|
key so hydration finds the dehydrated view. Keys the view (bare AST), not the lease.
|
|
74
|
+
|
|
75
|
+
## Streaming an LM response
|
|
76
|
+
|
|
77
|
+
`useStreamedText({ streamId, durable, live })` renders a language-model response that is arriving on
|
|
78
|
+
two planes: the checkpointed prefix through your ordinary query, and the not-yet-checkpointed tail
|
|
79
|
+
over SSE. It returns the merged text.
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
const data = useFragment(MessageFragment, message);
|
|
83
|
+
const streaming = data.status === "streaming" || data.status === "pending";
|
|
84
|
+
const text = useStreamedText({
|
|
85
|
+
streamId: data.id,
|
|
86
|
+
durable: assembleDurableText(data, data.chunks), // body ++ the un-compacted chunk rows
|
|
87
|
+
live: streaming,
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`durable` is a string, not the row, because the column names belong to your schema — the hook never
|
|
92
|
+
guesses where `body` lives. It handles the parts that are easy to get wrong: not reconnecting when the
|
|
93
|
+
durable text advances, seeding the tail with the offset it joined at, closing its own `EventSource` on
|
|
94
|
+
a terminal frame (`EventSource` reconnects on *any* close), keying the tail to its stream so
|
|
95
|
+
switching messages can't show the previous one's text, and splicing a resumed replay at its own
|
|
96
|
+
offset so an `EventSource` reconnect (which can rewind behind the tail) never duplicates text. Losing the live leg is not an error — without
|
|
97
|
+
`EventSource`, or on the wrong server instance, the text still advances through the query at
|
|
98
|
+
checkpoint granularity.
|
|
99
|
+
|
|
100
|
+
`StreamTransport` is injectable for WebSocket, a fetch stream, or a test. Server side:
|
|
101
|
+
**[`@rindle/api-server`](https://rindle.sh/docs/api-server)** (`openStream` / `subscribeStream`).
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,17 @@ export type RootRefResult<Q extends AnyQuery, F extends Fragment<any, any, any,
|
|
|
13
13
|
export type { ResultType } from "@rindle/client";
|
|
14
14
|
export type { Fragment, FragmentData, FragmentRef } from "@rindle/client";
|
|
15
15
|
export { fragmentKey } from "@rindle/client";
|
|
16
|
+
export { DEFAULT_STREAM_ENDPOINT, eventSourceTransport, streamSubscribeUrl, useStreamedText, } from "./stream.ts";
|
|
17
|
+
export type { StreamTransport, UseStreamedTextInput, UseStreamedTextOptions } from "./stream.ts";
|
|
18
|
+
export { assembleDurableText, spliceStreamText } from "@rindle/client";
|
|
19
|
+
export type { StreamFrame, StreamStatus } from "@rindle/client";
|
|
16
20
|
export interface RindleProps<S extends ColsMap = ColsMap> {
|
|
17
21
|
store: Store<S>;
|
|
22
|
+
/** Default grace window (ms) for every query in this tree — how long a view + its server lease are
|
|
23
|
+
* kept warm after the last subscriber unmounts. Defaults to 2s; see {@link QueryReleaseOptions}
|
|
24
|
+
* for why, and for the per-call-site override. Treat as a constant: changing it rebuilds the
|
|
25
|
+
* caches and tears down every live view. */
|
|
26
|
+
releaseDelayMs?: number;
|
|
18
27
|
children?: ReactNode;
|
|
19
28
|
}
|
|
20
29
|
interface QueryLease {
|
|
@@ -24,17 +33,56 @@ interface QueryLease {
|
|
|
24
33
|
interface SyncLease {
|
|
25
34
|
id: number;
|
|
26
35
|
coverageKey: string;
|
|
36
|
+
/** Resolved grace window for THIS lease (see {@link QueryReleaseOptions}). */
|
|
37
|
+
releaseDelayMs: number;
|
|
27
38
|
}
|
|
28
39
|
interface QueryCacheOptions {
|
|
29
40
|
releaseDelayMs?: number;
|
|
30
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-call-site override for how long a query is kept warm after its LAST subscriber unmounts.
|
|
44
|
+
*
|
|
45
|
+
* The default (2s, or whatever `<Rindle releaseDelayMs>` sets) exists so a changed filter/limit can
|
|
46
|
+
* re-materialize from the still-warm local base while the replacement server lease streams its first
|
|
47
|
+
* answer — it's what keeps navigation from flashing empty. That grace window is wrong for queries you
|
|
48
|
+
* KNOW you will never come back to, the canonical case being typeahead search: every keystroke is a
|
|
49
|
+
* distinct query, so a 2s window leaves one dead view + server subscription open per character typed.
|
|
50
|
+
* Pass `0` there to tear down on unmount:
|
|
51
|
+
*
|
|
52
|
+
* ```tsx
|
|
53
|
+
* const results = useQuery(searchIssues(term), { releaseDelayMs: 0 });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Treat the value as a constant per call site — changing it re-leases the query (drops the old lease
|
|
57
|
+
* and takes a fresh one), which is wasted work if it changes every render.
|
|
58
|
+
*
|
|
59
|
+
* The rule for a query several components share with DIFFERENT delays is a DEADLINE, not a duration:
|
|
60
|
+
* every release stamps `now + that lease's delay`, and the query stays warm until the latest deadline
|
|
61
|
+
* any of its leases asked for (max-wins over what REMAINS, matching the SSR preload TTL rule in
|
|
62
|
+
* `@rindle/client`'s `ssr.ts`). Two consequences worth internalizing:
|
|
63
|
+
*
|
|
64
|
+
* - The clock starts when a subscriber LEAVES, never when it arrives — a mounted reader is never
|
|
65
|
+
* timed out, however long it stays.
|
|
66
|
+
* - A deadline expires on its own, so a later lease inherits at most the RESIDUE of an older window,
|
|
67
|
+
* never a fresh copy of it. Unmount a 2s reader, remount a `releaseDelayMs: 0` one 1.9s later and
|
|
68
|
+
* drop it: teardown lands at the original 2s mark, not 1.9s past it.
|
|
69
|
+
*
|
|
70
|
+
* Only meaningful against a backend that can retain remote queries. A local-only store (the SSR seed
|
|
71
|
+
* over `OneShotBackend`, or a store with no remote leg) always tears its views down on release, so
|
|
72
|
+
* there is no window to shorten.
|
|
73
|
+
*/
|
|
74
|
+
export interface QueryReleaseOptions {
|
|
75
|
+
/** ms to keep this query warm after the last subscriber unmounts. `0` = release immediately.
|
|
76
|
+
* Defaults to the provider's `releaseDelayMs` (2s). */
|
|
77
|
+
releaseDelayMs?: number;
|
|
78
|
+
}
|
|
31
79
|
declare class RindleContextValue {
|
|
32
80
|
readonly store: Store<ColsMap>;
|
|
33
81
|
readonly cache: QueryCache;
|
|
34
82
|
readonly syncCache: SyncQueryCache;
|
|
35
|
-
constructor(store: Store<ColsMap
|
|
83
|
+
constructor(store: Store<ColsMap>, releaseDelayMs: number);
|
|
36
84
|
}
|
|
37
|
-
export declare function Rindle<S extends ColsMap>({ store, children }: RindleProps<S>): import("react").FunctionComponentElement<import("react").ProviderProps<RindleContextValue | null>>;
|
|
85
|
+
export declare function Rindle<S extends ColsMap>({ store, releaseDelayMs, children }: RindleProps<S>): import("react").FunctionComponentElement<import("react").ProviderProps<RindleContextValue | null>>;
|
|
38
86
|
export declare const RindleProvider: typeof Rindle;
|
|
39
87
|
export declare function useRindleStore<S extends ColsMap = ColsMap>(): Store<S>;
|
|
40
88
|
export interface RindleSSRProps<S extends ColsMap = ColsMap> {
|
|
@@ -69,18 +117,18 @@ export interface RindleSSRProps<S extends ColsMap = ColsMap> {
|
|
|
69
117
|
* hand-rolled per app as `src/RindleApp.tsx`).
|
|
70
118
|
*/
|
|
71
119
|
export declare function RindleSSR<S extends ColsMap>({ schema, ssrState, boot, children }: RindleSSRProps<S>): import("react").FunctionComponentElement<RindleProps<S>>;
|
|
72
|
-
export declare function useQuery<Q extends AnyQuery>(query: Q): QueryData<Q>;
|
|
120
|
+
export declare function useQuery<Q extends AnyQuery>(query: Q, opts?: QueryReleaseOptions): QueryData<Q>;
|
|
73
121
|
/** The SERVER-CHANNEL state of a query's view (`@rindle/client` {@link ResultType}): `unknown` while
|
|
74
122
|
* it loads (not yet server-authoritative), `complete` once the server has answered. A pending
|
|
75
123
|
* optimistic mutation no longer moves this — that is a separate axis now (FOLDED-MUTATIONS-DESIGN
|
|
76
124
|
* §7); the `error` variant is reserved and currently unproduced. Shares the same cached/leased view
|
|
77
125
|
* as {@link useQuery} (so reading both for one query is one subscription), and re-renders only when
|
|
78
126
|
* the status changes. */
|
|
79
|
-
export declare function useQueryStatus(query: AnyQuery): ResultType;
|
|
127
|
+
export declare function useQueryStatus(query: AnyQuery, opts?: QueryReleaseOptions): ResultType;
|
|
80
128
|
/** Retain a named server query for normalized/local-first sync coverage without subscribing React
|
|
81
129
|
* to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`
|
|
82
130
|
* until the backend reports that the retained coverage has hydrated. */
|
|
83
|
-
export declare function useSyncQuery(query: AnyQuery): ResultType;
|
|
131
|
+
export declare function useSyncQuery(query: AnyQuery, opts?: QueryReleaseOptions): ResultType;
|
|
84
132
|
/** Run a named root query and expose its local React-facing data. Fragment child relationships are
|
|
85
133
|
* refs, so child components can keep owning their own local reads. Passing a root fragment as the
|
|
86
134
|
* final argument switches the result to opaque root refs for that fragment. */
|
|
@@ -102,31 +150,40 @@ export declare function useRoot<Args, Ctx extends readonly unknown[], Q extends
|
|
|
102
150
|
* The hook opens a narrow local-only query for this exact fragment and keeps the root coverage
|
|
103
151
|
* lease retained while mounted. Passing a legacy projected data object is unsupported.
|
|
104
152
|
*/
|
|
105
|
-
export declare function useFragment<F extends Fragment<any, any, any, any>>(fragment: F, ref: FragmentRef<F> | null | undefined): FragmentData<F> | null;
|
|
153
|
+
export declare function useFragment<F extends Fragment<any, any, any, any>>(fragment: F, ref: FragmentRef<F> | null | undefined, opts?: QueryReleaseOptions): FragmentData<F> | null;
|
|
106
154
|
/**
|
|
107
155
|
* Render-prop sugar over {@link useFragment}: does the `null` check once. `from` is a fragment ref
|
|
108
156
|
* (or null/undefined — an absent to-one relationship, an emptied `.one()`, or a row deleted out from
|
|
109
157
|
* under a live read); when the row is present `children(data)` renders, otherwise `fallback` (default
|
|
110
158
|
* nothing). Keeps the per-row subscription isolation — a child-only edit re-renders just this read.
|
|
111
159
|
*/
|
|
112
|
-
export declare function Frag<F extends AnyFragment>({ of, from, fallback, children }: {
|
|
160
|
+
export declare function Frag<F extends AnyFragment>({ of, from, fallback, releaseDelayMs, children }: {
|
|
113
161
|
of: F;
|
|
114
162
|
from: FragmentRef<F> | null | undefined;
|
|
115
163
|
fallback?: ReactNode;
|
|
164
|
+
/** Per-call-site grace window — see {@link QueryReleaseOptions}. */
|
|
165
|
+
releaseDelayMs?: number;
|
|
116
166
|
children: (data: FragmentData<F>) => ReactNode;
|
|
117
167
|
}): ReactNode;
|
|
118
168
|
export declare class SyncQueryCache {
|
|
119
169
|
private readonly entries;
|
|
120
170
|
private nextLeaseId;
|
|
121
171
|
private readonly store;
|
|
122
|
-
private readonly
|
|
172
|
+
private readonly defaultReleaseDelayMs;
|
|
123
173
|
constructor(store: Store<ColsMap>, opts?: QueryCacheOptions);
|
|
124
|
-
|
|
174
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
175
|
+
* {@link QueryReleaseOptions}) — `0` asks for no warm window of its own, though an unexpired
|
|
176
|
+
* deadline from an earlier lease on this coverage still applies. */
|
|
177
|
+
retain(coverageKey: string, query: AnyQuery, releaseDelayMs?: number): SyncLease;
|
|
125
178
|
release(lease: SyncLease): void;
|
|
126
179
|
subscribe(coverageKey: string, listener: () => void): () => void;
|
|
127
180
|
resultType(coverageKey: string): ResultType;
|
|
128
181
|
size(): number;
|
|
129
182
|
private createHandle;
|
|
183
|
+
private resolveDelay;
|
|
184
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
185
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
186
|
+
* restarting the window. */
|
|
130
187
|
private scheduleRelease;
|
|
131
188
|
private finalizeRelease;
|
|
132
189
|
}
|
|
@@ -134,9 +191,12 @@ export declare class QueryCache {
|
|
|
134
191
|
private readonly entries;
|
|
135
192
|
private nextLeaseId;
|
|
136
193
|
private readonly store;
|
|
137
|
-
private readonly
|
|
194
|
+
private readonly defaultReleaseDelayMs;
|
|
138
195
|
constructor(store: Store<ColsMap>, opts?: QueryCacheOptions);
|
|
139
|
-
|
|
196
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
197
|
+
* {@link QueryReleaseOptions}). Ignored for a local-only store, whose views are always torn down
|
|
198
|
+
* on release. */
|
|
199
|
+
retain<Q extends AnyQuery>(viewKey: string, query: Q, releaseDelayMs?: number): QueryLease;
|
|
140
200
|
release(lease: QueryLease): void;
|
|
141
201
|
subscribe(viewKey: string, listener: () => void): () => void;
|
|
142
202
|
snapshot(viewKey: string, one: boolean): unknown;
|
|
@@ -156,6 +216,16 @@ export declare class QueryCache {
|
|
|
156
216
|
private createMaterializedLease;
|
|
157
217
|
private chooseCanonical;
|
|
158
218
|
private setCanonical;
|
|
219
|
+
private resolveDelay;
|
|
220
|
+
/** Hand back every deferred remote lease and cancel the pending teardown, WITHOUT touching
|
|
221
|
+
* `entry.releaseDeadline`. Called when a retain revives the entry: the new lease covers the query,
|
|
222
|
+
* so the deferred ones are redundant, and the timer armed for an idle entry is stale. The deadline
|
|
223
|
+
* is not — it is an outstanding claim, and dropping it here would let a remount silently refresh a
|
|
224
|
+
* window that must only ever decay. */
|
|
225
|
+
private flushPendingReleases;
|
|
226
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
227
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
228
|
+
* restarting the window. */
|
|
159
229
|
private scheduleSplitRelease;
|
|
160
230
|
private finalizeSplitRelease;
|
|
161
231
|
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,EAWL,KAAK,EAEN,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EACV,QAAQ,EAGR,OAAO,EACP,eAAe,EACf,QAAQ,EACR,YAAY,EAEZ,WAAW,EAGX,UAAU,EACV,cAAc,EACd,UAAU,EACV,MAAM,EACP,MAAM,gBAAgB,CAAC;AAGxB,KAAK,WAAW,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;AAGhD,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,QAAQ,IAAI,UAAU,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;AACjF,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,QAAQ,IAAI,cAAc,CAAC,CAAC,CAAC,CAAC;AAC7D,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,QAAQ,EAAE,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAChF,SAAS,CAAC,CAAC,CAAC,SAAS,SAAS,OAAO,EAAE,GAAG,SAAS,WAAW,CAAC,CAAC,CAAC,EAAE,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;AAC9F,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;CAC7B;AACD,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,QAAQ,IAAI,SAAS,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;AAChG,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,QAAQ,EAAE,CAAC,SAAS,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAClF,SAAS,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;AAE3D,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
2
2
|
import { createLocalFragmentRefForTable, fragmentAst, isFragment, isFragmentRelationship, localQueryReadAst, localFragmentReadAst, localRootFragmentRefsAst, OneShotBackend, queryFromAst, stableKey, Store, tableMeta, } from "@rindle/client";
|
|
3
3
|
export { fragmentKey } from "@rindle/client";
|
|
4
|
+
// The LM stream plane's client half (LM-STREAM-CHECKPOINT-DESIGN.md): the durable side arrives through
|
|
5
|
+
// an ordinary query, `useStreamedText` adds the live tail and merges the two. The pure reassembly
|
|
6
|
+
// helpers come from `@rindle/client` and are re-exported so a component needs ONE import.
|
|
7
|
+
export { DEFAULT_STREAM_ENDPOINT, eventSourceTransport, streamSubscribeUrl, useStreamedText, } from "./stream.js";
|
|
8
|
+
export { assembleDurableText, spliceStreamText } from "@rindle/client";
|
|
4
9
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
5
10
|
// Keep just-released coverage alive briefly so a changed filter/limit can re-materialize from the
|
|
6
11
|
// local base synchronously while the replacement server lease is still streaming its first answer.
|
|
12
|
+
// Overridable per tree (`<Rindle releaseDelayMs>`) and per call site ({@link QueryReleaseOptions}).
|
|
7
13
|
const REACT_CACHE_RELEASE_DELAY_MS = 2_000;
|
|
14
|
+
/** Monotonic clock for release deadlines. Deliberately NOT `Date.now()`: a wall-clock step backwards
|
|
15
|
+
* would extend every live warm window, and a step forwards would truncate them. */
|
|
16
|
+
function nowMs() {
|
|
17
|
+
return performance.now();
|
|
18
|
+
}
|
|
8
19
|
function setReleaseTimeout(fn, ms) {
|
|
9
20
|
const timer = setTimeout(fn, ms);
|
|
10
21
|
timer.unref?.();
|
|
@@ -14,15 +25,18 @@ class RindleContextValue {
|
|
|
14
25
|
store;
|
|
15
26
|
cache;
|
|
16
27
|
syncCache;
|
|
17
|
-
constructor(store) {
|
|
28
|
+
constructor(store, releaseDelayMs) {
|
|
18
29
|
this.store = store;
|
|
19
|
-
this.cache = new QueryCache(store, { releaseDelayMs
|
|
20
|
-
this.syncCache = new SyncQueryCache(store, { releaseDelayMs
|
|
30
|
+
this.cache = new QueryCache(store, { releaseDelayMs });
|
|
31
|
+
this.syncCache = new SyncQueryCache(store, { releaseDelayMs });
|
|
21
32
|
}
|
|
22
33
|
}
|
|
23
34
|
const RindleContext = createContext(null);
|
|
24
|
-
export function Rindle({ store, children }) {
|
|
25
|
-
const
|
|
35
|
+
export function Rindle({ store, releaseDelayMs, children }) {
|
|
36
|
+
const delay = releaseDelayMs ?? REACT_CACHE_RELEASE_DELAY_MS;
|
|
37
|
+
// `releaseDelayMs` is tree CONFIG, not state: changing it rebuilds the caches exactly like swapping
|
|
38
|
+
// `store` does, tearing down every live view. Pass a constant; use the per-hook option to vary it.
|
|
39
|
+
const value = useMemo(() => new RindleContextValue(store, delay), [store, delay]);
|
|
26
40
|
return createElement(RindleContext.Provider, { value }, children);
|
|
27
41
|
}
|
|
28
42
|
export const RindleProvider = Rindle;
|
|
@@ -77,19 +91,20 @@ export function RindleSSR({ schema, ssrState, boot, children }) {
|
|
|
77
91
|
}, []);
|
|
78
92
|
return createElement((Rindle), { store: liveStore ?? seedStore }, children);
|
|
79
93
|
}
|
|
80
|
-
export function useQuery(query) {
|
|
94
|
+
export function useQuery(query, opts) {
|
|
81
95
|
const ctx = useRindleContext();
|
|
82
96
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
83
97
|
const queryRef = useRef(query);
|
|
84
98
|
queryRef.current = query;
|
|
99
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
85
100
|
const subscribe = useCallback((onStoreChange) => {
|
|
86
|
-
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current);
|
|
101
|
+
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);
|
|
87
102
|
const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
88
103
|
return () => {
|
|
89
104
|
unsubscribe();
|
|
90
105
|
ctx.cache.release(lease);
|
|
91
106
|
};
|
|
92
|
-
}, [ctx.cache, descriptor.leaseKey, descriptor.viewKey]);
|
|
107
|
+
}, [ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs]);
|
|
93
108
|
const getSnapshot = useCallback(() => ctx.cache.snapshot(descriptor.viewKey, descriptor.one), [ctx.cache, descriptor.viewKey, descriptor.one]);
|
|
94
109
|
// SSR (SSR-DESIGN.md §6): the server calls `getServerSnapshot` (never `subscribe`), so it must
|
|
95
110
|
// surface the dehydrated/preloaded seed directly — not the live cache, which is never retained
|
|
@@ -103,19 +118,20 @@ export function useQuery(query) {
|
|
|
103
118
|
* §7); the `error` variant is reserved and currently unproduced. Shares the same cached/leased view
|
|
104
119
|
* as {@link useQuery} (so reading both for one query is one subscription), and re-renders only when
|
|
105
120
|
* the status changes. */
|
|
106
|
-
export function useQueryStatus(query) {
|
|
121
|
+
export function useQueryStatus(query, opts) {
|
|
107
122
|
const ctx = useRindleContext();
|
|
108
123
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
109
124
|
const queryRef = useRef(query);
|
|
110
125
|
queryRef.current = query;
|
|
126
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
111
127
|
const subscribe = useCallback((onStoreChange) => {
|
|
112
|
-
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current);
|
|
128
|
+
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);
|
|
113
129
|
const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
114
130
|
return () => {
|
|
115
131
|
unsubscribe();
|
|
116
132
|
ctx.cache.release(lease);
|
|
117
133
|
};
|
|
118
|
-
}, [ctx.cache, descriptor.leaseKey, descriptor.viewKey]);
|
|
134
|
+
}, [ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs]);
|
|
119
135
|
const getSnapshot = useCallback(() => ctx.cache.resultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);
|
|
120
136
|
// SSR: a seeded query is server-authoritative for first paint (`complete`); otherwise `unknown`.
|
|
121
137
|
const getServerSnapshot = useCallback(() => ctx.cache.serverResultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);
|
|
@@ -124,19 +140,20 @@ export function useQueryStatus(query) {
|
|
|
124
140
|
/** Retain a named server query for normalized/local-first sync coverage without subscribing React
|
|
125
141
|
* to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`
|
|
126
142
|
* until the backend reports that the retained coverage has hydrated. */
|
|
127
|
-
export function useSyncQuery(query) {
|
|
143
|
+
export function useSyncQuery(query, opts) {
|
|
128
144
|
const ctx = useRindleContext();
|
|
129
145
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
130
146
|
const queryRef = useRef(query);
|
|
131
147
|
queryRef.current = query;
|
|
148
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
132
149
|
const subscribe = useCallback((onStoreChange) => {
|
|
133
|
-
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current);
|
|
150
|
+
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current, releaseDelayMs);
|
|
134
151
|
const unsubscribe = ctx.syncCache.subscribe(descriptor.leaseKey, onStoreChange);
|
|
135
152
|
return () => {
|
|
136
153
|
unsubscribe();
|
|
137
154
|
ctx.syncCache.release(lease);
|
|
138
155
|
};
|
|
139
|
-
}, [ctx.syncCache, descriptor.leaseKey]);
|
|
156
|
+
}, [ctx.syncCache, descriptor.leaseKey, releaseDelayMs]);
|
|
140
157
|
const getSnapshot = useCallback(() => {
|
|
141
158
|
const live = ctx.syncCache.resultType(descriptor.leaseKey);
|
|
142
159
|
return live === "unknown" && ctx.cache.serverResultType(descriptor.viewKey) === "complete" ? "complete" : live;
|
|
@@ -250,8 +267,8 @@ function useRootRefData(query, fragment) {
|
|
|
250
267
|
* The hook opens a narrow local-only query for this exact fragment and keeps the root coverage
|
|
251
268
|
* lease retained while mounted. Passing a legacy projected data object is unsupported.
|
|
252
269
|
*/
|
|
253
|
-
export function useFragment(fragment, ref) {
|
|
254
|
-
return useLocalFragment(fragment, ref);
|
|
270
|
+
export function useFragment(fragment, ref, opts) {
|
|
271
|
+
return useLocalFragment(fragment, ref, opts);
|
|
255
272
|
}
|
|
256
273
|
/**
|
|
257
274
|
* Render-prop sugar over {@link useFragment}: does the `null` check once. `from` is a fragment ref
|
|
@@ -259,11 +276,11 @@ export function useFragment(fragment, ref) {
|
|
|
259
276
|
* under a live read); when the row is present `children(data)` renders, otherwise `fallback` (default
|
|
260
277
|
* nothing). Keeps the per-row subscription isolation — a child-only edit re-renders just this read.
|
|
261
278
|
*/
|
|
262
|
-
export function Frag({ of, from, fallback = null, children }) {
|
|
263
|
-
const data = useFragment(of, from);
|
|
279
|
+
export function Frag({ of, from, fallback = null, releaseDelayMs, children }) {
|
|
280
|
+
const data = useFragment(of, from, { releaseDelayMs });
|
|
264
281
|
return data == null ? fallback : children(data);
|
|
265
282
|
}
|
|
266
|
-
function useLocalFragment(fragment, ref) {
|
|
283
|
+
function useLocalFragment(fragment, ref, opts) {
|
|
267
284
|
const ctx = useRindleContext();
|
|
268
285
|
const coverage = ref?.coverage;
|
|
269
286
|
const coverageDescriptor = useMemo(() => (coverage ? describeQuery(coverage.query) : undefined), [coverage]);
|
|
@@ -271,11 +288,12 @@ function useLocalFragment(fragment, ref) {
|
|
|
271
288
|
const query = useMemo(() => (ast ? queryFromAst(ast) : undefined), [ast]);
|
|
272
289
|
const descriptor = useMemo(() => (query ? describeQuery(query) : undefined), [query]);
|
|
273
290
|
const projection = useMemo(() => (coverage ? new LocalFragmentProjection(fragmentAst(fragment), coverage, (table) => ctx.store.primaryKeyFor(table)) : undefined), [ctx.store, coverage, fragment]);
|
|
291
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
274
292
|
const subscribe = useCallback((onStoreChange) => {
|
|
275
293
|
if (!coverage || !descriptor || !query)
|
|
276
294
|
return () => { };
|
|
277
|
-
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query);
|
|
278
|
-
const localLease = ctx.cache.retain(descriptor.viewKey, query);
|
|
295
|
+
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query, releaseDelayMs);
|
|
296
|
+
const localLease = ctx.cache.retain(descriptor.viewKey, query, releaseDelayMs);
|
|
279
297
|
const unsubscribeSync = ctx.syncCache.subscribe(coverage.key, onStoreChange);
|
|
280
298
|
const unsubscribeLocal = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
281
299
|
return () => {
|
|
@@ -284,7 +302,7 @@ function useLocalFragment(fragment, ref) {
|
|
|
284
302
|
ctx.cache.release(localLease);
|
|
285
303
|
ctx.syncCache.release(syncLease);
|
|
286
304
|
};
|
|
287
|
-
}, [ctx.cache, ctx.syncCache, coverage, descriptor, query]);
|
|
305
|
+
}, [ctx.cache, ctx.syncCache, coverage, descriptor, query, releaseDelayMs]);
|
|
288
306
|
// Stale-while-revalidate (see useLocalRootQueryData): project the fragment's local view as soon
|
|
289
307
|
// as the row exists locally instead of returning null until its coverage is server-complete —
|
|
290
308
|
// otherwise every nested fragment (UserBadge, TagChip, CommentCard, …) flashes empty for a
|
|
@@ -511,12 +529,15 @@ export class SyncQueryCache {
|
|
|
511
529
|
entries = new Map();
|
|
512
530
|
nextLeaseId = 1;
|
|
513
531
|
store;
|
|
514
|
-
|
|
532
|
+
defaultReleaseDelayMs;
|
|
515
533
|
constructor(store, opts = {}) {
|
|
516
534
|
this.store = store;
|
|
517
|
-
this.
|
|
535
|
+
this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
|
|
518
536
|
}
|
|
519
|
-
|
|
537
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
538
|
+
* {@link QueryReleaseOptions}) — `0` asks for no warm window of its own, though an unexpired
|
|
539
|
+
* deadline from an earlier lease on this coverage still applies. */
|
|
540
|
+
retain(coverageKey, query, releaseDelayMs) {
|
|
520
541
|
let entry = this.entries.get(coverageKey);
|
|
521
542
|
if (!entry) {
|
|
522
543
|
const handle = this.createHandle(query);
|
|
@@ -526,6 +547,7 @@ export class SyncQueryCache {
|
|
|
526
547
|
listeners: new Set(),
|
|
527
548
|
unsubscribe: () => { },
|
|
528
549
|
releaseTimer: undefined,
|
|
550
|
+
releaseDeadline: 0,
|
|
529
551
|
};
|
|
530
552
|
entry.unsubscribe = handle.subscribe(() => {
|
|
531
553
|
for (const listener of entry.listeners)
|
|
@@ -534,10 +556,13 @@ export class SyncQueryCache {
|
|
|
534
556
|
this.entries.set(coverageKey, entry);
|
|
535
557
|
}
|
|
536
558
|
else if (entry.releaseTimer !== undefined) {
|
|
559
|
+
// Cancel the pending teardown — but NOT `releaseDeadline`. The timer is stale (it was armed for
|
|
560
|
+
// an entry that is live again); the deadline is an outstanding claim that must survive, or a
|
|
561
|
+
// remount would silently refresh a window that should only ever decay.
|
|
537
562
|
clearTimeout(entry.releaseTimer);
|
|
538
563
|
entry.releaseTimer = undefined;
|
|
539
564
|
}
|
|
540
|
-
const lease = { id: this.nextLeaseId++, coverageKey };
|
|
565
|
+
const lease = { id: this.nextLeaseId++, coverageKey, releaseDelayMs: this.resolveDelay(releaseDelayMs) };
|
|
541
566
|
entry.leases.push(lease);
|
|
542
567
|
return lease;
|
|
543
568
|
}
|
|
@@ -548,7 +573,14 @@ export class SyncQueryCache {
|
|
|
548
573
|
const index = entry.leases.findIndex((l) => l.id === lease.id);
|
|
549
574
|
if (index < 0)
|
|
550
575
|
return;
|
|
551
|
-
|
|
576
|
+
// Read the delay off the STORED lease, not the caller's handle — a caller can't widen its window
|
|
577
|
+
// after the fact by mutating the object `retain` handed back.
|
|
578
|
+
const [released] = entry.leases.splice(index, 1);
|
|
579
|
+
// Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted
|
|
580
|
+
// must not discard the window this lease just asked for, or the result would depend on unmount
|
|
581
|
+
// order. Recording it can never cause a premature teardown — only the last-out branch below arms
|
|
582
|
+
// a timer.
|
|
583
|
+
entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);
|
|
552
584
|
if (entry.leases.length > 0)
|
|
553
585
|
return;
|
|
554
586
|
this.scheduleRelease(lease.coverageKey, entry);
|
|
@@ -581,14 +613,28 @@ export class SyncQueryCache {
|
|
|
581
613
|
release: () => view.destroy(),
|
|
582
614
|
};
|
|
583
615
|
}
|
|
616
|
+
resolveDelay(releaseDelayMs) {
|
|
617
|
+
return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);
|
|
618
|
+
}
|
|
619
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
620
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
621
|
+
* restarting the window. */
|
|
584
622
|
scheduleRelease(coverageKey, entry) {
|
|
585
|
-
if (
|
|
623
|
+
if (entry.releaseTimer !== undefined) {
|
|
624
|
+
clearTimeout(entry.releaseTimer);
|
|
625
|
+
entry.releaseTimer = undefined;
|
|
626
|
+
}
|
|
627
|
+
const remaining = entry.releaseDeadline - nowMs();
|
|
628
|
+
if (remaining <= 0) {
|
|
586
629
|
this.finalizeRelease(coverageKey, entry);
|
|
587
630
|
return;
|
|
588
631
|
}
|
|
589
|
-
entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry),
|
|
632
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry), remaining);
|
|
590
633
|
}
|
|
591
634
|
finalizeRelease(coverageKey, entry) {
|
|
635
|
+
// `entry.leases.length > 0` is the guard that makes a live subscriber safe from a stale timer: a
|
|
636
|
+
// remount inside the window revives the entry, and the timer armed before it may still fire. Do
|
|
637
|
+
// not "simplify" this away.
|
|
592
638
|
if (this.entries.get(coverageKey) !== entry || entry.leases.length > 0)
|
|
593
639
|
return;
|
|
594
640
|
entry.releaseTimer = undefined;
|
|
@@ -601,24 +647,31 @@ export class QueryCache {
|
|
|
601
647
|
entries = new Map();
|
|
602
648
|
nextLeaseId = 1;
|
|
603
649
|
store;
|
|
604
|
-
|
|
650
|
+
defaultReleaseDelayMs;
|
|
605
651
|
constructor(store, opts = {}) {
|
|
606
652
|
this.store = store;
|
|
607
|
-
this.
|
|
653
|
+
this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
|
|
608
654
|
}
|
|
609
|
-
|
|
655
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
656
|
+
* {@link QueryReleaseOptions}). Ignored for a local-only store, whose views are always torn down
|
|
657
|
+
* on release. */
|
|
658
|
+
retain(viewKey, query, releaseDelayMs) {
|
|
610
659
|
let entry = this.entries.get(viewKey);
|
|
611
660
|
if (!entry) {
|
|
612
661
|
entry = this.store.canRetainRemoteQueries()
|
|
613
|
-
? this.createSplitEntry(viewKey, query)
|
|
662
|
+
? this.createSplitEntry(viewKey, query, releaseDelayMs)
|
|
614
663
|
: this.createMaterializedEntry(viewKey, query);
|
|
615
664
|
this.entries.set(viewKey, entry);
|
|
616
665
|
const lease = entry.leases[0];
|
|
617
666
|
return { id: lease.id, viewKey };
|
|
618
667
|
}
|
|
619
668
|
if (entry.mode === "split") {
|
|
620
|
-
|
|
669
|
+
// Take the new server lease FIRST, then hand the deferred ones back: `handle.retain` mints a
|
|
670
|
+
// distinct remote qid per call, so overlapping them keeps this query's coverage continuously
|
|
671
|
+
// live and the backend never re-subscribes.
|
|
672
|
+
const lease = this.createSplitLease(viewKey, entry.handle, query, releaseDelayMs);
|
|
621
673
|
entry.leases.push(lease);
|
|
674
|
+
this.flushPendingReleases(entry);
|
|
622
675
|
return { id: lease.id, viewKey };
|
|
623
676
|
}
|
|
624
677
|
const lease = this.createMaterializedLease(viewKey, query);
|
|
@@ -636,20 +689,22 @@ export class QueryCache {
|
|
|
636
689
|
return;
|
|
637
690
|
if (entry.mode === "split") {
|
|
638
691
|
const [released] = entry.leases.splice(index, 1);
|
|
639
|
-
|
|
692
|
+
// Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted
|
|
693
|
+
// must not discard the window this lease just asked for, or the result would depend on unmount
|
|
694
|
+
// order. Recording it can never cause a premature teardown — only the last-out branch below
|
|
695
|
+
// arms a timer.
|
|
696
|
+
entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);
|
|
697
|
+
// A non-last lease never needs its remote lease held: the entry (and its warm local view)
|
|
698
|
+
// outlives it, and the surviving leases keep the query subscribed.
|
|
699
|
+
if (entry.leases.length > 0) {
|
|
640
700
|
released.releaseRemote();
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
entry.pendingReleases.push(released);
|
|
644
|
-
this.scheduleSplitRelease(lease.viewKey, entry);
|
|
645
|
-
}
|
|
646
|
-
if (entry.leases.length > 0)
|
|
647
701
|
return;
|
|
648
|
-
if (this.releaseDelayMs <= 0) {
|
|
649
|
-
entry.canonicalUnsubscribe();
|
|
650
|
-
entry.handle.destroy();
|
|
651
|
-
this.entries.delete(lease.viewKey);
|
|
652
702
|
}
|
|
703
|
+
// Last one out. Its remote lease is what keeps the warm view fed until the deadline, so it is
|
|
704
|
+
// deferred. `scheduleSplitRelease` collapses an already-expired deadline into a synchronous
|
|
705
|
+
// teardown, so both paths funnel through one place.
|
|
706
|
+
entry.pendingReleases.push(released);
|
|
707
|
+
this.scheduleSplitRelease(lease.viewKey, entry);
|
|
653
708
|
return;
|
|
654
709
|
}
|
|
655
710
|
const [released] = entry.leases.splice(index, 1);
|
|
@@ -705,7 +760,7 @@ export class QueryCache {
|
|
|
705
760
|
size() {
|
|
706
761
|
return this.entries.size;
|
|
707
762
|
}
|
|
708
|
-
createSplitEntry(viewKey, query) {
|
|
763
|
+
createSplitEntry(viewKey, query, releaseDelayMs) {
|
|
709
764
|
const handle = this.store.createCachedQueryView(query);
|
|
710
765
|
const entry = {
|
|
711
766
|
mode: "split",
|
|
@@ -713,18 +768,24 @@ export class QueryCache {
|
|
|
713
768
|
leases: [],
|
|
714
769
|
pendingReleases: [],
|
|
715
770
|
releaseTimer: undefined,
|
|
771
|
+
releaseDeadline: 0,
|
|
716
772
|
canonicalUnsubscribe: () => { },
|
|
717
773
|
listeners: new Set(),
|
|
718
774
|
};
|
|
719
|
-
entry.leases.push(this.createSplitLease(viewKey, handle, query));
|
|
775
|
+
entry.leases.push(this.createSplitLease(viewKey, handle, query, releaseDelayMs));
|
|
720
776
|
entry.canonicalUnsubscribe = handle.view.subscribe(() => {
|
|
721
777
|
for (const listener of entry.listeners)
|
|
722
778
|
listener();
|
|
723
779
|
});
|
|
724
780
|
return entry;
|
|
725
781
|
}
|
|
726
|
-
createSplitLease(viewKey, handle, query) {
|
|
727
|
-
return {
|
|
782
|
+
createSplitLease(viewKey, handle, query, releaseDelayMs) {
|
|
783
|
+
return {
|
|
784
|
+
id: this.nextLeaseId++,
|
|
785
|
+
viewKey,
|
|
786
|
+
releaseRemote: handle.retain(query),
|
|
787
|
+
releaseDelayMs: this.resolveDelay(releaseDelayMs),
|
|
788
|
+
};
|
|
728
789
|
}
|
|
729
790
|
createMaterializedEntry(viewKey, query) {
|
|
730
791
|
const lease = this.createMaterializedLease(viewKey, query);
|
|
@@ -757,10 +818,36 @@ export class QueryCache {
|
|
|
757
818
|
listener();
|
|
758
819
|
});
|
|
759
820
|
}
|
|
821
|
+
resolveDelay(releaseDelayMs) {
|
|
822
|
+
return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);
|
|
823
|
+
}
|
|
824
|
+
/** Hand back every deferred remote lease and cancel the pending teardown, WITHOUT touching
|
|
825
|
+
* `entry.releaseDeadline`. Called when a retain revives the entry: the new lease covers the query,
|
|
826
|
+
* so the deferred ones are redundant, and the timer armed for an idle entry is stale. The deadline
|
|
827
|
+
* is not — it is an outstanding claim, and dropping it here would let a remount silently refresh a
|
|
828
|
+
* window that must only ever decay. */
|
|
829
|
+
flushPendingReleases(entry) {
|
|
830
|
+
if (entry.releaseTimer !== undefined) {
|
|
831
|
+
clearTimeout(entry.releaseTimer);
|
|
832
|
+
entry.releaseTimer = undefined;
|
|
833
|
+
}
|
|
834
|
+
for (const stale of entry.pendingReleases.splice(0))
|
|
835
|
+
stale.releaseRemote();
|
|
836
|
+
}
|
|
837
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
838
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
839
|
+
* restarting the window. */
|
|
760
840
|
scheduleSplitRelease(viewKey, entry) {
|
|
761
|
-
if (entry.releaseTimer !== undefined)
|
|
841
|
+
if (entry.releaseTimer !== undefined) {
|
|
762
842
|
clearTimeout(entry.releaseTimer);
|
|
763
|
-
|
|
843
|
+
entry.releaseTimer = undefined;
|
|
844
|
+
}
|
|
845
|
+
const remaining = entry.releaseDeadline - nowMs();
|
|
846
|
+
if (remaining <= 0) {
|
|
847
|
+
this.finalizeSplitRelease(viewKey, entry);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), remaining);
|
|
764
851
|
}
|
|
765
852
|
finalizeSplitRelease(viewKey, entry) {
|
|
766
853
|
if (this.entries.get(viewKey) !== entry)
|
|
@@ -769,6 +856,8 @@ export class QueryCache {
|
|
|
769
856
|
const pending = entry.pendingReleases.splice(0);
|
|
770
857
|
for (const lease of pending)
|
|
771
858
|
lease.releaseRemote();
|
|
859
|
+
// The guard that makes a live subscriber safe from a stale timer: a remount inside the window
|
|
860
|
+
// revives the entry, and the timer armed before it may still fire. Do not "simplify" this away.
|
|
772
861
|
if (entry.leases.length > 0)
|
|
773
862
|
return;
|
|
774
863
|
entry.canonicalUnsubscribe();
|