@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/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 releaseDelayMs;
172
+ private readonly defaultReleaseDelayMs;
123
173
  constructor(store: Store<ColsMap>, opts?: QueryCacheOptions);
124
- retain(coverageKey: string, query: AnyQuery): SyncLease;
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 releaseDelayMs;
194
+ private readonly defaultReleaseDelayMs;
138
195
  constructor(store: Store<ColsMap>, opts?: QueryCacheOptions);
139
- retain<Q extends AnyQuery>(viewKey: string, query: Q): QueryLease;
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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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: REACT_CACHE_RELEASE_DELAY_MS });
20
- this.syncCache = new SyncQueryCache(store, { releaseDelayMs: REACT_CACHE_RELEASE_DELAY_MS });
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 value = useMemo(() => new RindleContextValue(store), [store]);
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
- releaseDelayMs;
532
+ defaultReleaseDelayMs;
515
533
  constructor(store, opts = {}) {
516
534
  this.store = store;
517
- this.releaseDelayMs = opts.releaseDelayMs ?? 0;
535
+ this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
518
536
  }
519
- retain(coverageKey, query) {
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
- entry.leases.splice(index, 1);
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 (this.releaseDelayMs <= 0) {
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), this.releaseDelayMs);
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
- releaseDelayMs;
650
+ defaultReleaseDelayMs;
605
651
  constructor(store, opts = {}) {
606
652
  this.store = store;
607
- this.releaseDelayMs = opts.releaseDelayMs ?? 0;
653
+ this.defaultReleaseDelayMs = Math.max(0, opts.releaseDelayMs ?? 0);
608
654
  }
609
- retain(viewKey, query) {
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
- const lease = this.createSplitLease(viewKey, entry.handle, query);
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
- if (entry.leases.length > 0 || this.releaseDelayMs <= 0) {
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 { id: this.nextLeaseId++, viewKey, releaseRemote: handle.retain(query) };
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
- entry.releaseTimer = setReleaseTimeout(() => this.finalizeSplitRelease(viewKey, entry), this.releaseDelayMs);
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();