@typed/navigation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/DOM.d.ts +12 -0
  2. package/dist/DOM.d.ts.map +1 -0
  3. package/dist/DOM.js +89 -0
  4. package/dist/DOM.js.map +1 -0
  5. package/dist/Memory.d.ts +10 -0
  6. package/dist/Memory.d.ts.map +1 -0
  7. package/dist/Memory.js +55 -0
  8. package/dist/Memory.js.map +1 -0
  9. package/dist/Navigation.d.ts +117 -0
  10. package/dist/Navigation.d.ts.map +1 -0
  11. package/dist/Navigation.js +36 -0
  12. package/dist/Navigation.js.map +1 -0
  13. package/dist/_makeServerWindow.d.ts +16 -0
  14. package/dist/_makeServerWindow.d.ts.map +1 -0
  15. package/dist/_makeServerWindow.js +9 -0
  16. package/dist/_makeServerWindow.js.map +1 -0
  17. package/dist/cjs/DOM.d.ts +12 -0
  18. package/dist/cjs/DOM.d.ts.map +1 -0
  19. package/dist/cjs/DOM.js +118 -0
  20. package/dist/cjs/DOM.js.map +1 -0
  21. package/dist/cjs/Memory.d.ts +10 -0
  22. package/dist/cjs/Memory.d.ts.map +1 -0
  23. package/dist/cjs/Memory.js +82 -0
  24. package/dist/cjs/Memory.js.map +1 -0
  25. package/dist/cjs/Navigation.d.ts +117 -0
  26. package/dist/cjs/Navigation.d.ts.map +1 -0
  27. package/dist/cjs/Navigation.js +69 -0
  28. package/dist/cjs/Navigation.js.map +1 -0
  29. package/dist/cjs/_makeServerWindow.d.ts +16 -0
  30. package/dist/cjs/_makeServerWindow.d.ts.map +1 -0
  31. package/dist/cjs/_makeServerWindow.js +36 -0
  32. package/dist/cjs/_makeServerWindow.js.map +1 -0
  33. package/dist/cjs/constant.d.ts +2 -0
  34. package/dist/cjs/constant.d.ts.map +1 -0
  35. package/dist/cjs/constant.js +30 -0
  36. package/dist/cjs/constant.js.map +1 -0
  37. package/dist/cjs/dom-intent.d.ts +29 -0
  38. package/dist/cjs/dom-intent.d.ts.map +1 -0
  39. package/dist/cjs/dom-intent.js +173 -0
  40. package/dist/cjs/dom-intent.js.map +1 -0
  41. package/dist/cjs/history.d.ts +31 -0
  42. package/dist/cjs/history.d.ts.map +1 -0
  43. package/dist/cjs/history.js +115 -0
  44. package/dist/cjs/history.js.map +1 -0
  45. package/dist/cjs/index.d.ts +4 -0
  46. package/dist/cjs/index.d.ts.map +1 -0
  47. package/dist/cjs/index.js +20 -0
  48. package/dist/cjs/index.js.map +1 -0
  49. package/dist/cjs/json.d.ts +13 -0
  50. package/dist/cjs/json.d.ts.map +1 -0
  51. package/dist/cjs/json.js +24 -0
  52. package/dist/cjs/json.js.map +1 -0
  53. package/dist/cjs/memory-intent.d.ts +28 -0
  54. package/dist/cjs/memory-intent.d.ts.map +1 -0
  55. package/dist/cjs/memory-intent.js +149 -0
  56. package/dist/cjs/memory-intent.js.map +1 -0
  57. package/dist/cjs/model.d.ts +22 -0
  58. package/dist/cjs/model.d.ts.map +1 -0
  59. package/dist/cjs/model.js +48 -0
  60. package/dist/cjs/model.js.map +1 -0
  61. package/dist/cjs/shared-intent.d.ts +15 -0
  62. package/dist/cjs/shared-intent.d.ts.map +1 -0
  63. package/dist/cjs/shared-intent.js +82 -0
  64. package/dist/cjs/shared-intent.js.map +1 -0
  65. package/dist/cjs/storage.d.ts +19 -0
  66. package/dist/cjs/storage.d.ts.map +1 -0
  67. package/dist/cjs/storage.js +101 -0
  68. package/dist/cjs/storage.js.map +1 -0
  69. package/dist/cjs/util.d.ts +5 -0
  70. package/dist/cjs/util.d.ts.map +1 -0
  71. package/dist/cjs/util.js +39 -0
  72. package/dist/cjs/util.js.map +1 -0
  73. package/dist/constant.d.ts +2 -0
  74. package/dist/constant.d.ts.map +1 -0
  75. package/dist/constant.js +4 -0
  76. package/dist/constant.js.map +1 -0
  77. package/dist/dom-intent.d.ts +29 -0
  78. package/dist/dom-intent.d.ts.map +1 -0
  79. package/dist/dom-intent.js +141 -0
  80. package/dist/dom-intent.js.map +1 -0
  81. package/dist/history.d.ts +31 -0
  82. package/dist/history.d.ts.map +1 -0
  83. package/dist/history.js +88 -0
  84. package/dist/history.js.map +1 -0
  85. package/dist/index.d.ts +4 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +4 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/intent.d.ts +31 -0
  90. package/dist/intent.d.ts.map +1 -0
  91. package/dist/intent.js +157 -0
  92. package/dist/intent.js.map +1 -0
  93. package/dist/json.d.ts +13 -0
  94. package/dist/json.d.ts.map +1 -0
  95. package/dist/json.js +17 -0
  96. package/dist/json.js.map +1 -0
  97. package/dist/memory-intent.d.ts +28 -0
  98. package/dist/memory-intent.d.ts.map +1 -0
  99. package/dist/memory-intent.js +117 -0
  100. package/dist/memory-intent.js.map +1 -0
  101. package/dist/model.d.ts +22 -0
  102. package/dist/model.d.ts.map +1 -0
  103. package/dist/model.js +21 -0
  104. package/dist/model.js.map +1 -0
  105. package/dist/shared-intent.d.ts +15 -0
  106. package/dist/shared-intent.d.ts.map +1 -0
  107. package/dist/shared-intent.js +51 -0
  108. package/dist/shared-intent.js.map +1 -0
  109. package/dist/storage.d.ts +19 -0
  110. package/dist/storage.d.ts.map +1 -0
  111. package/dist/storage.js +73 -0
  112. package/dist/storage.js.map +1 -0
  113. package/dist/tsconfig.cjs.build.tsbuildinfo +1 -0
  114. package/dist/util.d.ts +5 -0
  115. package/dist/util.d.ts.map +1 -0
  116. package/dist/util.js +12 -0
  117. package/dist/util.js.map +1 -0
  118. package/eslintrc.json +3 -0
  119. package/package.json +38 -0
  120. package/project.json +43 -0
  121. package/src/DOM.test.ts +704 -0
  122. package/src/DOM.ts +165 -0
  123. package/src/Memory.test.ts +464 -0
  124. package/src/Memory.ts +102 -0
  125. package/src/Navigation.ts +192 -0
  126. package/src/_makeServerWindow.ts +28 -0
  127. package/src/constant.ts +5 -0
  128. package/src/dom-intent.ts +276 -0
  129. package/src/history.ts +141 -0
  130. package/src/index.ts +3 -0
  131. package/src/json.ts +31 -0
  132. package/src/memory-intent.ts +221 -0
  133. package/src/model.ts +54 -0
  134. package/src/shared-intent.ts +120 -0
  135. package/src/storage.ts +101 -0
  136. package/src/util.ts +20 -0
  137. package/tsconfig.build.json +4 -0
  138. package/tsconfig.build.tsbuildinfo +1 -0
  139. package/tsconfig.cjs.build.json +22 -0
  140. package/tsconfig.json +27 -0
  141. package/vite.config.js +3 -0
package/src/DOM.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { pipe } from '@effect/data/Function'
2
+ import * as Option from '@effect/data/Option'
3
+ import * as Cause from '@effect/io/Cause'
4
+ import * as Effect from '@effect/io/Effect'
5
+ import * as Layer from '@effect/io/Layer'
6
+ import * as Context from '@typed/context'
7
+ import { Location, History, Window, Storage, addWindowListener, Document } from '@typed/dom'
8
+ import * as Fx from '@typed/fx'
9
+
10
+ import { Destination, DestinationKey, Navigation, NavigationError } from './Navigation.js'
11
+ import { makeIntent } from './dom-intent.js'
12
+ import { onHistoryEvent, patchHistory } from './history.js'
13
+ import { NavigationEventJson } from './json.js'
14
+ import { makeModel } from './model.js'
15
+ import { getInitialValues } from './storage.js'
16
+
17
+ export type NavigationServices = Window | Document | Location | History | Storage
18
+
19
+ export interface DomNavigationOptions {
20
+ // Defaults to a random value, but you can provide your own
21
+ // Navigation keys can be provided to Navigation.navigate along the way as needed.
22
+ readonly initialKey?: DestinationKey
23
+
24
+ // Defaults to 50
25
+ // The maximum number of entries to keep in memory and storage.
26
+ readonly maxEntries?: number
27
+ }
28
+
29
+ export const dom = (
30
+ options: DomNavigationOptions = {},
31
+ ): Layer.Layer<NavigationServices, never, Navigation> => {
32
+ return Navigation.layerScoped(
33
+ Effect.gen(function* ($) {
34
+ // Get resources
35
+ const context = yield* $(Effect.context<NavigationServices>())
36
+ const history = Context.get(context, History)
37
+ const document = Context.get(context, Document)
38
+ const base = document.querySelector('base')
39
+ const baseHref = base ? getBasePathFromHref(base.href) : '/'
40
+
41
+ // Create model and intent
42
+ const [initialEntries, initialIndex] = yield* $(getInitialValues(baseHref, options))
43
+ const model = yield* $(makeModel(initialEntries, initialIndex))
44
+ const intent = makeIntent(model, baseHref, options)
45
+
46
+ // Used to ensure ordering of navigation events
47
+ const lock = Effect.unsafeMakeSemaphore(1).withPermits(1)
48
+
49
+ const handleNavigationError =
50
+ (depth: number) =>
51
+ (
52
+ error: NavigationError | Cause.NoSuchElementException,
53
+ ): Effect.Effect<never, never, Destination> =>
54
+ Effect.provideContext(
55
+ Effect.gen(function* ($) {
56
+ if (depth >= 50) {
57
+ throw new Error(
58
+ 'Too many redirects. You may have an infinite loop of onNavigation handlers that are redirecting.',
59
+ )
60
+ }
61
+
62
+ switch (error._tag) {
63
+ case 'NoSuchElementException':
64
+ case 'CancelNavigation':
65
+ return yield* $(model.currentEntry.get)
66
+ case 'RedirectNavigation':
67
+ return yield* $(
68
+ Effect.catchAll(
69
+ intent.navigate(error.url, error),
70
+ handleNavigationError(depth + 1),
71
+ ),
72
+ )
73
+ }
74
+ }),
75
+ context,
76
+ )
77
+
78
+ const catchNavigationError = <R, A>(
79
+ effect: Effect.Effect<R, NavigationError | Cause.NoSuchElementException, A>,
80
+ ) => Effect.catchAll(effect, handleNavigationError(0))
81
+
82
+ // Used to provide a locked effect with the current context
83
+ const provideLocked = <E, A>(effect: Effect.Effect<NavigationServices, E, A>) =>
84
+ Effect.provideContext(lock(effect), context)
85
+
86
+ // Constructor our service
87
+ const navigation: Navigation = {
88
+ back: provideLocked(catchNavigationError(intent.back(false))),
89
+ base: baseHref,
90
+ canGoBack: model.canGoBack,
91
+ canGoForward: model.canGoForward,
92
+ currentEntry: model.currentEntry,
93
+ entries: model.entries,
94
+ forward: provideLocked(catchNavigationError(intent.forward(false))),
95
+ goTo: (a) =>
96
+ pipe(
97
+ a,
98
+ intent.goTo,
99
+ Effect.catchAll((a) => pipe(a, handleNavigationError(0), Effect.map(Option.some))),
100
+ provideLocked,
101
+ ),
102
+ navigate: (url, options) => pipe(intent.navigate(url, options), catchNavigationError, provideLocked),
103
+ onNavigation: (handler, options) =>
104
+ pipe(intent.onNavigation(handler, options), catchNavigationError, Effect.asUnit),
105
+ onNavigationEnd: (handler, options) =>
106
+ Effect.asUnit(intent.onNavigationEnd(handler, options)),
107
+ reload: provideLocked(catchNavigationError(intent.reload)),
108
+ }
109
+
110
+ // Patch History API to enable sending events
111
+ const historyEvents = yield* $(patchHistory)
112
+
113
+ // Listen to various events and update our model
114
+ yield* $(
115
+ Fx.mergeAll(
116
+ // Listen to history events and keep track of entries
117
+ pipe(
118
+ historyEvents,
119
+ Fx.flatMapEffect((event) => lock(onHistoryEvent(event, intent))),
120
+ ),
121
+ // Listen to hash changes and push them to the history
122
+ pipe(
123
+ addWindowListener('hashchange', { capture: true }),
124
+ Fx.flatMapEffect((ev) => lock(intent.push(ev.newURL, { state: history.state }, true))),
125
+ ),
126
+ // Listen to popstate events and go to the correct entry
127
+ pipe(
128
+ addWindowListener('popstate'),
129
+ Fx.map((ev) => {
130
+ // TODO: Should we throw some kind of error here?
131
+ // This should never happen if you are solely using the Navigation Service
132
+ if (!ev.state || !ev.state.event) {
133
+ return Option.none()
134
+ }
135
+
136
+ const navigation = ev.state.event as NavigationEventJson
137
+
138
+ return Option.some(lock(intent.goTo(navigation.destination.key)))
139
+ }),
140
+ Fx.compact,
141
+ Fx.flattenEffect,
142
+ ),
143
+ ),
144
+ Fx.drain,
145
+ Effect.forkScoped,
146
+ )
147
+
148
+ return navigation
149
+ }),
150
+ )
151
+ }
152
+
153
+ export function getBasePathFromHref(href: string) {
154
+ try {
155
+ const url = new URL(href)
156
+
157
+ return getCurrentPathFromLocation(url)
158
+ } catch {
159
+ return href
160
+ }
161
+ }
162
+
163
+ export function getCurrentPathFromLocation(location: Location | HTMLAnchorElement | URL) {
164
+ return location.pathname + location.search + location.hash
165
+ }
@@ -0,0 +1,464 @@
1
+ import { deepStrictEqual, ok } from 'assert'
2
+
3
+ import * as Duration from '@effect/data/Duration'
4
+ import * as Option from '@effect/data/Option'
5
+ import * as Effect from '@effect/io/Effect'
6
+ import * as Fiber from '@effect/io/Fiber'
7
+ import * as Fx from '@typed/fx'
8
+ import { describe, it } from 'vitest'
9
+
10
+ import { MemoryNavigationOptions, memory } from './Memory.js'
11
+ import {
12
+ Destination,
13
+ DestinationKey,
14
+ Navigation,
15
+ NavigationType,
16
+ cancelNavigation,
17
+ redirect,
18
+ } from './Navigation.js'
19
+
20
+ const provide = <R, E, A>(effect: Effect.Effect<R, E, A>, options: MemoryNavigationOptions) =>
21
+ Effect.provideSomeLayer(memory(options))(effect)
22
+
23
+ const testKey = DestinationKey('keys-are-random-and-not-tested-by-default-assertions')
24
+ const testUrl = 'https://example.com'
25
+ const testDestination = Destination(DestinationKey('default'), new URL(testUrl))
26
+ const testPathname1 = `${testUrl}/1`
27
+ const testPathname1Destination = Destination(testKey, new URL(testPathname1))
28
+ const testPathname2 = `${testUrl}/2`
29
+ const testPathname2Destination = Destination(testKey, new URL(testPathname2))
30
+
31
+ const testNavigation = <Y extends Effect.EffectGen<any, any, any>, A>(
32
+ f: (adapter: Effect.Adapter, navigation: Navigation) => Generator<Y, A, any>,
33
+ options: MemoryNavigationOptions = { initialUrl: new URL(testUrl) },
34
+ ) =>
35
+ Effect.scoped(
36
+ provide(
37
+ Effect.tapErrorCause(
38
+ Effect.gen(function* ($) {
39
+ const navigation = yield* $(Navigation)
40
+ const result = yield* f($, navigation)
41
+
42
+ return result
43
+ }),
44
+ Effect.logError,
45
+ ),
46
+ options,
47
+ ),
48
+ )
49
+
50
+ const fxToFiber = <R, E, A>(fx: Fx.Fx<R, E, A>, take: number) =>
51
+ Effect.gen(function* ($) {
52
+ const fiber = yield* $(fx, Fx.take(take), Fx.toReadonlyArray, Effect.forkScoped)
53
+
54
+ yield* $(Effect.sleep(Duration.millis(0)))
55
+ yield* $(Effect.sleep(Duration.millis(0)))
56
+
57
+ return fiber
58
+ })
59
+
60
+ const assertEqualDestination: (a: Destination, b: Destination) => void = (a, b) => {
61
+ deepStrictEqual(
62
+ a.url.href,
63
+ b.url.href,
64
+ 'Urls should match. Actual: ' + a.url.href + ' Expected: ' + b.url.href,
65
+ )
66
+ deepStrictEqual(
67
+ a.state,
68
+ b.state,
69
+ 'State should match. Actual: ' +
70
+ JSON.stringify(a.state) +
71
+ ' Expeced: ' +
72
+ JSON.stringify(b.state),
73
+ )
74
+ }
75
+
76
+ const assertSomeDestination = (a: Option.Option<Destination>, b: Destination) => {
77
+ ok(Option.isSome(a))
78
+ assertEqualDestination(a.value, b)
79
+ }
80
+
81
+ const assertEqualDestinations: (a: readonly Destination[], b: readonly Destination[]) => void = (
82
+ a,
83
+ b,
84
+ ) => {
85
+ deepStrictEqual(a.length, b.length, 'Destinations should have the same length')
86
+
87
+ for (let i = 0; i < a.length; ++i) {
88
+ assertEqualDestination(a[i], b[i])
89
+ }
90
+ }
91
+
92
+ describe(import.meta.url, () => {
93
+ describe('entries', () => {
94
+ it('returns the initial entry immediately', async () => {
95
+ const test = testNavigation(function* ($, { entries }) {
96
+ const initial = yield* $(entries)
97
+
98
+ assertEqualDestinations(initial, [testDestination])
99
+ })
100
+
101
+ await Effect.runPromise(test)
102
+ })
103
+
104
+ it('is observerable', async () => {
105
+ const test = testNavigation(function* ($, { entries, navigate }) {
106
+ const fiber = yield* $(fxToFiber(entries, 2))
107
+
108
+ yield* $(navigate(testPathname1))
109
+
110
+ const results = yield* $(Fiber.join(fiber))
111
+
112
+ deepStrictEqual(results.length, 2)
113
+
114
+ assertEqualDestinations(results[0], [testDestination])
115
+ assertEqualDestinations(results[1], [testDestination, testPathname1Destination])
116
+ })
117
+
118
+ await Effect.runPromise(test)
119
+ })
120
+ })
121
+
122
+ describe('currentEntry', () => {
123
+ it('returns the initial entry immediately', async () => {
124
+ const test = testNavigation(function* ($, { currentEntry }) {
125
+ const initial = yield* $(currentEntry)
126
+
127
+ assertEqualDestination(initial, testDestination)
128
+ })
129
+
130
+ await Effect.runPromise(test)
131
+ })
132
+
133
+ it('is observerable', async () => {
134
+ const test = testNavigation(function* ($, { currentEntry, navigate }) {
135
+ const fiber = yield* $(fxToFiber(currentEntry, 3))
136
+
137
+ yield* $(navigate(testPathname1))
138
+ yield* $(navigate(testPathname2))
139
+
140
+ const results = yield* $(Fiber.join(fiber))
141
+
142
+ assertEqualDestinations(results, [
143
+ testDestination,
144
+ testPathname1Destination,
145
+ testPathname2Destination,
146
+ ])
147
+ })
148
+
149
+ await Effect.runPromise(test)
150
+ })
151
+ })
152
+
153
+ describe('navigate', () => {
154
+ it('navigates to a new url', async () => {
155
+ const test = testNavigation(function* ($, { currentEntry, navigate }) {
156
+ const initial = yield* $(currentEntry)
157
+
158
+ assertEqualDestination(initial, testDestination)
159
+
160
+ const destination = yield* $(navigate(testPathname1))
161
+
162
+ assertEqualDestination(destination, testPathname1Destination)
163
+ })
164
+
165
+ await Effect.runPromise(test)
166
+ })
167
+
168
+ it('sets state when provided', async () => {
169
+ const test = testNavigation(function* ($, { navigate }) {
170
+ const destination = yield* $(navigate(testPathname1, { state: testKey }))
171
+
172
+ assertEqualDestination(destination, Destination(testKey, new URL(testPathname1), testKey))
173
+ })
174
+
175
+ await Effect.runPromise(test)
176
+ })
177
+
178
+ it('allows replacing the current entry', async () => {
179
+ const test = testNavigation(function* ($, { entries, navigate }) {
180
+ assertEqualDestinations(yield* $(entries), [testDestination])
181
+
182
+ const destination = yield* $(navigate(testPathname1, { history: 'replace' }))
183
+
184
+ assertEqualDestinations(yield* $(entries), [destination])
185
+ })
186
+
187
+ await Effect.runPromise(test)
188
+ })
189
+ })
190
+
191
+ describe('onNavigation', () => {
192
+ it('allows subscribing to navigation events', async () => {
193
+ const test = testNavigation(function* ($, { navigate, onNavigation }) {
194
+ let i = 0
195
+ yield* $(
196
+ onNavigation((event) => {
197
+ if (i === 1) {
198
+ deepStrictEqual(event.navigationType, NavigationType.Push)
199
+ assertEqualDestination(event.destination, testPathname1Destination)
200
+ }
201
+
202
+ if (i === 2) {
203
+ deepStrictEqual(event.navigationType, NavigationType.Push)
204
+ assertEqualDestination(event.destination, testPathname2Destination)
205
+ }
206
+
207
+ i++
208
+
209
+ return Effect.unit
210
+ }),
211
+ )
212
+
213
+ yield* $(navigate(testPathname1))
214
+ yield* $(navigate(testPathname2))
215
+
216
+ deepStrictEqual(i, 3)
217
+ })
218
+
219
+ await Effect.runPromise(test)
220
+ })
221
+
222
+ it('allows canceling the requested navigation', async () => {
223
+ const test = testNavigation(function* ($, { navigate, onNavigation }) {
224
+ yield* $(onNavigation(() => cancelNavigation))
225
+
226
+ const destination = yield* $(navigate(testPathname1))
227
+
228
+ assertEqualDestination(destination, testDestination)
229
+ })
230
+
231
+ await Effect.runPromise(test)
232
+ })
233
+
234
+ it('allow redirection to a different url', async () => {
235
+ const test = testNavigation(function* ($, { navigate, onNavigation }) {
236
+ yield* $(
237
+ onNavigation(({ destination }) =>
238
+ destination.url.href === testPathname1 ? redirect(testPathname2) : Effect.unit,
239
+ ),
240
+ )
241
+
242
+ const destination = yield* $(navigate(testPathname1))
243
+
244
+ assertEqualDestination(destination, testPathname2Destination)
245
+ })
246
+
247
+ await Effect.runPromise(test)
248
+ })
249
+ })
250
+
251
+ describe('canGoBack', () => {
252
+ it('returns false when on the first entry', async () => {
253
+ const test = testNavigation(function* ($, { canGoBack }) {
254
+ deepStrictEqual(yield* $(canGoBack), false)
255
+ })
256
+
257
+ await Effect.runPromise(test)
258
+ })
259
+
260
+ it('returns true when there are entries to go back to', async () => {
261
+ const test = testNavigation(function* ($, { canGoBack, navigate }) {
262
+ yield* $(navigate(testPathname1))
263
+
264
+ deepStrictEqual(yield* $(canGoBack), true)
265
+ })
266
+
267
+ await Effect.runPromise(test)
268
+ })
269
+
270
+ it('is observable', async () => {
271
+ const test = testNavigation(function* ($, { canGoBack, navigate }) {
272
+ const fiber = yield* $(fxToFiber(canGoBack, 2))
273
+
274
+ yield* $(navigate(testPathname1))
275
+ yield* $(navigate(testPathname2))
276
+
277
+ const results = yield* $(Fiber.join(fiber))
278
+
279
+ deepStrictEqual(results, [false, true])
280
+ })
281
+
282
+ await Effect.runPromise(test)
283
+ })
284
+ })
285
+
286
+ describe('back', () => {
287
+ it('does nothing when there are no entries to go back to', async () => {
288
+ const test = testNavigation(function* ($, { back }) {
289
+ assertEqualDestination(yield* $(back), testDestination)
290
+ })
291
+
292
+ await Effect.runPromise(test)
293
+ })
294
+
295
+ it('goes back to the previous entry', async () => {
296
+ const test = testNavigation(function* ($, { back, navigate }) {
297
+ yield* $(navigate(testPathname1))
298
+
299
+ assertEqualDestination(yield* $(back), testDestination)
300
+ })
301
+
302
+ await Effect.runPromise(test)
303
+ })
304
+ })
305
+
306
+ describe('canGoForward', () => {
307
+ it('returns false when on the last entry', async () => {
308
+ const test = testNavigation(function* ($, { canGoForward }) {
309
+ deepStrictEqual(yield* $(canGoForward), false)
310
+ })
311
+
312
+ await Effect.runPromise(test)
313
+ })
314
+
315
+ it('returns true when there are entries to go forward to', async () => {
316
+ const test = testNavigation(function* ($, { canGoForward, back, navigate }) {
317
+ yield* $(navigate(testPathname1))
318
+ yield* $(navigate(testPathname2))
319
+
320
+ deepStrictEqual(yield* $(canGoForward), false)
321
+
322
+ yield* $(back)
323
+
324
+ deepStrictEqual(yield* $(canGoForward), true)
325
+ })
326
+
327
+ await Effect.runPromise(test)
328
+ })
329
+
330
+ it('is observable', async () => {
331
+ const test = testNavigation(function* ($, { canGoForward, back, navigate }) {
332
+ const fiber = yield* $(fxToFiber(canGoForward, 2))
333
+
334
+ yield* $(navigate(testPathname1))
335
+ yield* $(back)
336
+
337
+ const results = yield* $(Fiber.join(fiber))
338
+
339
+ deepStrictEqual(results, [false, true]) // Duplication between first and second entry is skipped
340
+ })
341
+
342
+ await Effect.runPromise(test)
343
+ })
344
+ })
345
+
346
+ describe('forward', () => {
347
+ it('does nothing when there are no entries to go forward to', async () => {
348
+ const test = testNavigation(function* ($, { forward }) {
349
+ assertEqualDestination(yield* $(forward), testDestination)
350
+ })
351
+
352
+ await Effect.runPromise(test)
353
+ })
354
+
355
+ it('goes forward to the next entry', async () => {
356
+ const test = testNavigation(function* ($, { forward, back, navigate }) {
357
+ yield* $(navigate(testPathname1))
358
+ yield* $(navigate(testPathname2))
359
+
360
+ assertEqualDestination(yield* $(back), testPathname1Destination)
361
+ assertEqualDestination(yield* $(forward), testPathname2Destination)
362
+ })
363
+
364
+ await Effect.runPromise(test)
365
+ })
366
+ })
367
+
368
+ describe('reload', () => {
369
+ it('reloads the current entry', async () => {
370
+ const test = testNavigation(function* ($, { reload, navigate }) {
371
+ yield* $(navigate(testPathname1))
372
+
373
+ assertEqualDestination(yield* $(reload), testPathname1Destination)
374
+ })
375
+
376
+ await Effect.runPromise(test)
377
+ })
378
+
379
+ it('sends a reload event to subscribers', async () => {
380
+ const test = testNavigation(function* ($, { reload, navigate, onNavigation }) {
381
+ let i = 0
382
+ yield* $(
383
+ onNavigation((event) => {
384
+ if (i === 1) {
385
+ deepStrictEqual(event.navigationType, NavigationType.Push)
386
+ assertEqualDestination(event.destination, testPathname1Destination)
387
+ }
388
+
389
+ if (i === 2) {
390
+ deepStrictEqual(event.navigationType, NavigationType.Reload)
391
+ assertEqualDestination(event.destination, testPathname1Destination)
392
+ }
393
+
394
+ i++
395
+
396
+ return Effect.unit
397
+ }),
398
+ )
399
+
400
+ yield* $(navigate(testPathname1))
401
+ yield* $(reload)
402
+
403
+ deepStrictEqual(i, 3)
404
+ })
405
+
406
+ await Effect.runPromise(test)
407
+ })
408
+ })
409
+
410
+ describe('goTo', () => {
411
+ it('goes to a specific entry', async () => {
412
+ const test = testNavigation(function* ($, { currentEntry, goTo, navigate }) {
413
+ const d0 = yield* $(currentEntry)
414
+ const d1 = yield* $(navigate(testPathname1))
415
+ const d2 = yield* $(navigate(testPathname2))
416
+
417
+ assertSomeDestination(yield* $(goTo(d0.key)), testDestination)
418
+ assertSomeDestination(yield* $(goTo(d1.key)), testPathname1Destination)
419
+ assertSomeDestination(yield* $(goTo(d2.key)), testPathname2Destination)
420
+ })
421
+
422
+ await Effect.runPromise(test)
423
+ })
424
+
425
+ it('returns None when the entry does not exist', async () => {
426
+ const test = testNavigation(function* ($, { goTo }) {
427
+ deepStrictEqual(yield* $(goTo(DestinationKey('does-not-exist'))), Option.none())
428
+ })
429
+
430
+ await Effect.runPromise(test)
431
+ })
432
+ })
433
+
434
+ describe('maxEntries', () => {
435
+ it('allows configuring how many entries are stored', async () => {
436
+ const test = testNavigation(
437
+ function* ($, { currentEntry, entries, navigate }) {
438
+ const fiber = yield* $(fxToFiber(currentEntry, 3))
439
+
440
+ yield* $(navigate(testPathname1))
441
+ yield* $(navigate(testPathname2))
442
+
443
+ const results = yield* $(Fiber.join(fiber))
444
+
445
+ assertEqualDestinations(results, [
446
+ testDestination,
447
+ testPathname1Destination,
448
+ testPathname2Destination,
449
+ ])
450
+
451
+ const entriesAfterNavigation = yield* $(entries)
452
+
453
+ assertEqualDestinations(entriesAfterNavigation, [
454
+ testPathname1Destination,
455
+ testPathname2Destination,
456
+ ])
457
+ },
458
+ { initialUrl: new URL(testUrl), maxEntries: 2 },
459
+ )
460
+
461
+ await Effect.runPromise(test)
462
+ })
463
+ })
464
+ })