@octanejs/tanstack-db 0.0.1
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/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +59 -0
- package/skills/tanstack-db/SKILL.md +432 -0
- package/src/index.ts +13 -0
- package/src/slot.ts +26 -0
- package/src/useLiveInfiniteQuery.ts +358 -0
- package/src/useLiveQuery.ts +473 -0
- package/src/useLiveQueryEffect.ts +74 -0
- package/src/useLiveSuspenseQuery.ts +251 -0
- package/src/usePacedMutations.ts +151 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from 'octane';
|
|
2
|
+
import {
|
|
3
|
+
BaseQueryBuilder,
|
|
4
|
+
createLiveQueryCollection,
|
|
5
|
+
createLiveQueryObserver,
|
|
6
|
+
isCollection,
|
|
7
|
+
} from '@tanstack/db';
|
|
8
|
+
import { splitTrailingSlot, subSlot } from './slot';
|
|
9
|
+
import type {
|
|
10
|
+
Collection,
|
|
11
|
+
CollectionStatus,
|
|
12
|
+
Context,
|
|
13
|
+
GetResult,
|
|
14
|
+
InferResultType,
|
|
15
|
+
InitialQueryBuilder,
|
|
16
|
+
LiveQueryCollectionConfig,
|
|
17
|
+
LiveQueryObserver,
|
|
18
|
+
NonSingleResult,
|
|
19
|
+
QueryBuilder,
|
|
20
|
+
SingleResult,
|
|
21
|
+
} from '@tanstack/db';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_GC_TIME_MS = 1; // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC)
|
|
24
|
+
|
|
25
|
+
export type UseLiveQueryStatus = CollectionStatus | `disabled`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a live query using a query function
|
|
29
|
+
* @param queryFn - Query function that defines what data to fetch
|
|
30
|
+
* @param deps - Array of dependencies that trigger query re-execution when changed
|
|
31
|
+
* @returns Object with reactive data, state, and status information
|
|
32
|
+
* @example
|
|
33
|
+
* // Basic query with object syntax
|
|
34
|
+
* const { data, isLoading } = useLiveQuery((q) =>
|
|
35
|
+
* q.from({ todos: todosCollection })
|
|
36
|
+
* .where(({ todos }) => eq(todos.completed, false))
|
|
37
|
+
* .select(({ todos }) => ({ id: todos.id, text: todos.text }))
|
|
38
|
+
* )
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // Single result query
|
|
42
|
+
* const { data } = useLiveQuery(
|
|
43
|
+
* (q) => q.from({ todos: todosCollection })
|
|
44
|
+
* .where(({ todos }) => eq(todos.id, 1))
|
|
45
|
+
* .findOne()
|
|
46
|
+
* )
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // With dependencies that trigger re-execution
|
|
50
|
+
* const { data, state } = useLiveQuery(
|
|
51
|
+
* (q) => q.from({ todos: todosCollection })
|
|
52
|
+
* .where(({ todos }) => gt(todos.priority, minPriority)),
|
|
53
|
+
* [minPriority] // Re-run when minPriority changes
|
|
54
|
+
* )
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* // Join pattern
|
|
58
|
+
* const { data } = useLiveQuery((q) =>
|
|
59
|
+
* q.from({ issues: issueCollection })
|
|
60
|
+
* .join({ persons: personCollection }, ({ issues, persons }) =>
|
|
61
|
+
* eq(issues.userId, persons.id)
|
|
62
|
+
* )
|
|
63
|
+
* .select(({ issues, persons }) => ({
|
|
64
|
+
* id: issues.id,
|
|
65
|
+
* title: issues.title,
|
|
66
|
+
* userName: persons.name
|
|
67
|
+
* }))
|
|
68
|
+
* )
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* // Handle loading and error states
|
|
72
|
+
* const { data, isLoading, isError, status } = useLiveQuery((q) =>
|
|
73
|
+
* q.from({ todos: todoCollection })
|
|
74
|
+
* )
|
|
75
|
+
*
|
|
76
|
+
* if (isLoading) return <div>Loading...</div>
|
|
77
|
+
* if (isError) return <div>Error: {status}</div>
|
|
78
|
+
*
|
|
79
|
+
* return (
|
|
80
|
+
* <ul>
|
|
81
|
+
* {data.map(todo => <li key={todo.id}>{todo.text}</li>)}
|
|
82
|
+
* </ul>
|
|
83
|
+
* )
|
|
84
|
+
*/
|
|
85
|
+
// Overload 1: Accept query function that always returns QueryBuilder
|
|
86
|
+
export function useLiveQuery<TContext extends Context>(
|
|
87
|
+
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
|
|
88
|
+
deps?: Array<unknown>,
|
|
89
|
+
): {
|
|
90
|
+
state: Map<string | number, GetResult<TContext>>;
|
|
91
|
+
data: InferResultType<TContext>;
|
|
92
|
+
collection: Collection<GetResult<TContext>, string | number, {}>;
|
|
93
|
+
status: CollectionStatus; // Can't be disabled if always returns QueryBuilder
|
|
94
|
+
isLoading: boolean;
|
|
95
|
+
isReady: boolean;
|
|
96
|
+
isIdle: boolean;
|
|
97
|
+
isError: boolean;
|
|
98
|
+
isCleanedUp: boolean;
|
|
99
|
+
isEnabled: true; // Always true if always returns QueryBuilder
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Overload 2: Accept query function that can return undefined/null
|
|
103
|
+
export function useLiveQuery<TContext extends Context>(
|
|
104
|
+
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext> | undefined | null,
|
|
105
|
+
deps?: Array<unknown>,
|
|
106
|
+
): {
|
|
107
|
+
state: Map<string | number, GetResult<TContext>> | undefined;
|
|
108
|
+
data: InferResultType<TContext> | undefined;
|
|
109
|
+
collection: Collection<GetResult<TContext>, string | number, {}> | undefined;
|
|
110
|
+
status: UseLiveQueryStatus;
|
|
111
|
+
isLoading: boolean;
|
|
112
|
+
isReady: boolean;
|
|
113
|
+
isIdle: boolean;
|
|
114
|
+
isError: boolean;
|
|
115
|
+
isCleanedUp: boolean;
|
|
116
|
+
isEnabled: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Overload 3: Accept query function that can return LiveQueryCollectionConfig
|
|
120
|
+
export function useLiveQuery<TContext extends Context>(
|
|
121
|
+
queryFn: (q: InitialQueryBuilder) => LiveQueryCollectionConfig<TContext> | undefined | null,
|
|
122
|
+
deps?: Array<unknown>,
|
|
123
|
+
): {
|
|
124
|
+
state: Map<string | number, GetResult<TContext>> | undefined;
|
|
125
|
+
data: InferResultType<TContext> | undefined;
|
|
126
|
+
collection: Collection<GetResult<TContext>, string | number, {}> | undefined;
|
|
127
|
+
status: UseLiveQueryStatus;
|
|
128
|
+
isLoading: boolean;
|
|
129
|
+
isReady: boolean;
|
|
130
|
+
isIdle: boolean;
|
|
131
|
+
isError: boolean;
|
|
132
|
+
isCleanedUp: boolean;
|
|
133
|
+
isEnabled: boolean;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Overload 4: Accept query function that can return Collection
|
|
137
|
+
export function useLiveQuery<
|
|
138
|
+
TResult extends object,
|
|
139
|
+
TKey extends string | number,
|
|
140
|
+
TUtils extends Record<string, any>,
|
|
141
|
+
>(
|
|
142
|
+
queryFn: (q: InitialQueryBuilder) => Collection<TResult, TKey, TUtils> | undefined | null,
|
|
143
|
+
deps?: Array<unknown>,
|
|
144
|
+
): {
|
|
145
|
+
state: Map<TKey, TResult> | undefined;
|
|
146
|
+
data: Array<TResult> | undefined;
|
|
147
|
+
collection: Collection<TResult, TKey, TUtils> | undefined;
|
|
148
|
+
status: UseLiveQueryStatus;
|
|
149
|
+
isLoading: boolean;
|
|
150
|
+
isReady: boolean;
|
|
151
|
+
isIdle: boolean;
|
|
152
|
+
isError: boolean;
|
|
153
|
+
isCleanedUp: boolean;
|
|
154
|
+
isEnabled: boolean;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Overload 5: Accept query function that can return all types
|
|
158
|
+
export function useLiveQuery<
|
|
159
|
+
TContext extends Context,
|
|
160
|
+
TResult extends object,
|
|
161
|
+
TKey extends string | number,
|
|
162
|
+
TUtils extends Record<string, any>,
|
|
163
|
+
>(
|
|
164
|
+
queryFn: (
|
|
165
|
+
q: InitialQueryBuilder,
|
|
166
|
+
) =>
|
|
167
|
+
| QueryBuilder<TContext>
|
|
168
|
+
| LiveQueryCollectionConfig<TContext>
|
|
169
|
+
| Collection<TResult, TKey, TUtils>
|
|
170
|
+
| undefined
|
|
171
|
+
| null,
|
|
172
|
+
deps?: Array<unknown>,
|
|
173
|
+
): {
|
|
174
|
+
state: Map<string | number, GetResult<TContext>> | Map<TKey, TResult> | undefined;
|
|
175
|
+
data: InferResultType<TContext> | Array<TResult> | undefined;
|
|
176
|
+
collection:
|
|
177
|
+
| Collection<GetResult<TContext>, string | number, {}>
|
|
178
|
+
| Collection<TResult, TKey, TUtils>
|
|
179
|
+
| undefined;
|
|
180
|
+
status: UseLiveQueryStatus;
|
|
181
|
+
isLoading: boolean;
|
|
182
|
+
isReady: boolean;
|
|
183
|
+
isIdle: boolean;
|
|
184
|
+
isError: boolean;
|
|
185
|
+
isCleanedUp: boolean;
|
|
186
|
+
isEnabled: boolean;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a live query using configuration object
|
|
191
|
+
* @param config - Configuration object with query and options
|
|
192
|
+
* @param deps - Array of dependencies that trigger query re-execution when changed
|
|
193
|
+
* @returns Object with reactive data, state, and status information
|
|
194
|
+
* @example
|
|
195
|
+
* // Basic config object usage
|
|
196
|
+
* const { data, status } = useLiveQuery({
|
|
197
|
+
* query: (q) => q.from({ todos: todosCollection }),
|
|
198
|
+
* gcTime: 60000
|
|
199
|
+
* })
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* // With query builder and options
|
|
203
|
+
* const queryBuilder = new Query()
|
|
204
|
+
* .from({ persons: collection })
|
|
205
|
+
* .where(({ persons }) => gt(persons.age, 30))
|
|
206
|
+
* .select(({ persons }) => ({ id: persons.id, name: persons.name }))
|
|
207
|
+
*
|
|
208
|
+
* const { data, isReady } = useLiveQuery({ query: queryBuilder })
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* // Handle all states uniformly
|
|
212
|
+
* const { data, isLoading, isReady, isError } = useLiveQuery({
|
|
213
|
+
* query: (q) => q.from({ items: itemCollection })
|
|
214
|
+
* })
|
|
215
|
+
*
|
|
216
|
+
* if (isLoading) return <div>Loading...</div>
|
|
217
|
+
* if (isError) return <div>Something went wrong</div>
|
|
218
|
+
* if (!isReady) return <div>Preparing...</div>
|
|
219
|
+
*
|
|
220
|
+
* return <div>{data.length} items loaded</div>
|
|
221
|
+
*/
|
|
222
|
+
// Overload 6: Accept config object
|
|
223
|
+
export function useLiveQuery<TContext extends Context>(
|
|
224
|
+
config: LiveQueryCollectionConfig<TContext>,
|
|
225
|
+
deps?: Array<unknown>,
|
|
226
|
+
): {
|
|
227
|
+
state: Map<string | number, GetResult<TContext>>;
|
|
228
|
+
data: InferResultType<TContext>;
|
|
229
|
+
collection: Collection<GetResult<TContext>, string | number, {}>;
|
|
230
|
+
status: CollectionStatus; // Can't be disabled for config objects
|
|
231
|
+
isLoading: boolean;
|
|
232
|
+
isReady: boolean;
|
|
233
|
+
isIdle: boolean;
|
|
234
|
+
isError: boolean;
|
|
235
|
+
isCleanedUp: boolean;
|
|
236
|
+
isEnabled: true; // Always true for config objects
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Subscribe to an existing live query collection
|
|
241
|
+
* @param liveQueryCollection - Pre-created live query collection to subscribe to
|
|
242
|
+
* @returns Object with reactive data, state, and status information
|
|
243
|
+
* @example
|
|
244
|
+
* // Using pre-created live query collection
|
|
245
|
+
* const myLiveQuery = createLiveQueryCollection((q) =>
|
|
246
|
+
* q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true))
|
|
247
|
+
* )
|
|
248
|
+
* const { data, collection } = useLiveQuery(myLiveQuery)
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* // Access collection methods directly
|
|
252
|
+
* const { data, collection, isReady } = useLiveQuery(existingCollection)
|
|
253
|
+
*
|
|
254
|
+
* // Use collection for mutations
|
|
255
|
+
* const handleToggle = (id) => {
|
|
256
|
+
* collection.update(id, draft => { draft.completed = !draft.completed })
|
|
257
|
+
* }
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* // Handle states consistently
|
|
261
|
+
* const { data, isLoading, isError } = useLiveQuery(sharedCollection)
|
|
262
|
+
*
|
|
263
|
+
* if (isLoading) return <div>Loading...</div>
|
|
264
|
+
* if (isError) return <div>Error loading data</div>
|
|
265
|
+
*
|
|
266
|
+
* return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>
|
|
267
|
+
*/
|
|
268
|
+
// Overload 7: Accept pre-created live query collection
|
|
269
|
+
export function useLiveQuery<
|
|
270
|
+
TResult extends object,
|
|
271
|
+
TKey extends string | number,
|
|
272
|
+
TUtils extends Record<string, any>,
|
|
273
|
+
>(
|
|
274
|
+
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
|
|
275
|
+
): {
|
|
276
|
+
state: Map<TKey, TResult>;
|
|
277
|
+
data: Array<TResult>;
|
|
278
|
+
collection: Collection<TResult, TKey, TUtils>;
|
|
279
|
+
status: CollectionStatus; // Can't be disabled for pre-created live query collections
|
|
280
|
+
isLoading: boolean;
|
|
281
|
+
isReady: boolean;
|
|
282
|
+
isIdle: boolean;
|
|
283
|
+
isError: boolean;
|
|
284
|
+
isCleanedUp: boolean;
|
|
285
|
+
isEnabled: true; // Always true for pre-created live query collections
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Overload 8: Accept pre-created live query collection with singleResult: true
|
|
289
|
+
export function useLiveQuery<
|
|
290
|
+
TResult extends object,
|
|
291
|
+
TKey extends string | number,
|
|
292
|
+
TUtils extends Record<string, any>,
|
|
293
|
+
>(
|
|
294
|
+
liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
|
|
295
|
+
): {
|
|
296
|
+
state: Map<TKey, TResult>;
|
|
297
|
+
data: TResult | undefined;
|
|
298
|
+
collection: Collection<TResult, TKey, TUtils> & SingleResult;
|
|
299
|
+
status: CollectionStatus; // Can't be disabled for pre-created live query collections
|
|
300
|
+
isLoading: boolean;
|
|
301
|
+
isReady: boolean;
|
|
302
|
+
isIdle: boolean;
|
|
303
|
+
isError: boolean;
|
|
304
|
+
isCleanedUp: boolean;
|
|
305
|
+
isEnabled: true; // Always true for pre-created live query collections
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Implementation - use function overloads to infer the actual collection type
|
|
309
|
+
export function useLiveQuery(configOrQueryOrCollection: any, ...rest: Array<unknown>) {
|
|
310
|
+
const [args, slot] = splitTrailingSlot(rest);
|
|
311
|
+
const deps = (args[0] as Array<unknown> | undefined) ?? [];
|
|
312
|
+
// Check if it's already a collection
|
|
313
|
+
const inputIsCollection = isCollection(configOrQueryOrCollection);
|
|
314
|
+
|
|
315
|
+
// Use refs to cache collection and track dependencies
|
|
316
|
+
const collectionRef = useRef<Collection<object, string | number, {}> | null>(
|
|
317
|
+
null,
|
|
318
|
+
subSlot(slot, `coll-ref`),
|
|
319
|
+
);
|
|
320
|
+
const depsRef = useRef<Array<unknown> | null>(null, subSlot(slot, `deps-ref`));
|
|
321
|
+
const configRef = useRef<unknown>(null, subSlot(slot, `cfg-ref`));
|
|
322
|
+
|
|
323
|
+
// The shared observer owns the collection subscription, the ready-race, the
|
|
324
|
+
// status-transition notifications, and the stable per-revision snapshot. It
|
|
325
|
+
// replaces the previous hand-rolled `subscribeChanges` + version-counter,
|
|
326
|
+
// which only fired on row changes and therefore missed status-only
|
|
327
|
+
// transitions (a collection going to `error` or `cleaned-up` without any row
|
|
328
|
+
// delta left `status`/`isError`/`isCleanedUp` stale). See TanStack DB #1642.
|
|
329
|
+
const observerRef = useRef<LiveQueryObserver<object, string | number> | null>(
|
|
330
|
+
null,
|
|
331
|
+
subSlot(slot, `obs-ref`),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// Check if we need to create/recreate the collection
|
|
335
|
+
const needsNewCollection =
|
|
336
|
+
!collectionRef.current ||
|
|
337
|
+
(inputIsCollection && configRef.current !== configOrQueryOrCollection) ||
|
|
338
|
+
(!inputIsCollection &&
|
|
339
|
+
(depsRef.current === null ||
|
|
340
|
+
depsRef.current.length !== deps.length ||
|
|
341
|
+
depsRef.current.some((dep, i) => dep !== deps[i])));
|
|
342
|
+
|
|
343
|
+
if (needsNewCollection) {
|
|
344
|
+
if (inputIsCollection) {
|
|
345
|
+
// Warn when passing a collection directly with on-demand sync mode
|
|
346
|
+
// In on-demand mode, data is only loaded when queries with predicates request it
|
|
347
|
+
// Passing the collection directly doesn't provide any predicates, so no data loads
|
|
348
|
+
const syncMode = (configOrQueryOrCollection as { config?: { syncMode?: string } }).config
|
|
349
|
+
?.syncMode;
|
|
350
|
+
if (syncMode === `on-demand`) {
|
|
351
|
+
console.warn(
|
|
352
|
+
`[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` +
|
|
353
|
+
`will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` +
|
|
354
|
+
`Instead, use a query builder function:\n` +
|
|
355
|
+
` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` +
|
|
356
|
+
`Or switch to syncMode "eager" if you want all data to sync automatically.`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
// It's already a collection, ensure sync is started for Octane hooks
|
|
360
|
+
configOrQueryOrCollection.startSyncImmediate();
|
|
361
|
+
collectionRef.current = configOrQueryOrCollection;
|
|
362
|
+
configRef.current = configOrQueryOrCollection;
|
|
363
|
+
} else {
|
|
364
|
+
// Handle different callback return types
|
|
365
|
+
if (typeof configOrQueryOrCollection === `function`) {
|
|
366
|
+
// Call the function with a query builder to see what it returns
|
|
367
|
+
const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder;
|
|
368
|
+
const result = configOrQueryOrCollection(queryBuilder);
|
|
369
|
+
|
|
370
|
+
if (result === undefined || result === null) {
|
|
371
|
+
// Callback returned undefined/null - disabled query
|
|
372
|
+
collectionRef.current = null;
|
|
373
|
+
} else if (isCollection(result)) {
|
|
374
|
+
// Callback returned a Collection instance - use it directly
|
|
375
|
+
result.startSyncImmediate();
|
|
376
|
+
collectionRef.current = result;
|
|
377
|
+
} else if (result instanceof BaseQueryBuilder) {
|
|
378
|
+
// Callback returned QueryBuilder - create live query collection using the original callback
|
|
379
|
+
// (not the result, since the result might be from a different query builder instance)
|
|
380
|
+
collectionRef.current = createLiveQueryCollection({
|
|
381
|
+
query: configOrQueryOrCollection,
|
|
382
|
+
startSync: true,
|
|
383
|
+
gcTime: DEFAULT_GC_TIME_MS,
|
|
384
|
+
});
|
|
385
|
+
} else if (result && typeof result === `object`) {
|
|
386
|
+
// Assume it's a LiveQueryCollectionConfig
|
|
387
|
+
collectionRef.current = createLiveQueryCollection({
|
|
388
|
+
startSync: true,
|
|
389
|
+
gcTime: DEFAULT_GC_TIME_MS,
|
|
390
|
+
...result,
|
|
391
|
+
});
|
|
392
|
+
} else {
|
|
393
|
+
// Unexpected return type
|
|
394
|
+
throw new Error(
|
|
395
|
+
`useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof result}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
depsRef.current = [...deps];
|
|
399
|
+
} else {
|
|
400
|
+
// Original logic for config objects
|
|
401
|
+
collectionRef.current = createLiveQueryCollection({
|
|
402
|
+
startSync: true,
|
|
403
|
+
gcTime: DEFAULT_GC_TIME_MS,
|
|
404
|
+
...configOrQueryOrCollection,
|
|
405
|
+
});
|
|
406
|
+
depsRef.current = [...deps];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Recreate the observer when the underlying collection changes (including to
|
|
412
|
+
// `null` for a disabled query, which yields a stable disabled snapshot). The
|
|
413
|
+
// observer is not disposed explicitly here or on unmount: useSyncExternalStore
|
|
414
|
+
// unsubscribes it when the subscribe function changes or the component
|
|
415
|
+
// unmounts, which detaches the collection subscription and lets the observer
|
|
416
|
+
// be GC'd. An unmount effect that disposed it would misfire under effect
|
|
417
|
+
// replay, leaving a disposed observer in the ref.
|
|
418
|
+
if (needsNewCollection) {
|
|
419
|
+
// Wholesale mode: Octane re-reads getSnapshot() on notify (matching
|
|
420
|
+
// useSyncExternalStore), preserves the hook's loading policy, and delivers
|
|
421
|
+
// nothing synchronously during subscribe — so subscribe never notifies the
|
|
422
|
+
// store from inside its own call.
|
|
423
|
+
observerRef.current = createLiveQueryObserver(collectionRef.current, {
|
|
424
|
+
mode: `wholesale`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const observer = observerRef.current!;
|
|
428
|
+
|
|
429
|
+
// Stable subscribe/getSnapshot bound to the current observer. The observer
|
|
430
|
+
// returns a stable per-revision snapshot whose shape (state, data, collection,
|
|
431
|
+
// status + flags, isEnabled) is exactly what this hook exposes, so no
|
|
432
|
+
// post-processing is needed.
|
|
433
|
+
const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>(
|
|
434
|
+
null,
|
|
435
|
+
subSlot(slot, `sub-ref`),
|
|
436
|
+
);
|
|
437
|
+
if (!subscribeRef.current || needsNewCollection) {
|
|
438
|
+
subscribeRef.current = (onStoreChange: () => void) => {
|
|
439
|
+
let unsubscribed = false;
|
|
440
|
+
const unsub = observer.subscribe(() => {
|
|
441
|
+
if (!unsubscribed) onStoreChange();
|
|
442
|
+
});
|
|
443
|
+
// Nudge Octane to re-read the snapshot on the next microtask. Octane's
|
|
444
|
+
// useSyncExternalStore runs its commit-time tear-check BEFORE this passive
|
|
445
|
+
// subscribe effect calls us (React re-checks AFTER subscribe), so a
|
|
446
|
+
// collection that is already `ready` — or that starts sync synchronously
|
|
447
|
+
// during subscribe — publishes no change the observer can forward and the
|
|
448
|
+
// first committed value would stay stale. Deferring to a microtask keeps
|
|
449
|
+
// the notify outside the render→commit window. See the
|
|
450
|
+
// `eager-onstorechange` regression test.
|
|
451
|
+
queueMicrotask(() => {
|
|
452
|
+
if (!unsubscribed) onStoreChange();
|
|
453
|
+
});
|
|
454
|
+
return () => {
|
|
455
|
+
unsubscribed = true;
|
|
456
|
+
unsub();
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const getSnapshotRef = useRef<(() => unknown) | null>(null, subSlot(slot, `gs-ref`));
|
|
462
|
+
if (!getSnapshotRef.current || needsNewCollection) {
|
|
463
|
+
getSnapshotRef.current = () => observer.getSnapshot();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Keep implementation return loose to satisfy the overload signatures.
|
|
467
|
+
return useSyncExternalStore(
|
|
468
|
+
subscribeRef.current,
|
|
469
|
+
getSnapshotRef.current,
|
|
470
|
+
getSnapshotRef.current,
|
|
471
|
+
subSlot(slot, `uses`),
|
|
472
|
+
) as any;
|
|
473
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { createEffect } from '@tanstack/db';
|
|
3
|
+
import { splitTrailingSlot, subSlot } from './slot';
|
|
4
|
+
import type { Effect, EffectConfig } from '@tanstack/db';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React hook for creating a reactive effect that fires handlers when rows
|
|
8
|
+
* enter, exit, or update within a query result.
|
|
9
|
+
*
|
|
10
|
+
* The effect is created on mount and disposed on unmount. If `deps` change,
|
|
11
|
+
* the previous effect is disposed and a new one is created.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* function ChatComponent() {
|
|
16
|
+
* useLiveQueryEffect(
|
|
17
|
+
* {
|
|
18
|
+
* query: (q) => q.from({ msg: messages }).where(({ msg }) => eq(msg.role, 'user')),
|
|
19
|
+
* skipInitial: true,
|
|
20
|
+
* onEnter: async (event) => {
|
|
21
|
+
* await generateResponse(event.value)
|
|
22
|
+
* },
|
|
23
|
+
* },
|
|
24
|
+
* []
|
|
25
|
+
* )
|
|
26
|
+
*
|
|
27
|
+
* return <div>...</div>
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function useLiveQueryEffect<
|
|
32
|
+
TRow extends object = Record<string, unknown>,
|
|
33
|
+
TKey extends string | number = string | number,
|
|
34
|
+
>(config: EffectConfig<TRow, TKey>, deps?: Array<unknown>): void;
|
|
35
|
+
export function useLiveQueryEffect(config: any, ...rest: Array<unknown>): void {
|
|
36
|
+
// Parse the optional `deps` array and the compiler-injected trailing slot
|
|
37
|
+
// together from `...rest`, exactly as useLiveQuery/useLiveInfiniteQuery do.
|
|
38
|
+
// Binding the slot to a named `deps` parameter breaks the common no-deps call
|
|
39
|
+
// `useLiveQueryEffect(config)`: the injected slot lands in `deps`, leaving
|
|
40
|
+
// `rest` empty and the slot undefined, so every ref/effect below loses its
|
|
41
|
+
// call-site identity.
|
|
42
|
+
const [args, slot] = splitTrailingSlot(rest);
|
|
43
|
+
const deps = (args[0] as Array<unknown> | undefined) ?? [];
|
|
44
|
+
|
|
45
|
+
const configRef = useRef<EffectConfig<any, any>>(config, subSlot(slot, `cfg-ref`));
|
|
46
|
+
configRef.current = config;
|
|
47
|
+
|
|
48
|
+
useEffect(
|
|
49
|
+
() => {
|
|
50
|
+
const effect: Effect = createEffect({
|
|
51
|
+
id: config.id,
|
|
52
|
+
query: config.query,
|
|
53
|
+
skipInitial: config.skipInitial,
|
|
54
|
+
onEnter: (event, ctx) => configRef.current.onEnter?.(event, ctx),
|
|
55
|
+
onUpdate: (event, ctx) => configRef.current.onUpdate?.(event, ctx),
|
|
56
|
+
onExit: (event, ctx) => configRef.current.onExit?.(event, ctx),
|
|
57
|
+
onBatch: (events, ctx) => configRef.current.onBatch?.(events, ctx),
|
|
58
|
+
onError: config.onError
|
|
59
|
+
? (error, event) => configRef.current.onError?.(error, event)
|
|
60
|
+
: undefined,
|
|
61
|
+
onSourceError: config.onSourceError
|
|
62
|
+
? (error) => configRef.current.onSourceError?.(error)
|
|
63
|
+
: undefined,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
// Fire-and-forget disposal; AbortSignal cancels in-flight work
|
|
68
|
+
effect.dispose();
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
deps,
|
|
72
|
+
subSlot(slot, `effect`),
|
|
73
|
+
);
|
|
74
|
+
}
|