jazz-tools 0.19.10 → 0.19.12

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.
Files changed (112) hide show
  1. package/.turbo/turbo-build.log +58 -54
  2. package/CHANGELOG.md +23 -0
  3. package/dist/{chunk-FFEEPZEG.js → chunk-AGF4HEDH.js} +61 -28
  4. package/dist/chunk-AGF4HEDH.js.map +1 -0
  5. package/dist/index.js +1 -1
  6. package/dist/inspector/account-switcher.d.ts +4 -0
  7. package/dist/inspector/account-switcher.d.ts.map +1 -0
  8. package/dist/inspector/chunk-YQNK5Y7B.js +4108 -0
  9. package/dist/inspector/chunk-YQNK5Y7B.js.map +1 -0
  10. package/dist/inspector/contexts/node.d.ts +19 -0
  11. package/dist/inspector/contexts/node.d.ts.map +1 -0
  12. package/dist/inspector/{custom-element-P76EIWEV.js → custom-element-KYV64IOC.js} +1057 -918
  13. package/dist/inspector/custom-element-KYV64IOC.js.map +1 -0
  14. package/dist/inspector/{viewer/new-app.d.ts → in-app.d.ts} +3 -3
  15. package/dist/inspector/in-app.d.ts.map +1 -0
  16. package/dist/inspector/index.d.ts +0 -11
  17. package/dist/inspector/index.d.ts.map +1 -1
  18. package/dist/inspector/index.js +56 -3910
  19. package/dist/inspector/index.js.map +1 -1
  20. package/dist/inspector/pages/home.d.ts +2 -0
  21. package/dist/inspector/pages/home.d.ts.map +1 -0
  22. package/dist/inspector/register-custom-element.js +1 -1
  23. package/dist/inspector/router/context.d.ts +12 -0
  24. package/dist/inspector/router/context.d.ts.map +1 -0
  25. package/dist/inspector/router/hash-router.d.ts +7 -0
  26. package/dist/inspector/router/hash-router.d.ts.map +1 -0
  27. package/dist/inspector/router/in-memory-router.d.ts +7 -0
  28. package/dist/inspector/router/in-memory-router.d.ts.map +1 -0
  29. package/dist/inspector/router/index.d.ts +5 -0
  30. package/dist/inspector/router/index.d.ts.map +1 -0
  31. package/dist/inspector/standalone.d.ts +6 -0
  32. package/dist/inspector/standalone.d.ts.map +1 -0
  33. package/dist/inspector/standalone.js +420 -0
  34. package/dist/inspector/standalone.js.map +1 -0
  35. package/dist/inspector/tests/router/hash-router.test.d.ts +2 -0
  36. package/dist/inspector/tests/router/hash-router.test.d.ts.map +1 -0
  37. package/dist/inspector/tests/router/in-memory-router.test.d.ts +2 -0
  38. package/dist/inspector/tests/router/in-memory-router.test.d.ts.map +1 -0
  39. package/dist/inspector/tests/utils/transactions-changes.test.d.ts +2 -0
  40. package/dist/inspector/tests/utils/transactions-changes.test.d.ts.map +1 -0
  41. package/dist/inspector/ui/modal.d.ts +1 -0
  42. package/dist/inspector/ui/modal.d.ts.map +1 -1
  43. package/dist/inspector/utils/transactions-changes.d.ts +13 -13
  44. package/dist/inspector/utils/transactions-changes.d.ts.map +1 -1
  45. package/dist/inspector/viewer/breadcrumbs.d.ts +1 -7
  46. package/dist/inspector/viewer/breadcrumbs.d.ts.map +1 -1
  47. package/dist/inspector/viewer/header.d.ts +7 -0
  48. package/dist/inspector/viewer/header.d.ts.map +1 -0
  49. package/dist/inspector/viewer/page-stack.d.ts +4 -13
  50. package/dist/inspector/viewer/page-stack.d.ts.map +1 -1
  51. package/dist/inspector/viewer/page.d.ts.map +1 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/provider.d.ts.map +1 -1
  55. package/dist/react-core/index.js +2 -2
  56. package/dist/react-core/index.js.map +1 -1
  57. package/dist/react-native/index.js +4 -1
  58. package/dist/react-native/index.js.map +1 -1
  59. package/dist/react-native-core/index.js +4 -1
  60. package/dist/react-native-core/index.js.map +1 -1
  61. package/dist/react-native-core/provider.d.ts.map +1 -1
  62. package/dist/testing.js +1 -1
  63. package/dist/tools/coValues/account.d.ts +7 -1
  64. package/dist/tools/coValues/account.d.ts.map +1 -1
  65. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  66. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts +8 -1
  67. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts.map +1 -1
  68. package/dist/tools/implementation/zodSchema/zodCo.d.ts.map +1 -1
  69. package/dist/tools/subscribe/SubscriptionScope.d.ts +3 -6
  70. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  71. package/dist/tools/testing.d.ts.map +1 -1
  72. package/package.json +9 -4
  73. package/src/inspector/account-switcher.tsx +440 -0
  74. package/src/inspector/contexts/node.tsx +129 -0
  75. package/src/inspector/custom-element.tsx +2 -2
  76. package/src/inspector/in-app.tsx +61 -0
  77. package/src/inspector/index.tsx +2 -22
  78. package/src/inspector/pages/home.tsx +77 -0
  79. package/src/inspector/router/context.ts +21 -0
  80. package/src/inspector/router/hash-router.tsx +128 -0
  81. package/src/inspector/{viewer/use-page-path.ts → router/in-memory-router.tsx} +31 -29
  82. package/src/inspector/router/index.ts +4 -0
  83. package/src/inspector/standalone.tsx +60 -0
  84. package/src/inspector/tests/router/hash-router.test.tsx +847 -0
  85. package/src/inspector/tests/router/in-memory-router.test.tsx +724 -0
  86. package/src/inspector/tests/utils/transactions-changes.test.ts +102 -0
  87. package/src/inspector/ui/icons/add-icon.tsx +3 -3
  88. package/src/inspector/ui/modal.tsx +5 -2
  89. package/src/inspector/utils/history.ts +6 -6
  90. package/src/inspector/utils/transactions-changes.ts +37 -3
  91. package/src/inspector/viewer/breadcrumbs.tsx +5 -11
  92. package/src/inspector/viewer/header.tsx +67 -0
  93. package/src/inspector/viewer/history-view.tsx +13 -13
  94. package/src/inspector/viewer/page-stack.tsx +18 -26
  95. package/src/inspector/viewer/page.tsx +0 -1
  96. package/src/react/provider.tsx +6 -1
  97. package/src/react-core/hooks.ts +2 -2
  98. package/src/react-core/tests/useSuspenseCoState.test.tsx +47 -0
  99. package/src/react-native-core/provider.tsx +6 -1
  100. package/src/tools/coValues/account.ts +13 -2
  101. package/src/tools/implementation/ContextManager.ts +10 -0
  102. package/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +8 -1
  103. package/src/tools/subscribe/SubscriptionScope.ts +61 -39
  104. package/src/tools/tests/account.test.ts +11 -4
  105. package/src/tools/tests/schema.resolved.test.ts +3 -3
  106. package/tsup.config.ts +1 -0
  107. package/dist/chunk-FFEEPZEG.js.map +0 -1
  108. package/dist/inspector/custom-element-P76EIWEV.js.map +0 -1
  109. package/dist/inspector/viewer/new-app.d.ts.map +0 -1
  110. package/dist/inspector/viewer/use-page-path.d.ts +0 -10
  111. package/dist/inspector/viewer/use-page-path.d.ts.map +0 -1
  112. package/src/inspector/viewer/new-app.tsx +0 -156
@@ -0,0 +1,724 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { type PropsWithChildren } from "react";
5
+ import { InMemoryRouterProvider } from "../../router/in-memory-router.js";
6
+ import { useRouter } from "../../router/context.js";
7
+ import type { PageInfo } from "../../viewer/types.js";
8
+ import type { CoID, RawCoValue } from "cojson";
9
+
10
+ const STORAGE_KEY = "jazz-inspector-paths";
11
+
12
+ function Wrapper({ children }: PropsWithChildren) {
13
+ return <InMemoryRouterProvider>{children}</InMemoryRouterProvider>;
14
+ }
15
+
16
+ describe("InMemoryRouterProvider", () => {
17
+ beforeEach(() => {
18
+ if (typeof localStorage !== "undefined") {
19
+ localStorage.clear();
20
+ }
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (typeof localStorage !== "undefined") {
26
+ localStorage.clear();
27
+ }
28
+ });
29
+
30
+ describe("initialization", () => {
31
+ it("should initialize with empty path when no localStorage and no defaultPath", () => {
32
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
33
+
34
+ expect(result.current.path).toEqual([]);
35
+ });
36
+
37
+ it("should initialize with defaultPath when provided", () => {
38
+ const defaultPath: PageInfo[] = [
39
+ { coId: "co_test1" as CoID<RawCoValue>, name: "Test1" },
40
+ { coId: "co_test2" as CoID<RawCoValue>, name: "Test2" },
41
+ ];
42
+
43
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
44
+ return (
45
+ <InMemoryRouterProvider defaultPath={defaultPath}>
46
+ {children}
47
+ </InMemoryRouterProvider>
48
+ );
49
+ }
50
+
51
+ const { result } = renderHook(() => useRouter(), {
52
+ wrapper: WrapperWithDefaultPath,
53
+ });
54
+ expect(result.current.path).toEqual(defaultPath);
55
+ });
56
+
57
+ it("should initialize from localStorage when available", () => {
58
+ const storedPath: PageInfo[] = [
59
+ { coId: "co_stored1" as CoID<RawCoValue>, name: "Stored1" },
60
+ ];
61
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(storedPath));
62
+
63
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
64
+
65
+ expect(result.current.path).toEqual(storedPath);
66
+ });
67
+
68
+ it("should sync defaultPath over localStorage when defaultPath is provided", async () => {
69
+ const storedPath: PageInfo[] = [
70
+ { coId: "co_stored" as CoID<RawCoValue>, name: "Stored" },
71
+ ];
72
+ const defaultPath: PageInfo[] = [
73
+ { coId: "co_default" as CoID<RawCoValue>, name: "Default" },
74
+ ];
75
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(storedPath));
76
+
77
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
78
+ return (
79
+ <InMemoryRouterProvider defaultPath={defaultPath}>
80
+ {children}
81
+ </InMemoryRouterProvider>
82
+ );
83
+ }
84
+
85
+ const { result } = renderHook(() => useRouter(), {
86
+ wrapper: WrapperWithDefaultPath,
87
+ });
88
+
89
+ await waitFor(() => {
90
+ expect(result.current.path).toEqual(defaultPath);
91
+ });
92
+ });
93
+
94
+ it("should handle invalid JSON in localStorage gracefully", () => {
95
+ localStorage.setItem(STORAGE_KEY, "invalid json");
96
+ const consoleWarnSpy = vi
97
+ .spyOn(console, "warn")
98
+ .mockImplementation(() => {});
99
+
100
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
101
+
102
+ expect(consoleWarnSpy).toHaveBeenCalled();
103
+ expect(result.current.path).toEqual([]);
104
+ consoleWarnSpy.mockRestore();
105
+ });
106
+
107
+ it("should handle SSR scenario - component initializes with empty path when no defaultPath", () => {
108
+ // In SSR, window is undefined, so the initial state should be empty array
109
+ // We test this by ensuring the component works correctly without localStorage
110
+ localStorage.removeItem(STORAGE_KEY);
111
+
112
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
113
+
114
+ // When no localStorage and no defaultPath, should initialize with empty array
115
+ expect(result.current.path).toEqual([]);
116
+ });
117
+ });
118
+
119
+ describe("localStorage persistence", () => {
120
+ it("should persist path changes to localStorage", async () => {
121
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
122
+
123
+ const newPages: PageInfo[] = [
124
+ { coId: "co_new1" as CoID<RawCoValue>, name: "New1" },
125
+ ];
126
+
127
+ act(() => {
128
+ result.current.addPages(newPages);
129
+ });
130
+
131
+ await waitFor(() => {
132
+ const stored = localStorage.getItem(STORAGE_KEY);
133
+ expect(stored).toBeTruthy();
134
+ expect(JSON.parse(stored!)).toEqual(newPages);
135
+ });
136
+ });
137
+
138
+ it("should update localStorage when path changes", async () => {
139
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
140
+
141
+ const firstPages: PageInfo[] = [
142
+ { coId: "co_first" as CoID<RawCoValue>, name: "First" },
143
+ ];
144
+
145
+ act(() => {
146
+ result.current.addPages(firstPages);
147
+ });
148
+
149
+ await waitFor(() => {
150
+ expect(result.current.path).toEqual(firstPages);
151
+ });
152
+
153
+ const secondPages: PageInfo[] = [
154
+ { coId: "co_second" as CoID<RawCoValue>, name: "Second" },
155
+ ];
156
+
157
+ act(() => {
158
+ result.current.addPages(secondPages);
159
+ });
160
+
161
+ await waitFor(() => {
162
+ const stored = localStorage.getItem(STORAGE_KEY);
163
+ const parsed = JSON.parse(stored!);
164
+ expect(parsed).toHaveLength(2);
165
+ expect(parsed[0]).toEqual(firstPages[0]);
166
+ expect(parsed[1]).toEqual(secondPages[0]);
167
+ });
168
+ });
169
+ });
170
+
171
+ describe("addPages", () => {
172
+ it("should add pages to the current path", async () => {
173
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
174
+
175
+ expect(result.current.path).toEqual([]);
176
+
177
+ const newPages: PageInfo[] = [
178
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
179
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
180
+ ];
181
+
182
+ act(() => {
183
+ result.current.addPages(newPages);
184
+ });
185
+
186
+ await waitFor(() => {
187
+ expect(result.current.path).toEqual(newPages);
188
+ });
189
+ });
190
+
191
+ it("should append pages to existing path", async () => {
192
+ // Don't use defaultPath here since it will override manual changes
193
+ const initialPath: PageInfo[] = [
194
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
195
+ ];
196
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
197
+
198
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
199
+
200
+ await waitFor(() => {
201
+ expect(result.current.path).toEqual(initialPath);
202
+ });
203
+
204
+ const newPages: PageInfo[] = [
205
+ { coId: "co_new" as CoID<RawCoValue>, name: "New" },
206
+ ];
207
+
208
+ act(() => {
209
+ result.current.addPages(newPages);
210
+ });
211
+
212
+ await waitFor(() => {
213
+ expect(result.current.path).toHaveLength(2);
214
+ expect(result.current.path[0]).toEqual(initialPath[0]);
215
+ expect(result.current.path[1]).toEqual(newPages[0]);
216
+ });
217
+ });
218
+
219
+ it("should handle adding empty array", async () => {
220
+ // Don't use defaultPath here since it will override manual changes
221
+ const initialPath: PageInfo[] = [
222
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
223
+ ];
224
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
225
+
226
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
227
+
228
+ await waitFor(() => {
229
+ expect(result.current.path).toEqual(initialPath);
230
+ });
231
+
232
+ act(() => {
233
+ result.current.addPages([]);
234
+ });
235
+
236
+ await waitFor(() => {
237
+ expect(result.current.path).toEqual(initialPath);
238
+ });
239
+ });
240
+ });
241
+
242
+ describe("goToIndex", () => {
243
+ it("should navigate to a specific index", async () => {
244
+ // Don't use defaultPath here since it will override manual changes
245
+ const initialPath: PageInfo[] = [
246
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
247
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
248
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
249
+ ];
250
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
251
+
252
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
253
+
254
+ await waitFor(() => {
255
+ expect(result.current.path).toEqual(initialPath);
256
+ });
257
+
258
+ act(() => {
259
+ result.current.goToIndex(1);
260
+ });
261
+
262
+ await waitFor(() => {
263
+ expect(result.current.path).toHaveLength(2);
264
+ expect(result.current.path[0]).toEqual(initialPath[0]);
265
+ expect(result.current.path[1]).toEqual(initialPath[1]);
266
+ });
267
+ });
268
+
269
+ it("should navigate to index 0", async () => {
270
+ // Don't use defaultPath here since it will override manual changes
271
+ const initialPath: PageInfo[] = [
272
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
273
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
274
+ ];
275
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
276
+
277
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
278
+
279
+ await waitFor(() => {
280
+ expect(result.current.path).toEqual(initialPath);
281
+ });
282
+
283
+ act(() => {
284
+ result.current.goToIndex(0);
285
+ });
286
+
287
+ await waitFor(() => {
288
+ expect(result.current.path).toHaveLength(1);
289
+ expect(result.current.path[0]).toEqual(initialPath[0]);
290
+ });
291
+ });
292
+
293
+ it("should handle going to last index", async () => {
294
+ const initialPath: PageInfo[] = [
295
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
296
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
297
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
298
+ ];
299
+
300
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
301
+ return (
302
+ <InMemoryRouterProvider defaultPath={initialPath}>
303
+ {children}
304
+ </InMemoryRouterProvider>
305
+ );
306
+ }
307
+
308
+ const { result } = renderHook(() => useRouter(), {
309
+ wrapper: WrapperWithDefaultPath,
310
+ });
311
+
312
+ await waitFor(() => {
313
+ expect(result.current.path).toEqual(initialPath);
314
+ });
315
+
316
+ act(() => {
317
+ result.current.goToIndex(2);
318
+ });
319
+
320
+ await waitFor(() => {
321
+ expect(result.current.path).toEqual(initialPath);
322
+ });
323
+ });
324
+
325
+ it("should handle empty path", async () => {
326
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
327
+
328
+ act(() => {
329
+ result.current.goToIndex(0);
330
+ });
331
+
332
+ await waitFor(() => {
333
+ expect(result.current.path).toEqual([]);
334
+ });
335
+ });
336
+ });
337
+
338
+ describe("setPage", () => {
339
+ it("should set path to a single page with Root name", async () => {
340
+ // Don't use defaultPath here since it will override manual changes
341
+ const initialPath: PageInfo[] = [
342
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
343
+ { coId: "co_initial2" as CoID<RawCoValue>, name: "Initial2" },
344
+ ];
345
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
346
+
347
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
348
+
349
+ await waitFor(() => {
350
+ expect(result.current.path).toEqual(initialPath);
351
+ });
352
+
353
+ const newCoId = "co_newroot" as CoID<RawCoValue>;
354
+
355
+ act(() => {
356
+ result.current.setPage(newCoId);
357
+ });
358
+
359
+ await waitFor(() => {
360
+ expect(result.current.path).toHaveLength(1);
361
+ expect(result.current.path[0]).toEqual({
362
+ coId: newCoId,
363
+ name: "Root",
364
+ });
365
+ });
366
+ });
367
+
368
+ it("should replace existing path", async () => {
369
+ // Don't use defaultPath here since it will override manual changes
370
+ const initialPath: PageInfo[] = [
371
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
372
+ { coId: "co_initial2" as CoID<RawCoValue>, name: "Initial2" },
373
+ { coId: "co_initial3" as CoID<RawCoValue>, name: "Initial3" },
374
+ ];
375
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
376
+
377
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
378
+
379
+ await waitFor(() => {
380
+ expect(result.current.path).toEqual(initialPath);
381
+ });
382
+
383
+ const newCoId = "co_newroot" as CoID<RawCoValue>;
384
+
385
+ act(() => {
386
+ result.current.setPage(newCoId);
387
+ });
388
+
389
+ await waitFor(() => {
390
+ expect(result.current.path).toHaveLength(1);
391
+ const firstPage = result.current.path[0];
392
+ expect(firstPage).toBeDefined();
393
+ expect(firstPage?.coId).toBe(newCoId);
394
+ expect(firstPage?.name).toBe("Root");
395
+ });
396
+ });
397
+ });
398
+
399
+ describe("goBack", () => {
400
+ it("should remove the last page from path", async () => {
401
+ // Don't use defaultPath here since it will override manual changes
402
+ const initialPath: PageInfo[] = [
403
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
404
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
405
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
406
+ ];
407
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
408
+
409
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
410
+
411
+ await waitFor(() => {
412
+ expect(result.current.path).toEqual(initialPath);
413
+ });
414
+
415
+ act(() => {
416
+ result.current.goBack();
417
+ });
418
+
419
+ await waitFor(() => {
420
+ expect(result.current.path).toHaveLength(2);
421
+ expect(result.current.path[0]).toEqual(initialPath[0]);
422
+ expect(result.current.path[1]).toEqual(initialPath[1]);
423
+ });
424
+ });
425
+
426
+ it("should handle going back from single page", async () => {
427
+ // Don't use defaultPath here since it will override manual changes
428
+ const initialPath: PageInfo[] = [
429
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
430
+ ];
431
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
432
+
433
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
434
+
435
+ await waitFor(() => {
436
+ expect(result.current.path).toEqual(initialPath);
437
+ });
438
+
439
+ act(() => {
440
+ result.current.goBack();
441
+ });
442
+
443
+ await waitFor(() => {
444
+ expect(result.current.path).toEqual([]);
445
+ });
446
+ });
447
+
448
+ it("should handle going back from empty path", async () => {
449
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
450
+
451
+ act(() => {
452
+ result.current.goBack();
453
+ });
454
+
455
+ await waitFor(() => {
456
+ expect(result.current.path).toEqual([]);
457
+ });
458
+ });
459
+
460
+ it("should handle multiple goBack calls", async () => {
461
+ // Don't use defaultPath here since it will override manual changes
462
+ const initialPath: PageInfo[] = [
463
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
464
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
465
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
466
+ ];
467
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(initialPath));
468
+
469
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
470
+
471
+ await waitFor(() => {
472
+ expect(result.current.path).toEqual(initialPath);
473
+ });
474
+
475
+ act(() => {
476
+ result.current.goBack();
477
+ });
478
+
479
+ await waitFor(() => {
480
+ expect(result.current.path).toHaveLength(2);
481
+ });
482
+
483
+ act(() => {
484
+ result.current.goBack();
485
+ });
486
+
487
+ await waitFor(() => {
488
+ expect(result.current.path).toHaveLength(1);
489
+ expect(result.current.path[0]).toEqual(initialPath[0]);
490
+ });
491
+ });
492
+ });
493
+
494
+ describe("defaultPath synchronization", () => {
495
+ it("should update path when defaultPath changes", async () => {
496
+ const initialDefaultPath: PageInfo[] = [
497
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
498
+ ];
499
+
500
+ function WrapperWithInitialPath({ children }: PropsWithChildren) {
501
+ return (
502
+ <InMemoryRouterProvider defaultPath={initialDefaultPath}>
503
+ {children}
504
+ </InMemoryRouterProvider>
505
+ );
506
+ }
507
+
508
+ const { result } = renderHook(() => useRouter(), {
509
+ wrapper: WrapperWithInitialPath,
510
+ });
511
+
512
+ await waitFor(() => {
513
+ expect(result.current.path).toEqual(initialDefaultPath);
514
+ });
515
+
516
+ const newDefaultPath: PageInfo[] = [
517
+ { coId: "co_new" as CoID<RawCoValue>, name: "New" },
518
+ ];
519
+
520
+ function WrapperWithNewPath({ children }: PropsWithChildren) {
521
+ return (
522
+ <InMemoryRouterProvider defaultPath={newDefaultPath}>
523
+ {children}
524
+ </InMemoryRouterProvider>
525
+ );
526
+ }
527
+
528
+ const { result: result2 } = renderHook(() => useRouter(), {
529
+ wrapper: WrapperWithNewPath,
530
+ });
531
+
532
+ await waitFor(() => {
533
+ expect(result2.current.path).toEqual(newDefaultPath);
534
+ });
535
+ });
536
+
537
+ it("should not update when defaultPath is the same", async () => {
538
+ const defaultPath: PageInfo[] = [
539
+ { coId: "co_test" as CoID<RawCoValue>, name: "Test" },
540
+ ];
541
+
542
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
543
+ return (
544
+ <InMemoryRouterProvider defaultPath={defaultPath}>
545
+ {children}
546
+ </InMemoryRouterProvider>
547
+ );
548
+ }
549
+
550
+ const { result } = renderHook(() => useRouter(), {
551
+ wrapper: WrapperWithDefaultPath,
552
+ });
553
+
554
+ await waitFor(() => {
555
+ expect(result.current.path).toEqual(defaultPath);
556
+ });
557
+
558
+ const initialPath = result.current.path;
559
+
560
+ // Re-render with same wrapper - path should remain the same
561
+ const { result: result2 } = renderHook(() => useRouter(), {
562
+ wrapper: WrapperWithDefaultPath,
563
+ });
564
+
565
+ await waitFor(() => {
566
+ expect(result2.current.path).toEqual(initialPath);
567
+ });
568
+ });
569
+
570
+ it("should override manual changes when defaultPath changes", async () => {
571
+ const defaultPath: PageInfo[] = [
572
+ { coId: "co_default" as CoID<RawCoValue>, name: "Default" },
573
+ ];
574
+
575
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
576
+ return (
577
+ <InMemoryRouterProvider defaultPath={defaultPath}>
578
+ {children}
579
+ </InMemoryRouterProvider>
580
+ );
581
+ }
582
+
583
+ const { result } = renderHook(() => useRouter(), {
584
+ wrapper: WrapperWithDefaultPath,
585
+ });
586
+
587
+ await waitFor(() => {
588
+ expect(result.current.path).toEqual(defaultPath);
589
+ });
590
+
591
+ const newDefaultPath: PageInfo[] = [
592
+ { coId: "co_newdefault" as CoID<RawCoValue>, name: "NewDefault" },
593
+ ];
594
+
595
+ function WrapperWithNewDefaultPath({ children }: PropsWithChildren) {
596
+ return (
597
+ <InMemoryRouterProvider defaultPath={newDefaultPath}>
598
+ {children}
599
+ </InMemoryRouterProvider>
600
+ );
601
+ }
602
+
603
+ const { result: result2 } = renderHook(() => useRouter(), {
604
+ wrapper: WrapperWithNewDefaultPath,
605
+ });
606
+
607
+ await waitFor(() => {
608
+ expect(result2.current.path).toEqual(newDefaultPath);
609
+ });
610
+ });
611
+ });
612
+
613
+ describe("router object stability", () => {
614
+ it("should provide stable router object reference", () => {
615
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
616
+
617
+ expect(result.current).toBeTruthy();
618
+ expect(result.current.path).toBeDefined();
619
+ expect(result.current.addPages).toBeDefined();
620
+ expect(result.current.goToIndex).toBeDefined();
621
+ expect(result.current.setPage).toBeDefined();
622
+ expect(result.current.goBack).toBeDefined();
623
+ });
624
+ });
625
+
626
+ describe("integration scenarios", () => {
627
+ it("should handle complex navigation flow", async () => {
628
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
629
+
630
+ const page1: PageInfo = {
631
+ coId: "co_page1" as CoID<RawCoValue>,
632
+ name: "Page1",
633
+ };
634
+ const page2: PageInfo = {
635
+ coId: "co_page2" as CoID<RawCoValue>,
636
+ name: "Page2",
637
+ };
638
+ const page3: PageInfo = {
639
+ coId: "co_page3" as CoID<RawCoValue>,
640
+ name: "Page3",
641
+ };
642
+
643
+ act(() => {
644
+ result.current.addPages([page1]);
645
+ });
646
+ await waitFor(() => {
647
+ expect(result.current.path).toEqual([page1]);
648
+ });
649
+
650
+ act(() => {
651
+ result.current.addPages([page2]);
652
+ });
653
+ await waitFor(() => {
654
+ expect(result.current.path).toEqual([page1, page2]);
655
+ });
656
+
657
+ act(() => {
658
+ result.current.addPages([page3]);
659
+ });
660
+ await waitFor(() => {
661
+ expect(result.current.path).toEqual([page1, page2, page3]);
662
+ });
663
+
664
+ act(() => {
665
+ result.current.goBack();
666
+ });
667
+ await waitFor(() => {
668
+ expect(result.current.path).toEqual([page1, page2]);
669
+ });
670
+
671
+ act(() => {
672
+ result.current.goToIndex(0);
673
+ });
674
+ await waitFor(() => {
675
+ expect(result.current.path).toEqual([page1]);
676
+ });
677
+
678
+ act(() => {
679
+ result.current.setPage("co_newroot" as CoID<RawCoValue>);
680
+ });
681
+ await waitFor(() => {
682
+ expect(result.current.path).toEqual([
683
+ { coId: "co_newroot" as CoID<RawCoValue>, name: "Root" },
684
+ ]);
685
+ });
686
+ });
687
+
688
+ it("should persist complex navigation flow to localStorage", async () => {
689
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
690
+
691
+ const page1: PageInfo = {
692
+ coId: "co_page1" as CoID<RawCoValue>,
693
+ name: "Page1",
694
+ };
695
+ const page2: PageInfo = {
696
+ coId: "co_page2" as CoID<RawCoValue>,
697
+ name: "Page2",
698
+ };
699
+
700
+ act(() => {
701
+ result.current.addPages([page1]);
702
+ });
703
+ await waitFor(() => {
704
+ expect(result.current.path).toEqual([page1]);
705
+ });
706
+
707
+ act(() => {
708
+ result.current.addPages([page2]);
709
+ });
710
+ await waitFor(() => {
711
+ expect(result.current.path).toEqual([page1, page2]);
712
+ });
713
+
714
+ act(() => {
715
+ result.current.goBack();
716
+ });
717
+ await waitFor(() => {
718
+ const stored = localStorage.getItem(STORAGE_KEY);
719
+ const parsed = JSON.parse(stored!);
720
+ expect(parsed).toEqual([page1]);
721
+ });
722
+ });
723
+ });
724
+ });