@verdant-web/react 15.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/src/hooks.tsx ADDED
@@ -0,0 +1,540 @@
1
+ import { CollectionIndexFilter, StorageSchema } from '@verdant-web/common';
2
+ import {
3
+ Query,
4
+ SyncTransportMode,
5
+ StorageDescriptor,
6
+ UserInfo,
7
+ Entity,
8
+ ClientWithCollections,
9
+ EntityFile,
10
+ Client,
11
+ } from '@verdant-web/store';
12
+ import {
13
+ createContext,
14
+ ReactNode,
15
+ Suspense,
16
+ useCallback,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useState,
21
+ useSyncExternalStore,
22
+ } from 'react';
23
+ import { suspend } from 'suspend-react';
24
+ import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector.js';
25
+
26
+ function useLiveQuery(liveQuery: Query<any> | null) {
27
+ if (liveQuery && liveQuery.status === 'initial') {
28
+ suspend(() => liveQuery.resolved, [liveQuery]);
29
+ }
30
+ return useSyncExternalStore(
31
+ (callback) => {
32
+ if (liveQuery) {
33
+ return liveQuery.subscribe(callback);
34
+ } else {
35
+ return () => {};
36
+ }
37
+ },
38
+ () => {
39
+ return liveQuery ? liveQuery.current : null;
40
+ },
41
+ );
42
+ }
43
+
44
+ function capitalize<T extends string>(str: T) {
45
+ return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;
46
+ }
47
+
48
+ type HookName = `use${string}`;
49
+
50
+ export function createHooks<Presence = any, Profile = any>(
51
+ schema: StorageSchema<any>,
52
+ ) {
53
+ const Context = createContext<StorageDescriptor<Presence, Profile> | null>(
54
+ null,
55
+ );
56
+
57
+ function useStorage(): ClientWithCollections {
58
+ const ctx = useContext(Context);
59
+ if (!ctx) {
60
+ throw new Error('No verdant provider was found');
61
+ }
62
+ return suspend(() => ctx.readyPromise, ['lofi_' + ctx.namespace]) as any;
63
+ }
64
+
65
+ function useWatch(liveObject: Entity | EntityFile | null, prop?: any) {
66
+ return useSyncExternalStore(
67
+ (handler) => {
68
+ if (liveObject) {
69
+ return (liveObject as any).subscribe('change', handler);
70
+ }
71
+ return () => {};
72
+ },
73
+ () => {
74
+ if (liveObject) {
75
+ if (liveObject instanceof EntityFile) {
76
+ return liveObject.url;
77
+ } else {
78
+ if (prop) {
79
+ return liveObject.get(prop);
80
+ }
81
+
82
+ return liveObject.getAll();
83
+ }
84
+ }
85
+
86
+ return undefined;
87
+ },
88
+ );
89
+ }
90
+
91
+ function useSelf() {
92
+ const storage = useStorage();
93
+ return useSyncExternalStore(
94
+ (callback) => storage.sync.presence.subscribe('selfChanged', callback),
95
+ () => storage.sync.presence.self,
96
+ );
97
+ }
98
+
99
+ function usePeerIds() {
100
+ const storage = useStorage();
101
+ return useSyncExternalStore(
102
+ (callback) => storage.sync.presence.subscribe('peersChanged', callback),
103
+ () => storage.sync.presence.peerIds,
104
+ );
105
+ }
106
+
107
+ function usePeer(peerId: string | null) {
108
+ const storage = useStorage();
109
+ return useSyncExternalStore(
110
+ (callback) => {
111
+ const unsubs: (() => void)[] = [];
112
+ unsubs.push(
113
+ storage.sync.presence.subscribe('peerChanged', (id, user) => {
114
+ if (id === peerId) {
115
+ callback();
116
+ }
117
+ }),
118
+ );
119
+ unsubs.push(
120
+ storage.sync.presence.subscribe('peerLeft', (id) => {
121
+ if (id === peerId) {
122
+ callback();
123
+ }
124
+ }),
125
+ );
126
+
127
+ return () => {
128
+ unsubs.forEach((unsub) => unsub());
129
+ };
130
+ },
131
+ () => (peerId ? storage.sync.presence.peers[peerId] ?? null : null),
132
+ );
133
+ }
134
+
135
+ function useFindPeer(
136
+ query: (peer: UserInfo<any, any>) => boolean,
137
+ options?: { includeSelf: boolean },
138
+ ) {
139
+ const storage = useStorage();
140
+ return useSyncExternalStore(
141
+ (callback) => {
142
+ const unsubs: (() => void)[] = [];
143
+ unsubs.push(
144
+ storage.sync.presence.subscribe('peerChanged', (id, user) => {
145
+ if (query(user)) {
146
+ callback();
147
+ }
148
+ }),
149
+ );
150
+ unsubs.push(
151
+ storage.sync.presence.subscribe('peerLeft', (id) => {
152
+ if (query(storage.sync.presence.peers[id])) {
153
+ callback();
154
+ }
155
+ }),
156
+ );
157
+ if (options?.includeSelf) {
158
+ unsubs.push(
159
+ storage.sync.presence.subscribe('selfChanged', (user) => {
160
+ if (query(user)) {
161
+ callback();
162
+ }
163
+ }),
164
+ );
165
+ }
166
+
167
+ return () => {
168
+ unsubs.forEach((unsub) => unsub());
169
+ };
170
+ },
171
+ () => {
172
+ const peers = Object.values(storage.sync.presence.peers);
173
+ if (options?.includeSelf) {
174
+ peers.push(storage.sync.presence.self);
175
+ }
176
+ return peers.find(query) || null;
177
+ },
178
+ );
179
+ }
180
+
181
+ function useFindPeers(
182
+ query: (peer: UserInfo<any, any>) => boolean,
183
+ options?: { includeSelf: boolean },
184
+ ) {
185
+ const storage = useStorage();
186
+ return useSyncExternalStoreWithSelector(
187
+ (callback) => {
188
+ const unsubs: (() => void)[] = [];
189
+ unsubs.push(
190
+ storage.sync.presence.subscribe('peerChanged', (id, user) => {
191
+ if (query(user)) {
192
+ callback();
193
+ }
194
+ }),
195
+ );
196
+ unsubs.push(
197
+ storage.sync.presence.subscribe('peerLeft', (id) => {
198
+ callback();
199
+ }),
200
+ );
201
+ if (options?.includeSelf) {
202
+ unsubs.push(
203
+ storage.sync.presence.subscribe('selfChanged', (user) => {
204
+ if (query(user)) {
205
+ callback();
206
+ }
207
+ }),
208
+ );
209
+ }
210
+
211
+ return () => {
212
+ unsubs.forEach((unsub) => unsub());
213
+ };
214
+ },
215
+ () => {
216
+ const peers = Object.values(storage.sync.presence.peers).filter(
217
+ Boolean,
218
+ );
219
+ if (options?.includeSelf) {
220
+ peers.push(storage.sync.presence.self);
221
+ }
222
+ return peers.filter(query);
223
+ },
224
+ () => [] as UserInfo<any, any>[],
225
+ (peers) => peers,
226
+ (a, b) => a.length === b.length && a.every((peer, i) => peer === b[i]),
227
+ );
228
+ }
229
+
230
+ function useSyncStatus() {
231
+ const storage = useStorage();
232
+ return useSyncExternalStore(
233
+ (callback) => storage.sync.subscribe('onlineChange', callback),
234
+ () => storage.sync.isConnected,
235
+ );
236
+ }
237
+
238
+ function useUndo() {
239
+ const storage = useStorage();
240
+
241
+ return useCallback(() => storage.undoHistory.undo(), [storage]);
242
+ }
243
+
244
+ function useRedo() {
245
+ const storage = useStorage();
246
+
247
+ return useCallback(() => storage.undoHistory.redo(), [storage]);
248
+ }
249
+
250
+ function useCanUndo() {
251
+ const storage = useStorage();
252
+
253
+ return useSyncExternalStore(
254
+ (callback) => storage.undoHistory.subscribe('change', callback),
255
+ () => storage.undoHistory.canUndo,
256
+ );
257
+ }
258
+
259
+ function useCanRedo() {
260
+ const storage = useStorage();
261
+ return useSyncExternalStore(
262
+ (callback) => storage.undoHistory.subscribe('change', callback),
263
+ () => storage.undoHistory.canRedo,
264
+ );
265
+ }
266
+
267
+ function useUnsuspendedClient() {
268
+ const desc = useContext(Context);
269
+
270
+ const client = desc?.current;
271
+
272
+ const [_, forceUpdate] = useState(0);
273
+ if (desc && !client) {
274
+ desc.readyPromise.then(() => forceUpdate((n) => n + 1));
275
+ }
276
+
277
+ return client || null;
278
+ }
279
+
280
+ /**
281
+ * Non-suspending hook which allows declarative sync start/stop
282
+ * control.
283
+ *
284
+ * You can optionally configure parameters as part of this as well.
285
+ */
286
+ function useSync(
287
+ isOn: boolean,
288
+ config: { mode?: SyncTransportMode; pullInterval?: number } = {},
289
+ ) {
290
+ const client = useUnsuspendedClient();
291
+
292
+ useEffect(() => {
293
+ if (client) {
294
+ if (isOn) {
295
+ client.sync.start();
296
+ } else {
297
+ client.sync.stop();
298
+ }
299
+ }
300
+ }, [client, isOn]);
301
+
302
+ useEffect(() => {
303
+ if (client) {
304
+ if (config.mode !== undefined) {
305
+ client.sync.setMode(config.mode);
306
+ }
307
+ }
308
+ }, [client, config.mode]);
309
+
310
+ useEffect(() => {
311
+ if (client) {
312
+ if (config.pullInterval !== undefined) {
313
+ client.sync.setPullInterval(config.pullInterval);
314
+ }
315
+ }
316
+ }, [client, config.pullInterval]);
317
+ }
318
+
319
+ function SyncController({ isOn }: { isOn: boolean }) {
320
+ useSync(isOn);
321
+ return null;
322
+ }
323
+
324
+ const hooks: Record<string, any> = {
325
+ useStorage,
326
+ useClient: useStorage,
327
+ useUnsuspendedClient,
328
+ useWatch,
329
+ useSelf,
330
+ usePeerIds,
331
+ usePeer,
332
+ useFindPeer,
333
+ useFindPeers,
334
+ useSyncStatus,
335
+ useUndo,
336
+ useRedo,
337
+ useCanUndo,
338
+ useCanRedo,
339
+ useSync,
340
+ Context,
341
+ Provider: ({
342
+ value,
343
+ children,
344
+ sync,
345
+ suspenseFallback,
346
+ ...rest
347
+ }: {
348
+ children?: ReactNode;
349
+ value: StorageDescriptor;
350
+ sync?: boolean;
351
+ suspenseFallback?: ReactNode;
352
+ }) => {
353
+ // auto-open storage when used in provider
354
+ useMemo(() => {
355
+ value.open();
356
+ }, [value]);
357
+ return (
358
+ <Context.Provider value={value} {...rest}>
359
+ <Suspense fallback={suspenseFallback || null}>
360
+ {children}
361
+ {sync !== undefined && <SyncController isOn={sync} />}
362
+ </Suspense>
363
+ </Context.Provider>
364
+ );
365
+ },
366
+ };
367
+
368
+ const collectionNames = Object.keys(schema.collections);
369
+ for (const name of collectionNames) {
370
+ const collection = schema.collections[name];
371
+ const getOneHookName = `use${capitalize(collection.name)}`;
372
+ hooks[getOneHookName] = function useIndividual(
373
+ id: string,
374
+ { skip }: { skip?: boolean } = {},
375
+ ) {
376
+ const storage = useStorage();
377
+ const liveQuery = useMemo(() => {
378
+ return skip ? null : storage[name].get(id);
379
+ }, [id, skip]);
380
+ const data = useLiveQuery(liveQuery);
381
+
382
+ return data;
383
+ };
384
+
385
+ const findOneHookName = `useOne${capitalize(collection.name)}`;
386
+ hooks[findOneHookName] = function useOne({
387
+ skip,
388
+ index,
389
+ key,
390
+ }: {
391
+ index?: CollectionIndexFilter;
392
+ skip?: boolean;
393
+ key?: string;
394
+ } = {}) {
395
+ const storage = useStorage();
396
+ const liveQuery = useMemo(() => {
397
+ return skip ? null : storage[name].findOne({ index, key });
398
+ }, [index, skip]);
399
+ const data = useLiveQuery(liveQuery);
400
+ return data;
401
+ };
402
+
403
+ const getAllHookName = `useAll${capitalize(
404
+ collection.pluralName || collection.name + 's',
405
+ )}`;
406
+ hooks[getAllHookName] = function useAll({
407
+ index,
408
+ skip,
409
+ key,
410
+ }: {
411
+ index?: CollectionIndexFilter;
412
+ skip?: boolean;
413
+ key?: string;
414
+ } = {}) {
415
+ const storage = useStorage();
416
+ // assumptions: this query getter is fast and returns the same
417
+ // query identity for subsequent calls.
418
+ const liveQuery = useMemo(
419
+ () => (skip ? null : storage[name].findAll({ index, key })),
420
+ [index, skip],
421
+ );
422
+ const data = useLiveQuery(liveQuery);
423
+ return data || [];
424
+ };
425
+ const getAllPaginatedHookName = `useAll${capitalize(
426
+ collection.pluralName || collection.name + 's',
427
+ )}Paginated`;
428
+ hooks[getAllPaginatedHookName] = function useAllPaginated({
429
+ index,
430
+ skip,
431
+ pageSize = 10,
432
+ key,
433
+ }: {
434
+ index?: CollectionIndexFilter;
435
+ skip?: boolean;
436
+ pageSize?: number;
437
+ key?: string;
438
+ } = {}) {
439
+ const storage = useStorage();
440
+ // assumptions: this query getter is fast and returns the same
441
+ // query identity for subsequent calls.
442
+ const liveQuery = useMemo(
443
+ () =>
444
+ skip
445
+ ? null
446
+ : storage[name].findPage({
447
+ index,
448
+ pageSize,
449
+ page: 0,
450
+ key: key || getAllPaginatedHookName,
451
+ }),
452
+ [index, skip, pageSize],
453
+ );
454
+ const data = useLiveQuery(liveQuery);
455
+
456
+ const tools = useMemo(
457
+ () => ({
458
+ next: () => liveQuery?.nextPage(),
459
+ previous: () => liveQuery?.previousPage(),
460
+ setPage: (page: number) => liveQuery?.setPage(page),
461
+
462
+ get hasPrevious() {
463
+ return liveQuery?.hasPreviousPage;
464
+ },
465
+
466
+ get hasNext() {
467
+ return liveQuery?.hasNextPage;
468
+ },
469
+ }),
470
+ [liveQuery],
471
+ );
472
+
473
+ return [data, tools] as const;
474
+ };
475
+ const getAllInfiniteHookName = `useAll${capitalize(
476
+ collection.pluralName || collection.name + 's',
477
+ )}Infinite`;
478
+ hooks[getAllInfiniteHookName] = function useAllInfinite({
479
+ index,
480
+ skip,
481
+ pageSize = 10,
482
+ key,
483
+ }: {
484
+ index?: CollectionIndexFilter;
485
+ skip?: boolean;
486
+ pageSize?: number;
487
+ key?: string;
488
+ } = {}) {
489
+ const storage = useStorage();
490
+ // assumptions: this query getter is fast and returns the same
491
+ // query identity for subsequent calls.
492
+ const liveQuery = useMemo(
493
+ () =>
494
+ skip
495
+ ? null
496
+ : storage[name].findAllInfinite({
497
+ index,
498
+ pageSize,
499
+ key: key || getAllInfiniteHookName,
500
+ }),
501
+ [index, skip, pageSize],
502
+ );
503
+ const data = useLiveQuery(liveQuery);
504
+
505
+ const tools = useMemo(
506
+ () => ({
507
+ loadMore: () => liveQuery?.loadMore(),
508
+
509
+ get hasMore() {
510
+ return liveQuery?.hasMore;
511
+ },
512
+ }),
513
+ [liveQuery],
514
+ );
515
+
516
+ return [data, tools] as const;
517
+ };
518
+ }
519
+
520
+ hooks.withMutations = <
521
+ Mutations extends {
522
+ [key: HookName]: (client: Client, ...args: any[]) => any;
523
+ },
524
+ >(
525
+ mutations: Mutations,
526
+ ) => {
527
+ const augmentedHooks = {
528
+ ...hooks,
529
+ };
530
+ for (const [name, subHook] of Object.entries(mutations)) {
531
+ augmentedHooks[name] = (...args: any[]) => {
532
+ const client = hooks.useClient();
533
+ return subHook(client, ...args);
534
+ };
535
+ }
536
+ return augmentedHooks;
537
+ };
538
+
539
+ return hooks as any;
540
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './hooks.js';