@rindle/react 0.7.11 → 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/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: REACT_CACHE_RELEASE_DELAY_MS });
143
- this.syncCache = new SyncQueryCache(store, { releaseDelayMs: REACT_CACHE_RELEASE_DELAY_MS });
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 value = useMemo(() => new RindleContextValue(store as unknown as Store<ColsMap>), [store]);
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 releaseDelayMs: number;
927
+ private readonly defaultReleaseDelayMs: number;
838
928
 
839
929
  constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {
840
930
  this.store = store;
841
- this.releaseDelayMs = opts.releaseDelayMs ?? 0;
931
+ this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
842
932
  }
843
933
 
844
- retain(coverageKey: string, query: AnyQuery): SyncLease {
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
- entry.leases.splice(index, 1);
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 (this.releaseDelayMs <= 0) {
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), this.releaseDelayMs);
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 releaseDelayMs: number;
1048
+ private readonly defaultReleaseDelayMs: number;
930
1049
 
931
1050
  constructor(store: Store<ColsMap>, opts: QueryCacheOptions = {}) {
932
1051
  this.store = store;
933
- this.releaseDelayMs = opts.releaseDelayMs ?? 0;
1052
+ this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
934
1053
  }
935
1054
 
936
- retain<Q extends AnyQuery>(viewKey: string, query: Q): QueryLease {
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
- const lease = this.createSplitLease(viewKey, entry.handle, query);
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
- if (entry.leases.length > 0 || this.releaseDelayMs <= 0) {
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
- } else {
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>(viewKey: string, query: Q): SplitCacheEntry {
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 { id: this.nextLeaseId++, viewKey, releaseRemote: handle.retain(query) };
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) clearTimeout(entry.releaseTimer);
1095
- entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), this.releaseDelayMs);
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();