@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/src/index.ts
CHANGED
|
@@ -61,8 +61,26 @@ export type { ResultType } from "@rindle/client";
|
|
|
61
61
|
export type { Fragment, FragmentData, FragmentRef } from "@rindle/client";
|
|
62
62
|
export { fragmentKey } from "@rindle/client";
|
|
63
63
|
|
|
64
|
+
// The LM stream plane's client half (LM-STREAM-CHECKPOINT-DESIGN.md): the durable side arrives through
|
|
65
|
+
// an ordinary query, `useStreamedText` adds the live tail and merges the two. The pure reassembly
|
|
66
|
+
// helpers come from `@rindle/client` and are re-exported so a component needs ONE import.
|
|
67
|
+
export {
|
|
68
|
+
DEFAULT_STREAM_ENDPOINT,
|
|
69
|
+
eventSourceTransport,
|
|
70
|
+
streamSubscribeUrl,
|
|
71
|
+
useStreamedText,
|
|
72
|
+
} from "./stream.ts";
|
|
73
|
+
export type { StreamTransport, UseStreamedTextInput, UseStreamedTextOptions } from "./stream.ts";
|
|
74
|
+
export { assembleDurableText, spliceStreamText } from "@rindle/client";
|
|
75
|
+
export type { StreamFrame, StreamStatus } from "@rindle/client";
|
|
76
|
+
|
|
64
77
|
export interface RindleProps<S extends ColsMap = ColsMap> {
|
|
65
78
|
store: Store<S>;
|
|
79
|
+
/** Default grace window (ms) for every query in this tree — how long a view + its server lease are
|
|
80
|
+
* kept warm after the last subscriber unmounts. Defaults to 2s; see {@link QueryReleaseOptions}
|
|
81
|
+
* for why, and for the per-call-site override. Treat as a constant: changing it rebuilds the
|
|
82
|
+
* caches and tears down every live view. */
|
|
83
|
+
releaseDelayMs?: number;
|
|
66
84
|
children?: ReactNode;
|
|
67
85
|
}
|
|
68
86
|
|
|
@@ -80,6 +98,8 @@ interface QueryLease {
|
|
|
80
98
|
interface SyncLease {
|
|
81
99
|
id: number;
|
|
82
100
|
coverageKey: string;
|
|
101
|
+
/** Resolved grace window for THIS lease (see {@link QueryReleaseOptions}). */
|
|
102
|
+
releaseDelayMs: number;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
interface MaterializedLease extends QueryLease {
|
|
@@ -89,6 +109,8 @@ interface MaterializedLease extends QueryLease {
|
|
|
89
109
|
|
|
90
110
|
interface SplitLease extends QueryLease {
|
|
91
111
|
releaseRemote: () => void;
|
|
112
|
+
/** Resolved grace window for THIS lease (see {@link QueryReleaseOptions}). */
|
|
113
|
+
releaseDelayMs: number;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
type CacheLease = MaterializedLease | SplitLease;
|
|
@@ -105,6 +127,11 @@ interface SplitCacheEntry extends BaseCacheEntry {
|
|
|
105
127
|
leases: SplitLease[];
|
|
106
128
|
pendingReleases: SplitLease[];
|
|
107
129
|
releaseTimer: ReleaseTimer | undefined;
|
|
130
|
+
/** Absolute monotonic ms this entry's warm window expires at — the latest `release time + that
|
|
131
|
+
* lease's delay` asked for by ANY lease on this entry (see {@link QueryReleaseOptions}). Written
|
|
132
|
+
* only in `release`, so the clock starts when a subscriber LEAVES. Monotone, and it expires on its
|
|
133
|
+
* own, which is what makes a later lease inherit only the residue of an older window. */
|
|
134
|
+
releaseDeadline: number;
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
interface MaterializedCacheEntry extends BaseCacheEntry {
|
|
@@ -118,6 +145,7 @@ type CacheEntry = SplitCacheEntry | MaterializedCacheEntry;
|
|
|
118
145
|
const EMPTY_ARRAY: readonly never[] = Object.freeze([]);
|
|
119
146
|
// Keep just-released coverage alive briefly so a changed filter/limit can re-materialize from the
|
|
120
147
|
// local base synchronously while the replacement server lease is still streaming its first answer.
|
|
148
|
+
// Overridable per tree (`<Rindle releaseDelayMs>`) and per call site ({@link QueryReleaseOptions}).
|
|
121
149
|
const REACT_CACHE_RELEASE_DELAY_MS = 2_000;
|
|
122
150
|
|
|
123
151
|
type ReleaseTimer = ReturnType<typeof setTimeout>;
|
|
@@ -126,6 +154,50 @@ interface QueryCacheOptions {
|
|
|
126
154
|
releaseDelayMs?: number;
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Per-call-site override for how long a query is kept warm after its LAST subscriber unmounts.
|
|
159
|
+
*
|
|
160
|
+
* The default (2s, or whatever `<Rindle releaseDelayMs>` sets) exists so a changed filter/limit can
|
|
161
|
+
* re-materialize from the still-warm local base while the replacement server lease streams its first
|
|
162
|
+
* answer — it's what keeps navigation from flashing empty. That grace window is wrong for queries you
|
|
163
|
+
* KNOW you will never come back to, the canonical case being typeahead search: every keystroke is a
|
|
164
|
+
* distinct query, so a 2s window leaves one dead view + server subscription open per character typed.
|
|
165
|
+
* Pass `0` there to tear down on unmount:
|
|
166
|
+
*
|
|
167
|
+
* ```tsx
|
|
168
|
+
* const results = useQuery(searchIssues(term), { releaseDelayMs: 0 });
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* Treat the value as a constant per call site — changing it re-leases the query (drops the old lease
|
|
172
|
+
* and takes a fresh one), which is wasted work if it changes every render.
|
|
173
|
+
*
|
|
174
|
+
* The rule for a query several components share with DIFFERENT delays is a DEADLINE, not a duration:
|
|
175
|
+
* every release stamps `now + that lease's delay`, and the query stays warm until the latest deadline
|
|
176
|
+
* any of its leases asked for (max-wins over what REMAINS, matching the SSR preload TTL rule in
|
|
177
|
+
* `@rindle/client`'s `ssr.ts`). Two consequences worth internalizing:
|
|
178
|
+
*
|
|
179
|
+
* - The clock starts when a subscriber LEAVES, never when it arrives — a mounted reader is never
|
|
180
|
+
* timed out, however long it stays.
|
|
181
|
+
* - A deadline expires on its own, so a later lease inherits at most the RESIDUE of an older window,
|
|
182
|
+
* never a fresh copy of it. Unmount a 2s reader, remount a `releaseDelayMs: 0` one 1.9s later and
|
|
183
|
+
* drop it: teardown lands at the original 2s mark, not 1.9s past it.
|
|
184
|
+
*
|
|
185
|
+
* Only meaningful against a backend that can retain remote queries. A local-only store (the SSR seed
|
|
186
|
+
* over `OneShotBackend`, or a store with no remote leg) always tears its views down on release, so
|
|
187
|
+
* there is no window to shorten.
|
|
188
|
+
*/
|
|
189
|
+
export interface QueryReleaseOptions {
|
|
190
|
+
/** ms to keep this query warm after the last subscriber unmounts. `0` = release immediately.
|
|
191
|
+
* Defaults to the provider's `releaseDelayMs` (2s). */
|
|
192
|
+
releaseDelayMs?: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Monotonic clock for release deadlines. Deliberately NOT `Date.now()`: a wall-clock step backwards
|
|
196
|
+
* would extend every live warm window, and a step forwards would truncate them. */
|
|
197
|
+
function nowMs(): number {
|
|
198
|
+
return performance.now();
|
|
199
|
+
}
|
|
200
|
+
|
|
129
201
|
function setReleaseTimeout(fn: () => void, ms: number): ReleaseTimer {
|
|
130
202
|
const timer = setTimeout(fn, ms);
|
|
131
203
|
(timer as { unref?: () => void }).unref?.();
|
|
@@ -137,17 +209,23 @@ class RindleContextValue {
|
|
|
137
209
|
readonly cache: QueryCache;
|
|
138
210
|
readonly syncCache: SyncQueryCache;
|
|
139
211
|
|
|
140
|
-
constructor(store: Store<ColsMap
|
|
212
|
+
constructor(store: Store<ColsMap>, releaseDelayMs: number) {
|
|
141
213
|
this.store = store;
|
|
142
|
-
this.cache = new QueryCache(store, { releaseDelayMs
|
|
143
|
-
this.syncCache = new SyncQueryCache(store, { releaseDelayMs
|
|
214
|
+
this.cache = new QueryCache(store, { releaseDelayMs });
|
|
215
|
+
this.syncCache = new SyncQueryCache(store, { releaseDelayMs });
|
|
144
216
|
}
|
|
145
217
|
}
|
|
146
218
|
|
|
147
219
|
const RindleContext = createContext<RindleContextValue | null>(null);
|
|
148
220
|
|
|
149
|
-
export function Rindle<S extends ColsMap>({ store, children }: RindleProps<S>) {
|
|
150
|
-
const
|
|
221
|
+
export function Rindle<S extends ColsMap>({ store, releaseDelayMs, children }: RindleProps<S>) {
|
|
222
|
+
const delay = releaseDelayMs ?? REACT_CACHE_RELEASE_DELAY_MS;
|
|
223
|
+
// `releaseDelayMs` is tree CONFIG, not state: changing it rebuilds the caches exactly like swapping
|
|
224
|
+
// `store` does, tearing down every live view. Pass a constant; use the per-hook option to vary it.
|
|
225
|
+
const value = useMemo(
|
|
226
|
+
() => new RindleContextValue(store as unknown as Store<ColsMap>, delay),
|
|
227
|
+
[store, delay],
|
|
228
|
+
);
|
|
151
229
|
return createElement(RindleContext.Provider, { value }, children);
|
|
152
230
|
}
|
|
153
231
|
|
|
@@ -221,22 +299,23 @@ export function RindleSSR<S extends ColsMap>({ schema, ssrState, boot, children
|
|
|
221
299
|
return createElement(Rindle<S>, { store: liveStore ?? seedStore }, children);
|
|
222
300
|
}
|
|
223
301
|
|
|
224
|
-
export function useQuery<Q extends AnyQuery>(query: Q): QueryData<Q> {
|
|
302
|
+
export function useQuery<Q extends AnyQuery>(query: Q, opts?: QueryReleaseOptions): QueryData<Q> {
|
|
225
303
|
const ctx = useRindleContext();
|
|
226
304
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
227
305
|
const queryRef = useRef(query);
|
|
228
306
|
queryRef.current = query;
|
|
307
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
229
308
|
|
|
230
309
|
const subscribe = useCallback(
|
|
231
310
|
(onStoreChange: () => void) => {
|
|
232
|
-
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current);
|
|
311
|
+
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);
|
|
233
312
|
const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
234
313
|
return () => {
|
|
235
314
|
unsubscribe();
|
|
236
315
|
ctx.cache.release(lease);
|
|
237
316
|
};
|
|
238
317
|
},
|
|
239
|
-
[ctx.cache, descriptor.leaseKey, descriptor.viewKey],
|
|
318
|
+
[ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs],
|
|
240
319
|
);
|
|
241
320
|
|
|
242
321
|
const getSnapshot = useCallback(
|
|
@@ -261,22 +340,23 @@ export function useQuery<Q extends AnyQuery>(query: Q): QueryData<Q> {
|
|
|
261
340
|
* §7); the `error` variant is reserved and currently unproduced. Shares the same cached/leased view
|
|
262
341
|
* as {@link useQuery} (so reading both for one query is one subscription), and re-renders only when
|
|
263
342
|
* the status changes. */
|
|
264
|
-
export function useQueryStatus(query: AnyQuery): ResultType {
|
|
343
|
+
export function useQueryStatus(query: AnyQuery, opts?: QueryReleaseOptions): ResultType {
|
|
265
344
|
const ctx = useRindleContext();
|
|
266
345
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
267
346
|
const queryRef = useRef(query);
|
|
268
347
|
queryRef.current = query;
|
|
348
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
269
349
|
|
|
270
350
|
const subscribe = useCallback(
|
|
271
351
|
(onStoreChange: () => void) => {
|
|
272
|
-
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current);
|
|
352
|
+
const lease = ctx.cache.retain(descriptor.viewKey, queryRef.current, releaseDelayMs);
|
|
273
353
|
const unsubscribe = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
274
354
|
return () => {
|
|
275
355
|
unsubscribe();
|
|
276
356
|
ctx.cache.release(lease);
|
|
277
357
|
};
|
|
278
358
|
},
|
|
279
|
-
[ctx.cache, descriptor.leaseKey, descriptor.viewKey],
|
|
359
|
+
[ctx.cache, descriptor.leaseKey, descriptor.viewKey, releaseDelayMs],
|
|
280
360
|
);
|
|
281
361
|
|
|
282
362
|
const getSnapshot = useCallback(() => ctx.cache.resultType(descriptor.viewKey), [ctx.cache, descriptor.viewKey]);
|
|
@@ -293,22 +373,23 @@ export function useQueryStatus(query: AnyQuery): ResultType {
|
|
|
293
373
|
/** Retain a named server query for normalized/local-first sync coverage without subscribing React
|
|
294
374
|
* to that query's broad result tree. The returned value is lifecycle state only; it is `unknown`
|
|
295
375
|
* until the backend reports that the retained coverage has hydrated. */
|
|
296
|
-
export function useSyncQuery(query: AnyQuery): ResultType {
|
|
376
|
+
export function useSyncQuery(query: AnyQuery, opts?: QueryReleaseOptions): ResultType {
|
|
297
377
|
const ctx = useRindleContext();
|
|
298
378
|
const descriptor = useMemo(() => describeQuery(query), [query]);
|
|
299
379
|
const queryRef = useRef(query);
|
|
300
380
|
queryRef.current = query;
|
|
381
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
301
382
|
|
|
302
383
|
const subscribe = useCallback(
|
|
303
384
|
(onStoreChange: () => void) => {
|
|
304
|
-
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current);
|
|
385
|
+
const lease = ctx.syncCache.retain(descriptor.leaseKey, queryRef.current, releaseDelayMs);
|
|
305
386
|
const unsubscribe = ctx.syncCache.subscribe(descriptor.leaseKey, onStoreChange);
|
|
306
387
|
return () => {
|
|
307
388
|
unsubscribe();
|
|
308
389
|
ctx.syncCache.release(lease);
|
|
309
390
|
};
|
|
310
391
|
},
|
|
311
|
-
[ctx.syncCache, descriptor.leaseKey],
|
|
392
|
+
[ctx.syncCache, descriptor.leaseKey, releaseDelayMs],
|
|
312
393
|
);
|
|
313
394
|
|
|
314
395
|
const getSnapshot = useCallback(() => {
|
|
@@ -510,8 +591,9 @@ function useRootRefData<Q extends AnyQuery, F extends Fragment<any, any, any, an
|
|
|
510
591
|
export function useFragment<F extends Fragment<any, any, any, any>>(
|
|
511
592
|
fragment: F,
|
|
512
593
|
ref: FragmentRef<F> | null | undefined,
|
|
594
|
+
opts?: QueryReleaseOptions,
|
|
513
595
|
): FragmentData<F> | null {
|
|
514
|
-
return useLocalFragment(fragment, ref);
|
|
596
|
+
return useLocalFragment(fragment, ref, opts);
|
|
515
597
|
}
|
|
516
598
|
|
|
517
599
|
/**
|
|
@@ -521,20 +603,23 @@ export function useFragment<F extends Fragment<any, any, any, any>>(
|
|
|
521
603
|
* nothing). Keeps the per-row subscription isolation — a child-only edit re-renders just this read.
|
|
522
604
|
*/
|
|
523
605
|
export function Frag<F extends AnyFragment>(
|
|
524
|
-
{ of, from, fallback = null, children }: {
|
|
606
|
+
{ of, from, fallback = null, releaseDelayMs, children }: {
|
|
525
607
|
of: F;
|
|
526
608
|
from: FragmentRef<F> | null | undefined;
|
|
527
609
|
fallback?: ReactNode;
|
|
610
|
+
/** Per-call-site grace window — see {@link QueryReleaseOptions}. */
|
|
611
|
+
releaseDelayMs?: number;
|
|
528
612
|
children: (data: FragmentData<F>) => ReactNode;
|
|
529
613
|
},
|
|
530
614
|
): ReactNode {
|
|
531
|
-
const data = useFragment(of, from);
|
|
615
|
+
const data = useFragment(of, from, { releaseDelayMs });
|
|
532
616
|
return data == null ? fallback : children(data);
|
|
533
617
|
}
|
|
534
618
|
|
|
535
619
|
function useLocalFragment<F extends Fragment<any, any, any, any>>(
|
|
536
620
|
fragment: F,
|
|
537
621
|
ref: LocalFragmentRef<F> | null | undefined,
|
|
622
|
+
opts?: QueryReleaseOptions,
|
|
538
623
|
): FragmentData<F> | null {
|
|
539
624
|
const ctx = useRindleContext();
|
|
540
625
|
const coverage = ref?.coverage;
|
|
@@ -550,11 +635,12 @@ function useLocalFragment<F extends Fragment<any, any, any, any>>(
|
|
|
550
635
|
[ctx.store, coverage, fragment],
|
|
551
636
|
);
|
|
552
637
|
|
|
638
|
+
const releaseDelayMs = opts?.releaseDelayMs;
|
|
553
639
|
const subscribe = useCallback(
|
|
554
640
|
(onStoreChange: () => void) => {
|
|
555
641
|
if (!coverage || !descriptor || !query) return () => {};
|
|
556
|
-
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query);
|
|
557
|
-
const localLease = ctx.cache.retain(descriptor.viewKey, query);
|
|
642
|
+
const syncLease = ctx.syncCache.retain(coverage.key, coverage.query, releaseDelayMs);
|
|
643
|
+
const localLease = ctx.cache.retain(descriptor.viewKey, query, releaseDelayMs);
|
|
558
644
|
const unsubscribeSync = ctx.syncCache.subscribe(coverage.key, onStoreChange);
|
|
559
645
|
const unsubscribeLocal = ctx.cache.subscribe(descriptor.viewKey, onStoreChange);
|
|
560
646
|
return () => {
|
|
@@ -564,7 +650,7 @@ function useLocalFragment<F extends Fragment<any, any, any, any>>(
|
|
|
564
650
|
ctx.syncCache.release(syncLease);
|
|
565
651
|
};
|
|
566
652
|
},
|
|
567
|
-
[ctx.cache, ctx.syncCache, coverage, descriptor, query],
|
|
653
|
+
[ctx.cache, ctx.syncCache, coverage, descriptor, query, releaseDelayMs],
|
|
568
654
|
);
|
|
569
655
|
|
|
570
656
|
// Stale-while-revalidate (see useLocalRootQueryData): project the fragment's local view as soon
|
|
@@ -822,6 +908,10 @@ interface SyncCacheEntry {
|
|
|
822
908
|
listeners: Set<() => void>;
|
|
823
909
|
unsubscribe: () => void;
|
|
824
910
|
releaseTimer: ReleaseTimer | undefined;
|
|
911
|
+
/** Absolute monotonic ms this coverage's warm window expires at — same deadline rule as
|
|
912
|
+
* {@link SplitCacheEntry.releaseDeadline}, so both caches implement the one contract documented on
|
|
913
|
+
* {@link QueryReleaseOptions}. */
|
|
914
|
+
releaseDeadline: number;
|
|
825
915
|
}
|
|
826
916
|
|
|
827
917
|
interface SyncQueryHandle {
|
|
@@ -834,14 +924,17 @@ export class SyncQueryCache {
|
|
|
834
924
|
private readonly entries = new Map<string, SyncCacheEntry>();
|
|
835
925
|
private nextLeaseId = 1;
|
|
836
926
|
private readonly store: Store<ColsMap>;
|
|
837
|
-
private readonly
|
|
927
|
+
private readonly defaultReleaseDelayMs: number;
|
|
838
928
|
|
|
839
929
|
constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {
|
|
840
930
|
this.store = store;
|
|
841
|
-
this.
|
|
931
|
+
this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
|
|
842
932
|
}
|
|
843
933
|
|
|
844
|
-
|
|
934
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
935
|
+
* {@link QueryReleaseOptions}) — `0` asks for no warm window of its own, though an unexpired
|
|
936
|
+
* deadline from an earlier lease on this coverage still applies. */
|
|
937
|
+
retain(coverageKey: string, query: AnyQuery, releaseDelayMs?: number): SyncLease {
|
|
845
938
|
let entry = this.entries.get(coverageKey);
|
|
846
939
|
if (!entry) {
|
|
847
940
|
const handle = this.createHandle(query);
|
|
@@ -851,16 +944,20 @@ export class SyncQueryCache {
|
|
|
851
944
|
listeners: new Set(),
|
|
852
945
|
unsubscribe: () => {},
|
|
853
946
|
releaseTimer: undefined,
|
|
947
|
+
releaseDeadline: 0,
|
|
854
948
|
};
|
|
855
949
|
entry.unsubscribe = handle.subscribe(() => {
|
|
856
950
|
for (const listener of entry!.listeners) listener();
|
|
857
951
|
});
|
|
858
952
|
this.entries.set(coverageKey, entry);
|
|
859
953
|
} else if (entry.releaseTimer !== undefined) {
|
|
954
|
+
// Cancel the pending teardown — but NOT `releaseDeadline`. The timer is stale (it was armed for
|
|
955
|
+
// an entry that is live again); the deadline is an outstanding claim that must survive, or a
|
|
956
|
+
// remount would silently refresh a window that should only ever decay.
|
|
860
957
|
clearTimeout(entry.releaseTimer);
|
|
861
958
|
entry.releaseTimer = undefined;
|
|
862
959
|
}
|
|
863
|
-
const lease = { id: this.nextLeaseId++, coverageKey };
|
|
960
|
+
const lease = { id: this.nextLeaseId++, coverageKey, releaseDelayMs: this.resolveDelay(releaseDelayMs) };
|
|
864
961
|
entry.leases.push(lease);
|
|
865
962
|
return lease;
|
|
866
963
|
}
|
|
@@ -870,7 +967,14 @@ export class SyncQueryCache {
|
|
|
870
967
|
if (!entry) return;
|
|
871
968
|
const index = entry.leases.findIndex((l) => l.id === lease.id);
|
|
872
969
|
if (index < 0) return;
|
|
873
|
-
|
|
970
|
+
// Read the delay off the STORED lease, not the caller's handle — a caller can't widen its window
|
|
971
|
+
// after the fact by mutating the object `retain` handed back.
|
|
972
|
+
const [released] = entry.leases.splice(index, 1);
|
|
973
|
+
// Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted
|
|
974
|
+
// must not discard the window this lease just asked for, or the result would depend on unmount
|
|
975
|
+
// order. Recording it can never cause a premature teardown — only the last-out branch below arms
|
|
976
|
+
// a timer.
|
|
977
|
+
entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);
|
|
874
978
|
if (entry.leases.length > 0) return;
|
|
875
979
|
this.scheduleRelease(lease.coverageKey, entry);
|
|
876
980
|
}
|
|
@@ -905,15 +1009,30 @@ export class SyncQueryCache {
|
|
|
905
1009
|
};
|
|
906
1010
|
}
|
|
907
1011
|
|
|
1012
|
+
private resolveDelay(releaseDelayMs: number | undefined): number {
|
|
1013
|
+
return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
1017
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
1018
|
+
* restarting the window. */
|
|
908
1019
|
private scheduleRelease(coverageKey: string, entry: SyncCacheEntry): void {
|
|
909
|
-
if (
|
|
1020
|
+
if (entry.releaseTimer !== undefined) {
|
|
1021
|
+
clearTimeout(entry.releaseTimer);
|
|
1022
|
+
entry.releaseTimer = undefined;
|
|
1023
|
+
}
|
|
1024
|
+
const remaining = entry.releaseDeadline - nowMs();
|
|
1025
|
+
if (remaining <= 0) {
|
|
910
1026
|
this.finalizeRelease(coverageKey, entry);
|
|
911
1027
|
return;
|
|
912
1028
|
}
|
|
913
|
-
entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry),
|
|
1029
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeRelease(coverageKey, entry), remaining);
|
|
914
1030
|
}
|
|
915
1031
|
|
|
916
1032
|
private finalizeRelease(coverageKey: string, entry: SyncCacheEntry): void {
|
|
1033
|
+
// `entry.leases.length > 0` is the guard that makes a live subscriber safe from a stale timer: a
|
|
1034
|
+
// remount inside the window revives the entry, and the timer armed before it may still fire. Do
|
|
1035
|
+
// not "simplify" this away.
|
|
917
1036
|
if (this.entries.get(coverageKey) !== entry || entry.leases.length > 0) return;
|
|
918
1037
|
entry.releaseTimer = undefined;
|
|
919
1038
|
entry.unsubscribe();
|
|
@@ -926,26 +1045,33 @@ export class QueryCache {
|
|
|
926
1045
|
private readonly entries = new Map<string, CacheEntry>();
|
|
927
1046
|
private nextLeaseId = 1;
|
|
928
1047
|
private readonly store: Store<ColsMap>;
|
|
929
|
-
private readonly
|
|
1048
|
+
private readonly defaultReleaseDelayMs: number;
|
|
930
1049
|
|
|
931
1050
|
constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {
|
|
932
1051
|
this.store = store;
|
|
933
|
-
this.
|
|
1052
|
+
this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
|
|
934
1053
|
}
|
|
935
1054
|
|
|
936
|
-
|
|
1055
|
+
/** `releaseDelayMs` overrides the cache default for THIS lease only (see
|
|
1056
|
+
* {@link QueryReleaseOptions}). Ignored for a local-only store, whose views are always torn down
|
|
1057
|
+
* on release. */
|
|
1058
|
+
retain<Q extends AnyQuery>(viewKey: string, query: Q, releaseDelayMs?: number): QueryLease {
|
|
937
1059
|
let entry = this.entries.get(viewKey);
|
|
938
1060
|
if (!entry) {
|
|
939
1061
|
entry = this.store.canRetainRemoteQueries()
|
|
940
|
-
? this.createSplitEntry(viewKey, query)
|
|
1062
|
+
? this.createSplitEntry(viewKey, query, releaseDelayMs)
|
|
941
1063
|
: this.createMaterializedEntry(viewKey, query);
|
|
942
1064
|
this.entries.set(viewKey, entry);
|
|
943
1065
|
const lease = entry.leases[0];
|
|
944
1066
|
return { id: lease.id, viewKey };
|
|
945
1067
|
}
|
|
946
1068
|
if (entry.mode === "split") {
|
|
947
|
-
|
|
1069
|
+
// Take the new server lease FIRST, then hand the deferred ones back: `handle.retain` mints a
|
|
1070
|
+
// distinct remote qid per call, so overlapping them keeps this query's coverage continuously
|
|
1071
|
+
// live and the backend never re-subscribes.
|
|
1072
|
+
const lease = this.createSplitLease(viewKey, entry.handle, query, releaseDelayMs);
|
|
948
1073
|
entry.leases.push(lease);
|
|
1074
|
+
this.flushPendingReleases(entry);
|
|
949
1075
|
return { id: lease.id, viewKey };
|
|
950
1076
|
}
|
|
951
1077
|
const lease = this.createMaterializedLease(viewKey, query);
|
|
@@ -961,18 +1087,22 @@ export class QueryCache {
|
|
|
961
1087
|
if (index < 0) return;
|
|
962
1088
|
if (entry.mode === "split") {
|
|
963
1089
|
const [released] = entry.leases.splice(index, 1);
|
|
964
|
-
|
|
1090
|
+
// Stamp the deadline for EVERY release, not just the last one: a sibling that is still mounted
|
|
1091
|
+
// must not discard the window this lease just asked for, or the result would depend on unmount
|
|
1092
|
+
// order. Recording it can never cause a premature teardown — only the last-out branch below
|
|
1093
|
+
// arms a timer.
|
|
1094
|
+
entry.releaseDeadline = Math.max(entry.releaseDeadline, nowMs() + released.releaseDelayMs);
|
|
1095
|
+
// A non-last lease never needs its remote lease held: the entry (and its warm local view)
|
|
1096
|
+
// outlives it, and the surviving leases keep the query subscribed.
|
|
1097
|
+
if (entry.leases.length > 0) {
|
|
965
1098
|
released.releaseRemote();
|
|
966
|
-
|
|
967
|
-
entry.pendingReleases.push(released);
|
|
968
|
-
this.scheduleSplitRelease(lease.viewKey, entry);
|
|
969
|
-
}
|
|
970
|
-
if (entry.leases.length > 0) return;
|
|
971
|
-
if (this.releaseDelayMs <= 0) {
|
|
972
|
-
entry.canonicalUnsubscribe();
|
|
973
|
-
entry.handle.destroy();
|
|
974
|
-
this.entries.delete(lease.viewKey);
|
|
1099
|
+
return;
|
|
975
1100
|
}
|
|
1101
|
+
// Last one out. Its remote lease is what keeps the warm view fed until the deadline, so it is
|
|
1102
|
+
// deferred. `scheduleSplitRelease` collapses an already-expired deadline into a synchronous
|
|
1103
|
+
// teardown, so both paths funnel through one place.
|
|
1104
|
+
entry.pendingReleases.push(released);
|
|
1105
|
+
this.scheduleSplitRelease(lease.viewKey, entry);
|
|
976
1106
|
return;
|
|
977
1107
|
}
|
|
978
1108
|
const [released] = entry.leases.splice(index, 1);
|
|
@@ -1030,7 +1160,11 @@ export class QueryCache {
|
|
|
1030
1160
|
return this.entries.size;
|
|
1031
1161
|
}
|
|
1032
1162
|
|
|
1033
|
-
private createSplitEntry<Q extends AnyQuery>(
|
|
1163
|
+
private createSplitEntry<Q extends AnyQuery>(
|
|
1164
|
+
viewKey: string,
|
|
1165
|
+
query: Q,
|
|
1166
|
+
releaseDelayMs?: number,
|
|
1167
|
+
): SplitCacheEntry {
|
|
1034
1168
|
const handle = this.store.createCachedQueryView(query) as unknown as CachedQueryView<AnyQuery>;
|
|
1035
1169
|
const entry: SplitCacheEntry = {
|
|
1036
1170
|
mode: "split",
|
|
@@ -1038,10 +1172,11 @@ export class QueryCache {
|
|
|
1038
1172
|
leases: [],
|
|
1039
1173
|
pendingReleases: [],
|
|
1040
1174
|
releaseTimer: undefined,
|
|
1175
|
+
releaseDeadline: 0,
|
|
1041
1176
|
canonicalUnsubscribe: () => {},
|
|
1042
1177
|
listeners: new Set(),
|
|
1043
1178
|
};
|
|
1044
|
-
entry.leases.push(this.createSplitLease(viewKey, handle, query));
|
|
1179
|
+
entry.leases.push(this.createSplitLease(viewKey, handle, query, releaseDelayMs));
|
|
1045
1180
|
entry.canonicalUnsubscribe = handle.view.subscribe(() => {
|
|
1046
1181
|
for (const listener of entry.listeners) listener();
|
|
1047
1182
|
});
|
|
@@ -1052,8 +1187,14 @@ export class QueryCache {
|
|
|
1052
1187
|
viewKey: string,
|
|
1053
1188
|
handle: CachedQueryView<AnyQuery>,
|
|
1054
1189
|
query: Q,
|
|
1190
|
+
releaseDelayMs?: number,
|
|
1055
1191
|
): SplitLease {
|
|
1056
|
-
return {
|
|
1192
|
+
return {
|
|
1193
|
+
id: this.nextLeaseId++,
|
|
1194
|
+
viewKey,
|
|
1195
|
+
releaseRemote: handle.retain(query),
|
|
1196
|
+
releaseDelayMs: this.resolveDelay(releaseDelayMs),
|
|
1197
|
+
};
|
|
1057
1198
|
}
|
|
1058
1199
|
|
|
1059
1200
|
private createMaterializedEntry<Q extends AnyQuery>(viewKey: string, query: Q): MaterializedCacheEntry {
|
|
@@ -1090,9 +1231,37 @@ export class QueryCache {
|
|
|
1090
1231
|
});
|
|
1091
1232
|
}
|
|
1092
1233
|
|
|
1234
|
+
private resolveDelay(releaseDelayMs: number | undefined): number {
|
|
1235
|
+
return Math.max(0, releaseDelayMs ?? this.defaultReleaseDelayMs);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Hand back every deferred remote lease and cancel the pending teardown, WITHOUT touching
|
|
1239
|
+
* `entry.releaseDeadline`. Called when a retain revives the entry: the new lease covers the query,
|
|
1240
|
+
* so the deferred ones are redundant, and the timer armed for an idle entry is stale. The deadline
|
|
1241
|
+
* is not — it is an outstanding claim, and dropping it here would let a remount silently refresh a
|
|
1242
|
+
* window that must only ever decay. */
|
|
1243
|
+
private flushPendingReleases(entry: SplitCacheEntry): void {
|
|
1244
|
+
if (entry.releaseTimer !== undefined) {
|
|
1245
|
+
clearTimeout(entry.releaseTimer);
|
|
1246
|
+
entry.releaseTimer = undefined;
|
|
1247
|
+
}
|
|
1248
|
+
for (const stale of entry.pendingReleases.splice(0)) stale.releaseRemote();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Arm (or re-arm) the teardown for `entry.releaseDeadline`. Because the deadline is an ABSOLUTE
|
|
1252
|
+
* instant, re-arming is idempotent — a later release recomputes the same wake-up time instead of
|
|
1253
|
+
* restarting the window. */
|
|
1093
1254
|
private scheduleSplitRelease(viewKey: string, entry: SplitCacheEntry): void {
|
|
1094
|
-
if (entry.releaseTimer !== undefined)
|
|
1095
|
-
|
|
1255
|
+
if (entry.releaseTimer !== undefined) {
|
|
1256
|
+
clearTimeout(entry.releaseTimer);
|
|
1257
|
+
entry.releaseTimer = undefined;
|
|
1258
|
+
}
|
|
1259
|
+
const remaining = entry.releaseDeadline - nowMs();
|
|
1260
|
+
if (remaining <= 0) {
|
|
1261
|
+
this.finalizeSplitRelease(viewKey, entry);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), remaining);
|
|
1096
1265
|
}
|
|
1097
1266
|
|
|
1098
1267
|
private finalizeSplitRelease(viewKey: string, entry: SplitCacheEntry): void {
|
|
@@ -1100,6 +1269,8 @@ export class QueryCache {
|
|
|
1100
1269
|
entry.releaseTimer = undefined;
|
|
1101
1270
|
const pending = entry.pendingReleases.splice(0);
|
|
1102
1271
|
for (const lease of pending) lease.releaseRemote();
|
|
1272
|
+
// The guard that makes a live subscriber safe from a stale timer: a remount inside the window
|
|
1273
|
+
// revives the entry, and the timer armed before it may still fire. Do not "simplify" this away.
|
|
1103
1274
|
if (entry.leases.length > 0) return;
|
|
1104
1275
|
entry.canonicalUnsubscribe();
|
|
1105
1276
|
entry.handle.destroy();
|