@typed/router 1.0.0-beta.2 → 1.0.0-beta.4

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 (31) hide show
  1. package/README.md +1 -1
  2. package/dist/CurrentRoute.d.ts +2 -2
  3. package/dist/CurrentRoute.d.ts.map +1 -1
  4. package/dist/CurrentRoute.js +4 -4
  5. package/dist/Matcher.d.ts +18 -19
  6. package/dist/Matcher.d.ts.map +1 -1
  7. package/dist/Matcher.js +112 -109
  8. package/dist/MatcherV2.d.ts +3 -0
  9. package/dist/MatcherV2.d.ts.map +1 -0
  10. package/dist/MatcherV2.js +1 -0
  11. package/dist/test-utils/matcherBrowserHarness.d.ts +10 -0
  12. package/dist/test-utils/matcherBrowserHarness.d.ts.map +1 -0
  13. package/dist/test-utils/matcherBrowserHarness.js +13 -0
  14. package/package.json +21 -18
  15. package/src/CurrentRoute.ts +5 -5
  16. package/src/Matcher.browser.test.ts +767 -0
  17. package/src/Matcher.test.ts +348 -73
  18. package/src/Matcher.ts +170 -166
  19. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--back---restores-previous-match-1.png +0 -0
  20. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--catchTag-RouteNotFound-can-navigate-and-re-match--browser-history--1.png +0 -0
  21. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--decodes-query-params-from-pathname-search-1.png +0 -0
  22. 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
  23. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--history-back-restores-previous-match--popstate-sync--1.png +0 -0
  24. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--path-and-query-params-both-decode--distinct-names--1.png +0 -0
  25. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--prefix-scopes-routes-under-a-path-segment-1.png +0 -0
  26. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--provideService-supplies-a-service-to-handlers-1.png +0 -0
  27. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--query-param-wins-over-path-param-when-names-collide-1.png +0 -0
  28. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-away-from-unmatched-path--side-effect--1.png +0 -0
  29. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-then-matches-target-route-1.png +0 -0
  30. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--reuses-shared-layers-and-layouts-across-route-changes-1.png +0 -0
  31. package/src/test-utils/matcherBrowserHarness.ts +22 -0
@@ -0,0 +1,767 @@
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 Context from "effect/Context";
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.effectContext(
239
+ Ref.update(mounts, (n) => n + 1).pipe(Effect.as(Context.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 Context.Service<SvcA, { readonly n: number }>()("SvcA") {}
470
+ class SvcB extends Context.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 Context.Service<RouteSvc, { readonly x: number }>()("RouteSvc") {}
494
+ class MatcherSvc extends Context.Service<MatcherSvc, { readonly y: string }>()(
495
+ "MatcherSvc",
496
+ ) {}
497
+ const about = Route.Parse("about");
498
+ const fx = Matcher.empty
499
+ .match(about, {
500
+ handler: Effect.gen(function* () {
501
+ const x = yield* RouteSvc;
502
+ const y = yield* MatcherSvc;
503
+ return `${x.x}:${y.y}`;
504
+ }),
505
+ dependencies: [Layer.succeed(RouteSvc, { x: 3 })],
506
+ } as any)
507
+ .provide(Layer.succeed(MatcherSvc, { y: "q" }));
508
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
509
+ assert.deepStrictEqual(values, ["3:q"]);
510
+ }),
511
+ ));
512
+
513
+ it("guard fail then none then success visits all guards in order", () =>
514
+ runWithBrowserRouter(
515
+ Effect.gen(function* () {
516
+ yield* Navigation.navigate(absoluteUrl("/users/1"));
517
+ const order = yield* Ref.make<ReadonlyArray<string>>([]);
518
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
519
+ const fx = Matcher.empty
520
+ .match(
521
+ users,
522
+ () =>
523
+ Ref.update(order, (xs) => [...xs, "fail"]).pipe(
524
+ Effect.flatMap(() => Effect.fail("g1")),
525
+ ),
526
+ "skip1",
527
+ )
528
+ .match(
529
+ users,
530
+ () => Ref.update(order, (xs) => [...xs, "none"]).pipe(Effect.as(Option.none())),
531
+ "skip2",
532
+ )
533
+ .match(
534
+ users,
535
+ () =>
536
+ Ref.update(order, (xs) => [...xs, "ok"]).pipe(
537
+ Effect.as(Option.some({ ok: true as const })),
538
+ ),
539
+ "hit",
540
+ );
541
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
542
+ assert.deepStrictEqual(values, ["hit"]);
543
+ assert.deepStrictEqual(yield* Ref.get(order), ["fail", "none", "ok"]);
544
+ }),
545
+ ));
546
+
547
+ it("instance catchTag does not catch RouteGuardError from guards", () =>
548
+ runWithBrowserRouter(
549
+ Effect.gen(function* () {
550
+ yield* Navigation.navigate(absoluteUrl("/users/1"));
551
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
552
+ const inner = pipe(
553
+ Matcher.empty
554
+ .match(users, () => Effect.fail("g"), "x")
555
+ // @ts-expect-error - invalid tag
556
+ .catchTag("RouteGuardError", () => Fx.succeed("never")),
557
+ );
558
+ const exited = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(Effect.exit);
559
+ assert.isTrue(Exit.isFailure(exited));
560
+ }),
561
+ ));
562
+ });
563
+
564
+ it("layout receives updated params when staying on same route", () =>
565
+ runWithBrowserRouter(
566
+ Effect.gen(function* () {
567
+ yield* Navigation.navigate(absoluteUrl("/users/1"));
568
+ const layoutMounts = yield* Ref.make(0);
569
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
570
+ const matcher = Matcher.empty
571
+ .match(users, (params) => Fx.map(params, ({ id }) => id))
572
+ .layout(({ content }) =>
573
+ Fx.unwrap(Ref.update(layoutMounts, (n) => n + 1).pipe(Effect.as(content))),
574
+ );
575
+ const fx = matcher;
576
+ const values: Array<string> = [];
577
+ const first = Latch.makeUnsafe();
578
+ const done = Latch.makeUnsafe();
579
+ const fiber = yield* Effect.forkChild(
580
+ Fx.observe(fx, (value) =>
581
+ Effect.sync(() => values.push(value)).pipe(
582
+ Effect.flatMap(() => {
583
+ if (values.length === 1) return first.open;
584
+ if (values.length === 2) return done.open;
585
+ return Effect.void;
586
+ }),
587
+ ),
588
+ ),
589
+ );
590
+ yield* Effect.yieldNow;
591
+ yield* first.await;
592
+ yield* Navigation.navigate(absoluteUrl("/users/2"));
593
+ yield* done.await;
594
+ yield* Fiber.interrupt(fiber);
595
+ assert.deepStrictEqual(values, ["1", "2"]);
596
+ assert.strictEqual(yield* Ref.get(layoutMounts), 1);
597
+ }),
598
+ ));
599
+
600
+ it("per-route dependencies provide services to handler", () =>
601
+ runWithBrowserRouter(
602
+ Effect.gen(function* () {
603
+ yield* Navigation.navigate(absoluteUrl("/about"));
604
+ class Counter extends Context.Service<Counter, { readonly value: number }>()("Counter") {}
605
+ const counterLayer = Layer.succeed(Counter, { value: 42 });
606
+ const about = Route.Parse("about");
607
+ const matcher = Matcher.empty.match(about, {
608
+ handler: Fx.unwrap(
609
+ Effect.gen(function* () {
610
+ const counter = yield* Counter;
611
+ return Fx.succeed(counter.value);
612
+ }),
613
+ ),
614
+ dependencies: [counterLayer],
615
+ });
616
+ const fx = matcher;
617
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
618
+ assert.deepStrictEqual(values, [42]);
619
+ }),
620
+ ));
621
+
622
+ it("layer finalizer does not run when guard fails after layer build on another branch", () =>
623
+ runWithBrowserRouter(
624
+ Effect.gen(function* () {
625
+ yield* Navigation.navigate(absoluteUrl("/other"));
626
+ const finalized = yield* Ref.make(false);
627
+ const about = Route.Parse("about");
628
+ const other = Route.Parse("other");
629
+ const layerWithFinalizer = Layer.effectContext(
630
+ Effect.acquireRelease(Effect.succeed(Context.empty()), () => Ref.set(finalized, true)),
631
+ );
632
+ const matcher = Matcher.empty
633
+ .match(about, {
634
+ handler: "about",
635
+ dependencies: [layerWithFinalizer],
636
+ })
637
+ .match(other, "other");
638
+ const fx = matcher;
639
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
640
+ assert.deepStrictEqual(values, ["other"]);
641
+ assert.isFalse(yield* Ref.get(finalized));
642
+ }),
643
+ ));
644
+
645
+ it("merge combines matchers from different roots", () =>
646
+ runWithBrowserRouter(
647
+ Effect.gen(function* () {
648
+ yield* Navigation.navigate(absoluteUrl("/about"));
649
+ const about = Matcher.empty.match(Route.Parse("about"), "about-page");
650
+ const users = Matcher.empty.match(
651
+ Route.Join(Route.Parse("users"), Route.Param("id")),
652
+ (p) => Fx.map(p, ({ id }) => `user:${id}`),
653
+ );
654
+ const merged = Matcher.merge(about, users);
655
+ const fx = merged;
656
+ const values: Array<string> = [];
657
+ const l1 = Latch.makeUnsafe();
658
+ const l2 = Latch.makeUnsafe();
659
+ const fiber = yield* Effect.forkChild(
660
+ Fx.observe(fx, (value) =>
661
+ Effect.sync(() => values.push(value as string)).pipe(
662
+ Effect.flatMap(() => {
663
+ if (values.length === 1) return l1.open;
664
+ if (values.length === 2) return l2.open;
665
+ return Effect.void;
666
+ }),
667
+ ),
668
+ ),
669
+ );
670
+ yield* Effect.yieldNow;
671
+ yield* l1.await;
672
+ yield* Navigation.navigate(absoluteUrl("/users/9"));
673
+ yield* l2.await;
674
+ yield* Fiber.interrupt(fiber);
675
+ assert.deepStrictEqual(values, ["about-page", "user:9"]);
676
+ }),
677
+ ));
678
+
679
+ it("prefix scopes routes under a path segment", () =>
680
+ runWithBrowserRouter(
681
+ Effect.gen(function* () {
682
+ yield* Navigation.navigate(absoluteUrl("/admin/panel"));
683
+ const matcher = Matcher.empty
684
+ .match(Route.Parse("panel"), "admin-panel")
685
+ .prefix(Route.Parse("admin"));
686
+ const fx = matcher;
687
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
688
+ assert.deepStrictEqual(values, ["admin-panel"]);
689
+ }),
690
+ ));
691
+
692
+ it("decodes percent-encoded path params (browser URL + find-my-way)", () =>
693
+ runWithBrowserRouter(
694
+ Effect.gen(function* () {
695
+ const route = Route.Join(Route.Parse("tag"), Route.Param("label"));
696
+ yield* Navigation.navigate(absoluteUrl("/tag/hello%20world"));
697
+ const fx = Matcher.empty.match(route, (params) => Fx.map(params, ({ label }) => label));
698
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
699
+ assert.deepStrictEqual(values, ["hello world"]);
700
+ }),
701
+ ));
702
+
703
+ it("Stream handler emits multiple values", () =>
704
+ runWithBrowserRouter(
705
+ Effect.gen(function* () {
706
+ yield* Navigation.navigate(absoluteUrl("/stream"));
707
+ const route = Route.Parse("stream");
708
+ const fx = Matcher.empty.match(route, Stream.fromIterable(["a", "b"] as const));
709
+ const values = yield* Fx.collectAll(Fx.take(fx, 2));
710
+ assert.deepStrictEqual(values, ["a", "b"]);
711
+ }),
712
+ ));
713
+
714
+ it("provideService supplies a service to handlers", () =>
715
+ runWithBrowserRouter(
716
+ Effect.gen(function* () {
717
+ yield* Navigation.navigate(absoluteUrl("/svc"));
718
+ class Svc extends Context.Service<Svc, { readonly n: number }>()("Svc") {}
719
+ const route = Route.Parse("svc");
720
+ const matcher = Matcher.empty
721
+ .match(route, () =>
722
+ Fx.unwrap(
723
+ Effect.gen(function* () {
724
+ const s = yield* Svc;
725
+ return Fx.succeed(s.n);
726
+ }),
727
+ ),
728
+ )
729
+ .provideService(Svc, { n: 99 });
730
+ const values = yield* Fx.collectAll(Fx.take(matcher, 1));
731
+ assert.deepStrictEqual(values, [99]);
732
+ }),
733
+ ));
734
+
735
+ it("succeeds when first guard fails but later guard succeeds", () =>
736
+ runWithBrowserRouter(
737
+ Effect.gen(function* () {
738
+ yield* Navigation.navigate(absoluteUrl("/users/1"));
739
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
740
+ const fx = Matcher.empty
741
+ .match(users, () => Effect.fail("bad"), "skip")
742
+ .match(
743
+ users,
744
+ () => Effect.succeed(Option.some({ ok: true as const })),
745
+ (p) => Fx.map(p, (x) => x.ok),
746
+ );
747
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
748
+ assert.deepStrictEqual(values, [true]);
749
+ }),
750
+ ));
751
+
752
+ it("RouteGuardError has empty causes when all guards return Option.none", () =>
753
+ runWithBrowserRouter(
754
+ Effect.gen(function* () {
755
+ yield* Navigation.navigate(absoluteUrl("/users/1"));
756
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
757
+ const fx = Matcher.empty
758
+ .match(users, () => Effect.succeed(Option.none()), "a")
759
+ .match(users, () => Effect.succeed(Option.none()), "b");
760
+ const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
761
+ Effect.as("matched" as const),
762
+ Effect.catchTag("RouteGuardError", (e) => Effect.succeed(e.causes.length)),
763
+ );
764
+ assert.strictEqual(result, 0);
765
+ }),
766
+ ));
767
+ });