@automerge/automerge-repo-solid-primitives 2.2.0

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,394 @@
1
+ import {
2
+ Repo,
3
+ type PeerId,
4
+ type AutomergeUrl,
5
+ type DocHandle,
6
+ } from "@automerge/automerge-repo"
7
+ import { render, renderHook, testEffect } from "@solidjs/testing-library"
8
+ import { describe, expect, it, vi } from "vitest"
9
+ import { RepoContext } from "../src/context.js"
10
+ import {
11
+ createEffect,
12
+ createSignal,
13
+ type Accessor,
14
+ type ParentComponent,
15
+ } from "solid-js"
16
+ import useDocHandle from "../src/useDocHandle.js"
17
+ import createDocumentProjection from "../src/createDocumentProjection.js"
18
+
19
+ describe("createDocumentProjection", () => {
20
+ function setup() {
21
+ const repo = new Repo({
22
+ peerId: "bob" as PeerId,
23
+ })
24
+
25
+ const create = () =>
26
+ repo.create<ExampleDoc>({
27
+ key: "value",
28
+ array: [1, 2, 3],
29
+ hellos: [{ hello: "world" }, { hello: "hedgehog" }],
30
+ projects: [
31
+ { title: "one", items: [{ title: "go shopping" }] },
32
+ { title: "two", items: [] },
33
+ ],
34
+ })
35
+
36
+ const handle = create()
37
+ const wrapper: ParentComponent = props => {
38
+ return (
39
+ <RepoContext.Provider value={repo}>
40
+ {props.children}
41
+ </RepoContext.Provider>
42
+ )
43
+ }
44
+
45
+ return {
46
+ repo,
47
+ handle,
48
+ wrapper,
49
+ create,
50
+ }
51
+ }
52
+
53
+ it("should notify on a property change", async () => {
54
+ const { handle } = setup()
55
+ const { result: doc, owner } = renderHook(
56
+ createDocumentProjection<ExampleDoc>,
57
+ {
58
+ initialProps: [() => handle],
59
+ }
60
+ )
61
+
62
+ const done = testEffect(done => {
63
+ createEffect((run: number = 0) => {
64
+ if (run == 0) {
65
+ expect(doc()?.key).toBe("value")
66
+ handle.change(doc => (doc.key = "hello world!"))
67
+ } else if (run == 1) {
68
+ expect(doc()?.key).toBe("hello world!")
69
+ handle.change(doc => (doc.key = "friday night!"))
70
+ } else if (run == 2) {
71
+ expect(doc()?.key).toBe("friday night!")
72
+ done()
73
+ }
74
+ return run + 1
75
+ })
76
+ }, owner!)
77
+ return done
78
+ })
79
+
80
+ it("should not apply patches multiple times just because there are multiple projections", async () => {
81
+ const { handle } = setup()
82
+ const { result: one, owner: owner1 } = renderHook(
83
+ createDocumentProjection<ExampleDoc>,
84
+ {
85
+ initialProps: [() => handle],
86
+ }
87
+ )
88
+ const { result: two, owner: owner2 } = renderHook(
89
+ createDocumentProjection<ExampleDoc>,
90
+ {
91
+ initialProps: [() => handle],
92
+ }
93
+ )
94
+
95
+ const done2 = testEffect(done => {
96
+ createEffect((run: number = 0) => {
97
+ if (run == 0) {
98
+ expect(two()?.array).toEqual([1, 2, 3])
99
+ } else if (run == 1) {
100
+ expect(two()?.array).toEqual([1, 2, 3, 4])
101
+ } else if (run == 2) {
102
+ expect(two()?.array).toEqual([1, 2, 3, 4, 5])
103
+ done()
104
+ }
105
+ return run + 1
106
+ })
107
+ }, owner2!)
108
+
109
+ const done1 = testEffect(done => {
110
+ createEffect((run: number = 0) => {
111
+ if (run == 0) {
112
+ expect(one()?.array).toEqual([1, 2, 3])
113
+ handle.change(doc => doc.array.push(4))
114
+ } else if (run == 1) {
115
+ expect(one()?.array).toEqual([1, 2, 3, 4])
116
+ handle.change(doc => doc.array.push(5))
117
+ } else if (run == 2) {
118
+ expect(one()?.array).toEqual([1, 2, 3, 4, 5])
119
+ done()
120
+ }
121
+ return run + 1
122
+ })
123
+ }, owner1!)
124
+
125
+ return Promise.allSettled([done1, done2])
126
+ })
127
+
128
+ it("should work with useDocHandle", async () => {
129
+ const {
130
+ handle: { url: startingUrl },
131
+ wrapper,
132
+ } = setup()
133
+
134
+ const [url, setURL] = createSignal<AutomergeUrl>()
135
+
136
+ const { result: handle } = renderHook(useDocHandle<ExampleDoc>, {
137
+ initialProps: [url],
138
+ wrapper,
139
+ })
140
+
141
+ const { result: doc, owner } = renderHook(
142
+ createDocumentProjection<ExampleDoc>,
143
+ {
144
+ initialProps: [handle],
145
+ }
146
+ )
147
+
148
+ const done = testEffect(done => {
149
+ createEffect((run: number = 0) => {
150
+ if (run == 0) {
151
+ expect(doc()?.key).toBe(undefined)
152
+ setURL(startingUrl)
153
+ } else if (run == 1) {
154
+ expect(doc()?.key).toBe("value")
155
+ handle()?.change(doc => (doc.key = "hello world!"))
156
+ } else if (run == 2) {
157
+ expect(doc()?.key).toBe("hello world!")
158
+ handle()?.change(doc => (doc.key = "friday night!"))
159
+ } else if (run == 3) {
160
+ expect(doc()?.key).toBe("friday night!")
161
+ done()
162
+ }
163
+
164
+ return run + 1
165
+ })
166
+ }, owner!)
167
+
168
+ return done
169
+ })
170
+
171
+ it("should work with a signal url", async () => {
172
+ const { create, wrapper } = setup()
173
+ const [url, setURL] = createSignal<AutomergeUrl>()
174
+ const { result: handle } = renderHook(useDocHandle<ExampleDoc>, {
175
+ initialProps: [url],
176
+ wrapper,
177
+ })
178
+ const { result: doc, owner } = renderHook(
179
+ createDocumentProjection<ExampleDoc>,
180
+ {
181
+ initialProps: [handle],
182
+ wrapper,
183
+ }
184
+ )
185
+ const done = testEffect(done => {
186
+ createEffect((run: number = 0) => {
187
+ if (run == 0) {
188
+ expect(doc()?.key).toBe(undefined)
189
+ setURL(create().url)
190
+ } else if (run == 1) {
191
+ expect(doc()?.key).toBe("value")
192
+ handle()?.change(doc => (doc.key = "hello world!"))
193
+ } else if (run == 2) {
194
+ expect(doc()?.key).toBe("hello world!")
195
+ setURL(create().url)
196
+ } else if (run == 3) {
197
+ expect(doc()?.key).toBe("value")
198
+ handle()?.change(doc => (doc.key = "friday night!"))
199
+ } else if (run == 4) {
200
+ expect(doc()?.key).toBe("friday night!")
201
+ done()
202
+ }
203
+
204
+ return run + 1
205
+ })
206
+ }, owner!)
207
+ return done
208
+ })
209
+
210
+ it("should clear the store when the signal returns to nothing", async () => {
211
+ const { create, wrapper } = setup()
212
+ const [url, setURL] = createSignal<AutomergeUrl>()
213
+ const { result: handle } = renderHook(useDocHandle<ExampleDoc>, {
214
+ initialProps: [url],
215
+ wrapper,
216
+ })
217
+ const { result: doc, owner } = renderHook(
218
+ createDocumentProjection<ExampleDoc>,
219
+ {
220
+ initialProps: [handle],
221
+ wrapper,
222
+ }
223
+ )
224
+
225
+ const done = testEffect(done => {
226
+ createEffect((run: number = 0) => {
227
+ if (run == 0) {
228
+ expect(doc()?.key).toBe(undefined)
229
+ setURL(create().url)
230
+ } else if (run == 1) {
231
+ expect(doc()?.key).toBe("value")
232
+ setURL(undefined)
233
+ } else if (run == 2) {
234
+ expect(doc()?.key).toBe(undefined)
235
+ setURL(create().url)
236
+ } else if (run == 3) {
237
+ expect(doc()?.key).toBe("value")
238
+ done()
239
+ }
240
+
241
+ return run + 1
242
+ })
243
+ }, owner!)
244
+ return done
245
+ })
246
+
247
+ it("should not return the wrong store when handle changes", async () => {
248
+ const { create } = setup()
249
+
250
+ const h1 = create()
251
+ const h2 = create()
252
+
253
+ const [stableHandle] = createSignal(h1)
254
+ // initially handle2 is the same as handle1
255
+ const [changingHandle, setChangingHandle] = createSignal(h1)
256
+
257
+ const { result } = renderHook<[], () => readonly [string, string]>(() => {
258
+ function Component(props: {
259
+ stableHandle: Accessor<DocHandle<ExampleDoc>>
260
+ changingHandle: Accessor<DocHandle<ExampleDoc>>
261
+ }) {
262
+ const stableDoc = createDocumentProjection<ExampleDoc>(
263
+ // eslint-disable-next-line solid/reactivity
264
+ props.stableHandle
265
+ )
266
+
267
+ const changingDoc = createDocumentProjection<ExampleDoc>(
268
+ // eslint-disable-next-line solid/reactivity
269
+ props.changingHandle
270
+ )
271
+
272
+ return () => [stableDoc()!.key, changingDoc()!.key] as const
273
+ }
274
+
275
+ return Component({
276
+ stableHandle,
277
+ changingHandle,
278
+ })
279
+ })
280
+
281
+ return testEffect(async done => {
282
+ h2.change(doc => (doc.key = "document-2"))
283
+ expect(result()).toEqual(["value", "value"])
284
+
285
+ h1.change(doc => (doc.key = "hello"))
286
+ await new Promise<void>(setImmediate)
287
+ expect(result()).toEqual(["hello", "hello"])
288
+
289
+ setChangingHandle(() => h2)
290
+ expect(result()).toEqual(["hello", "document-2"])
291
+
292
+ setChangingHandle(() => h1)
293
+ expect(result()).toEqual(["hello", "hello"])
294
+
295
+ setChangingHandle(h2)
296
+ h2.change(doc => (doc.key = "world"))
297
+ await new Promise<void>(setImmediate)
298
+ expect(result()).toEqual(["hello", "world"])
299
+ done()
300
+ })
301
+ })
302
+
303
+ it("should work ok with a slow handle", async () => {
304
+ const { repo } = setup()
305
+
306
+ const originalFind = repo.find.bind(repo)
307
+ repo.find = vi.fn().mockImplementation(async (...args) => {
308
+ await new Promise(resolve => setTimeout(resolve, 900))
309
+ // @ts-expect-error this is ok
310
+ return originalFind(...args)
311
+ })
312
+
313
+ await testEffect(done => {
314
+ const handle = useDocHandle<{ im: "slow" }>(
315
+ () => repo.create({ im: "slow" }).url,
316
+ { repo }
317
+ )
318
+ const doc = createDocumentProjection(handle)
319
+
320
+ createEffect((run: number = 0) => {
321
+ if (run == 0) {
322
+ expect(doc()?.im).toBe("slow")
323
+ done()
324
+ }
325
+ return run + 1
326
+ })
327
+ })
328
+
329
+ repo.find = originalFind
330
+ })
331
+
332
+ it("should not notify on properties nobody cares about", async () => {
333
+ const { handle } = setup()
334
+ let fn = vi.fn()
335
+
336
+ const { result: doc, owner } = renderHook(
337
+ createDocumentProjection<ExampleDoc>,
338
+ {
339
+ initialProps: [() => handle],
340
+ }
341
+ )
342
+ testEffect(() => {
343
+ createEffect(() => {
344
+ fn(doc()?.projects[1].title)
345
+ })
346
+ })
347
+ const arrayDotThree = testEffect(done => {
348
+ createEffect((run: number = 0) => {
349
+ if (run == 0) {
350
+ expect(doc()?.array[3]).toBeUndefined()
351
+ handle.change(doc => (doc.array[2] = 22))
352
+ handle.change(doc => (doc.key = "hello world!"))
353
+ handle.change(doc => (doc.array[1] = 11))
354
+ handle.change(doc => (doc.array[3] = 145))
355
+ } else if (run == 1) {
356
+ expect(doc()?.array[3]).toBe(145)
357
+ handle.change(doc => (doc.projects[0].title = "hello world!"))
358
+ handle.change(
359
+ doc => (doc.projects[0].items[0].title = "hello world!")
360
+ )
361
+ handle.change(doc => (doc.array[3] = 147))
362
+ } else if (run == 2) {
363
+ expect(doc()?.array[3]).toBe(147)
364
+ done()
365
+ }
366
+ return run + 1
367
+ })
368
+ }, owner!)
369
+ const projectZeroItemZeroTitle = testEffect(done => {
370
+ createEffect((run: number = 0) => {
371
+ if (run == 0) {
372
+ expect(doc()?.projects[0].items[0].title).toBe("hello world!")
373
+ done()
374
+ }
375
+ return run + 1
376
+ })
377
+ }, owner!)
378
+
379
+ expect(fn).toHaveBeenCalledOnce()
380
+ expect(fn).toHaveBeenCalledWith("two")
381
+
382
+ return Promise.all([arrayDotThree, projectZeroItemZeroTitle])
383
+ })
384
+ })
385
+
386
+ interface ExampleDoc {
387
+ key: string
388
+ array: number[]
389
+ hellos: { hello: string }[]
390
+ projects: {
391
+ title: string
392
+ items: { title: string; complete?: number }[]
393
+ }[]
394
+ }
@@ -0,0 +1,290 @@
1
+ import { type PeerId, Repo, type DocHandle } from "@automerge/automerge-repo"
2
+
3
+ import { renderHook, testEffect } from "@solidjs/testing-library"
4
+ import { describe, expect, it, vi } from "vitest"
5
+ import {
6
+ createEffect,
7
+ createRoot,
8
+ createSignal,
9
+ type ParentComponent,
10
+ } from "solid-js"
11
+ import makeDocumentProjection from "../src/makeDocumentProjection.js"
12
+ import { RepoContext } from "../src/context.js"
13
+
14
+ describe("makeDocumentProjection", () => {
15
+ function setup() {
16
+ const repo = new Repo({
17
+ peerId: "bob" as PeerId,
18
+ })
19
+
20
+ const create = () =>
21
+ repo.create<ExampleDoc>({
22
+ key: "value",
23
+ array: [1, 2, 3],
24
+ hellos: [{ hello: "world" }, { hello: "hedgehog" }],
25
+ projects: [
26
+ { title: "one", items: [{ title: "go shopping" }] },
27
+ { title: "two", items: [] },
28
+ ],
29
+ })
30
+
31
+ const handle = create()
32
+ const wrapper: ParentComponent = props => {
33
+ return (
34
+ <RepoContext.Provider value={repo}>
35
+ {props.children}
36
+ </RepoContext.Provider>
37
+ )
38
+ }
39
+
40
+ return {
41
+ repo,
42
+ handle,
43
+ wrapper,
44
+ create,
45
+ }
46
+ }
47
+
48
+ it("should notify on a property change", async () => {
49
+ const { handle } = setup()
50
+ const { result: doc, owner } = renderHook(
51
+ makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
52
+ {
53
+ initialProps: [handle],
54
+ }
55
+ )
56
+
57
+ const done = testEffect(done => {
58
+ createEffect((run: number = 0) => {
59
+ if (run == 0) {
60
+ expect(doc.key).toBe("value")
61
+ handle.change(doc => (doc.key = "hello world!"))
62
+ } else if (run == 1) {
63
+ expect(doc.key).toBe("hello world!")
64
+ handle.change(doc => (doc.key = "friday night!"))
65
+ } else if (run == 2) {
66
+ expect(doc.key).toBe("friday night!")
67
+ done()
68
+ }
69
+ return run + 1
70
+ })
71
+ }, owner!)
72
+ return done
73
+ })
74
+
75
+ it("should not apply patches multiple times just because there are multiple projections of the same handle", async () => {
76
+ const { handle } = setup()
77
+ const { result: one, owner: owner1 } = renderHook(
78
+ makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
79
+ {
80
+ initialProps: [handle],
81
+ }
82
+ )
83
+ const { result: two, owner: owner2 } = renderHook(
84
+ makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
85
+ {
86
+ initialProps: [handle],
87
+ }
88
+ )
89
+
90
+ const done2 = testEffect(done => {
91
+ createEffect((run: number = 0) => {
92
+ if (run == 0) {
93
+ expect(two.array).toEqual([1, 2, 3])
94
+ } else if (run == 1) {
95
+ expect(two.array).toEqual([1, 2, 3, 4])
96
+ } else if (run == 2) {
97
+ expect(two.array).toEqual([1, 2, 3, 4, 5])
98
+ done()
99
+ }
100
+ return run + 1
101
+ })
102
+ }, owner2!)
103
+
104
+ const done1 = testEffect(done => {
105
+ createEffect((run: number = 0) => {
106
+ if (run == 0) {
107
+ expect(one.array).toEqual([1, 2, 3])
108
+ handle.change(doc => doc.array.push(4))
109
+ } else if (run == 1) {
110
+ expect(one.array).toEqual([1, 2, 3, 4])
111
+ handle.change(doc => doc.array.push(5))
112
+ } else if (run == 2) {
113
+ expect(one.array).toEqual([1, 2, 3, 4, 5])
114
+ done()
115
+ }
116
+ return run + 1
117
+ })
118
+ }, owner1!)
119
+
120
+ return Promise.allSettled([done1, done2])
121
+ })
122
+
123
+ it("should notify on a deep property change", async () => {
124
+ const { handle } = setup()
125
+ return createRoot(() => {
126
+ const doc = makeDocumentProjection<ExampleDoc>(handle)
127
+ return testEffect(done => {
128
+ createEffect((run: number = 0) => {
129
+ if (run == 0) {
130
+ expect(doc.projects[0].title).toBe("one")
131
+ handle.change(doc => (doc.projects[0].title = "hello world!"))
132
+ } else if (run == 1) {
133
+ expect(doc.projects[0].title).toBe("hello world!")
134
+ handle.change(doc => (doc.projects[0].title = "friday night!"))
135
+ } else if (run == 2) {
136
+ expect(doc.projects[0].title).toBe("friday night!")
137
+ done()
138
+ }
139
+ return run + 1
140
+ })
141
+ })
142
+ })
143
+ })
144
+
145
+ it("should not clean up when it should not clean up", async () => {
146
+ const { handle } = setup()
147
+
148
+ return createRoot(() => {
149
+ const [one, clean1] = createRoot(c => [makeDocumentProjection(handle), c])
150
+ const [two, clean2] = createRoot(c => [makeDocumentProjection(handle), c])
151
+ const [three, clean3] = createRoot(c => [
152
+ makeDocumentProjection(handle),
153
+ c,
154
+ ])
155
+ const [signal, setSignal] = createSignal(0)
156
+ return testEffect(done => {
157
+ createEffect((run: number = 0) => {
158
+ signal()
159
+ expect(one.projects[0].title).not.toBeUndefined()
160
+ expect(two.projects[0].title).not.toBeUndefined()
161
+ expect(three.projects[0].title).not.toBeUndefined()
162
+
163
+ if (run == 0) {
164
+ // immediately clean up the first projection. updates should
165
+ // carry on because there is still another reference
166
+ clean1()
167
+ expect(one.projects[0].title).toBe("one")
168
+ expect(two.projects[0].title).toBe("one")
169
+ expect(three.projects[0].title).toBe("one")
170
+ handle.change(doc => (doc.projects[0].title = "hello world!"))
171
+ } else if (run == 1) {
172
+ // clean up another projection. updates should carry on
173
+ // because there is still one left
174
+ clean3()
175
+ expect(one.projects[0].title).toBe("hello world!")
176
+ expect(two.projects[0].title).toBe("hello world!")
177
+ expect(three.projects[0].title).toBe("hello world!")
178
+ setSignal(1)
179
+ } else if (run == 2) {
180
+ // now all the stores are cleaned up so further updates
181
+ // should not show in the store
182
+ clean2()
183
+ setSignal(2)
184
+ } else if (run == 3) {
185
+ handle.change(doc => (doc.projects[0].title = "friday night!"))
186
+ // force the test to run again
187
+ setSignal(3)
188
+ } else if (run == 4) {
189
+ expect(one.projects[0].title).toBe("hello world!")
190
+ expect(two.projects[0].title).toBe("hello world!")
191
+ expect(three.projects[0].title).toBe("hello world!")
192
+ done()
193
+ }
194
+ return run + 1
195
+ })
196
+ })
197
+ })
198
+ })
199
+
200
+ it("should not notify on properties nobody cares about", async () => {
201
+ const { handle } = setup()
202
+ let fn = vi.fn()
203
+
204
+ const { result: doc, owner } = renderHook(
205
+ makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
206
+ {
207
+ initialProps: [handle],
208
+ }
209
+ )
210
+ testEffect(() => {
211
+ createEffect(() => {
212
+ fn(doc?.projects[1].title)
213
+ })
214
+ })
215
+ const arrayDotThree = testEffect(done => {
216
+ createEffect((run: number = 0) => {
217
+ if (run == 0) {
218
+ expect(doc.array[3]).toBeUndefined()
219
+
220
+ handle.change(doc => (doc.array[2] = 22))
221
+
222
+ handle.change(doc => (doc.key = "hello world!"))
223
+ handle.change(doc => (doc.array[1] = 11))
224
+ handle.change(doc => (doc.array[3] = 145))
225
+ } else if (run == 1) {
226
+ expect(doc?.array[3]).toBe(145)
227
+ handle.change(doc => (doc.projects[0].title = "hello world!"))
228
+ handle.change(
229
+ doc => (doc.projects[0].items[0].title = "hello world!")
230
+ )
231
+ handle.change(doc => (doc.array[3] = 147))
232
+ } else if (run == 2) {
233
+ expect(doc?.array[3]).toBe(147)
234
+ done()
235
+ }
236
+ return run + 1
237
+ })
238
+ }, owner!)
239
+ const projectZeroItemZeroTitle = testEffect(done => {
240
+ createEffect((run: number = 0) => {
241
+ if (run == 0) {
242
+ expect(doc?.projects[0].items[0].title).toBe("hello world!")
243
+ done()
244
+ }
245
+ return run + 1
246
+ })
247
+ }, owner!)
248
+
249
+ expect(fn).toHaveBeenCalledOnce()
250
+ expect(fn).toHaveBeenCalledWith("two")
251
+
252
+ return Promise.all([arrayDotThree, projectZeroItemZeroTitle])
253
+ })
254
+
255
+ it("should remain reactive on an mount, unmount, and then remount of the same doc handle", async () => {
256
+ const { handle } = setup()
257
+
258
+ for (let i = 0; i < 2; ++i) {
259
+ const [doc, clean] = createRoot(c => [makeDocumentProjection(handle), c])
260
+ const lastRun = await testEffect<number>(done => {
261
+ createEffect((run: number = 0) => {
262
+ if (run == 0) {
263
+ expect(doc.key).toBe("value")
264
+ handle.change(doc => (doc.key = "hello world!"))
265
+ } else if (run == 1) {
266
+ expect(doc.key).toBe("hello world!")
267
+ handle.change(doc => (doc.key = "friday night!"))
268
+ } else if (run == 2) {
269
+ expect(doc.key).toBe("friday night!")
270
+ handle.change(doc => (doc.key = "value"))
271
+ done(run)
272
+ }
273
+ return run + 1
274
+ })
275
+ })
276
+ expect(lastRun).toBe(2)
277
+ clean()
278
+ }
279
+ })
280
+ })
281
+
282
+ interface ExampleDoc {
283
+ key: string
284
+ array: number[]
285
+ hellos: { hello: string }[]
286
+ projects: {
287
+ title: string
288
+ items: { title: string; complete?: number }[]
289
+ }[]
290
+ }