@typed/navigation 0.18.0 → 0.18.1

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 (113) hide show
  1. package/.nvmrc +1 -0
  2. package/biome.json +39 -0
  3. package/dist/Blocking.d.ts +23 -0
  4. package/dist/Blocking.js +41 -0
  5. package/dist/Blocking.js.map +1 -0
  6. package/dist/Destination.d.ts +11 -0
  7. package/dist/Destination.js +10 -0
  8. package/dist/Destination.js.map +1 -0
  9. package/dist/Error.d.ts +33 -0
  10. package/dist/Error.js +22 -0
  11. package/dist/Error.js.map +1 -0
  12. package/dist/Event.d.ts +45 -0
  13. package/dist/Event.js +17 -0
  14. package/dist/Event.js.map +1 -0
  15. package/dist/Forms.d.ts +79 -0
  16. package/dist/Forms.js +111 -0
  17. package/dist/Forms.js.map +1 -0
  18. package/dist/Handler.d.ts +6 -0
  19. package/dist/Handler.js +2 -0
  20. package/dist/Handler.js.map +1 -0
  21. package/dist/{dts/Layer.d.ts → Layer.d.ts} +11 -8
  22. package/dist/{esm/Layer.js → Layer.js} +2 -2
  23. package/dist/Layer.js.map +1 -0
  24. package/dist/NavigateOptions.d.ts +7 -0
  25. package/dist/NavigateOptions.js +7 -0
  26. package/dist/NavigateOptions.js.map +1 -0
  27. package/dist/Navigation.d.ts +79 -0
  28. package/dist/Navigation.js +49 -0
  29. package/dist/Navigation.js.map +1 -0
  30. package/dist/NavigationType.d.ts +3 -0
  31. package/dist/NavigationType.js +3 -0
  32. package/dist/NavigationType.js.map +1 -0
  33. package/dist/ProposedDestination.d.ts +13 -0
  34. package/dist/ProposedDestination.js +4 -0
  35. package/dist/ProposedDestination.js.map +1 -0
  36. package/dist/Url.d.ts +13 -0
  37. package/dist/Url.js +72 -0
  38. package/dist/Url.js.map +1 -0
  39. package/dist/index.d.ts +12 -0
  40. package/dist/index.js +13 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/internal/fromWindow.d.ts +4 -0
  43. package/dist/{esm/internal → internal}/fromWindow.js +125 -136
  44. package/dist/internal/fromWindow.js.map +1 -0
  45. package/dist/internal/memory.d.ts +6 -0
  46. package/dist/internal/memory.js +59 -0
  47. package/dist/internal/memory.js.map +1 -0
  48. package/dist/{dts/internal → internal}/shared.d.ts +44 -46
  49. package/dist/{esm/internal → internal}/shared.js +121 -168
  50. package/dist/internal/shared.js.map +1 -0
  51. package/package.json +35 -53
  52. package/readme.md +243 -0
  53. package/src/Blocking.ts +65 -65
  54. package/src/Destination.ts +14 -0
  55. package/src/Error.ts +28 -0
  56. package/src/Event.ts +26 -0
  57. package/src/Forms.ts +216 -0
  58. package/src/Handler.ts +16 -0
  59. package/src/Layer.ts +20 -9
  60. package/src/NavigateOptions.ts +9 -0
  61. package/src/Navigation.test.ts +697 -0
  62. package/src/Navigation.ts +135 -472
  63. package/src/NavigationType.ts +5 -0
  64. package/src/ProposedDestination.ts +8 -0
  65. package/src/Url.ts +106 -0
  66. package/src/index.ts +12 -17
  67. package/src/internal/fromWindow.ts +163 -234
  68. package/src/internal/memory.ts +62 -49
  69. package/src/internal/shared.ts +218 -377
  70. package/tsconfig.json +30 -0
  71. package/Blocking/package.json +0 -6
  72. package/LICENSE +0 -21
  73. package/Layer/package.json +0 -6
  74. package/Navigation/package.json +0 -6
  75. package/README.md +0 -5
  76. package/dist/cjs/Blocking.js +0 -58
  77. package/dist/cjs/Blocking.js.map +0 -1
  78. package/dist/cjs/Layer.js +0 -27
  79. package/dist/cjs/Layer.js.map +0 -1
  80. package/dist/cjs/Navigation.js +0 -278
  81. package/dist/cjs/Navigation.js.map +0 -1
  82. package/dist/cjs/index.js +0 -39
  83. package/dist/cjs/index.js.map +0 -1
  84. package/dist/cjs/internal/fromWindow.js +0 -436
  85. package/dist/cjs/internal/fromWindow.js.map +0 -1
  86. package/dist/cjs/internal/memory.js +0 -72
  87. package/dist/cjs/internal/memory.js.map +0 -1
  88. package/dist/cjs/internal/shared.js +0 -525
  89. package/dist/cjs/internal/shared.js.map +0 -1
  90. package/dist/dts/Blocking.d.ts +0 -34
  91. package/dist/dts/Blocking.d.ts.map +0 -1
  92. package/dist/dts/Layer.d.ts.map +0 -1
  93. package/dist/dts/Navigation.d.ts +0 -451
  94. package/dist/dts/Navigation.d.ts.map +0 -1
  95. package/dist/dts/index.d.ts +0 -17
  96. package/dist/dts/index.d.ts.map +0 -1
  97. package/dist/dts/internal/fromWindow.d.ts +0 -12
  98. package/dist/dts/internal/fromWindow.d.ts.map +0 -1
  99. package/dist/dts/internal/memory.d.ts +0 -6
  100. package/dist/dts/internal/memory.d.ts.map +0 -1
  101. package/dist/dts/internal/shared.d.ts.map +0 -1
  102. package/dist/esm/Blocking.js +0 -46
  103. package/dist/esm/Blocking.js.map +0 -1
  104. package/dist/esm/Layer.js.map +0 -1
  105. package/dist/esm/Navigation.js +0 -238
  106. package/dist/esm/Navigation.js.map +0 -1
  107. package/dist/esm/index.js +0 -17
  108. package/dist/esm/index.js.map +0 -1
  109. package/dist/esm/internal/fromWindow.js.map +0 -1
  110. package/dist/esm/internal/memory.js +0 -56
  111. package/dist/esm/internal/memory.js.map +0 -1
  112. package/dist/esm/internal/shared.js.map +0 -1
  113. package/dist/esm/package.json +0 -4
@@ -0,0 +1,697 @@
1
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient'
2
+ import * as Headers from '@effect/platform/Headers'
3
+ import { describe, expect, it } from '@effect/vitest'
4
+ import { GetRandomValues, isUuid4, makeUuid4 } from '@typed/id'
5
+ import * as LazyRef from '@typed/lazy-ref'
6
+ import { Cause, Effect, Exit, Stream } from 'effect'
7
+ import * as Option from 'effect/Option'
8
+ import * as happyDOM from 'happy-dom'
9
+ import { deepStrictEqual, ok } from 'node:assert'
10
+ import * as Navigation from './index.js'
11
+ import type { PatchedState } from './internal/shared.js'
12
+
13
+ const equalDestination = (a: Navigation.Destination, b: Navigation.Destination) => {
14
+ const { id: _aId, ...aRest } = a
15
+ const { id: _bId, ...bRest } = b
16
+ deepStrictEqual(aRest, bRest)
17
+ }
18
+
19
+ const equalDestinations = (
20
+ a: ReadonlyArray<Navigation.Destination>,
21
+ b: ReadonlyArray<Navigation.Destination>,
22
+ ) => {
23
+ const as = a.map(({ id: _, ...rest }) => rest)
24
+ const bs = b.map(({ id: _, ...rest }) => rest)
25
+
26
+ return deepStrictEqual(as, bs)
27
+ }
28
+
29
+ const makePatchedState = (state: unknown): PatchedState => {
30
+ return {
31
+ __typed__navigation__id__: makeUuid4.pipe(
32
+ Effect.provide(GetRandomValues.CryptoRandom),
33
+ Effect.runSync,
34
+ ),
35
+ __typed__navigation__key__: makeUuid4.pipe(
36
+ Effect.provide(GetRandomValues.CryptoRandom),
37
+ Effect.runSync,
38
+ ),
39
+ __typed__navigation__state__: state,
40
+ }
41
+ }
42
+
43
+ describe(__filename, () => {
44
+ describe('Navigation', () => {
45
+ it.effect('memory', () => {
46
+ const url = new URL('https://example.com/foo/1')
47
+ const state = { x: Math.random() }
48
+ return Effect.gen(function* () {
49
+ const initial = yield* Navigation.CurrentEntry
50
+
51
+ expect(isUuid4(initial.id)).toEqual(true)
52
+ expect(isUuid4(initial.key)).toEqual(true)
53
+ expect(initial.url).toEqual(url)
54
+ expect(initial.state).toEqual(state)
55
+ expect(initial.sameDocument).toEqual(true)
56
+ expect(yield* Navigation.Entries).toEqual([initial])
57
+
58
+ const count = yield* LazyRef.of(0)
59
+
60
+ yield* Navigation.beforeNavigation(() =>
61
+ Effect.succeedSome(LazyRef.update(count, (x) => x + 10)),
62
+ )
63
+ yield* Navigation.onNavigation(() =>
64
+ Effect.succeedSome(LazyRef.update(count, (x) => x * 2)),
65
+ )
66
+
67
+ const second = yield* Navigation.navigate('/foo/2')
68
+
69
+ expect(second.url).toEqual(new URL('/foo/2', url.origin))
70
+ expect(second.state).toEqual(undefined)
71
+ expect(second.sameDocument).toEqual(true)
72
+ equalDestinations(yield* Navigation.Entries, [initial, second])
73
+
74
+ expect(yield* count).toEqual(20)
75
+
76
+ equalDestination(yield* Navigation.back(), initial)
77
+ equalDestination(yield* Navigation.forward(), second)
78
+
79
+ expect(yield* count).toEqual(140)
80
+
81
+ const third = yield* Navigation.navigate('/foo/3')
82
+
83
+ expect(third.url).toEqual(new URL('/foo/3', url.origin))
84
+ expect(third.state).toEqual(undefined)
85
+ expect(third.sameDocument).toEqual(true)
86
+ equalDestinations(yield* Navigation.Entries, [initial, second, third])
87
+
88
+ expect(yield* count).toEqual(300)
89
+
90
+ equalDestination(yield* Navigation.traverseTo(initial.key), initial)
91
+ equalDestination(yield* Navigation.forward(), second)
92
+
93
+ expect(yield* count).toEqual(1260)
94
+
95
+ // Test that the maxEntries option is respected
96
+
97
+ const fourth = yield* Navigation.navigate(new URL('/foo/4', url.origin))
98
+ const fifth = yield* Navigation.navigate(new URL('/foo/5', url.origin))
99
+ const sixth = yield* Navigation.navigate(new URL('/foo/6', url.origin))
100
+
101
+ expect(yield* Navigation.Entries).toEqual([fourth, fifth, sixth])
102
+ }).pipe(
103
+ Effect.provide(Navigation.initialMemory({ url, state, maxEntries: 3 })),
104
+ Effect.provide(GetRandomValues.CryptoRandom),
105
+ Effect.scoped,
106
+ )
107
+ })
108
+
109
+ describe('window', () => {
110
+ const url = new URL('https://example.com/foo/1')
111
+ const state = makePatchedState({
112
+ x: Math.random(),
113
+ })
114
+
115
+ it('manages navigation', async () => {
116
+ const window = makeWindow({ url: url.href }, state)
117
+ const test = Effect.gen(function* (_) {
118
+ const initial = yield* Navigation.CurrentEntry
119
+
120
+ expect(isUuid4(initial.id)).toEqual(true)
121
+ expect(isUuid4(initial.key)).toEqual(true)
122
+ expect(initial.url).toEqual(url)
123
+ expect(initial.state).toEqual(state.__typed__navigation__state__)
124
+ expect(initial.sameDocument).toEqual(true)
125
+ expect(yield* Navigation.Entries).toEqual([initial])
126
+
127
+ const count = yield* LazyRef.of(0)
128
+
129
+ yield* Navigation.beforeNavigation(() =>
130
+ Effect.succeedSome(LazyRef.update(count, (x) => x + 10)),
131
+ )
132
+ yield* Navigation.onNavigation(() =>
133
+ Effect.succeedSome(LazyRef.update(count, (x) => x * 2)),
134
+ )
135
+
136
+ const second = yield* Navigation.navigate('/foo/2')
137
+
138
+ expect(second.url).toEqual(new URL('/foo/2', url.origin))
139
+ expect(second.state).toEqual(undefined)
140
+ expect(second.sameDocument).toEqual(true)
141
+ equalDestinations(yield* Navigation.Entries, [initial, second])
142
+
143
+ expect(yield* count).toEqual(20)
144
+
145
+ equalDestination(yield* Navigation.back(), initial)
146
+ equalDestination(yield* Navigation.forward(), second)
147
+
148
+ expect(yield* count).toEqual(140)
149
+
150
+ const third = yield* Navigation.navigate('/foo/3')
151
+
152
+ expect(third.url).toEqual(new URL('/foo/3', url.origin))
153
+ expect(third.state).toEqual(undefined)
154
+ expect(third.sameDocument).toEqual(true)
155
+ equalDestinations(yield* Navigation.Entries, [initial, second, third])
156
+
157
+ expect(yield* count).toEqual(300)
158
+
159
+ equalDestination(yield* Navigation.traverseTo(initial.key), initial)
160
+ equalDestination(yield* Navigation.forward(), second)
161
+
162
+ expect(yield* count).toEqual(1260)
163
+ }).pipe(
164
+ Effect.provide(Navigation.fromWindow(window)),
165
+ Effect.provide(GetRandomValues.CryptoRandom),
166
+ Effect.scoped,
167
+ )
168
+
169
+ await Effect.runPromise(test)
170
+ })
171
+
172
+ it('manages state with History API', async () => {
173
+ const window = makeWindow({ url: url.href }, state)
174
+ const test = Effect.gen(function* (_) {
175
+ const current = yield* Navigation.CurrentEntry
176
+
177
+ // Initializes from History state when possible
178
+ deepStrictEqual(current.id, state.__typed__navigation__id__)
179
+ deepStrictEqual(current.key, state.__typed__navigation__key__)
180
+ deepStrictEqual(current.state, state.__typed__navigation__state__)
181
+ deepStrictEqual(window.history.state, state.__typed__navigation__state__)
182
+
183
+ const next = yield* Navigation.navigate('/foo/2')
184
+
185
+ deepStrictEqual(next.state, undefined)
186
+ deepStrictEqual(window.history.state, undefined)
187
+ }).pipe(
188
+ Effect.provide(Navigation.fromWindow(window)),
189
+ Effect.provide(GetRandomValues.CryptoRandom),
190
+ Effect.scoped,
191
+ )
192
+
193
+ await Effect.runPromise(test)
194
+ })
195
+
196
+ it('responds to popstate events', async () => {
197
+ const window = makeWindow({ url: url.href }, state)
198
+ const test = Effect.gen(function* (_) {
199
+ const { history, location } = window
200
+
201
+ const current = yield* Navigation.CurrentEntry
202
+
203
+ // Initializes from History state when possible
204
+ deepStrictEqual(current.id, state.__typed__navigation__id__)
205
+ deepStrictEqual(current.key, state.__typed__navigation__key__)
206
+ deepStrictEqual(current.state, state.__typed__navigation__state__)
207
+
208
+ const next = yield* Navigation.navigate('/foo/2')
209
+
210
+ deepStrictEqual(next.state, undefined)
211
+ deepStrictEqual(history.state, undefined)
212
+
213
+ // Manually change the URL
214
+ location.href = url.href
215
+
216
+ history.back()
217
+ const ev = new window.PopStateEvent('popstate', {
218
+ state,
219
+ })
220
+ Object.assign(ev, { state })
221
+ window.dispatchEvent(ev)
222
+ const popstate = yield* Navigation.CurrentEntry
223
+
224
+ deepStrictEqual(popstate.id, state.__typed__navigation__id__)
225
+ deepStrictEqual(popstate.key, state.__typed__navigation__key__)
226
+ deepStrictEqual(popstate.state, state.__typed__navigation__state__)
227
+ deepStrictEqual(history.state, state.__typed__navigation__state__)
228
+ }).pipe(
229
+ Effect.provide(Navigation.fromWindow(window)),
230
+ Effect.provide(GetRandomValues.CryptoRandom),
231
+ Effect.scoped,
232
+ )
233
+
234
+ const exit = await Effect.runPromiseExit(test)
235
+
236
+ if (Exit.isFailure(exit)) {
237
+ console.error(Cause.pretty(exit.cause))
238
+ throw exit.cause
239
+ }
240
+ })
241
+
242
+ it('responds to hashchange events', async () => {
243
+ const window = makeWindow({ url: url.href }, state)
244
+ const test = Effect.gen(function* (_) {
245
+ const { history, location } = window
246
+ const current = yield* Navigation.CurrentEntry
247
+
248
+ // Initializes from History state when possible
249
+ deepStrictEqual(current.key, state.__typed__navigation__key__)
250
+ deepStrictEqual(current.url.hash, '')
251
+
252
+ deepStrictEqual(current.state, state.__typed__navigation__state__)
253
+ deepStrictEqual(history.state, state.__typed__navigation__state__)
254
+
255
+ const hashChangeEvent = new window.HashChangeEvent('hashchange')
256
+
257
+ // We need to force hasChangeEvent to have these proeprties
258
+ Object.assign(hashChangeEvent, {
259
+ oldURL: location.href,
260
+ newURL: `${location.href}#baz`,
261
+ })
262
+
263
+ window.dispatchEvent(hashChangeEvent)
264
+
265
+ yield* Effect.sleep(1)
266
+
267
+ const hashChange = yield* Navigation.CurrentEntry
268
+
269
+ deepStrictEqual(hashChange.key, state.__typed__navigation__key__)
270
+ deepStrictEqual(hashChange.url.hash, '#baz')
271
+ deepStrictEqual(hashChange.state, state.__typed__navigation__state__)
272
+ // deepStrictEqual(history.state, {
273
+ // ...initialState,
274
+ // id: hashChange.id,
275
+ // });
276
+ }).pipe(
277
+ Effect.provide(Navigation.fromWindow(window)),
278
+ Effect.provide(GetRandomValues.CryptoRandom),
279
+ Effect.scoped,
280
+ )
281
+
282
+ await Effect.runPromise(test)
283
+ })
284
+ })
285
+
286
+ describe('beforeNavigation', () => {
287
+ const url = new URL('https://example.com/foo/1')
288
+ const state = { initial: Math.random() }
289
+ const redirectUrl = new URL('https://example.com/bar/42')
290
+ const redirect = Navigation.redirectToPath(redirectUrl)
291
+
292
+ it('allows performing redirects', async () => {
293
+ const test = Effect.gen(function* (_) {
294
+ const initial = yield* Navigation.CurrentEntry
295
+
296
+ deepStrictEqual(initial.url, url)
297
+
298
+ yield* Navigation.beforeNavigation((handler) =>
299
+ Effect.gen(function* (_) {
300
+ const current = yield* Navigation.CurrentEntry
301
+
302
+ // Runs before the URL has been committed
303
+ deepStrictEqual(current.url, handler.from.url)
304
+
305
+ return yield* handler.to.url === url ? Effect.fail(redirect) : Effect.succeedNone
306
+ }),
307
+ )
308
+
309
+ yield* Navigation.navigate(url)
310
+
311
+ const next = yield* Navigation.CurrentEntry
312
+
313
+ deepStrictEqual(next.url, redirectUrl)
314
+
315
+ // Redirects replace the current entry
316
+ deepStrictEqual(yield* Navigation.Entries, [next])
317
+ }).pipe(
318
+ Effect.provide(Navigation.initialMemory({ url, state })),
319
+ Effect.provide(GetRandomValues.CryptoRandom),
320
+ Effect.scoped,
321
+ )
322
+
323
+ await Effect.runPromise(test)
324
+ })
325
+
326
+ it('allows canceling navigation', async () => {
327
+ const test = Effect.gen(function* (_) {
328
+ const initial = yield* Navigation.CurrentEntry
329
+
330
+ deepStrictEqual(initial.url, url)
331
+
332
+ yield* Navigation.beforeNavigation((handler) =>
333
+ Effect.gen(function* (_) {
334
+ const current = yield* Navigation.CurrentEntry
335
+
336
+ // Runs before the URL has been committed
337
+ deepStrictEqual(current.url, handler.from.url)
338
+
339
+ return yield* handler.to.url === redirectUrl
340
+ ? Navigation.cancelNavigation
341
+ : Effect.succeedNone
342
+ }),
343
+ )
344
+
345
+ yield* Navigation.navigate(redirectUrl)
346
+
347
+ const next = yield* Navigation.CurrentEntry
348
+
349
+ deepStrictEqual(next.url, url)
350
+
351
+ deepStrictEqual(yield* Navigation.Entries, [initial])
352
+ }).pipe(
353
+ Effect.provide(Navigation.initialMemory({ url, state })),
354
+ Effect.provide(GetRandomValues.CryptoRandom),
355
+ Effect.scoped,
356
+ )
357
+
358
+ await Effect.runPromise(test)
359
+ })
360
+ })
361
+
362
+ describe('onNavigation', () => {
363
+ const url = new URL('https://example.com/foo/1')
364
+ const redirectUrl = new URL('https://example.com/bar/42')
365
+ const redirect = Navigation.redirectToPath(redirectUrl)
366
+ const intermmediateUrl = new URL('https://example.com/foo/2')
367
+
368
+ it('runs only after the url has been committed', async () => {
369
+ const test = Effect.gen(function* (_) {
370
+ const navigation = yield* Navigation.Navigation
371
+
372
+ let beforeCount = 0
373
+ let afterCount = 0
374
+
375
+ yield* navigation.beforeNavigation((event) =>
376
+ Effect.gen(function* (_) {
377
+ beforeCount++
378
+
379
+ if (event.to.url === intermmediateUrl) {
380
+ return yield* Effect.fail(redirect)
381
+ }
382
+
383
+ return Option.none()
384
+ }),
385
+ )
386
+
387
+ yield* navigation.onNavigation((event) =>
388
+ Effect.sync(() => {
389
+ deepStrictEqual(event.destination.url, redirectUrl)
390
+
391
+ afterCount++
392
+ return Option.none()
393
+ }),
394
+ )
395
+
396
+ yield* Navigation.navigate(intermmediateUrl)
397
+
398
+ // Called once for intermmediateUrl
399
+ // Then again for the redirectUrl
400
+ deepStrictEqual(beforeCount, 2)
401
+
402
+ // Only called once with the redirectUrl
403
+ deepStrictEqual(afterCount, 1)
404
+ }).pipe(
405
+ Effect.provide(Navigation.initialMemory({ url })),
406
+ Effect.provide(GetRandomValues.layer((length) => Effect.succeed(new Uint8Array(length)))),
407
+ Effect.scoped,
408
+ )
409
+
410
+ await Effect.runPromise(test)
411
+ })
412
+ })
413
+
414
+ describe('transition', () => {
415
+ const url = new URL('https://example.com/foo/1')
416
+ const nextUrl = new URL('https://example.com/foo/2')
417
+
418
+ it('captures any ongoing transitions', async () => {
419
+ const test = Effect.gen(function* () {
420
+ const fiber = yield* Navigation.Transition.pipe(
421
+ Stream.take(2),
422
+ Stream.runCollect,
423
+ Effect.map((_) => Array.from(_)),
424
+ Effect.forkScoped,
425
+ )
426
+
427
+ // Allow fiber to start
428
+ yield* Effect.sleep(0)
429
+
430
+ yield* Navigation.navigate(nextUrl)
431
+
432
+ const events = yield* Effect.fromFiber(fiber)
433
+
434
+ deepStrictEqual(events.length, 2)
435
+ deepStrictEqual(events[0], Option.none())
436
+ ok(Option.isSome(events[1]))
437
+ const event = events[1].value
438
+ deepStrictEqual(event.from.url, url)
439
+ deepStrictEqual(event.to.url, nextUrl)
440
+ }).pipe(
441
+ Effect.provide(Navigation.initialMemory({ url })),
442
+ Effect.provide(GetRandomValues.CryptoRandom),
443
+ Effect.scoped,
444
+ )
445
+ await Effect.runPromise(test)
446
+ })
447
+ })
448
+
449
+ describe('native navigation', () => {
450
+ const url = new URL('https://example.com/foo/1')
451
+ const state = makePatchedState({ x: Math.random() })
452
+ it('manages navigation', async () => {
453
+ const window = makeWindow({ url: url.href }, state)
454
+ const NavigationPolyfill = await import('@virtualstate/navigation')
455
+ const { navigation } = NavigationPolyfill.getCompletePolyfill({
456
+ window: window as any,
457
+ history: window.history as any,
458
+ })
459
+ ;(window as any).navigation = navigation as any
460
+ const test = Effect.gen(function* (_) {
461
+ const initial = yield* Navigation.CurrentEntry
462
+
463
+ expect(isUuid4(initial.id)).toEqual(true)
464
+ expect(isUuid4(initial.key)).toEqual(true)
465
+ expect(initial.url).toEqual(url)
466
+ expect(initial.state).toEqual(state)
467
+ expect(initial.sameDocument).toEqual(true)
468
+ expect(yield* Navigation.Entries).toEqual([initial])
469
+
470
+ const count = yield* LazyRef.of(0)
471
+
472
+ yield* Navigation.beforeNavigation(() =>
473
+ Effect.succeedSome(LazyRef.update(count, (x) => x + 10)),
474
+ )
475
+ yield* Navigation.onNavigation(() =>
476
+ Effect.succeedSome(LazyRef.update(count, (x) => x * 2)),
477
+ )
478
+
479
+ const second = yield* Navigation.navigate('/foo/2')
480
+
481
+ expect(second.url).toEqual(new URL('/foo/2', url.origin))
482
+ expect(second.state).toEqual(undefined)
483
+ expect(second.sameDocument).toEqual(true)
484
+ equalDestinations(yield* Navigation.Entries, [initial, second])
485
+
486
+ expect(yield* count).toEqual(20)
487
+
488
+ equalDestination(yield* Navigation.back(), initial)
489
+ equalDestination(yield* Navigation.forward(), second)
490
+
491
+ expect(yield* count).toEqual(140)
492
+
493
+ const third = yield* Navigation.navigate('/foo/3')
494
+
495
+ expect(third.url).toEqual(new URL('/foo/3', url.origin))
496
+ expect(third.state).toEqual(undefined)
497
+ expect(third.sameDocument).toEqual(true)
498
+ equalDestinations(yield* Navigation.Entries, [initial, second, third])
499
+
500
+ expect(yield* count).toEqual(300)
501
+
502
+ equalDestination(yield* Navigation.traverseTo(initial.key), initial)
503
+ equalDestination(yield* Navigation.forward(), second)
504
+
505
+ expect(yield* count).toEqual(1260)
506
+ }).pipe(
507
+ Effect.provide(Navigation.fromWindow(window)),
508
+ Effect.provide(GetRandomValues.CryptoRandom),
509
+ Effect.scoped,
510
+ )
511
+
512
+ await Effect.runPromise(test)
513
+ })
514
+ })
515
+ })
516
+
517
+ describe('useBlockNavigation', () => {
518
+ const url = new URL('https://example.com/foo/1')
519
+ const nextUrl = new URL('https://example.com/bar/42')
520
+
521
+ it('allows blocking the current navigation', async () => {
522
+ const test = Effect.gen(function* (_) {
523
+ const blockNavigation = yield* Navigation.useBlockNavigation()
524
+ let didBlock = false
525
+
526
+ yield* Effect.forkScoped(
527
+ blockNavigation.whenBlocked((blocking) => {
528
+ didBlock = true
529
+ return blocking.confirm
530
+ }),
531
+ )
532
+
533
+ yield* Navigation.navigate(nextUrl)
534
+
535
+ deepStrictEqual(didBlock, true)
536
+
537
+ deepStrictEqual(yield* Navigation.CurrentPath, '/bar/42')
538
+ }).pipe(
539
+ Effect.provide(Navigation.initialMemory({ url })),
540
+ Effect.provide(GetRandomValues.CryptoRandom),
541
+ Effect.scoped,
542
+ )
543
+
544
+ await Effect.runPromise(test)
545
+ })
546
+
547
+ it('allows cancelling the current navigation', async () => {
548
+ const test = Effect.gen(function* (_) {
549
+ const blockNavigation = yield* Navigation.useBlockNavigation()
550
+ let didBlock = false
551
+
552
+ yield* Effect.forkScoped(
553
+ blockNavigation.whenBlocked((blocking) => {
554
+ didBlock = true
555
+ return blocking.cancel
556
+ }),
557
+ )
558
+
559
+ yield* Navigation.navigate(nextUrl)
560
+
561
+ deepStrictEqual(didBlock, true)
562
+ deepStrictEqual(yield* Navigation.CurrentPath, '/foo/1')
563
+ }).pipe(
564
+ Effect.provide(Navigation.initialMemory({ url })),
565
+ Effect.provide(GetRandomValues.CryptoRandom),
566
+ Effect.scoped,
567
+ )
568
+
569
+ await Effect.runPromise(test)
570
+ })
571
+ })
572
+
573
+ describe('submit', () => {
574
+ describe('get', () => {
575
+ const url = new URL('https://example.com/foo/1')
576
+ const nextUrl = new URL('https://example.com/bar/42')
577
+
578
+ it.effect('intercepts redirects when submitting a form', () =>
579
+ Effect.gen(function* () {
580
+ const [destination, response] = yield* Navigation.submit({
581
+ method: 'get',
582
+ name: 'foo',
583
+ })
584
+
585
+ deepStrictEqual(destination.url, nextUrl)
586
+ deepStrictEqual(response.status, 302)
587
+ deepStrictEqual(Headers.get(response.headers, 'location'), Option.some(nextUrl.href))
588
+ }).pipe(
589
+ Effect.provide([Navigation.initialMemory({ url }), FetchHttpClient.layer]),
590
+ Effect.provide(GetRandomValues.CryptoRandom),
591
+ Effect.provideService(FetchHttpClient.Fetch, () =>
592
+ Promise.resolve(
593
+ new Response(null, { status: 302, headers: { Location: nextUrl.href } }),
594
+ ),
595
+ ),
596
+ Effect.scoped,
597
+ ),
598
+ )
599
+
600
+ it.effect('ignores non-redirects', () =>
601
+ Effect.gen(function* () {
602
+ const [destination, response] = yield* Navigation.submit({
603
+ method: 'get',
604
+ name: 'foo',
605
+ })
606
+
607
+ deepStrictEqual(destination.url, url)
608
+ deepStrictEqual(response.status, 400)
609
+ }).pipe(
610
+ Effect.provide([Navigation.initialMemory({ url }), FetchHttpClient.layer]),
611
+ Effect.provide(GetRandomValues.CryptoRandom),
612
+ Effect.provideService(FetchHttpClient.Fetch, () =>
613
+ Promise.resolve(new Response(null, { status: 400 })),
614
+ ),
615
+ Effect.scoped,
616
+ ),
617
+ )
618
+ })
619
+
620
+ describe('post', () => {
621
+ const url = new URL('https://example.com/foo/1')
622
+ const nextUrl = new URL('https://example.com/bar/42')
623
+
624
+ it.effect('intercepts redirects when submitting a form', () =>
625
+ Effect.gen(function* () {
626
+ const [destination, response] = yield* Navigation.submit({
627
+ method: 'post',
628
+ name: 'foo',
629
+ })
630
+
631
+ deepStrictEqual(destination.url, nextUrl)
632
+ deepStrictEqual(response.status, 302)
633
+ deepStrictEqual(Headers.get(response.headers, 'location'), Option.some(nextUrl.href))
634
+ }).pipe(
635
+ Effect.provide([Navigation.initialMemory({ url }), FetchHttpClient.layer]),
636
+ Effect.provide(GetRandomValues.CryptoRandom),
637
+ Effect.provideService(FetchHttpClient.Fetch, () =>
638
+ Promise.resolve(
639
+ new Response(null, { status: 302, headers: { Location: nextUrl.href } }),
640
+ ),
641
+ ),
642
+ Effect.scoped,
643
+ ),
644
+ )
645
+
646
+ it.effect('ignores non-redirects', () =>
647
+ Effect.gen(function* () {
648
+ const [destination, response] = yield* Navigation.submit({
649
+ method: 'post',
650
+ name: 'foo',
651
+ })
652
+
653
+ deepStrictEqual(destination.url, url)
654
+ deepStrictEqual(response.status, 400)
655
+ }).pipe(
656
+ Effect.provide([Navigation.initialMemory({ url }), FetchHttpClient.layer]),
657
+ Effect.provide(GetRandomValues.CryptoRandom),
658
+ Effect.provideService(FetchHttpClient.Fetch, () =>
659
+ Promise.resolve(new Response(null, { status: 400 })),
660
+ ),
661
+ Effect.scoped,
662
+ ),
663
+ )
664
+ })
665
+ })
666
+
667
+ describe('base', () => {
668
+ const url = new URL('https://example.com/foo/1')
669
+
670
+ it.effect('uses the base when navigating relative urls', () => {
671
+ const test = Effect.gen(function* (_) {
672
+ const destination = yield* Navigation.navigate('/2')
673
+ deepStrictEqual(destination.url, new URL('/foo/2', url.origin))
674
+ })
675
+
676
+ return test.pipe(
677
+ Effect.provide(Navigation.initialMemory({ url, base: '/foo' })),
678
+ Effect.provide(GetRandomValues.CryptoRandom),
679
+ Effect.scoped,
680
+ )
681
+ })
682
+ })
683
+ })
684
+
685
+ function makeWindow(
686
+ options?: ConstructorParameters<typeof happyDOM.Window>[0],
687
+ state?: PatchedState,
688
+ ) {
689
+ const window = new happyDOM.Window(options)
690
+
691
+ // If state is provided, replace the current history state
692
+ if (state !== undefined && window.history) {
693
+ window.history.replaceState(state, '', window.location.href)
694
+ }
695
+
696
+ return window as any as Window & typeof globalThis & Pick<happyDOM.Window, 'happyDOM'>
697
+ }