@typed/router 1.0.0-beta.1 → 1.0.0-beta.3
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/dist/Matcher.d.ts +18 -13
- package/dist/Matcher.d.ts.map +1 -1
- package/dist/Matcher.js +102 -97
- package/dist/MatcherV2.d.ts +3 -0
- package/dist/MatcherV2.d.ts.map +1 -0
- package/dist/MatcherV2.js +1 -0
- package/dist/Route.d.ts +5 -0
- package/dist/Route.d.ts.map +1 -1
- package/dist/test-utils/matcherBrowserHarness.d.ts +10 -0
- package/dist/test-utils/matcherBrowserHarness.d.ts.map +1 -0
- package/dist/test-utils/matcherBrowserHarness.js +13 -0
- package/package.json +21 -18
- package/src/Matcher.browser.test.ts +771 -0
- package/src/Matcher.test.ts +344 -67
- package/src/Matcher.ts +165 -132
- package/src/Route.ts +6 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--back---restores-previous-match-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--catchTag-RouteNotFound-can-navigate-and-re-match--browser-history--1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--decodes-query-params-from-pathname-search-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--function-Matcher-catch--and-composition--browser--instance-catchTag-does-not-catch-RouteGuardError-from-guards-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--history-back-restores-previous-match--popstate-sync--1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--path-and-query-params-both-decode--distinct-names--1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--prefix-scopes-routes-under-a-path-segment-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--provideService-supplies-a-service-to-handlers-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--query-param-wins-over-path-param-when-names-collide-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-away-from-unmatched-path--side-effect--1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-then-matches-target-route-1.png +0 -0
- package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--reuses-shared-layers-and-layouts-across-route-changes-1.png +0 -0
- package/src/test-utils/matcherBrowserHarness.ts +22 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { assert, describe, it } from "vitest";
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Data from "effect/Data";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Exit from "effect/Exit";
|
|
6
|
+
import * as Fiber from "effect/Fiber";
|
|
7
|
+
import * as Latch from "effect/Latch";
|
|
8
|
+
import * as Layer from "effect/Layer";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import * as Ref from "effect/Ref";
|
|
11
|
+
import * as Result from "effect/Result";
|
|
12
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
13
|
+
import * as Stream from "effect/Stream";
|
|
14
|
+
import { Fx } from "@typed/fx";
|
|
15
|
+
import type { RefSubject } from "@typed/fx/RefSubject";
|
|
16
|
+
import { Navigation } from "@typed/navigation";
|
|
17
|
+
import * as Matcher from "./Matcher.js";
|
|
18
|
+
import * as Route from "./Route.js";
|
|
19
|
+
import { absoluteUrl, runWithBrowserRouter } from "./test-utils/matcherBrowserHarness.js";
|
|
20
|
+
import { pipe } from "effect";
|
|
21
|
+
|
|
22
|
+
class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {}
|
|
23
|
+
|
|
24
|
+
class OtherError extends Data.TaggedError("OtherError")<{ readonly message: string }> {}
|
|
25
|
+
|
|
26
|
+
describe("typed/router/Matcher (browser)", () => {
|
|
27
|
+
it("match with { handler, layout } composes under BrowserRouter", () =>
|
|
28
|
+
runWithBrowserRouter(
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
yield* Navigation.navigate(absoluteUrl("/dashboard"));
|
|
31
|
+
const route = Route.Parse("dashboard");
|
|
32
|
+
const matcher = Matcher.empty.match(route, {
|
|
33
|
+
handler: Fx.succeed(42),
|
|
34
|
+
layout: ({ content }) => Fx.map(content, (n) => `wrapped:${n}`),
|
|
35
|
+
});
|
|
36
|
+
const values = yield* Fx.collectAll(Fx.take(matcher, 1));
|
|
37
|
+
assert.deepStrictEqual(values, ["wrapped:42"]);
|
|
38
|
+
}),
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
it("emits as path changes via Navigation (push)", () =>
|
|
42
|
+
runWithBrowserRouter(
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
45
|
+
|
|
46
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
47
|
+
const about = Route.Parse("about");
|
|
48
|
+
const fx = Matcher.empty
|
|
49
|
+
.match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
|
|
50
|
+
.match(about, "about");
|
|
51
|
+
const values: Array<string> = [];
|
|
52
|
+
const first = Latch.makeUnsafe();
|
|
53
|
+
const done = Latch.makeUnsafe();
|
|
54
|
+
const fiber = yield* Effect.forkChild(
|
|
55
|
+
Fx.observe(fx, (value) =>
|
|
56
|
+
Effect.sync(() => {
|
|
57
|
+
values.push(value);
|
|
58
|
+
}).pipe(
|
|
59
|
+
Effect.flatMap(() => {
|
|
60
|
+
if (values.length === 1) return first.open;
|
|
61
|
+
if (values.length === 2) return done.open;
|
|
62
|
+
return Effect.void;
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
yield* Effect.yieldNow;
|
|
68
|
+
yield* first.await;
|
|
69
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
70
|
+
yield* done.await;
|
|
71
|
+
yield* Fiber.interrupt(fiber);
|
|
72
|
+
assert.deepStrictEqual(values, ["users:1", "about"]);
|
|
73
|
+
}),
|
|
74
|
+
));
|
|
75
|
+
|
|
76
|
+
it("replace history updates CurrentPath", () =>
|
|
77
|
+
runWithBrowserRouter(
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
yield* Navigation.navigate(absoluteUrl("/a"));
|
|
80
|
+
const a = Route.Parse("a");
|
|
81
|
+
const b = Route.Parse("b");
|
|
82
|
+
const fx = Matcher.empty.match(a, "a").match(b, "b");
|
|
83
|
+
const values: Array<string> = [];
|
|
84
|
+
const step1 = Latch.makeUnsafe();
|
|
85
|
+
const step2 = Latch.makeUnsafe();
|
|
86
|
+
const fiber = yield* Effect.forkChild(
|
|
87
|
+
Fx.observe(fx, (value) =>
|
|
88
|
+
Effect.sync(() => values.push(value as string)).pipe(
|
|
89
|
+
Effect.flatMap(() => {
|
|
90
|
+
if (values.length === 1) return step1.open;
|
|
91
|
+
if (values.length === 2) return step2.open;
|
|
92
|
+
return Effect.void;
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
yield* Effect.yieldNow;
|
|
98
|
+
yield* step1.await;
|
|
99
|
+
yield* Navigation.navigate(absoluteUrl("/b"), { history: "replace" });
|
|
100
|
+
yield* step2.await;
|
|
101
|
+
yield* Fiber.interrupt(fiber);
|
|
102
|
+
assert.deepStrictEqual(values, ["a", "b"]);
|
|
103
|
+
}),
|
|
104
|
+
));
|
|
105
|
+
|
|
106
|
+
it("navigating back to a prior path re-matches (explicit navigate)", () =>
|
|
107
|
+
runWithBrowserRouter(
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
yield* Navigation.navigate(absoluteUrl("/one"));
|
|
110
|
+
const one = Route.Parse("one");
|
|
111
|
+
const two = Route.Parse("two");
|
|
112
|
+
const fx = Matcher.empty.match(one, "1").match(two, "2");
|
|
113
|
+
const values: Array<string> = [];
|
|
114
|
+
const l1 = Latch.makeUnsafe();
|
|
115
|
+
const l2 = Latch.makeUnsafe();
|
|
116
|
+
const l3 = Latch.makeUnsafe();
|
|
117
|
+
const fiber = yield* Effect.forkChild(
|
|
118
|
+
Fx.observe(fx, (value) =>
|
|
119
|
+
Effect.sync(() => values.push(value as string)).pipe(
|
|
120
|
+
Effect.flatMap(() => {
|
|
121
|
+
if (values.length === 1) return l1.open;
|
|
122
|
+
if (values.length === 2) return l2.open;
|
|
123
|
+
if (values.length === 3) return l3.open;
|
|
124
|
+
return Effect.void;
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
yield* Effect.yieldNow;
|
|
130
|
+
yield* l1.await;
|
|
131
|
+
yield* Navigation.navigate(absoluteUrl("/two"));
|
|
132
|
+
yield* l2.await;
|
|
133
|
+
yield* Navigation.navigate(absoluteUrl("/one"));
|
|
134
|
+
yield* l3.await;
|
|
135
|
+
yield* Fiber.interrupt(fiber);
|
|
136
|
+
assert.deepStrictEqual(values, ["1", "2", "1"]);
|
|
137
|
+
}),
|
|
138
|
+
));
|
|
139
|
+
|
|
140
|
+
it("RouteNotFound includes path from CurrentPath", () =>
|
|
141
|
+
runWithBrowserRouter(
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
yield* Navigation.navigate(absoluteUrl("/nope"));
|
|
144
|
+
const route = Route.Parse("about");
|
|
145
|
+
const fx = Matcher.empty.match(route, "about");
|
|
146
|
+
const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
|
|
147
|
+
Effect.as("matched" as const),
|
|
148
|
+
Effect.catchTag("RouteNotFound", (e) => Effect.succeed(e.path)),
|
|
149
|
+
);
|
|
150
|
+
assert.strictEqual(result, "/nope");
|
|
151
|
+
}),
|
|
152
|
+
));
|
|
153
|
+
|
|
154
|
+
it("updates params without re-running the handler for the same route", () =>
|
|
155
|
+
runWithBrowserRouter(
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
158
|
+
const mounts = yield* Ref.make(0);
|
|
159
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
160
|
+
const matcher = Matcher.empty.match(users, (params) =>
|
|
161
|
+
Fx.unwrap(
|
|
162
|
+
Ref.update(mounts, (n) => n + 1).pipe(Effect.as(Fx.map(params, ({ id }) => id))),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const fx = matcher;
|
|
166
|
+
const values: Array<string> = [];
|
|
167
|
+
const first = Latch.makeUnsafe();
|
|
168
|
+
const done = Latch.makeUnsafe();
|
|
169
|
+
const fiber = yield* Effect.forkChild(
|
|
170
|
+
Fx.observe(fx, (value) =>
|
|
171
|
+
Effect.sync(() => values.push(value)).pipe(
|
|
172
|
+
Effect.flatMap(() => {
|
|
173
|
+
if (values.length === 1) return first.open;
|
|
174
|
+
if (values.length === 2) return done.open;
|
|
175
|
+
return Effect.void;
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
yield* Effect.yieldNow;
|
|
181
|
+
yield* first.await;
|
|
182
|
+
yield* Navigation.navigate(absoluteUrl("/users/2"));
|
|
183
|
+
yield* done.await;
|
|
184
|
+
yield* Fiber.interrupt(fiber);
|
|
185
|
+
assert.deepStrictEqual(values, ["1", "2"]);
|
|
186
|
+
assert.strictEqual(yield* Ref.get(mounts), 1);
|
|
187
|
+
}),
|
|
188
|
+
));
|
|
189
|
+
|
|
190
|
+
it("runs guards in order and uses the guard output", () =>
|
|
191
|
+
runWithBrowserRouter(
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
194
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
195
|
+
const calls = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
196
|
+
const fx = Matcher.empty
|
|
197
|
+
.match(
|
|
198
|
+
users,
|
|
199
|
+
() => Ref.update(calls, (entries) => [...entries, "g1"]).pipe(Effect.as(Option.none())),
|
|
200
|
+
"skip",
|
|
201
|
+
)
|
|
202
|
+
.match(
|
|
203
|
+
users,
|
|
204
|
+
(input) =>
|
|
205
|
+
Ref.update(calls, (entries) => [...entries, "g2"]).pipe(
|
|
206
|
+
Effect.as(Option.some({ ...input, ok: true as const })),
|
|
207
|
+
),
|
|
208
|
+
(params) => Fx.map(params, (p) => p.ok),
|
|
209
|
+
);
|
|
210
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
211
|
+
assert.deepStrictEqual(values, [true]);
|
|
212
|
+
assert.deepStrictEqual(yield* Ref.get(calls), ["g1", "g2"]);
|
|
213
|
+
}),
|
|
214
|
+
));
|
|
215
|
+
|
|
216
|
+
it("accumulates guard failures when no guard matches", () =>
|
|
217
|
+
runWithBrowserRouter(
|
|
218
|
+
Effect.gen(function* () {
|
|
219
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
220
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
221
|
+
const fx = Matcher.empty
|
|
222
|
+
.match(users, () => Effect.fail("g1"), "ok")
|
|
223
|
+
.match(users, () => Effect.fail("g2"), "ok");
|
|
224
|
+
const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
|
|
225
|
+
Effect.as(0),
|
|
226
|
+
Effect.catchTag("RouteGuardError", (e) => Effect.succeed(e.causes.length)),
|
|
227
|
+
);
|
|
228
|
+
assert.strictEqual(result, 2);
|
|
229
|
+
}),
|
|
230
|
+
));
|
|
231
|
+
|
|
232
|
+
it("reuses shared layers and layouts across route changes", () =>
|
|
233
|
+
runWithBrowserRouter(
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
236
|
+
const mounts = yield* Ref.make(0);
|
|
237
|
+
const layouts = yield* Ref.make(0);
|
|
238
|
+
const sharedLayer = Layer.effectServices(
|
|
239
|
+
Ref.update(mounts, (n) => n + 1).pipe(Effect.as(ServiceMap.empty())),
|
|
240
|
+
);
|
|
241
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
242
|
+
const about = Route.Parse("about");
|
|
243
|
+
const fx = Matcher.empty
|
|
244
|
+
.match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
|
|
245
|
+
.match(about, "about")
|
|
246
|
+
.provide(sharedLayer)
|
|
247
|
+
.layout(({ content }) =>
|
|
248
|
+
Fx.unwrap(Ref.update(layouts, (n) => n + 1).pipe(Effect.as(content))),
|
|
249
|
+
);
|
|
250
|
+
const values: Array<string> = [];
|
|
251
|
+
const first = Latch.makeUnsafe();
|
|
252
|
+
const done = Latch.makeUnsafe();
|
|
253
|
+
const fiber = yield* Effect.forkChild(
|
|
254
|
+
Fx.observe(fx, (value) =>
|
|
255
|
+
Effect.sync(() => values.push(value)).pipe(
|
|
256
|
+
Effect.flatMap(() => {
|
|
257
|
+
if (values.length === 1) return first.open;
|
|
258
|
+
if (values.length === 2) return done.open;
|
|
259
|
+
return Effect.void;
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
yield* Effect.yieldNow;
|
|
265
|
+
yield* first.await;
|
|
266
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
267
|
+
yield* done.await;
|
|
268
|
+
yield* Fiber.interrupt(fiber);
|
|
269
|
+
assert.deepStrictEqual(values, ["users:1", "about"]);
|
|
270
|
+
assert.strictEqual(yield* Ref.get(mounts), 1);
|
|
271
|
+
assert.strictEqual(yield* Ref.get(layouts), 1);
|
|
272
|
+
}),
|
|
273
|
+
));
|
|
274
|
+
|
|
275
|
+
it("ignores trailing slashes", () =>
|
|
276
|
+
runWithBrowserRouter(
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
yield* Navigation.navigate(absoluteUrl("/about/"));
|
|
279
|
+
const route = Route.Parse("about");
|
|
280
|
+
const fx = Matcher.empty.match(route, "ok");
|
|
281
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
282
|
+
assert.deepStrictEqual(values, ["ok"]);
|
|
283
|
+
}),
|
|
284
|
+
));
|
|
285
|
+
|
|
286
|
+
it("is case insensitive for segments", () =>
|
|
287
|
+
runWithBrowserRouter(
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
yield* Navigation.navigate(absoluteUrl("/ABOUT"));
|
|
290
|
+
const route = Route.Parse("about");
|
|
291
|
+
const fx = Matcher.empty.match(route, "ok");
|
|
292
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
293
|
+
assert.deepStrictEqual(values, ["ok"]);
|
|
294
|
+
}),
|
|
295
|
+
));
|
|
296
|
+
|
|
297
|
+
it("Matcher.catch recovers from typed failures", () =>
|
|
298
|
+
runWithBrowserRouter(
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
301
|
+
const about = Route.Parse("about");
|
|
302
|
+
const matcher = Matcher.empty
|
|
303
|
+
.match(about, Fx.fail(new TestError({ message: "fail" })))
|
|
304
|
+
.catch(() => Fx.succeed("recovered"));
|
|
305
|
+
const fx = matcher;
|
|
306
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
307
|
+
assert.deepStrictEqual(values, ["recovered"]);
|
|
308
|
+
}),
|
|
309
|
+
));
|
|
310
|
+
|
|
311
|
+
it("Matcher.catchTag recovers for matching tag", () =>
|
|
312
|
+
runWithBrowserRouter(
|
|
313
|
+
Effect.gen(function* () {
|
|
314
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
315
|
+
const about = Route.Parse("about");
|
|
316
|
+
const matcher = Matcher.empty
|
|
317
|
+
.match(about, Fx.fail(new TestError({ message: "fail" })))
|
|
318
|
+
.catchTag("TestError", () => Fx.succeed("recovered"));
|
|
319
|
+
const fx = matcher;
|
|
320
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
321
|
+
assert.deepStrictEqual(values, ["recovered"]);
|
|
322
|
+
}),
|
|
323
|
+
));
|
|
324
|
+
|
|
325
|
+
it("Matcher.catchCause recovers from any cause", () =>
|
|
326
|
+
runWithBrowserRouter(
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
329
|
+
const about = Route.Parse("about");
|
|
330
|
+
const matcher = Matcher.empty
|
|
331
|
+
.match(about, Fx.fail(new TestError({ message: "fail" })))
|
|
332
|
+
.catchCause((causeRef) =>
|
|
333
|
+
Fx.unwrap(
|
|
334
|
+
Effect.gen(function* () {
|
|
335
|
+
const cause = yield* causeRef;
|
|
336
|
+
const msg = Cause.hasFails(cause) ? "recovered" : "other";
|
|
337
|
+
return Fx.succeed(msg);
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
const fx = matcher;
|
|
342
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
343
|
+
assert.deepStrictEqual(values, ["recovered"]);
|
|
344
|
+
}),
|
|
345
|
+
));
|
|
346
|
+
|
|
347
|
+
describe("function Matcher.catch* and composition (browser)", () => {
|
|
348
|
+
it("Matcher.catchCause (function) recovers Effect.sync defect in inner handler Fx", () =>
|
|
349
|
+
runWithBrowserRouter(
|
|
350
|
+
Effect.gen(function* () {
|
|
351
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
352
|
+
const about = Route.Parse("about");
|
|
353
|
+
const inner = Matcher.empty.match(about, () =>
|
|
354
|
+
Fx.unwrap(
|
|
355
|
+
Effect.sync(() => {
|
|
356
|
+
throw new Error("sync-boom");
|
|
357
|
+
}),
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
const handler = (causeRef: RefSubject.RefSubject<Cause.Cause<unknown>>) =>
|
|
361
|
+
Fx.unwrap(
|
|
362
|
+
Effect.gen(function* () {
|
|
363
|
+
const cause = yield* causeRef;
|
|
364
|
+
if (Cause.hasFails(cause)) {
|
|
365
|
+
return Fx.fromEffect(Effect.failCause(cause));
|
|
366
|
+
}
|
|
367
|
+
return Fx.succeed("recovered-sync");
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
const wide = inner as any;
|
|
371
|
+
const values = yield* Fx.collectAll(Fx.take(Matcher.catchCause(handler)(wide), 1));
|
|
372
|
+
assert.deepStrictEqual(values, ["recovered-sync"]);
|
|
373
|
+
}),
|
|
374
|
+
));
|
|
375
|
+
|
|
376
|
+
it("RouteGuardError from guards recoverable at Effect boundary", () =>
|
|
377
|
+
runWithBrowserRouter(
|
|
378
|
+
Effect.gen(function* () {
|
|
379
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
380
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
381
|
+
const inner = Matcher.empty
|
|
382
|
+
.match(users, () => Effect.fail("g1"), "a")
|
|
383
|
+
.match(users, () => Effect.fail("g2"), "b");
|
|
384
|
+
const recovered = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(
|
|
385
|
+
Effect.as("" as const),
|
|
386
|
+
Effect.catchTag("RouteGuardError", () => Effect.succeed("recovered-guard" as const)),
|
|
387
|
+
);
|
|
388
|
+
assert.strictEqual(recovered, "recovered-guard");
|
|
389
|
+
}),
|
|
390
|
+
));
|
|
391
|
+
|
|
392
|
+
it("function Matcher.catchTag and Matcher.catch (curried)", () =>
|
|
393
|
+
runWithBrowserRouter(
|
|
394
|
+
Effect.gen(function* () {
|
|
395
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
396
|
+
const about = Route.Parse("about");
|
|
397
|
+
const inner = Matcher.empty.match(about, Fx.fail(new TestError({ message: "x" })));
|
|
398
|
+
const wide = inner as any;
|
|
399
|
+
const a = yield* Fx.collectAll(
|
|
400
|
+
Fx.take((Matcher.catchTag as any)("TestError", () => Fx.succeed("a"))(wide), 1),
|
|
401
|
+
);
|
|
402
|
+
const b = yield* Fx.collectAll(Fx.take(Matcher.catch(() => Fx.succeed("b"))(wide), 1));
|
|
403
|
+
assert.deepStrictEqual(a, ["a"]);
|
|
404
|
+
assert.deepStrictEqual(b, ["b"]);
|
|
405
|
+
}),
|
|
406
|
+
));
|
|
407
|
+
|
|
408
|
+
it("function Matcher.catchCause recovers OtherError from inner Fx", () =>
|
|
409
|
+
runWithBrowserRouter(
|
|
410
|
+
Effect.gen(function* () {
|
|
411
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
412
|
+
const about = Route.Parse("about");
|
|
413
|
+
const inner = Matcher.empty.match(about, Fx.fail(new OtherError({ message: "o" })));
|
|
414
|
+
const fx = pipe(
|
|
415
|
+
inner,
|
|
416
|
+
Matcher.catchCause((causeRef) =>
|
|
417
|
+
Fx.unwrap(
|
|
418
|
+
Effect.gen(function* () {
|
|
419
|
+
const cause = yield* causeRef;
|
|
420
|
+
const fr = Cause.findFail(cause);
|
|
421
|
+
if (Result.isFailure(fr)) {
|
|
422
|
+
return Fx.fromEffect(Effect.failCause(fr.failure));
|
|
423
|
+
}
|
|
424
|
+
assert.strictEqual((fr.success.error as OtherError)._tag, "OtherError");
|
|
425
|
+
return Fx.succeed("ok");
|
|
426
|
+
}),
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
431
|
+
assert.deepStrictEqual(values, ["ok"]);
|
|
432
|
+
}),
|
|
433
|
+
));
|
|
434
|
+
|
|
435
|
+
it("stacked function catchTag: inner recovers, outer RouteNotFound unused", () =>
|
|
436
|
+
runWithBrowserRouter(
|
|
437
|
+
Effect.gen(function* () {
|
|
438
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
439
|
+
const about = Route.Parse("about");
|
|
440
|
+
const inner = Matcher.empty.match(about, Fx.fail(new TestError({ message: "t" })));
|
|
441
|
+
const wrapped = pipe(
|
|
442
|
+
inner,
|
|
443
|
+
Fx.catchTag("TestError", () => Fx.succeed("inner-ok")),
|
|
444
|
+
Fx.catchTag("RouteNotFound", () => Fx.succeed("no")),
|
|
445
|
+
);
|
|
446
|
+
const values = yield* Fx.collectAll(Fx.take(wrapped, 1));
|
|
447
|
+
assert.deepStrictEqual(values, ["inner-ok"]);
|
|
448
|
+
}),
|
|
449
|
+
));
|
|
450
|
+
|
|
451
|
+
it("multiple layout calls compose outermost last", () =>
|
|
452
|
+
runWithBrowserRouter(
|
|
453
|
+
Effect.gen(function* () {
|
|
454
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
455
|
+
const about = Route.Parse("about");
|
|
456
|
+
const fx = Matcher.empty
|
|
457
|
+
.match(about, Fx.succeed("core"))
|
|
458
|
+
.layout(({ content }) => Fx.map(content, (s) => `L1:${s}`))
|
|
459
|
+
.layout(({ content }) => Fx.map(content, (s) => `L2:${s}`));
|
|
460
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
461
|
+
assert.deepStrictEqual(values, ["L2:L1:core"]);
|
|
462
|
+
}),
|
|
463
|
+
));
|
|
464
|
+
|
|
465
|
+
it("chained provide merges services", () =>
|
|
466
|
+
runWithBrowserRouter(
|
|
467
|
+
Effect.gen(function* () {
|
|
468
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
469
|
+
class SvcA extends ServiceMap.Service<SvcA, { readonly n: number }>()("SvcA") {}
|
|
470
|
+
class SvcB extends ServiceMap.Service<SvcB, { readonly s: string }>()("SvcB") {}
|
|
471
|
+
const about = Route.Parse("about");
|
|
472
|
+
const fx = Matcher.empty
|
|
473
|
+
.match(about, () =>
|
|
474
|
+
Fx.unwrap(
|
|
475
|
+
Effect.gen(function* () {
|
|
476
|
+
const a = yield* SvcA;
|
|
477
|
+
const b = yield* SvcB;
|
|
478
|
+
return Fx.succeed(`${a.n}:${b.s}`);
|
|
479
|
+
}),
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
.provide(Layer.succeed(SvcA, { n: 7 }))
|
|
483
|
+
.provide(Layer.succeed(SvcB, { s: "z" }));
|
|
484
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
485
|
+
assert.deepStrictEqual(values, ["7:z"]);
|
|
486
|
+
}),
|
|
487
|
+
));
|
|
488
|
+
|
|
489
|
+
it("route dependencies plus matcher provide", () =>
|
|
490
|
+
runWithBrowserRouter(
|
|
491
|
+
Effect.gen(function* () {
|
|
492
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
493
|
+
class RouteSvc extends ServiceMap.Service<RouteSvc, { readonly x: number }>()(
|
|
494
|
+
"RouteSvc",
|
|
495
|
+
) {}
|
|
496
|
+
class MatcherSvc extends ServiceMap.Service<MatcherSvc, { readonly y: string }>()(
|
|
497
|
+
"MatcherSvc",
|
|
498
|
+
) {}
|
|
499
|
+
const about = Route.Parse("about");
|
|
500
|
+
const fx = Matcher.empty
|
|
501
|
+
.match(about, {
|
|
502
|
+
handler: Effect.gen(function* () {
|
|
503
|
+
const x = yield* RouteSvc;
|
|
504
|
+
const y = yield* MatcherSvc;
|
|
505
|
+
return `${x.x}:${y.y}`;
|
|
506
|
+
}),
|
|
507
|
+
dependencies: [Layer.succeed(RouteSvc, { x: 3 })],
|
|
508
|
+
} as any)
|
|
509
|
+
.provide(Layer.succeed(MatcherSvc, { y: "q" }));
|
|
510
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
511
|
+
assert.deepStrictEqual(values, ["3:q"]);
|
|
512
|
+
}),
|
|
513
|
+
));
|
|
514
|
+
|
|
515
|
+
it("guard fail then none then success visits all guards in order", () =>
|
|
516
|
+
runWithBrowserRouter(
|
|
517
|
+
Effect.gen(function* () {
|
|
518
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
519
|
+
const order = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
520
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
521
|
+
const fx = Matcher.empty
|
|
522
|
+
.match(
|
|
523
|
+
users,
|
|
524
|
+
() =>
|
|
525
|
+
Ref.update(order, (xs) => [...xs, "fail"]).pipe(
|
|
526
|
+
Effect.flatMap(() => Effect.fail("g1")),
|
|
527
|
+
),
|
|
528
|
+
"skip1",
|
|
529
|
+
)
|
|
530
|
+
.match(
|
|
531
|
+
users,
|
|
532
|
+
() => Ref.update(order, (xs) => [...xs, "none"]).pipe(Effect.as(Option.none())),
|
|
533
|
+
"skip2",
|
|
534
|
+
)
|
|
535
|
+
.match(
|
|
536
|
+
users,
|
|
537
|
+
() =>
|
|
538
|
+
Ref.update(order, (xs) => [...xs, "ok"]).pipe(
|
|
539
|
+
Effect.as(Option.some({ ok: true as const })),
|
|
540
|
+
),
|
|
541
|
+
"hit",
|
|
542
|
+
);
|
|
543
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
544
|
+
assert.deepStrictEqual(values, ["hit"]);
|
|
545
|
+
assert.deepStrictEqual(yield* Ref.get(order), ["fail", "none", "ok"]);
|
|
546
|
+
}),
|
|
547
|
+
));
|
|
548
|
+
|
|
549
|
+
it("instance catchTag does not catch RouteGuardError from guards", () =>
|
|
550
|
+
runWithBrowserRouter(
|
|
551
|
+
Effect.gen(function* () {
|
|
552
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
553
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
554
|
+
const inner = pipe(
|
|
555
|
+
Matcher.empty
|
|
556
|
+
.match(users, () => Effect.fail("g"), "x")
|
|
557
|
+
// @ts-expect-error - invalid tag
|
|
558
|
+
.catchTag("RouteGuardError", () => Fx.succeed("never")),
|
|
559
|
+
);
|
|
560
|
+
const exited = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(Effect.exit);
|
|
561
|
+
assert.isTrue(Exit.isFailure(exited));
|
|
562
|
+
}),
|
|
563
|
+
));
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("layout receives updated params when staying on same route", () =>
|
|
567
|
+
runWithBrowserRouter(
|
|
568
|
+
Effect.gen(function* () {
|
|
569
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
570
|
+
const layoutMounts = yield* Ref.make(0);
|
|
571
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
572
|
+
const matcher = Matcher.empty
|
|
573
|
+
.match(users, (params) => Fx.map(params, ({ id }) => id))
|
|
574
|
+
.layout(({ content }) =>
|
|
575
|
+
Fx.unwrap(Ref.update(layoutMounts, (n) => n + 1).pipe(Effect.as(content))),
|
|
576
|
+
);
|
|
577
|
+
const fx = matcher;
|
|
578
|
+
const values: Array<string> = [];
|
|
579
|
+
const first = Latch.makeUnsafe();
|
|
580
|
+
const done = Latch.makeUnsafe();
|
|
581
|
+
const fiber = yield* Effect.forkChild(
|
|
582
|
+
Fx.observe(fx, (value) =>
|
|
583
|
+
Effect.sync(() => values.push(value)).pipe(
|
|
584
|
+
Effect.flatMap(() => {
|
|
585
|
+
if (values.length === 1) return first.open;
|
|
586
|
+
if (values.length === 2) return done.open;
|
|
587
|
+
return Effect.void;
|
|
588
|
+
}),
|
|
589
|
+
),
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
yield* Effect.yieldNow;
|
|
593
|
+
yield* first.await;
|
|
594
|
+
yield* Navigation.navigate(absoluteUrl("/users/2"));
|
|
595
|
+
yield* done.await;
|
|
596
|
+
yield* Fiber.interrupt(fiber);
|
|
597
|
+
assert.deepStrictEqual(values, ["1", "2"]);
|
|
598
|
+
assert.strictEqual(yield* Ref.get(layoutMounts), 1);
|
|
599
|
+
}),
|
|
600
|
+
));
|
|
601
|
+
|
|
602
|
+
it("per-route dependencies provide services to handler", () =>
|
|
603
|
+
runWithBrowserRouter(
|
|
604
|
+
Effect.gen(function* () {
|
|
605
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
606
|
+
class Counter extends ServiceMap.Service<Counter, { readonly value: number }>()(
|
|
607
|
+
"Counter",
|
|
608
|
+
) {}
|
|
609
|
+
const counterLayer = Layer.succeed(Counter, { value: 42 });
|
|
610
|
+
const about = Route.Parse("about");
|
|
611
|
+
const matcher = Matcher.empty.match(about, {
|
|
612
|
+
handler: Fx.unwrap(
|
|
613
|
+
Effect.gen(function* () {
|
|
614
|
+
const counter = yield* Counter;
|
|
615
|
+
return Fx.succeed(counter.value);
|
|
616
|
+
}),
|
|
617
|
+
),
|
|
618
|
+
dependencies: [counterLayer],
|
|
619
|
+
});
|
|
620
|
+
const fx = matcher;
|
|
621
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
622
|
+
assert.deepStrictEqual(values, [42]);
|
|
623
|
+
}),
|
|
624
|
+
));
|
|
625
|
+
|
|
626
|
+
it("layer finalizer does not run when guard fails after layer build on another branch", () =>
|
|
627
|
+
runWithBrowserRouter(
|
|
628
|
+
Effect.gen(function* () {
|
|
629
|
+
yield* Navigation.navigate(absoluteUrl("/other"));
|
|
630
|
+
const finalized = yield* Ref.make(false);
|
|
631
|
+
const about = Route.Parse("about");
|
|
632
|
+
const other = Route.Parse("other");
|
|
633
|
+
const layerWithFinalizer = Layer.effectServices(
|
|
634
|
+
Effect.acquireRelease(Effect.succeed(ServiceMap.empty()), () => Ref.set(finalized, true)),
|
|
635
|
+
);
|
|
636
|
+
const matcher = Matcher.empty
|
|
637
|
+
.match(about, {
|
|
638
|
+
handler: "about",
|
|
639
|
+
dependencies: [layerWithFinalizer],
|
|
640
|
+
})
|
|
641
|
+
.match(other, "other");
|
|
642
|
+
const fx = matcher;
|
|
643
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
644
|
+
assert.deepStrictEqual(values, ["other"]);
|
|
645
|
+
assert.isFalse(yield* Ref.get(finalized));
|
|
646
|
+
}),
|
|
647
|
+
));
|
|
648
|
+
|
|
649
|
+
it("merge combines matchers from different roots", () =>
|
|
650
|
+
runWithBrowserRouter(
|
|
651
|
+
Effect.gen(function* () {
|
|
652
|
+
yield* Navigation.navigate(absoluteUrl("/about"));
|
|
653
|
+
const about = Matcher.empty.match(Route.Parse("about"), "about-page");
|
|
654
|
+
const users = Matcher.empty.match(
|
|
655
|
+
Route.Join(Route.Parse("users"), Route.Param("id")),
|
|
656
|
+
(p) => Fx.map(p, ({ id }) => `user:${id}`),
|
|
657
|
+
);
|
|
658
|
+
const merged = Matcher.merge(about, users);
|
|
659
|
+
const fx = merged;
|
|
660
|
+
const values: Array<string> = [];
|
|
661
|
+
const l1 = Latch.makeUnsafe();
|
|
662
|
+
const l2 = Latch.makeUnsafe();
|
|
663
|
+
const fiber = yield* Effect.forkChild(
|
|
664
|
+
Fx.observe(fx, (value) =>
|
|
665
|
+
Effect.sync(() => values.push(value as string)).pipe(
|
|
666
|
+
Effect.flatMap(() => {
|
|
667
|
+
if (values.length === 1) return l1.open;
|
|
668
|
+
if (values.length === 2) return l2.open;
|
|
669
|
+
return Effect.void;
|
|
670
|
+
}),
|
|
671
|
+
),
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
yield* Effect.yieldNow;
|
|
675
|
+
yield* l1.await;
|
|
676
|
+
yield* Navigation.navigate(absoluteUrl("/users/9"));
|
|
677
|
+
yield* l2.await;
|
|
678
|
+
yield* Fiber.interrupt(fiber);
|
|
679
|
+
assert.deepStrictEqual(values, ["about-page", "user:9"]);
|
|
680
|
+
}),
|
|
681
|
+
));
|
|
682
|
+
|
|
683
|
+
it("prefix scopes routes under a path segment", () =>
|
|
684
|
+
runWithBrowserRouter(
|
|
685
|
+
Effect.gen(function* () {
|
|
686
|
+
yield* Navigation.navigate(absoluteUrl("/admin/panel"));
|
|
687
|
+
const matcher = Matcher.empty
|
|
688
|
+
.match(Route.Parse("panel"), "admin-panel")
|
|
689
|
+
.prefix(Route.Parse("admin"));
|
|
690
|
+
const fx = matcher;
|
|
691
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
692
|
+
assert.deepStrictEqual(values, ["admin-panel"]);
|
|
693
|
+
}),
|
|
694
|
+
));
|
|
695
|
+
|
|
696
|
+
it("decodes percent-encoded path params (browser URL + find-my-way)", () =>
|
|
697
|
+
runWithBrowserRouter(
|
|
698
|
+
Effect.gen(function* () {
|
|
699
|
+
const route = Route.Join(Route.Parse("tag"), Route.Param("label"));
|
|
700
|
+
yield* Navigation.navigate(absoluteUrl("/tag/hello%20world"));
|
|
701
|
+
const fx = Matcher.empty.match(route, (params) => Fx.map(params, ({ label }) => label));
|
|
702
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
703
|
+
assert.deepStrictEqual(values, ["hello world"]);
|
|
704
|
+
}),
|
|
705
|
+
));
|
|
706
|
+
|
|
707
|
+
it("Stream handler emits multiple values", () =>
|
|
708
|
+
runWithBrowserRouter(
|
|
709
|
+
Effect.gen(function* () {
|
|
710
|
+
yield* Navigation.navigate(absoluteUrl("/stream"));
|
|
711
|
+
const route = Route.Parse("stream");
|
|
712
|
+
const fx = Matcher.empty.match(route, Stream.fromIterable(["a", "b"] as const));
|
|
713
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 2));
|
|
714
|
+
assert.deepStrictEqual(values, ["a", "b"]);
|
|
715
|
+
}),
|
|
716
|
+
));
|
|
717
|
+
|
|
718
|
+
it("provideService supplies a service to handlers", () =>
|
|
719
|
+
runWithBrowserRouter(
|
|
720
|
+
Effect.gen(function* () {
|
|
721
|
+
yield* Navigation.navigate(absoluteUrl("/svc"));
|
|
722
|
+
class Svc extends ServiceMap.Service<Svc, { readonly n: number }>()("Svc") {}
|
|
723
|
+
const route = Route.Parse("svc");
|
|
724
|
+
const matcher = Matcher.empty
|
|
725
|
+
.match(route, () =>
|
|
726
|
+
Fx.unwrap(
|
|
727
|
+
Effect.gen(function* () {
|
|
728
|
+
const s = yield* Svc;
|
|
729
|
+
return Fx.succeed(s.n);
|
|
730
|
+
}),
|
|
731
|
+
),
|
|
732
|
+
)
|
|
733
|
+
.provideService(Svc, { n: 99 });
|
|
734
|
+
const values = yield* Fx.collectAll(Fx.take(matcher, 1));
|
|
735
|
+
assert.deepStrictEqual(values, [99]);
|
|
736
|
+
}),
|
|
737
|
+
));
|
|
738
|
+
|
|
739
|
+
it("succeeds when first guard fails but later guard succeeds", () =>
|
|
740
|
+
runWithBrowserRouter(
|
|
741
|
+
Effect.gen(function* () {
|
|
742
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
743
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
744
|
+
const fx = Matcher.empty
|
|
745
|
+
.match(users, () => Effect.fail("bad"), "skip")
|
|
746
|
+
.match(
|
|
747
|
+
users,
|
|
748
|
+
() => Effect.succeed(Option.some({ ok: true as const })),
|
|
749
|
+
(p) => Fx.map(p, (x) => x.ok),
|
|
750
|
+
);
|
|
751
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
752
|
+
assert.deepStrictEqual(values, [true]);
|
|
753
|
+
}),
|
|
754
|
+
));
|
|
755
|
+
|
|
756
|
+
it("RouteGuardError has empty causes when all guards return Option.none", () =>
|
|
757
|
+
runWithBrowserRouter(
|
|
758
|
+
Effect.gen(function* () {
|
|
759
|
+
yield* Navigation.navigate(absoluteUrl("/users/1"));
|
|
760
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
761
|
+
const fx = Matcher.empty
|
|
762
|
+
.match(users, () => Effect.succeed(Option.none()), "a")
|
|
763
|
+
.match(users, () => Effect.succeed(Option.none()), "b");
|
|
764
|
+
const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
|
|
765
|
+
Effect.as("matched" as const),
|
|
766
|
+
Effect.catchTag("RouteGuardError", (e) => Effect.succeed(e.causes.length)),
|
|
767
|
+
);
|
|
768
|
+
assert.strictEqual(result, 0);
|
|
769
|
+
}),
|
|
770
|
+
));
|
|
771
|
+
});
|