@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.
- package/.nvmrc +1 -0
- package/biome.json +39 -0
- package/dist/Blocking.d.ts +23 -0
- package/dist/Blocking.js +41 -0
- package/dist/Blocking.js.map +1 -0
- package/dist/Destination.d.ts +11 -0
- package/dist/Destination.js +10 -0
- package/dist/Destination.js.map +1 -0
- package/dist/Error.d.ts +33 -0
- package/dist/Error.js +22 -0
- package/dist/Error.js.map +1 -0
- package/dist/Event.d.ts +45 -0
- package/dist/Event.js +17 -0
- package/dist/Event.js.map +1 -0
- package/dist/Forms.d.ts +79 -0
- package/dist/Forms.js +111 -0
- package/dist/Forms.js.map +1 -0
- package/dist/Handler.d.ts +6 -0
- package/dist/Handler.js +2 -0
- package/dist/Handler.js.map +1 -0
- package/dist/{dts/Layer.d.ts → Layer.d.ts} +11 -8
- package/dist/{esm/Layer.js → Layer.js} +2 -2
- package/dist/Layer.js.map +1 -0
- package/dist/NavigateOptions.d.ts +7 -0
- package/dist/NavigateOptions.js +7 -0
- package/dist/NavigateOptions.js.map +1 -0
- package/dist/Navigation.d.ts +79 -0
- package/dist/Navigation.js +49 -0
- package/dist/Navigation.js.map +1 -0
- package/dist/NavigationType.d.ts +3 -0
- package/dist/NavigationType.js +3 -0
- package/dist/NavigationType.js.map +1 -0
- package/dist/ProposedDestination.d.ts +13 -0
- package/dist/ProposedDestination.js +4 -0
- package/dist/ProposedDestination.js.map +1 -0
- package/dist/Url.d.ts +13 -0
- package/dist/Url.js +72 -0
- package/dist/Url.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/fromWindow.d.ts +4 -0
- package/dist/{esm/internal → internal}/fromWindow.js +125 -136
- package/dist/internal/fromWindow.js.map +1 -0
- package/dist/internal/memory.d.ts +6 -0
- package/dist/internal/memory.js +59 -0
- package/dist/internal/memory.js.map +1 -0
- package/dist/{dts/internal → internal}/shared.d.ts +44 -46
- package/dist/{esm/internal → internal}/shared.js +121 -168
- package/dist/internal/shared.js.map +1 -0
- package/package.json +35 -53
- package/readme.md +243 -0
- package/src/Blocking.ts +65 -65
- package/src/Destination.ts +14 -0
- package/src/Error.ts +28 -0
- package/src/Event.ts +26 -0
- package/src/Forms.ts +216 -0
- package/src/Handler.ts +16 -0
- package/src/Layer.ts +20 -9
- package/src/NavigateOptions.ts +9 -0
- package/src/Navigation.test.ts +697 -0
- package/src/Navigation.ts +135 -472
- package/src/NavigationType.ts +5 -0
- package/src/ProposedDestination.ts +8 -0
- package/src/Url.ts +106 -0
- package/src/index.ts +12 -17
- package/src/internal/fromWindow.ts +163 -234
- package/src/internal/memory.ts +62 -49
- package/src/internal/shared.ts +218 -377
- package/tsconfig.json +30 -0
- package/Blocking/package.json +0 -6
- package/LICENSE +0 -21
- package/Layer/package.json +0 -6
- package/Navigation/package.json +0 -6
- package/README.md +0 -5
- package/dist/cjs/Blocking.js +0 -58
- package/dist/cjs/Blocking.js.map +0 -1
- package/dist/cjs/Layer.js +0 -27
- package/dist/cjs/Layer.js.map +0 -1
- package/dist/cjs/Navigation.js +0 -278
- package/dist/cjs/Navigation.js.map +0 -1
- package/dist/cjs/index.js +0 -39
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/internal/fromWindow.js +0 -436
- package/dist/cjs/internal/fromWindow.js.map +0 -1
- package/dist/cjs/internal/memory.js +0 -72
- package/dist/cjs/internal/memory.js.map +0 -1
- package/dist/cjs/internal/shared.js +0 -525
- package/dist/cjs/internal/shared.js.map +0 -1
- package/dist/dts/Blocking.d.ts +0 -34
- package/dist/dts/Blocking.d.ts.map +0 -1
- package/dist/dts/Layer.d.ts.map +0 -1
- package/dist/dts/Navigation.d.ts +0 -451
- package/dist/dts/Navigation.d.ts.map +0 -1
- package/dist/dts/index.d.ts +0 -17
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/dts/internal/fromWindow.d.ts +0 -12
- package/dist/dts/internal/fromWindow.d.ts.map +0 -1
- package/dist/dts/internal/memory.d.ts +0 -6
- package/dist/dts/internal/memory.d.ts.map +0 -1
- package/dist/dts/internal/shared.d.ts.map +0 -1
- package/dist/esm/Blocking.js +0 -46
- package/dist/esm/Blocking.js.map +0 -1
- package/dist/esm/Layer.js.map +0 -1
- package/dist/esm/Navigation.js +0 -238
- package/dist/esm/Navigation.js.map +0 -1
- package/dist/esm/index.js +0 -17
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal/fromWindow.js.map +0 -1
- package/dist/esm/internal/memory.js +0 -56
- package/dist/esm/internal/memory.js.map +0 -1
- package/dist/esm/internal/shared.js.map +0 -1
- 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
|
+
}
|