@peerbit/react 0.0.31 → 0.0.33

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,518 @@
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 peerReader2: Peerbit | undefined;
58
+ let dbWriter: PostsDB;
59
+ let dbReader: PostsDB;
60
+ let dbReader2: PostsDB | undefined;
61
+ let autoUnmount: undefined | (() => void);
62
+
63
+ beforeEach(async () => {
64
+ await sodium.ready;
65
+ peerWriter = await Peerbit.create();
66
+ peerReader = await Peerbit.create();
67
+ peerReader2 = undefined;
68
+ dbReader2 = undefined;
69
+ });
70
+ const setupConnected = async () => {
71
+ await peerWriter.dial(peerReader);
72
+ dbWriter = await peerWriter.open(new PostsDB(), {
73
+ existing: "reuse",
74
+ args: { replicate: true },
75
+ });
76
+ dbReader = await peerReader.open<PostsDB>(dbWriter.address, {
77
+ args: { replicate: false },
78
+ });
79
+ // ensure reader knows about writer as replicator for the log
80
+ await dbReader.posts.log.waitForReplicator(
81
+ peerWriter.identity.publicKey
82
+ );
83
+ };
84
+
85
+ const setupDisconnected = async () => {
86
+ dbWriter = await peerWriter.open(new PostsDB(), {
87
+ existing: "reuse",
88
+ args: { replicate: true },
89
+ });
90
+ dbReader = await peerReader.open<PostsDB>(dbWriter.clone(), {
91
+ args: { replicate: false },
92
+ });
93
+ };
94
+
95
+ afterEach(async () => {
96
+ // Unmount React trees before tearing down peers
97
+ autoUnmount?.();
98
+ autoUnmount = undefined;
99
+ await peerWriter?.stop();
100
+ await peerReader?.stop();
101
+ await peerReader2?.stop();
102
+ });
103
+
104
+ function renderUseQuery<R extends boolean>(
105
+ db: PostsDB,
106
+ options: UseQuerySharedOptions<Post, PostIndexed, R>
107
+ ) {
108
+ const result: {
109
+ current: ReturnType<typeof useQuery<Post, PostIndexed, R>>;
110
+ } = {} as any;
111
+
112
+ function HookCmp({
113
+ opts,
114
+ }: {
115
+ opts: UseQuerySharedOptions<Post, PostIndexed, R>;
116
+ }) {
117
+ const hook = useQuery<Post, PostIndexed, R>(db.posts, opts);
118
+ useEffect(() => {
119
+ result.current = hook;
120
+ }, [hook]);
121
+ return null;
122
+ }
123
+
124
+ const api = render(React.createElement(HookCmp, { opts: options }));
125
+ const rerender = (opts: UseQuerySharedOptions<Post, PostIndexed, R>) =>
126
+ api.rerender(React.createElement(HookCmp, { opts }));
127
+ let hasUnmounted = false;
128
+ const doUnmount = () => {
129
+ if (hasUnmounted) return;
130
+ hasUnmounted = true;
131
+ api.unmount();
132
+ if (autoUnmount === doUnmount) {
133
+ // clear outer reference if we still own it
134
+ autoUnmount = undefined;
135
+ }
136
+ };
137
+ // Expose to outer afterEach so tests don't need to remember calling unmount
138
+ autoUnmount = doUnmount;
139
+ return { result, rerender, unmount: doUnmount };
140
+ }
141
+
142
+ it("local query", async () => {
143
+ await setupConnected();
144
+ await dbWriter.posts.put(new Post({ message: "hello" }));
145
+ const { result } = renderUseQuery(dbWriter, {
146
+ query: {},
147
+ resolve: true,
148
+ local: true,
149
+ prefetch: true,
150
+ });
151
+ await waitFor(() => expect(result.current?.items?.length ?? 0).toBe(1));
152
+
153
+ await act(async () => {
154
+ expect(result.current.items.length).toBe(1);
155
+ expect(result.current.items[0].message).toBe("hello");
156
+ });
157
+ });
158
+
159
+ it("does not mutate the options object passed in", async () => {
160
+ await setupConnected();
161
+ const cfg = {
162
+ query: {},
163
+ resolve: true,
164
+ local: true,
165
+ remote: { reach: { eager: true }, wait: { timeout: 10_000 } },
166
+ prefetch: false,
167
+ batchSize: 10,
168
+ };
169
+ const cfgOrg = { ...cfg };
170
+ renderUseQuery(dbReader, cfg);
171
+ // expect that cfg has not been modified
172
+ expect(cfg).to.deep.equal(cfgOrg);
173
+ });
174
+
175
+ it("respects remote warmup before iterating", async () => {
176
+ await setupConnected();
177
+ await dbWriter.posts.put(new Post({ message: "hello" }));
178
+
179
+ const cfg: UseQuerySharedOptions<Post, PostIndexed, true> = {
180
+ query: {},
181
+ resolve: true,
182
+ local: true,
183
+ remote: { reach: { eager: true }, wait: { timeout: 10_000 } },
184
+ prefetch: false,
185
+ batchSize: 10,
186
+ };
187
+ const { result, rerender } = renderUseQuery(dbReader, cfg);
188
+
189
+ await waitFor(() => {
190
+ if (!result.current) throw new Error("no result yet");
191
+ return true;
192
+ });
193
+
194
+ expect(result.current.items.length).toBe(0);
195
+
196
+ await act(async () => {
197
+ await result.current.loadMore();
198
+ });
199
+
200
+ expect(result.current.items.length).toBe(1);
201
+ expect(result.current.items[0].message).toBe("hello");
202
+
203
+ await act(async () => {
204
+ rerender(cfg);
205
+ });
206
+ await act(async () => {
207
+ await result.current.loadMore();
208
+ });
209
+ await waitFor(() => expect(result.current.items.length).toBe(1));
210
+ });
211
+
212
+ it("honors remote.wait.timeout by resolving after connection", async () => {
213
+ // create isolated peers not connected yet
214
+ await setupDisconnected();
215
+
216
+ const { result } = renderUseQuery(dbReader, {
217
+ query: {},
218
+ resolve: true,
219
+ local: false,
220
+ remote: {
221
+ reach: { eager: true },
222
+ wait: { behavior: "block", timeout: 5_000 },
223
+ },
224
+ prefetch: true,
225
+ });
226
+
227
+ await waitFor(() => expect(result.current).toBeDefined());
228
+
229
+ // Now connect and write
230
+
231
+ await act(async () => {
232
+ await dbReader.node.dial(dbWriter.node.getMultiaddrs());
233
+ await dbWriter.posts.put(new Post({ message: "late" }));
234
+ await dbReader.posts.log.waitForReplicator(
235
+ dbWriter.node.identity.publicKey
236
+ );
237
+ });
238
+
239
+ await waitFor(() => expect(result.current.items.length).toBe(1));
240
+ expect(result.current.items[0].message).toBe("late");
241
+ });
242
+
243
+ it("pushes remote writes from replicator to non-replicator", async () => {
244
+ await setupConnected();
245
+
246
+ const { result } = renderUseQuery(dbReader, {
247
+ query: {},
248
+ resolve: true,
249
+ local: false,
250
+ remote: { reach: { eager: true } },
251
+ updates: { merge: true },
252
+ prefetch: false,
253
+ });
254
+
255
+ await waitFor(() => {
256
+ expect(result.current).toBeDefined();
257
+ });
258
+
259
+ expect(result.current.items.length).toBe(0);
260
+
261
+ await act(async () => {
262
+ await dbWriter.posts.put(new Post({ message: "replicator-push" }));
263
+ });
264
+
265
+ await act(async () => {
266
+ await result.current.loadMore();
267
+ });
268
+
269
+ await waitFor(
270
+ () =>
271
+ expect(
272
+ result.current.items.map((p) => (p as Post).message)
273
+ ).toContain("replicator-push"),
274
+ { timeout: 10_000 }
275
+ );
276
+ });
277
+
278
+ it("fanouts pushed updates to multiple observers", async () => {
279
+ await setupConnected();
280
+
281
+ peerReader2 = await Peerbit.create();
282
+ await peerReader2.dial(peerWriter);
283
+ dbReader2 = await peerReader2.open<PostsDB>(dbWriter.address, {
284
+ args: { replicate: false },
285
+ });
286
+
287
+ await dbReader2.posts.log.waitForReplicator(
288
+ peerWriter.identity.publicKey
289
+ );
290
+
291
+ const hookOne = renderUseQuery(dbReader, {
292
+ query: {},
293
+ resolve: true,
294
+ local: false,
295
+ remote: { reach: { eager: true } },
296
+ updates: { merge: true },
297
+ prefetch: false,
298
+ });
299
+
300
+ const hookTwo = renderUseQuery(dbReader2, {
301
+ query: {},
302
+ resolve: true,
303
+ local: false,
304
+ remote: { reach: { eager: true } },
305
+ updates: { merge: true },
306
+ prefetch: false,
307
+ });
308
+
309
+ await waitFor(() => {
310
+ expect(hookOne.result.current).toBeDefined();
311
+ expect(hookTwo.result.current).toBeDefined();
312
+ });
313
+
314
+ await act(async () => {
315
+ await dbWriter.posts.put(new Post({ message: "broadcast" }));
316
+ });
317
+
318
+ await act(async () => {
319
+ await hookOne.result.current.loadMore();
320
+ await hookTwo.result.current.loadMore();
321
+ });
322
+
323
+ await waitFor(
324
+ () =>
325
+ expect(
326
+ hookOne.result.current.items.some(
327
+ (p) => (p as Post).message === "broadcast"
328
+ )
329
+ ).toBe(true),
330
+ { timeout: 10_000 }
331
+ );
332
+
333
+ await waitFor(
334
+ () =>
335
+ expect(
336
+ hookTwo.result.current.items.some(
337
+ (p) => (p as Post).message === "broadcast"
338
+ )
339
+ ).toBe(true),
340
+ { timeout: 10_000 }
341
+ );
342
+
343
+ await act(async () => {
344
+ await dbReader.posts.put(new Post({ message: "observer-origin" }));
345
+ });
346
+
347
+ await act(async () => {
348
+ await hookOne.result.current.loadMore();
349
+ await hookTwo.result.current.loadMore();
350
+ });
351
+
352
+ await waitFor(
353
+ () =>
354
+ expect(
355
+ hookTwo.result.current.items.some(
356
+ (p) => (p as Post).message === "observer-origin"
357
+ )
358
+ ).toBe(true),
359
+ { timeout: 10_000 }
360
+ );
361
+
362
+ hookOne.unmount();
363
+ hookTwo.unmount();
364
+ });
365
+
366
+ describe("merge", () => {
367
+ const checkAsResolvedResults = async <R extends boolean>(
368
+ out: ReturnType<typeof renderUseQuery<R>>,
369
+ resolved: R
370
+ ) => {
371
+ const { result } = out;
372
+ await waitFor(() => expect(result.current).toBeDefined());
373
+
374
+ // Initially empty
375
+ expect(result.current.items.length).toBe(0);
376
+
377
+ // Create a post on writer and expect reader hook to merge it automatically
378
+ const id = `${Date.now()}-merge`;
379
+ await act(async () => {
380
+ // the reader actually does the put (a user)
381
+ await dbReader.posts.put(new Post({ id, message: "first" }));
382
+ });
383
+
384
+ await waitFor(() => expect(result.current.items.length).toBe(1), {
385
+ timeout: 1e4,
386
+ });
387
+ if (resolved) {
388
+ expect((result.current.items[0] as Post).message).toBe("first");
389
+ expect(result.current.items[0]).to.be.instanceOf(Post);
390
+ } else {
391
+ expect(
392
+ (result.current.items[0] as PostIndexed).indexedMessage
393
+ ).toBe("first");
394
+ expect(result.current.items[0]).to.be.instanceOf(PostIndexed);
395
+ }
396
+ };
397
+
398
+ it("updates.merge merges new writes into state without manual iteration, as resolved", async () => {
399
+ await setupConnected();
400
+
401
+ // resolved undefined means we should resolve
402
+ await checkAsResolvedResults(
403
+ renderUseQuery<true>(dbReader, {
404
+ query: {},
405
+ local: false,
406
+ remote: { reach: { eager: true } },
407
+ prefetch: false,
408
+ updates: { merge: true },
409
+ }),
410
+ true
411
+ );
412
+
413
+ // resolved true means we should resolve
414
+ await checkAsResolvedResults(
415
+ renderUseQuery<true>(dbReader, {
416
+ query: {},
417
+ local: false,
418
+ resolve: true,
419
+ remote: { reach: { eager: true } },
420
+ prefetch: false,
421
+ updates: { merge: true },
422
+ }),
423
+ true
424
+ );
425
+
426
+ // resolved false means we should NOT resolve
427
+ await checkAsResolvedResults(
428
+ renderUseQuery<false>(dbReader, {
429
+ query: {},
430
+ local: false,
431
+ resolve: false,
432
+ remote: { reach: { eager: true } },
433
+ prefetch: false,
434
+ updates: { merge: true },
435
+ }),
436
+ false
437
+ );
438
+ });
439
+ });
440
+
441
+ /*
442
+ it("updates.merge reflects document mutation in hook state", async () => {
443
+ await setupConnected();
444
+
445
+ const id = `${Date.now()}-mut`;
446
+ await dbWriter.posts.put(new Post({ id, message: "v1" }));
447
+
448
+ const { result } = renderUseQuery(dbReader, {
449
+ query: {},
450
+ resolve: true,
451
+ local: false,
452
+ remote: { reach: { eager: true } },
453
+ prefetch: true,
454
+ updates: { merge: true },
455
+ });
456
+
457
+ await waitFor(() => expect(result.current.items.length).toBe(1), {
458
+ timeout: 1e4,
459
+ });
460
+ expect(result.current.items[0].message).toBe("v1");
461
+
462
+ // Mutate by putting a new version with the same id
463
+ await act(async () => {
464
+ // the reader actually does the put (a user)
465
+ await dbReader.posts.put(new Post({ id, message: "v2" }));
466
+ });
467
+
468
+ // Expect the hook state to reflect the updated content
469
+ await waitFor(
470
+ () => {
471
+ const found = result.current.items.find((p) => p.id === id);
472
+ expect(found?.message).toBe("v2");
473
+ },
474
+ { timeout: 1e4 }
475
+ );
476
+ });
477
+ */
478
+ it("clears results when props change (e.g. reverse toggled)", async () => {
479
+ await setupConnected();
480
+ await dbWriter.posts.put(new Post({ message: "one" }));
481
+ await dbWriter.posts.put(new Post({ message: "two" }));
482
+
483
+ const { result, rerender } = renderUseQuery(dbReader, {
484
+ query: {},
485
+ resolve: true,
486
+ local: true,
487
+ remote: false,
488
+ prefetch: true,
489
+ reverse: false,
490
+ });
491
+
492
+ await waitFor(() =>
493
+ expect(result.current.items.length).toBeGreaterThan(0)
494
+ );
495
+
496
+ // Toggle a prop that triggers iterator rebuild
497
+ await act(async () => {
498
+ rerender({
499
+ query: {},
500
+ resolve: true,
501
+ local: true,
502
+ remote: false,
503
+ prefetch: false,
504
+ reverse: true,
505
+ });
506
+ });
507
+
508
+ // After reset we expect cleared results until re-fetched
509
+ await waitFor(() => expect(result.current.items.length).toBe(0));
510
+
511
+ await act(async () => {
512
+ await result.current.loadMore();
513
+ });
514
+ await waitFor(() =>
515
+ expect(result.current.items.length).toBeGreaterThan(0)
516
+ );
517
+ });
518
+ });
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 }
package/src/usePeer.tsx CHANGED
@@ -17,9 +17,9 @@ import { useMount } from "./useMount.js";
17
17
  import { createClient, createHost } from "@peerbit/proxy-window";
18
18
  import { ProgramClient } from "@peerbit/program";
19
19
  import { webSockets } from "@libp2p/websockets";
20
- import * as filters from "@libp2p/websockets/filters";
21
20
  import { detectIncognito } from "detectincognitojs";
22
-
21
+ import { waitFor } from "@testing-library/dom";
22
+ import { Libp2p } from "@libp2p/interface";
23
23
  const isInStandaloneMode = () =>
24
24
  window.matchMedia("(display-mode: standalone)").matches ||
25
25
  window.navigator["standalone"] ||
@@ -90,7 +90,7 @@ export type NetworkOption =
90
90
  type NodeOptions = {
91
91
  type?: "node";
92
92
  network: "local" | "remote" | NetworkOption;
93
- waitForConnnected?: boolean;
93
+ waitForConnnected?: boolean | "in-flight";
94
94
  keypair?: Ed25519Keypair;
95
95
  host?: boolean;
96
96
  singleton?: boolean;
@@ -212,7 +212,7 @@ export const PeerProvider = (options: PeerOptions) => {
212
212
  // Mark expired and remove proactively
213
213
  try {
214
214
  mutex.release(lockKey);
215
- } catch {}
215
+ } catch { }
216
216
  }
217
217
  };
218
218
  document.addEventListener(
@@ -289,20 +289,23 @@ export const PeerProvider = (options: PeerOptions) => {
289
289
  connectionMonitor: { enabled: false },
290
290
  ...(nodeOptions.network === "local"
291
291
  ? {
292
- connectionGater: {
293
- denyDialMultiaddr: () => false,
294
- },
295
- transports: [
296
- webSockets({ filter: filters.all }) /* ,
292
+ connectionGater: {
293
+ denyDialMultiaddr: () => false,
294
+ },
295
+ transports: [
296
+ webSockets({}) /* ,
297
297
  circuitRelayTransport(), */,
298
- ],
299
- }
298
+ ],
299
+ }
300
300
  : {
301
- transports: [
302
- webSockets() /* ,
301
+ connectionGater: {
302
+ denyDialMultiaddr: () => false, // TODO do right here, dont allow local dials except bootstrap
303
+ },
304
+ transports: [
305
+ webSockets() /* ,
303
306
  circuitRelayTransport(), */,
304
- ],
305
- }) /*
307
+ ],
308
+ }) /*
306
309
  services: {
307
310
  pubsub: (c) =>
308
311
  new DirectSub(c, { canRelayMessage: true }),
@@ -370,7 +373,7 @@ export const PeerProvider = (options: PeerOptions) => {
370
373
  }
371
374
  // 3) Remote default: use bootstrap service (no explicit bootstrap provided)
372
375
  else {
373
- await newPeer["bootstrap"]?.();
376
+ await (newPeer as Peerbit).bootstrap?.();
374
377
  }
375
378
  setConnectionState("connected");
376
379
  } catch (err: any) {
@@ -410,10 +413,30 @@ export const PeerProvider = (options: PeerOptions) => {
410
413
  })
411
414
  );
412
415
  }
413
- } catch {}
416
+ } catch { }
414
417
  });
415
- if (nodeOptions.waitForConnnected !== false) {
418
+ if (nodeOptions.waitForConnnected === true) {
416
419
  await promise;
420
+ } else if (nodeOptions.waitForConnnected === "in-flight") {
421
+ // wait for dialQueue to not be empty or connections to contains the peerId
422
+ // or done
423
+ let isDone = false;
424
+ promise.finally(() => {
425
+ isDone = true;
426
+ });
427
+ await waitFor(() => {
428
+ if (isDone) {
429
+ return true;
430
+ }
431
+ const libp2p = newPeer as any as Libp2p;
432
+ if (libp2p.getDialQueue().length > 0) {
433
+ return true;
434
+ }
435
+ if (libp2p.getConnections().length > 0) {
436
+ return true;
437
+ }
438
+ return false;
439
+ });
417
440
  }
418
441
  } else {
419
442
  // When in proxy mode (iframe), use the provided targetOrigin.
@@ -430,7 +453,7 @@ export const PeerProvider = (options: PeerOptions) => {
430
453
  detail: (window as any).__peerInfo,
431
454
  })
432
455
  );
433
- } catch {}
456
+ } catch { }
434
457
  }
435
458
 
436
459
  setPeer(newPeer);
@@ -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> & {