@peerbit/react 0.0.31 → 0.0.32

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.
@@ -0,0 +1,391 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "vitest";
2
+ import { Peerbit } from "peerbit";
3
+ import { field, variant } from "@dao-xyz/borsh";
4
+ import { Documents, WithContext, WithIndexedContext } from "@peerbit/document";
5
+ import { Program } from "@peerbit/program";
6
+ import React, { useEffect } from "react";
7
+ import { render, act, waitFor } from "@testing-library/react";
8
+ import { useQuery, UseQuerySharedOptions } from "../useQuery.js";
9
+ import sodium from "libsodium-wrappers";
10
+
11
+ // Minimal Post model and Program with Documents for integration-like tests
12
+ @variant(0)
13
+ class Post {
14
+ @field({ type: "string" })
15
+ id!: string;
16
+ @field({ type: "string" })
17
+ message!: string;
18
+ constructor(props?: { id?: string; message?: string }) {
19
+ if (!props) return; // borsh
20
+ this.id = props.id ?? `${Date.now()}-${Math.random()}`;
21
+ this.message = props.message ?? "";
22
+ }
23
+ }
24
+ @variant(0)
25
+ class PostIndexed {
26
+ @field({ type: "string" })
27
+ id!: string;
28
+ @field({ type: "string" })
29
+ indexedMessage!: string;
30
+ constructor(props?: Post) {
31
+ if (!props) return; // borsh
32
+ this.id = props.id ?? `${Date.now()}-${Math.random()}`;
33
+ this.indexedMessage = props.message ?? "";
34
+ }
35
+ }
36
+
37
+ @variant("posts-db")
38
+ class PostsDB extends Program<{ replicate?: boolean }> {
39
+ @field({ type: Documents })
40
+ posts: Documents<Post, PostIndexed>;
41
+ constructor() {
42
+ super();
43
+ this.posts = new Documents<Post, PostIndexed>();
44
+ }
45
+ async open(args?: { replicate?: boolean }): Promise<void> {
46
+ await this.posts.open({
47
+ type: Post,
48
+ index: { type: PostIndexed },
49
+ replicate: args?.replicate ? { factor: 1 } : false,
50
+ });
51
+ }
52
+ }
53
+
54
+ describe("useQuery (integration with Documents)", () => {
55
+ let peerWriter: Peerbit;
56
+ let peerReader: Peerbit;
57
+ let dbWriter: PostsDB;
58
+ let dbReader: PostsDB;
59
+ let autoUnmount: undefined | (() => void);
60
+
61
+ beforeEach(async () => {
62
+ await sodium.ready;
63
+ peerWriter = await Peerbit.create();
64
+ peerReader = await Peerbit.create();
65
+ });
66
+ const setupConnected = async () => {
67
+ await peerWriter.dial(peerReader);
68
+ dbWriter = await peerWriter.open(new PostsDB(), {
69
+ existing: "reuse",
70
+ args: { replicate: true },
71
+ });
72
+ dbReader = await peerReader.open<PostsDB>(dbWriter.address, {
73
+ args: { replicate: false },
74
+ });
75
+ // ensure reader knows about writer as replicator for the log
76
+ await dbReader.posts.log.waitForReplicator(
77
+ peerWriter.identity.publicKey
78
+ );
79
+ };
80
+
81
+ const setupDisconnected = async () => {
82
+ dbWriter = await peerWriter.open(new PostsDB(), {
83
+ existing: "reuse",
84
+ args: { replicate: true },
85
+ });
86
+ dbReader = await peerReader.open<PostsDB>(dbWriter.clone(), {
87
+ args: { replicate: false },
88
+ });
89
+ };
90
+
91
+ afterEach(async () => {
92
+ // Unmount React trees before tearing down peers
93
+ autoUnmount?.();
94
+ autoUnmount = undefined;
95
+ await peerWriter?.stop();
96
+ await peerReader?.stop();
97
+ });
98
+
99
+ function renderUseQuery<R extends boolean>(
100
+ db: PostsDB,
101
+ options: UseQuerySharedOptions<Post, PostIndexed, R>
102
+ ) {
103
+ const result: {
104
+ current: ReturnType<typeof useQuery<Post, PostIndexed, R>>;
105
+ } = {} as any;
106
+
107
+ function HookCmp({
108
+ opts,
109
+ }: {
110
+ opts: UseQuerySharedOptions<Post, PostIndexed, R>;
111
+ }) {
112
+ const hook = useQuery<Post, PostIndexed, R>(db.posts, opts);
113
+ useEffect(() => {
114
+ result.current = hook;
115
+ }, [hook]);
116
+ return null;
117
+ }
118
+
119
+ const api = render(React.createElement(HookCmp, { opts: options }));
120
+ const rerender = (opts: UseQuerySharedOptions<Post, PostIndexed, R>) =>
121
+ api.rerender(React.createElement(HookCmp, { opts }));
122
+ let hasUnmounted = false;
123
+ const doUnmount = () => {
124
+ if (hasUnmounted) return;
125
+ hasUnmounted = true;
126
+ api.unmount();
127
+ if (autoUnmount === doUnmount) {
128
+ // clear outer reference if we still own it
129
+ autoUnmount = undefined;
130
+ }
131
+ };
132
+ // Expose to outer afterEach so tests don't need to remember calling unmount
133
+ autoUnmount = doUnmount;
134
+ return { result, rerender, unmount: doUnmount };
135
+ }
136
+
137
+ it("local query", async () => {
138
+ await setupConnected();
139
+ await dbWriter.posts.put(new Post({ message: "hello" }));
140
+ const { result } = renderUseQuery(dbWriter, {
141
+ query: {},
142
+ resolve: true,
143
+ local: true,
144
+ prefetch: true,
145
+ });
146
+ await waitFor(() => expect(result.current?.items?.length ?? 0).toBe(1));
147
+
148
+ await act(async () => {
149
+ expect(result.current.items.length).toBe(1);
150
+ expect(result.current.items[0].message).toBe("hello");
151
+ });
152
+ });
153
+
154
+ it("does not mutate the options object passed in", async () => {
155
+ await setupConnected();
156
+ const cfg = {
157
+ query: {},
158
+ resolve: true,
159
+ local: true,
160
+ remote: { reach: { eager: true }, wait: { timeout: 10_000 } },
161
+ prefetch: false,
162
+ batchSize: 10,
163
+ };
164
+ const cfgOrg = { ...cfg };
165
+ renderUseQuery(dbReader, cfg);
166
+ // expect that cfg has not been modified
167
+ expect(cfg).to.deep.equal(cfgOrg);
168
+ });
169
+
170
+ it("respects remote warmup before iterating", async () => {
171
+ await setupConnected();
172
+ await dbWriter.posts.put(new Post({ message: "hello" }));
173
+
174
+ const cfg: UseQuerySharedOptions<Post, PostIndexed, true> = {
175
+ query: {},
176
+ resolve: true,
177
+ local: true,
178
+ remote: { reach: { eager: true }, wait: { timeout: 10_000 } },
179
+ prefetch: false,
180
+ batchSize: 10,
181
+ };
182
+ const { result, rerender } = renderUseQuery(dbReader, cfg);
183
+
184
+ await waitFor(() => {
185
+ if (!result.current) throw new Error("no result yet");
186
+ return true;
187
+ });
188
+
189
+ expect(result.current.items.length).toBe(0);
190
+
191
+ await act(async () => {
192
+ await result.current.loadMore();
193
+ });
194
+
195
+ expect(result.current.items.length).toBe(1);
196
+ expect(result.current.items[0].message).toBe("hello");
197
+
198
+ await act(async () => {
199
+ rerender(cfg);
200
+ });
201
+ await act(async () => {
202
+ await result.current.loadMore();
203
+ });
204
+ await waitFor(() => expect(result.current.items.length).toBe(1));
205
+ });
206
+
207
+ it("honors remote.wait.timeout by resolving after connection", async () => {
208
+ // create isolated peers not connected yet
209
+ await setupDisconnected();
210
+
211
+ const { result } = renderUseQuery(dbReader, {
212
+ query: {},
213
+ resolve: true,
214
+ local: false,
215
+ remote: {
216
+ reach: { eager: true },
217
+ wait: { behavior: "block", timeout: 5_000 },
218
+ },
219
+ prefetch: true,
220
+ });
221
+
222
+ await waitFor(() => expect(result.current).toBeDefined());
223
+
224
+ // Now connect and write
225
+
226
+ await act(async () => {
227
+ await dbReader.node.dial(dbWriter.node.getMultiaddrs());
228
+ await dbWriter.posts.put(new Post({ message: "late" }));
229
+ await dbReader.posts.log.waitForReplicator(
230
+ dbWriter.node.identity.publicKey
231
+ );
232
+ });
233
+
234
+ await waitFor(() => expect(result.current.items.length).toBe(1));
235
+ expect(result.current.items[0].message).toBe("late");
236
+ });
237
+
238
+ describe("merge", () => {
239
+ const checkAsResolvedResults = async <R extends boolean>(
240
+ out: ReturnType<typeof renderUseQuery<R>>,
241
+ resolved: R
242
+ ) => {
243
+ const { result } = out;
244
+ await waitFor(() => expect(result.current).toBeDefined());
245
+
246
+ // Initially empty
247
+ expect(result.current.items.length).toBe(0);
248
+
249
+ // Create a post on writer and expect reader hook to merge it automatically
250
+ const id = `${Date.now()}-merge`;
251
+ await act(async () => {
252
+ // the reader actually does the put (a user)
253
+ await dbReader.posts.put(new Post({ id, message: "first" }));
254
+ });
255
+
256
+ await waitFor(() => expect(result.current.items.length).toBe(1), {
257
+ timeout: 1e4,
258
+ });
259
+ if (resolved) {
260
+ expect((result.current.items[0] as Post).message).toBe("first");
261
+ expect(result.current.items[0]).to.be.instanceOf(Post);
262
+ } else {
263
+ expect(
264
+ (result.current.items[0] as PostIndexed).indexedMessage
265
+ ).toBe("first");
266
+ expect(result.current.items[0]).to.be.instanceOf(PostIndexed);
267
+ }
268
+ };
269
+
270
+ it("updates.merge merges new writes into state without manual iteration, as resolved", async () => {
271
+ await setupConnected();
272
+
273
+ // resolved undefined means we should resolve
274
+ await checkAsResolvedResults(
275
+ renderUseQuery<true>(dbReader, {
276
+ query: {},
277
+ local: false,
278
+ remote: { reach: { eager: true } },
279
+ prefetch: false,
280
+ updates: { merge: true },
281
+ }),
282
+ true
283
+ );
284
+
285
+ // resolved true means we should resolve
286
+ await checkAsResolvedResults(
287
+ renderUseQuery<true>(dbReader, {
288
+ query: {},
289
+ local: false,
290
+ resolve: true,
291
+ remote: { reach: { eager: true } },
292
+ prefetch: false,
293
+ updates: { merge: true },
294
+ }),
295
+ true
296
+ );
297
+
298
+ // resolved false means we should NOT resolve
299
+ await checkAsResolvedResults(
300
+ renderUseQuery<false>(dbReader, {
301
+ query: {},
302
+ local: false,
303
+ resolve: false,
304
+ remote: { reach: { eager: true } },
305
+ prefetch: false,
306
+ updates: { merge: true },
307
+ }),
308
+ false
309
+ );
310
+ });
311
+ });
312
+
313
+ /* TODO not yet supported
314
+
315
+ it("updates.merge reflects document mutation in hook state", async () => {
316
+ await setupConnected();
317
+
318
+ const id = `${Date.now()}-mut`;
319
+ await dbWriter.posts.put(new Post({ id, message: "v1" }));
320
+
321
+ const { result } = renderUseQuery(dbReader, {
322
+ query: {},
323
+ resolve: true,
324
+ local: false,
325
+ remote: { reach: { eager: true } },
326
+ prefetch: true,
327
+ updates: { merge: true },
328
+ });
329
+
330
+ await waitFor(() => expect(result.current.items.length).toBe(1), {
331
+ timeout: 1e4,
332
+ });
333
+ expect(result.current.items[0].message).toBe("v1");
334
+
335
+ // Mutate by putting a new version with the same id
336
+ await act(async () => {
337
+ // the reader actually does the put (a user)
338
+ await dbReader.posts.put(new Post({ id, message: "v2" }));
339
+ });
340
+
341
+ // Expect the hook state to reflect the updated content
342
+ await waitFor(
343
+ () => {
344
+ const found = result.current.items.find((p) => p.id === id);
345
+ expect(found?.message).toBe("v2");
346
+ },
347
+ { timeout: 1e4 }
348
+ );
349
+ });
350
+ */
351
+ it("clears results when props change (e.g. reverse toggled)", async () => {
352
+ await setupConnected();
353
+ await dbWriter.posts.put(new Post({ message: "one" }));
354
+ await dbWriter.posts.put(new Post({ message: "two" }));
355
+
356
+ const { result, rerender } = renderUseQuery(dbReader, {
357
+ query: {},
358
+ resolve: true,
359
+ local: true,
360
+ remote: false,
361
+ prefetch: true,
362
+ reverse: false,
363
+ });
364
+
365
+ await waitFor(() =>
366
+ expect(result.current.items.length).toBeGreaterThan(0)
367
+ );
368
+
369
+ // Toggle a prop that triggers iterator rebuild
370
+ await act(async () => {
371
+ rerender({
372
+ query: {},
373
+ resolve: true,
374
+ local: true,
375
+ remote: false,
376
+ prefetch: false,
377
+ reverse: true,
378
+ });
379
+ });
380
+
381
+ // After reset we expect cleared results until re-fetched
382
+ await waitFor(() => expect(result.current.items.length).toBe(0));
383
+
384
+ await act(async () => {
385
+ await result.current.loadMore();
386
+ });
387
+ await waitFor(() =>
388
+ expect(result.current.items.length).toBeGreaterThan(0)
389
+ );
390
+ });
391
+ });
package/src/useLocal.tsx CHANGED
@@ -29,7 +29,7 @@ export const useLocal = <
29
29
  T extends Record<string, any>,
30
30
  I extends Record<string, any>,
31
31
  R extends boolean | undefined = true,
32
- RT = R extends false ? WithContext<I> : WithContext<T>
32
+ RT = R extends false ? WithContext<I> : WithContext<T>,
33
33
  >(
34
34
  db?: Documents<T, I>,
35
35
  options?: {
package/src/useOnline.tsx CHANGED
@@ -15,7 +15,7 @@ type ExtractEvents<T> = T extends Program<any, infer Events> ? Events : never;
15
15
 
16
16
  export const useOnline = <
17
17
  P extends Program<ExtractArgs<P>, ExtractEvents<P>> &
18
- Program<any, ProgramEvents>
18
+ Program<any, ProgramEvents>,
19
19
  >(
20
20
  program?: P,
21
21
  options?: { id?: string; debug?: boolean }
@@ -16,7 +16,7 @@ type ExtractEvents<T> = T extends Program<any, infer Events> ? Events : never;
16
16
 
17
17
  export function useProgram<
18
18
  P extends Program<ExtractArgs<P>, ExtractEvents<P>> &
19
- Program<any, ProgramEvents>
19
+ Program<any, ProgramEvents>,
20
20
  >(
21
21
  addressOrOpen?: P | string,
22
22
  options?: OpenOptions<P> & {