jazz-tools 0.19.8 → 0.19.11

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 (154) hide show
  1. package/.turbo/turbo-build.log +56 -50
  2. package/CHANGELOG.md +30 -3
  3. package/dist/{chunk-2S3Z2CN6.js → chunk-HX5S6W5E.js} +372 -103
  4. package/dist/chunk-HX5S6W5E.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-C6BJPHBQ.js +4096 -0
  9. package/dist/inspector/chunk-C6BJPHBQ.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-GJVBPZES.js} +1011 -884
  13. package/dist/inspector/custom-element-GJVBPZES.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/ui/modal.d.ts +1 -0
  40. package/dist/inspector/ui/modal.d.ts.map +1 -1
  41. package/dist/inspector/viewer/breadcrumbs.d.ts +1 -7
  42. package/dist/inspector/viewer/breadcrumbs.d.ts.map +1 -1
  43. package/dist/inspector/viewer/header.d.ts +7 -0
  44. package/dist/inspector/viewer/header.d.ts.map +1 -0
  45. package/dist/inspector/viewer/page-stack.d.ts +4 -13
  46. package/dist/inspector/viewer/page-stack.d.ts.map +1 -1
  47. package/dist/inspector/viewer/page.d.ts.map +1 -1
  48. package/dist/react/hooks.d.ts +1 -1
  49. package/dist/react/hooks.d.ts.map +1 -1
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +5 -1
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react-core/hooks.d.ts +59 -0
  55. package/dist/react-core/hooks.d.ts.map +1 -1
  56. package/dist/react-core/index.js +124 -36
  57. package/dist/react-core/index.js.map +1 -1
  58. package/dist/react-core/tests/testUtils.d.ts +1 -0
  59. package/dist/react-core/tests/testUtils.d.ts.map +1 -1
  60. package/dist/react-core/tests/useSuspenseAccount.test.d.ts +2 -0
  61. package/dist/react-core/tests/useSuspenseAccount.test.d.ts.map +1 -0
  62. package/dist/react-core/tests/useSuspenseCoState.test.d.ts +2 -0
  63. package/dist/react-core/tests/useSuspenseCoState.test.d.ts.map +1 -0
  64. package/dist/react-core/use.d.ts +3 -0
  65. package/dist/react-core/use.d.ts.map +1 -0
  66. package/dist/react-native/index.js +5 -1
  67. package/dist/react-native/index.js.map +1 -1
  68. package/dist/react-native-core/crypto/RNCrypto.d.ts +2 -0
  69. package/dist/react-native-core/crypto/RNCrypto.d.ts.map +1 -0
  70. package/dist/react-native-core/crypto/RNCrypto.js +3 -0
  71. package/dist/react-native-core/crypto/RNCrypto.js.map +1 -0
  72. package/dist/react-native-core/hooks.d.ts +1 -1
  73. package/dist/react-native-core/hooks.d.ts.map +1 -1
  74. package/dist/react-native-core/index.js +5 -1
  75. package/dist/react-native-core/index.js.map +1 -1
  76. package/dist/react-native-core/platform.d.ts +2 -1
  77. package/dist/react-native-core/platform.d.ts.map +1 -1
  78. package/dist/testing.js +1 -1
  79. package/dist/testing.js.map +1 -1
  80. package/dist/tools/coValues/account.d.ts +7 -1
  81. package/dist/tools/coValues/account.d.ts.map +1 -1
  82. package/dist/tools/coValues/interfaces.d.ts +1 -1
  83. package/dist/tools/coValues/interfaces.d.ts.map +1 -1
  84. package/dist/tools/implementation/ContextManager.d.ts +3 -0
  85. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  86. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts +8 -1
  87. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts.map +1 -1
  88. package/dist/tools/implementation/zodSchema/zodCo.d.ts.map +1 -1
  89. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts +8 -22
  90. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts.map +1 -1
  91. package/dist/tools/subscribe/SubscriptionCache.d.ts +51 -0
  92. package/dist/tools/subscribe/SubscriptionCache.d.ts.map +1 -0
  93. package/dist/tools/subscribe/SubscriptionScope.d.ts +17 -1
  94. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  95. package/dist/tools/subscribe/utils.d.ts +9 -1
  96. package/dist/tools/subscribe/utils.d.ts.map +1 -1
  97. package/dist/tools/testing.d.ts +2 -2
  98. package/dist/tools/testing.d.ts.map +1 -1
  99. package/dist/tools/tests/SubscriptionCache.test.d.ts +2 -0
  100. package/dist/tools/tests/SubscriptionCache.test.d.ts.map +1 -0
  101. package/package.json +18 -6
  102. package/src/inspector/account-switcher.tsx +440 -0
  103. package/src/inspector/contexts/node.tsx +129 -0
  104. package/src/inspector/custom-element.tsx +2 -2
  105. package/src/inspector/in-app.tsx +61 -0
  106. package/src/inspector/index.tsx +2 -22
  107. package/src/inspector/pages/home.tsx +77 -0
  108. package/src/inspector/router/context.ts +21 -0
  109. package/src/inspector/router/hash-router.tsx +128 -0
  110. package/src/inspector/{viewer/use-page-path.ts → router/in-memory-router.tsx} +31 -29
  111. package/src/inspector/router/index.ts +4 -0
  112. package/src/inspector/standalone.tsx +60 -0
  113. package/src/inspector/tests/router/hash-router.test.tsx +847 -0
  114. package/src/inspector/tests/router/in-memory-router.test.tsx +724 -0
  115. package/src/inspector/ui/modal.tsx +5 -2
  116. package/src/inspector/viewer/breadcrumbs.tsx +5 -11
  117. package/src/inspector/viewer/header.tsx +67 -0
  118. package/src/inspector/viewer/page-stack.tsx +18 -26
  119. package/src/inspector/viewer/page.tsx +0 -1
  120. package/src/react/hooks.tsx +2 -0
  121. package/src/react/index.ts +1 -14
  122. package/src/react-core/hooks.ts +167 -18
  123. package/src/react-core/tests/createCoValueSubscriptionContext.test.tsx +18 -8
  124. package/src/react-core/tests/testUtils.tsx +67 -5
  125. package/src/react-core/tests/useCoState.test.ts +3 -7
  126. package/src/react-core/tests/useSubscriptionSelector.test.ts +3 -7
  127. package/src/react-core/tests/useSuspenseAccount.test.tsx +343 -0
  128. package/src/react-core/tests/useSuspenseCoState.test.tsx +1182 -0
  129. package/src/react-core/use.ts +46 -0
  130. package/src/react-native-core/crypto/RNCrypto.ts +1 -0
  131. package/src/react-native-core/hooks.tsx +2 -0
  132. package/src/react-native-core/platform.ts +2 -1
  133. package/src/tools/coValues/account.ts +13 -2
  134. package/src/tools/coValues/interfaces.ts +2 -3
  135. package/src/tools/implementation/ContextManager.ts +13 -0
  136. package/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +8 -1
  137. package/src/tools/subscribe/CoValueCoreSubscription.ts +71 -100
  138. package/src/tools/subscribe/SubscriptionCache.ts +272 -0
  139. package/src/tools/subscribe/SubscriptionScope.ts +113 -7
  140. package/src/tools/subscribe/utils.ts +77 -0
  141. package/src/tools/testing.ts +0 -3
  142. package/src/tools/tests/CoValueCoreSubscription.test.ts +46 -12
  143. package/src/tools/tests/ContextManager.test.ts +85 -0
  144. package/src/tools/tests/SubscriptionCache.test.ts +237 -0
  145. package/src/tools/tests/account.test.ts +11 -4
  146. package/src/tools/tests/coMap.test.ts +5 -7
  147. package/src/tools/tests/schema.resolved.test.ts +3 -3
  148. package/tsup.config.ts +2 -0
  149. package/dist/chunk-2S3Z2CN6.js.map +0 -1
  150. package/dist/inspector/custom-element-P76EIWEV.js.map +0 -1
  151. package/dist/inspector/viewer/new-app.d.ts.map +0 -1
  152. package/dist/inspector/viewer/use-page-path.d.ts +0 -10
  153. package/dist/inspector/viewer/use-page-path.d.ts.map +0 -1
  154. package/src/inspector/viewer/new-app.tsx +0 -156
@@ -0,0 +1,847 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, screen, waitFor } from "@testing-library/react";
4
+ import React, { type PropsWithChildren } from "react";
5
+ import { HashRouterProvider } from "../../router/hash-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
+ function Wrapper({ children }: PropsWithChildren) {
11
+ return <HashRouterProvider>{children}</HashRouterProvider>;
12
+ }
13
+
14
+ function encodePathToHash(path: PageInfo[]): string {
15
+ return path
16
+ .map((page) => {
17
+ if (page.name && page.name !== "Root") {
18
+ return `${page.coId}:${encodeURIComponent(page.name)}`;
19
+ }
20
+ return page.coId;
21
+ })
22
+ .join("/");
23
+ }
24
+
25
+ async function setHash(path: PageInfo[]) {
26
+ window.history.replaceState({}, "", `#/${encodePathToHash(path)}`);
27
+ }
28
+
29
+ describe("HashRouterProvider", () => {
30
+ afterEach(async () => {
31
+ setHash([]);
32
+ expect(window.location.hash).toBe("#/");
33
+ });
34
+
35
+ describe("initialization", () => {
36
+ it("should initialize with empty path when no hash and no defaultPath", async () => {
37
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
38
+
39
+ await waitFor(() => {
40
+ expect(result.current.path).toEqual([]);
41
+ expect(window.location.hash).toBe("#/");
42
+ });
43
+ });
44
+
45
+ it("should initialize with defaultPath when provided", async () => {
46
+ const defaultPath: PageInfo[] = [
47
+ { coId: "co_test1" as CoID<RawCoValue>, name: "Test1" },
48
+ { coId: "co_test2" as CoID<RawCoValue>, name: "Test2" },
49
+ ];
50
+
51
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
52
+ return (
53
+ <HashRouterProvider defaultPath={defaultPath}>
54
+ {children}
55
+ </HashRouterProvider>
56
+ );
57
+ }
58
+
59
+ const { result } = renderHook(() => useRouter(), {
60
+ wrapper: WrapperWithDefaultPath,
61
+ });
62
+ expect(result.current.path).toEqual(defaultPath);
63
+ await waitFor(() => {
64
+ expect(window.location.hash).toBe(`#/${encodePathToHash(defaultPath)}`);
65
+ });
66
+ });
67
+
68
+ it("should initialize from hash when available", async () => {
69
+ const storedPath: PageInfo[] = [
70
+ { coId: "co_stored1" as CoID<RawCoValue>, name: "Stored1" },
71
+ ];
72
+ await setHash(storedPath);
73
+
74
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
75
+
76
+ await waitFor(() => {
77
+ expect(result.current.path).toEqual(storedPath);
78
+ expect(window.location.hash).toBe(`#/${encodePathToHash(storedPath)}`);
79
+ });
80
+ });
81
+
82
+ it("should sync defaultPath over hash when defaultPath is provided", async () => {
83
+ const storedPath: PageInfo[] = [
84
+ { coId: "co_stored" as CoID<RawCoValue>, name: "Stored" },
85
+ ];
86
+ const defaultPath: PageInfo[] = [
87
+ { coId: "co_default" as CoID<RawCoValue>, name: "Default" },
88
+ ];
89
+ await setHash(storedPath);
90
+
91
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
92
+ return (
93
+ <HashRouterProvider defaultPath={defaultPath}>
94
+ {children}
95
+ </HashRouterProvider>
96
+ );
97
+ }
98
+
99
+ const { result } = renderHook(() => useRouter(), {
100
+ wrapper: WrapperWithDefaultPath,
101
+ });
102
+
103
+ await waitFor(() => {
104
+ expect(result.current.path).toEqual(defaultPath);
105
+ expect(window.location.hash).toBe(`#/${encodePathToHash(defaultPath)}`);
106
+ });
107
+ });
108
+
109
+ it("should handle invalid hash gracefully", async () => {
110
+ // The decodePathFromHash function doesn't actually throw errors for invalid formats
111
+ // It just parses what it can. So we test with a malformed hash that might cause issues
112
+ await setHash([
113
+ {
114
+ coId: "invalid:hash:format" as CoID<RawCoValue>,
115
+ name: "Invalid Hash Format",
116
+ },
117
+ ]);
118
+ const consoleErrorSpy = vi
119
+ .spyOn(console, "error")
120
+ .mockImplementation(() => {});
121
+
122
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
123
+
124
+ // Wait for initialization
125
+ await waitFor(() => {
126
+ // The hash will be parsed, might not be empty
127
+ expect(result.current.path).toBeDefined();
128
+ });
129
+
130
+ // decodePathFromHash doesn't throw, it just parses segments
131
+ // So we might not get an error, but the path should be valid
132
+ expect(Array.isArray(result.current.path)).toBe(true);
133
+ consoleErrorSpy.mockRestore();
134
+ });
135
+
136
+ it("should handle SSR scenario - component initializes with empty path when no defaultPath", async () => {
137
+ // In SSR, window is undefined, so the initial state should be empty array
138
+ // We test this by ensuring the component works correctly without hash
139
+ // Note: We can't actually set window to undefined in happy-dom environment
140
+ // So we just verify it works when hash is empty
141
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
142
+
143
+ // When no hash and no defaultPath, should initialize with empty array
144
+ await waitFor(() => {
145
+ expect(result.current.path).toEqual([]);
146
+ });
147
+ });
148
+
149
+ it("should decode hash with names correctly", async () => {
150
+ const path: PageInfo[] = [
151
+ { coId: "co_test1" as CoID<RawCoValue>, name: "Test Name" },
152
+ { coId: "co_test2" as CoID<RawCoValue>, name: "Another Name" },
153
+ ];
154
+ await setHash(path);
155
+
156
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
157
+
158
+ await waitFor(() => {
159
+ expect(result.current.path).toEqual(path);
160
+ });
161
+ });
162
+
163
+ it("should decode hash without names correctly", async () => {
164
+ const path: PageInfo[] = [
165
+ { coId: "co_test1" as CoID<RawCoValue> },
166
+ { coId: "co_test2" as CoID<RawCoValue> },
167
+ ];
168
+ setHash(path);
169
+
170
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
171
+
172
+ await waitFor(() => {
173
+ expect(window.location.hash).toBe(`#/${encodePathToHash(path)}`);
174
+ expect(result.current.path).toEqual(path);
175
+ });
176
+ });
177
+
178
+ it("should handle Root name in hash", async () => {
179
+ const path: PageInfo[] = [
180
+ { coId: "co_test1" as CoID<RawCoValue>, name: "Root" },
181
+ ];
182
+ // Root name should not be encoded in hash
183
+ await setHash([{ coId: "co_test1" as CoID<RawCoValue>, name: "Root" }]);
184
+
185
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
186
+
187
+ await waitFor(() => {
188
+ expect(result.current.path[0]?.coId).toBe("co_test1");
189
+ // Root name might be undefined or "Root" depending on implementation
190
+ });
191
+ });
192
+
193
+ it("should update path when defaultPath changes", async () => {
194
+ const initialDefaultPath: PageInfo[] = [
195
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
196
+ ];
197
+
198
+ const newPage: PageInfo = {
199
+ coId: "co_page1" as CoID<RawCoValue>,
200
+ name: "Page1",
201
+ };
202
+
203
+ const newDefaultPath: PageInfo[] = [
204
+ { coId: "co_new" as CoID<RawCoValue>, name: "New" },
205
+ ];
206
+
207
+ function WrapperWithInitialPath({ children }: PropsWithChildren) {
208
+ const [defaultPath, setDefaultPath] =
209
+ React.useState(initialDefaultPath);
210
+ return (
211
+ <HashRouterProvider defaultPath={defaultPath}>
212
+ {children}
213
+ <button onClick={() => setDefaultPath(newDefaultPath)}>
214
+ Set Default Path
215
+ </button>
216
+ </HashRouterProvider>
217
+ );
218
+ }
219
+
220
+ const { result } = renderHook(() => useRouter(), {
221
+ wrapper: WrapperWithInitialPath,
222
+ });
223
+
224
+ await waitFor(() => {
225
+ expect(result.current.path).toEqual(initialDefaultPath);
226
+ });
227
+
228
+ act(() => {
229
+ result.current.addPages([newPage]);
230
+ });
231
+
232
+ await waitFor(() => {
233
+ expect(result.current.path).toEqual(
234
+ initialDefaultPath.concat([newPage]),
235
+ );
236
+ expect(window.location.hash).toBe(
237
+ `#/${encodePathToHash(initialDefaultPath.concat([newPage]))}`,
238
+ );
239
+ });
240
+
241
+ act(() => {
242
+ screen.getByRole("button", { name: "Set Default Path" }).click();
243
+ });
244
+
245
+ await waitFor(() => {
246
+ expect(result.current.path).toEqual(newDefaultPath);
247
+ });
248
+ });
249
+ });
250
+
251
+ describe("hash persistence", () => {
252
+ it("should persist path changes to hash", async () => {
253
+ await setHash([]);
254
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
255
+
256
+ await waitFor(() => {
257
+ expect(result.current.path).toEqual([]);
258
+ });
259
+
260
+ const newPages: PageInfo[] = [
261
+ { coId: "co_new1" as CoID<RawCoValue>, name: "New1" },
262
+ ];
263
+
264
+ act(() => {
265
+ result.current.addPages(newPages);
266
+ });
267
+
268
+ await waitFor(() => {
269
+ expect(result.current.path).toEqual(newPages);
270
+ const hash = window.location.hash.slice(2); // Remove '#/'
271
+ expect(hash).toBeTruthy();
272
+ const decoded = hash.split("/").map((segment) => {
273
+ const [coId, encodedName] = segment.split(":");
274
+ return {
275
+ coId,
276
+ name: encodedName ? decodeURIComponent(encodedName) : undefined,
277
+ } as PageInfo;
278
+ });
279
+ expect(decoded).toEqual(newPages);
280
+ });
281
+ });
282
+
283
+ it("should update hash when path changes", async () => {
284
+ // Ensure hash is cleared before starting
285
+ await setHash([]);
286
+
287
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
288
+
289
+ await waitFor(() => {
290
+ expect(result.current.path).toEqual([]);
291
+ });
292
+
293
+ const firstPages: PageInfo[] = [
294
+ { coId: "co_first" as CoID<RawCoValue>, name: "First" },
295
+ ];
296
+
297
+ act(() => {
298
+ result.current.addPages(firstPages);
299
+ });
300
+
301
+ await waitFor(() => {
302
+ expect(result.current.path).toEqual(firstPages);
303
+ });
304
+
305
+ const secondPages: PageInfo[] = [
306
+ { coId: "co_second" as CoID<RawCoValue>, name: "Second" },
307
+ ];
308
+
309
+ act(() => {
310
+ result.current.addPages(secondPages);
311
+ });
312
+
313
+ await waitFor(() => {
314
+ expect(result.current.path).toEqual([...firstPages, ...secondPages]);
315
+ const hash = window.location.hash.slice(2);
316
+ const decoded = hash.split("/").map((segment) => {
317
+ const [coId, encodedName] = segment.split(":");
318
+ return {
319
+ coId,
320
+ name: encodedName ? decodeURIComponent(encodedName) : undefined,
321
+ } as PageInfo;
322
+ });
323
+ expect(decoded).toHaveLength(2);
324
+ expect(decoded[0]).toEqual(firstPages[0]);
325
+ expect(decoded[1]).toEqual(secondPages[0]);
326
+ });
327
+ });
328
+ });
329
+
330
+ describe("hashchange event", () => {
331
+ it("should update path when hash changes", async () => {
332
+ // Ensure hash is cleared before starting
333
+ await setHash([]);
334
+
335
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
336
+
337
+ await waitFor(() => {
338
+ expect(result.current.path).toEqual([]);
339
+ });
340
+
341
+ const newPath: PageInfo[] = [
342
+ { coId: "co_new" as CoID<RawCoValue>, name: "New" },
343
+ ];
344
+
345
+ await setHash(newPath);
346
+
347
+ // Wait for hashchange event to be processed
348
+ await waitFor(
349
+ () => {
350
+ expect(result.current.path).toEqual(newPath);
351
+ },
352
+ { timeout: 1000 },
353
+ );
354
+ });
355
+
356
+ it("should use defaultPath when hash is cleared", async () => {
357
+ const defaultPath: PageInfo[] = [
358
+ { coId: "co_default" as CoID<RawCoValue>, name: "Default" },
359
+ ];
360
+
361
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
362
+ return (
363
+ <HashRouterProvider defaultPath={defaultPath}>
364
+ {children}
365
+ </HashRouterProvider>
366
+ );
367
+ }
368
+
369
+ const { result } = renderHook(() => useRouter(), {
370
+ wrapper: WrapperWithDefaultPath,
371
+ });
372
+
373
+ await waitFor(() => {
374
+ expect(result.current.path).toEqual(defaultPath);
375
+ });
376
+
377
+ await setHash([]);
378
+
379
+ await waitFor(
380
+ () => {
381
+ expect(result.current.path).toEqual(defaultPath);
382
+ },
383
+ { timeout: 1000 },
384
+ );
385
+ });
386
+
387
+ it("should handle hash changes in hashchange event", async () => {
388
+ // Ensure hash is cleared before starting
389
+ await setHash([]);
390
+
391
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
392
+
393
+ await waitFor(() => {
394
+ expect(result.current.path).toEqual([]);
395
+ });
396
+
397
+ const newPath: PageInfo[] = [
398
+ { coId: "co_testhash" as CoID<RawCoValue>, name: "TestHash" },
399
+ ];
400
+
401
+ await setHash(newPath);
402
+
403
+ // Wait for hashchange to process
404
+ await waitFor(
405
+ () => {
406
+ expect(result.current.path).toEqual(newPath);
407
+ },
408
+ { timeout: 1000 },
409
+ );
410
+ });
411
+ });
412
+
413
+ describe("addPages", () => {
414
+ it("should add pages to the current path", async () => {
415
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
416
+
417
+ await waitFor(() => {
418
+ expect(result.current.path).toEqual([]);
419
+ });
420
+
421
+ const newPages: PageInfo[] = [
422
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
423
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
424
+ ];
425
+
426
+ act(() => {
427
+ result.current.addPages(newPages);
428
+ });
429
+
430
+ await waitFor(() => {
431
+ expect(result.current.path).toEqual(newPages);
432
+ });
433
+ });
434
+
435
+ it("should append pages to existing path", async () => {
436
+ // Don't use defaultPath here since it will override manual changes
437
+ const initialPath: PageInfo[] = [
438
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
439
+ ];
440
+ await setHash(initialPath);
441
+
442
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
443
+
444
+ await waitFor(() => {
445
+ expect(result.current.path).toEqual(initialPath);
446
+ });
447
+
448
+ const newPages: PageInfo[] = [
449
+ { coId: "co_new" as CoID<RawCoValue>, name: "New" },
450
+ ];
451
+
452
+ act(() => {
453
+ result.current.addPages(newPages);
454
+ });
455
+
456
+ await waitFor(() => {
457
+ expect(result.current.path).toHaveLength(2);
458
+ expect(result.current.path[0]).toEqual(initialPath[0]);
459
+ expect(result.current.path[1]).toEqual(newPages[0]);
460
+ });
461
+ });
462
+
463
+ it("should handle adding empty array", async () => {
464
+ // Don't use defaultPath here since it will override manual changes
465
+ const initialPath: PageInfo[] = [
466
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
467
+ ];
468
+ await setHash(initialPath);
469
+
470
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
471
+
472
+ await waitFor(() => {
473
+ expect(result.current.path).toEqual(initialPath);
474
+ });
475
+
476
+ act(() => {
477
+ result.current.addPages([]);
478
+ });
479
+
480
+ await waitFor(() => {
481
+ expect(result.current.path).toEqual(initialPath);
482
+ });
483
+ });
484
+ });
485
+
486
+ describe("goToIndex", () => {
487
+ it("should navigate to a specific index", async () => {
488
+ // Don't use defaultPath here since it will override manual changes
489
+ const initialPath: PageInfo[] = [
490
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
491
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
492
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
493
+ ];
494
+
495
+ await setHash(initialPath);
496
+
497
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
498
+
499
+ await waitFor(() => {
500
+ expect(result.current.path).toEqual(initialPath);
501
+ });
502
+
503
+ act(() => {
504
+ result.current.goToIndex(1);
505
+ });
506
+
507
+ await waitFor(() => {
508
+ expect(result.current.path).toHaveLength(2);
509
+ expect(result.current.path[0]).toEqual(initialPath[0]);
510
+ expect(result.current.path[1]).toEqual(initialPath[1]);
511
+ });
512
+ });
513
+
514
+ it("should navigate to index 0", async () => {
515
+ // Don't use defaultPath here since it will override manual changes
516
+ const initialPath: PageInfo[] = [
517
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
518
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
519
+ ];
520
+ await setHash(initialPath);
521
+
522
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
523
+
524
+ await waitFor(() => {
525
+ expect(result.current.path).toEqual(initialPath);
526
+ });
527
+
528
+ act(() => {
529
+ result.current.goToIndex(0);
530
+ });
531
+
532
+ await waitFor(() => {
533
+ expect(result.current.path).toHaveLength(1);
534
+ expect(result.current.path[0]).toEqual(initialPath[0]);
535
+ });
536
+ });
537
+
538
+ it("should handle going to last index", async () => {
539
+ const initialPath: PageInfo[] = [
540
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
541
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
542
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
543
+ ];
544
+
545
+ function WrapperWithDefaultPath({ children }: PropsWithChildren) {
546
+ return (
547
+ <HashRouterProvider defaultPath={initialPath}>
548
+ {children}
549
+ </HashRouterProvider>
550
+ );
551
+ }
552
+
553
+ const { result } = renderHook(() => useRouter(), {
554
+ wrapper: WrapperWithDefaultPath,
555
+ });
556
+
557
+ await waitFor(() => {
558
+ expect(result.current.path).toEqual(initialPath);
559
+ });
560
+
561
+ act(() => {
562
+ result.current.goToIndex(2);
563
+ });
564
+
565
+ await waitFor(() => {
566
+ expect(result.current.path).toEqual(initialPath);
567
+ });
568
+ });
569
+
570
+ it("should handle empty path", async () => {
571
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
572
+
573
+ await waitFor(() => {
574
+ expect(result.current.path).toEqual([]);
575
+ });
576
+
577
+ act(() => {
578
+ result.current.goToIndex(0);
579
+ });
580
+
581
+ await waitFor(() => {
582
+ expect(result.current.path).toEqual([]);
583
+ });
584
+ });
585
+ });
586
+
587
+ describe("setPage", () => {
588
+ it("should set path to a single page with Root name", async () => {
589
+ // Don't use defaultPath here since it will override manual changes
590
+ const initialPath: PageInfo[] = [
591
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
592
+ { coId: "co_initial2" as CoID<RawCoValue>, name: "Initial2" },
593
+ ];
594
+ await setHash(initialPath);
595
+
596
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
597
+
598
+ await waitFor(() => {
599
+ expect(result.current.path).toEqual(initialPath);
600
+ });
601
+
602
+ const newCoId = "co_newroot" as CoID<RawCoValue>;
603
+
604
+ act(() => {
605
+ result.current.setPage(newCoId);
606
+ });
607
+
608
+ await waitFor(() => {
609
+ expect(result.current.path).toHaveLength(1);
610
+ expect(result.current.path[0]).toEqual({
611
+ coId: newCoId,
612
+ name: "Root",
613
+ });
614
+ });
615
+ });
616
+
617
+ it("should replace existing path", async () => {
618
+ // Don't use defaultPath here since it will override manual changes
619
+ const initialPath: PageInfo[] = [
620
+ { coId: "co_initial" as CoID<RawCoValue>, name: "Initial" },
621
+ { coId: "co_initial2" as CoID<RawCoValue>, name: "Initial2" },
622
+ { coId: "co_initial3" as CoID<RawCoValue>, name: "Initial3" },
623
+ ];
624
+ await setHash(initialPath);
625
+
626
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
627
+
628
+ await waitFor(() => {
629
+ expect(result.current.path).toEqual(initialPath);
630
+ });
631
+
632
+ const newCoId = "co_newroot" as CoID<RawCoValue>;
633
+
634
+ act(() => {
635
+ result.current.setPage(newCoId);
636
+ });
637
+
638
+ await waitFor(() => {
639
+ expect(result.current.path).toHaveLength(1);
640
+ const firstPage = result.current.path[0];
641
+ expect(firstPage).toBeDefined();
642
+ expect(firstPage?.coId).toBe(newCoId);
643
+ expect(firstPage?.name).toBe("Root");
644
+ });
645
+ });
646
+ });
647
+
648
+ describe("goBack", () => {
649
+ it("should remove the last page from path", async () => {
650
+ // Don't use defaultPath here since it will override manual changes
651
+ const initialPath: PageInfo[] = [
652
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
653
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
654
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
655
+ ];
656
+ await setHash(initialPath);
657
+
658
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
659
+
660
+ await waitFor(() => {
661
+ expect(result.current.path).toEqual(initialPath);
662
+ });
663
+
664
+ act(() => {
665
+ result.current.goBack();
666
+ });
667
+
668
+ await waitFor(() => {
669
+ expect(result.current.path).toHaveLength(2);
670
+ expect(result.current.path[0]).toEqual(initialPath[0]);
671
+ expect(result.current.path[1]).toEqual(initialPath[1]);
672
+ });
673
+ });
674
+
675
+ it("should handle going back from single page", async () => {
676
+ // Don't use defaultPath here since it will override manual changes
677
+ const initialPath: PageInfo[] = [
678
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
679
+ ];
680
+ await setHash(initialPath);
681
+
682
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
683
+
684
+ await waitFor(() => {
685
+ expect(result.current.path).toEqual(initialPath);
686
+ });
687
+
688
+ act(() => {
689
+ result.current.goBack();
690
+ });
691
+
692
+ await waitFor(() => {
693
+ expect(result.current.path).toEqual([]);
694
+ });
695
+ });
696
+
697
+ it("should handle going back from empty path", async () => {
698
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
699
+
700
+ await waitFor(() => {
701
+ expect(result.current.path).toEqual([]);
702
+ });
703
+
704
+ act(() => {
705
+ result.current.goBack();
706
+ });
707
+
708
+ await waitFor(() => {
709
+ expect(result.current.path).toEqual([]);
710
+ });
711
+ });
712
+
713
+ it("should handle multiple goBack calls", async () => {
714
+ // Don't use defaultPath here since it will override manual changes
715
+ const initialPath: PageInfo[] = [
716
+ { coId: "co_page1" as CoID<RawCoValue>, name: "Page1" },
717
+ { coId: "co_page2" as CoID<RawCoValue>, name: "Page2" },
718
+ { coId: "co_page3" as CoID<RawCoValue>, name: "Page3" },
719
+ ];
720
+ await setHash(initialPath);
721
+
722
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
723
+
724
+ await waitFor(() => {
725
+ expect(result.current.path).toEqual(initialPath);
726
+ });
727
+
728
+ act(() => {
729
+ result.current.goBack();
730
+ });
731
+
732
+ await waitFor(() => {
733
+ expect(result.current.path).toHaveLength(2);
734
+ });
735
+
736
+ act(() => {
737
+ result.current.goBack();
738
+ });
739
+
740
+ await waitFor(() => {
741
+ expect(result.current.path).toHaveLength(1);
742
+ expect(result.current.path[0]).toEqual(initialPath[0]);
743
+ });
744
+ });
745
+ });
746
+
747
+ describe("integration scenarios", () => {
748
+ it("should handle complex navigation flow", async () => {
749
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
750
+
751
+ expect(result.current.path).toEqual([]);
752
+
753
+ const page1: PageInfo = {
754
+ coId: "co_page1" as CoID<RawCoValue>,
755
+ name: "Page1",
756
+ };
757
+ const page2: PageInfo = {
758
+ coId: "co_page2" as CoID<RawCoValue>,
759
+ name: "Page2",
760
+ };
761
+ const page3: PageInfo = {
762
+ coId: "co_page3" as CoID<RawCoValue>,
763
+ name: "Page3",
764
+ };
765
+
766
+ act(() => {
767
+ result.current.addPages([page1]);
768
+ });
769
+
770
+ expect(result.current.path).toEqual([page1]);
771
+
772
+ act(() => {
773
+ result.current.addPages([page2]);
774
+ });
775
+
776
+ expect(result.current.path).toEqual([page1, page2]);
777
+
778
+ act(() => {
779
+ result.current.addPages([page3]);
780
+ });
781
+
782
+ expect(result.current.path).toEqual([page1, page2, page3]);
783
+
784
+ act(() => {
785
+ result.current.goBack();
786
+ });
787
+
788
+ expect(result.current.path).toEqual([page1, page2]);
789
+
790
+ act(() => {
791
+ result.current.goToIndex(0);
792
+ });
793
+
794
+ expect(result.current.path).toEqual([page1]);
795
+
796
+ act(() => {
797
+ result.current.setPage("co_newroot" as CoID<RawCoValue>);
798
+ });
799
+
800
+ expect(result.current.path).toEqual([
801
+ { coId: "co_newroot" as CoID<RawCoValue>, name: "Root" },
802
+ ]);
803
+ });
804
+
805
+ it("should persist complex navigation flow to hash", async () => {
806
+ const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
807
+
808
+ expect(result.current.path).toEqual([]);
809
+
810
+ const page1: PageInfo = {
811
+ coId: "co_page1" as CoID<RawCoValue>,
812
+ name: "Page1",
813
+ };
814
+ const page2: PageInfo = {
815
+ coId: "co_page2" as CoID<RawCoValue>,
816
+ name: "Page2",
817
+ };
818
+
819
+ act(() => {
820
+ result.current.addPages([page1]);
821
+ });
822
+
823
+ expect(result.current.path).toEqual([page1]);
824
+
825
+ act(() => {
826
+ result.current.addPages([page2]);
827
+ });
828
+
829
+ expect(result.current.path).toEqual([page1, page2]);
830
+
831
+ act(() => {
832
+ result.current.goBack();
833
+ });
834
+
835
+ expect(result.current.path).toEqual([page1]);
836
+ const hash = window.location.hash.slice(2);
837
+ const decoded = hash.split("/").map((segment) => {
838
+ const [coId, encodedName] = segment.split(":");
839
+ return {
840
+ coId,
841
+ name: encodedName ? decodeURIComponent(encodedName) : undefined,
842
+ } as PageInfo;
843
+ });
844
+ expect(decoded).toEqual([page1]);
845
+ });
846
+ });
847
+ });