forked from rocicorp/mono
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuse-query.tsx
More file actions
348 lines (317 loc) · 10.3 KB
/
use-query.tsx
File metadata and controls
348 lines (317 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
import {useSyncExternalStore} from 'react';
import {deepClone} from '../../shared/src/deep-clone.ts';
import type {Immutable} from '../../shared/src/immutable.ts';
import type {ReadonlyJSONValue} from '../../shared/src/json.ts';
import type {Schema} from '../../zero-schema/src/builder/schema-builder.ts';
import type {Format} from '../../zql/src/ivm/view.ts';
import {AbstractQuery} from '../../zql/src/query/query-impl.ts';
import {type HumanReadable, type Query} from '../../zql/src/query/query.ts';
import {DEFAULT_TTL, type TTL} from '../../zql/src/query/ttl.ts';
import type {ResultType, TypedView} from '../../zql/src/query/typed-view.ts';
import {useZero} from './use-zero.tsx';
export type QueryResultDetails = Readonly<{
type: ResultType;
}>;
export type QueryResult<TReturn> = readonly [
HumanReadable<TReturn>,
QueryResultDetails,
];
export type UseQueryOptions = {
enabled?: boolean | undefined;
/**
* Time to live (TTL) in seconds. Controls how long query results are cached
* after the query is removed. During this time, Zero continues to sync the query.
* Default is 'never'.
*/
ttl?: TTL | undefined;
};
export function useQuery<
TSchema extends Schema,
TTable extends keyof TSchema['tables'] & string,
TReturn,
>(
query: Query<TSchema, TTable, TReturn>,
options?: UseQueryOptions | boolean,
): QueryResult<TReturn> {
let enabled = true;
let ttl: TTL = DEFAULT_TTL;
if (typeof options === 'boolean') {
enabled = options;
} else if (options) {
({enabled = true, ttl = DEFAULT_TTL} = options);
}
const z = useZero();
const view = viewStore.getView(
z.clientID,
query as AbstractQuery<TSchema, TTable, TReturn>,
enabled,
ttl,
);
// https://react.dev/reference/react/useSyncExternalStore
return useSyncExternalStore(
view.subscribeReactInternals,
view.getSnapshot,
view.getSnapshot,
);
}
const emptyArray: unknown[] = [];
const disabledSubscriber = () => () => {};
const resultTypeUnknown = {type: 'unknown'} as const;
const resultTypeComplete = {type: 'complete'} as const;
const emptySnapshotSingularUnknown = [undefined, resultTypeUnknown] as const;
const emptySnapshotSingularComplete = [undefined, resultTypeComplete] as const;
const emptySnapshotPluralUnknown = [emptyArray, resultTypeUnknown] as const;
const emptySnapshotPluralComplete = [emptyArray, resultTypeComplete] as const;
function getDefaultSnapshot<TReturn>(singular: boolean): QueryResult<TReturn> {
return (
singular ? emptySnapshotSingularUnknown : emptySnapshotPluralUnknown
) as QueryResult<TReturn>;
}
/**
* Returns a new snapshot or one of the empty predefined ones. Returning the
* predefined ones is important to prevent unnecessary re-renders in React.
*/
function getSnapshot<TReturn>(
singular: boolean,
data: HumanReadable<TReturn>,
resultType: string,
): QueryResult<TReturn> {
if (singular && data === undefined) {
return (resultType === 'complete'
? emptySnapshotSingularComplete
: emptySnapshotSingularUnknown) as unknown as QueryResult<TReturn>;
}
if (!singular && (data as unknown[]).length === 0) {
return (
resultType === 'complete'
? emptySnapshotPluralComplete
: emptySnapshotPluralUnknown
) as QueryResult<TReturn>;
}
return [
data,
resultType === 'complete' ? resultTypeComplete : resultTypeUnknown,
];
}
declare const TESTING: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ViewWrapperAny = ViewWrapper<any, any, any>;
const allViews = new WeakMap<ViewStore, Map<string, ViewWrapperAny>>();
export function getAllViewsSizeForTesting(store: ViewStore): number {
if (TESTING) {
return allViews.get(store)?.size ?? 0;
}
return 0;
}
/**
* A global store of all active views.
*
* React subscribes and unsubscribes to these views
* via `useSyncExternalStore`.
*
* Managing views through `useEffect` or `useLayoutEffect` causes
* inconsistencies because effects run after render.
*
* For example, if useQuery used use*Effect in the component below:
* ```ts
* function Foo({issueID}) {
* const issue = useQuery(z.query.issue.where('id', issueID).one());
* if (issue?.id !== undefined && issue.id !== issueID) {
* console.log('MISMATCH!', issue.id, issueID);
* }
* }
* ```
*
* `MISMATCH` will be printed whenever the `issueID` prop changes.
*
* This is because the component will render once with
* the old state returned from `useQuery`. Then the effect inside
* `useQuery` will run. The component will render again with the new
* state. This inconsistent transition can cause unexpected results.
*
* Emulating `useEffect` via `useState` and `if` causes resource leaks.
* That is:
*
* ```ts
* function useQuery(q) {
* const [oldHash, setOldHash] = useState();
* if (hash(q) !== oldHash) {
* // make new view
* }
*
* useEffect(() => {
* return () => view.destroy();
* }, []);
* }
* ```
*
* I'm not sure why but in strict mode the cleanup function
* fails to be called for the first instance of the view and only
* cleans up later instances.
*
* Swapping `useState` to `useRef` has similar problems.
*/
export class ViewStore {
#views = new Map<string, ViewWrapperAny>();
constructor() {
if (TESTING) {
allViews.set(this, this.#views);
}
}
getView<
TSchema extends Schema,
TTable extends keyof TSchema['tables'] & string,
TReturn,
>(
clientID: string,
query: AbstractQuery<TSchema, TTable, TReturn>,
enabled: boolean,
ttl: TTL,
): {
getSnapshot: () => QueryResult<TReturn>;
subscribeReactInternals: (internals: () => void) => () => void;
updateTTL: (ttl: TTL) => void;
} {
const {format} = query;
if (!enabled) {
return {
getSnapshot: () => getDefaultSnapshot(format.singular),
subscribeReactInternals: disabledSubscriber,
updateTTL: () => {},
};
}
const hash = query.hash() + clientID;
let existing = this.#views.get(hash);
if (!existing) {
existing = new ViewWrapper(
query,
format,
ttl,
view => {
const lastView = this.#views.get(hash);
// I don't think this can happen
// but lets guard against it so we don't
// leak resources.
if (lastView && lastView !== view) {
throw new Error('View already exists');
}
this.#views.set(hash, view);
},
() => {
this.#views.delete(hash);
},
) as ViewWrapper<TSchema, TTable, TReturn>;
this.#views.set(hash, existing);
} else {
existing.updateTTL(ttl);
}
return existing as ViewWrapper<TSchema, TTable, TReturn>;
}
}
const viewStore = new ViewStore();
/**
* This wraps and ref counts a view.
*
* The only signal we have from React as to whether or not it is
* done with a view is when it calls `unsubscribe`.
*
* In non-strict-mode we can clean up the view as soon
* as the listener count goes to 0.
*
* In strict-mode, the listener count will go to 0 then a
* new listener for the same view is immediately added back.
*
* This is why the `onMaterialized` and `onDematerialized` callbacks exist --
* they allow a view which React is still referencing to be added
* back into the store when React re-subscribes to it.
*
* This wrapper also exists to deal with the various
* `useSyncExternalStore` caveats that cause excessive
* re-renders and materializations.
*
* See: https://react.dev/reference/react/useSyncExternalStore#caveats
* Especially:
* 1. The store snapshot returned by getSnapshot must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot.
* 2. If a different subscribe function is passed during a re-render, React will re-subscribe to the store using the newly passed subscribe function. You can prevent this by declaring subscribe outside the component.
*/
class ViewWrapper<
TSchema extends Schema,
TTable extends keyof TSchema['tables'] & string,
TReturn,
> {
#view: TypedView<HumanReadable<TReturn>> | undefined;
readonly #onDematerialized;
readonly #onMaterialized;
readonly #query: Query<TSchema, TTable, TReturn>;
readonly #format: Format;
#snapshot: QueryResult<TReturn>;
#reactInternals: Set<() => void>;
#ttl: TTL;
constructor(
query: AbstractQuery<TSchema, TTable, TReturn>,
format: Format,
ttl: TTL,
onMaterialized: (view: ViewWrapper<TSchema, TTable, TReturn>) => void,
onDematerialized: () => void,
) {
this.#query = query;
this.#format = format;
this.#ttl = ttl;
this.#onMaterialized = onMaterialized;
this.#onDematerialized = onDematerialized;
this.#snapshot = getDefaultSnapshot(format.singular);
this.#reactInternals = new Set();
this.#materializeIfNeeded();
}
#onData = (
snap: Immutable<HumanReadable<TReturn>>,
resultType: ResultType,
) => {
const data =
snap === undefined
? snap
: (deepClone(snap as ReadonlyJSONValue) as HumanReadable<TReturn>);
this.#snapshot = getSnapshot(this.#format.singular, data, resultType);
for (const internals of this.#reactInternals) {
internals();
}
};
#materializeIfNeeded = () => {
if (this.#view) {
return;
}
this.#view = this.#query.materialize(this.#ttl);
this.#view.addListener(this.#onData);
this.#onMaterialized(this);
};
getSnapshot = () => this.#snapshot;
subscribeReactInternals = (internals: () => void): (() => void) => {
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
return () => {
this.#reactInternals.delete(internals);
// only schedule a cleanup task if we have no listeners left
if (this.#reactInternals.size === 0) {
setTimeout(() => {
// Someone re-registered a listener on this view before the timeout elapsed.
// This happens often in strict-mode which forces a component
// to mount, unmount, remount.
if (this.#reactInternals.size > 0) {
return;
}
// We already destroyed the view
if (this.#view === undefined) {
return;
}
this.#view?.destroy();
this.#view = undefined;
this.#onDematerialized();
}, 10);
}
};
};
updateTTL(ttl: TTL): void {
this.#ttl = ttl;
this.#view?.updateTTL(ttl);
}
}