@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.
Files changed (29) hide show
  1. package/dist/Matcher.d.ts +18 -13
  2. package/dist/Matcher.d.ts.map +1 -1
  3. package/dist/Matcher.js +102 -97
  4. package/dist/MatcherV2.d.ts +3 -0
  5. package/dist/MatcherV2.d.ts.map +1 -0
  6. package/dist/MatcherV2.js +1 -0
  7. package/dist/Route.d.ts +5 -0
  8. package/dist/Route.d.ts.map +1 -1
  9. package/dist/test-utils/matcherBrowserHarness.d.ts +10 -0
  10. package/dist/test-utils/matcherBrowserHarness.d.ts.map +1 -0
  11. package/dist/test-utils/matcherBrowserHarness.js +13 -0
  12. package/package.json +21 -18
  13. package/src/Matcher.browser.test.ts +771 -0
  14. package/src/Matcher.test.ts +344 -67
  15. package/src/Matcher.ts +165 -132
  16. package/src/Route.ts +6 -0
  17. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--back---restores-previous-match-1.png +0 -0
  18. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--catchTag-RouteNotFound-can-navigate-and-re-match--browser-history--1.png +0 -0
  19. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--decodes-query-params-from-pathname-search-1.png +0 -0
  20. 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
  21. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--history-back-restores-previous-match--popstate-sync--1.png +0 -0
  22. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--path-and-query-params-both-decode--distinct-names--1.png +0 -0
  23. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--prefix-scopes-routes-under-a-path-segment-1.png +0 -0
  24. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--provideService-supplies-a-service-to-handlers-1.png +0 -0
  25. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--query-param-wins-over-path-param-when-names-collide-1.png +0 -0
  26. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-away-from-unmatched-path--side-effect--1.png +0 -0
  27. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-then-matches-target-route-1.png +0 -0
  28. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--reuses-shared-layers-and-layouts-across-route-changes-1.png +0 -0
  29. package/src/test-utils/matcherBrowserHarness.ts +22 -0
@@ -2,20 +2,28 @@ import { assert, describe, it } from "vitest";
2
2
  import * as Cause from "effect/Cause";
3
3
  import * as Data from "effect/Data";
4
4
  import * as Effect from "effect/Effect";
5
+ import * as Exit from "effect/Exit";
5
6
  import * as Latch from "effect/Latch";
6
7
  import * as Fiber from "effect/Fiber";
7
8
  import * as Layer from "effect/Layer";
8
9
  import * as Option from "effect/Option";
9
10
  import * as Ref from "effect/Ref";
11
+ import * as Result from "effect/Result";
10
12
  import * as ServiceMap from "effect/ServiceMap";
11
13
  import { Fx } from "@typed/fx";
14
+ import type { RefSubject } from "@typed/fx/RefSubject";
12
15
  import { Navigation } from "@typed/navigation";
13
16
  import * as Matcher from "./Matcher.js";
14
17
  import * as Route from "./Route.js";
15
18
  import { ServerRouter } from "./Router.js";
16
19
 
20
+ /** Runs a fully-scoped Effect in tests; `as any` avoids R-channel mismatch with `Effect.runPromise`. */
21
+ const runEff = (eff: Effect.Effect<unknown, unknown, unknown>) => Effect.runPromise(eff as any);
22
+
17
23
  class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {}
18
24
 
25
+ class OtherError extends Data.TaggedError("OtherError")<{ readonly message: string }> {}
26
+
19
27
  describe("typed/router/Matcher", () => {
20
28
  it("type check for match options inference", () => {
21
29
  const route = Route.Parse("type");
@@ -38,8 +46,7 @@ describe("typed/router/Matcher", () => {
38
46
  layout: ({ content }) => Fx.map(content, (n) => `wrapped:${n}`),
39
47
  });
40
48
 
41
- const fx = Matcher.run(matcher);
42
- const values = yield* Fx.collectAll(Fx.take(fx, 1));
49
+ const values = yield* Fx.collectAll(Fx.take(matcher, 1));
43
50
 
44
51
  assert.deepStrictEqual(values, ["wrapped:42"]);
45
52
  }).pipe(
@@ -53,11 +60,9 @@ describe("typed/router/Matcher", () => {
53
60
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
54
61
  const about = Route.Parse("about");
55
62
 
56
- const fx = Matcher.run(
57
- Matcher.empty
58
- .match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
59
- .match(about, "about"),
60
- );
63
+ const fx = Matcher.empty
64
+ .match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
65
+ .match(about, "about");
61
66
 
62
67
  const values: Array<string> = [];
63
68
  const first = Latch.makeUnsafe();
@@ -93,14 +98,14 @@ describe("typed/router/Matcher", () => {
93
98
  it("fails with RouteNotFound when no route matches", () =>
94
99
  Effect.gen(function* () {
95
100
  const route = Route.Parse("about");
96
- const fx = Matcher.run(Matcher.empty.match(route, "about"));
101
+ const fx = Matcher.empty.match(route, () => "about");
97
102
 
98
- const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
99
- Effect.as("matched" as const),
103
+ const path = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
104
+ Effect.as("" as const),
100
105
  Effect.catchTag("RouteNotFound", (e) => Effect.succeed(e.path)),
101
106
  );
102
107
 
103
- assert.strictEqual(result, "/nope");
108
+ assert.strictEqual(path, "/nope");
104
109
  }).pipe(
105
110
  Effect.provide(ServerRouter({ url: "http://localhost/nope" })),
106
111
  Effect.scoped,
@@ -116,7 +121,7 @@ describe("typed/router/Matcher", () => {
116
121
  Fx.unwrap(Ref.update(mounts, (n) => n + 1).pipe(Effect.as(Fx.map(params, ({ id }) => id)))),
117
122
  );
118
123
 
119
- const fx = Matcher.run(matcher);
124
+ const fx = matcher;
120
125
 
121
126
  const values: Array<string> = [];
122
127
  const first = Latch.makeUnsafe();
@@ -155,22 +160,20 @@ describe("typed/router/Matcher", () => {
155
160
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
156
161
  const calls = yield* Ref.make<ReadonlyArray<string>>([]);
157
162
 
158
- const fx = Matcher.run(
159
- Matcher.empty
160
- .match(
161
- users,
162
- () => Ref.update(calls, (entries) => [...entries, "g1"]).pipe(Effect.as(Option.none())),
163
- "skip",
164
- )
165
- .match(
166
- users,
167
- (input) =>
168
- Ref.update(calls, (entries) => [...entries, "g2"]).pipe(
169
- Effect.as(Option.some({ ...input, ok: true as const })),
170
- ),
171
- (params) => Fx.map(params, (p) => p.ok),
172
- ),
173
- );
163
+ const fx = Matcher.empty
164
+ .match(
165
+ users,
166
+ () => Ref.update(calls, (entries) => [...entries, "g1"]).pipe(Effect.as(Option.none())),
167
+ "skip",
168
+ )
169
+ .match(
170
+ users,
171
+ (input) =>
172
+ Ref.update(calls, (entries) => [...entries, "g2"]).pipe(
173
+ Effect.as(Option.some({ ...input, ok: true as const })),
174
+ ),
175
+ (params) => Fx.map(params, (p) => p.ok),
176
+ );
174
177
 
175
178
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
176
179
  assert.deepStrictEqual(values, [true]);
@@ -184,11 +187,9 @@ describe("typed/router/Matcher", () => {
184
187
  it("accumulates guard failures when no guard matches", () =>
185
188
  Effect.gen(function* () {
186
189
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
187
- const fx = Matcher.run(
188
- Matcher.empty
189
- .match(users, () => Effect.fail("g1"), "ok")
190
- .match(users, () => Effect.fail("g2"), "ok"),
191
- );
190
+ const fx = Matcher.empty
191
+ .match(users, () => Effect.fail("g1"), "ok")
192
+ .match(users, () => Effect.fail("g2"), "ok");
192
193
 
193
194
  const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
194
195
  Effect.as(0),
@@ -214,15 +215,13 @@ describe("typed/router/Matcher", () => {
214
215
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
215
216
  const about = Route.Parse("about");
216
217
 
217
- const fx = Matcher.run(
218
- Matcher.empty
219
- .match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
220
- .match(about, "about")
221
- .provide(sharedLayer)
222
- .layout(({ content }) =>
223
- Fx.unwrap(Ref.update(layouts, (n) => n + 1).pipe(Effect.as(content))),
224
- ),
225
- );
218
+ const fx = Matcher.empty
219
+ .match(users, (params) => Fx.map(params, ({ id }) => `users:${id}`))
220
+ .match(about, "about")
221
+ .provide(sharedLayer)
222
+ .layout(({ content }) =>
223
+ Fx.unwrap(Ref.update(layouts, (n) => n + 1).pipe(Effect.as(content))),
224
+ );
226
225
 
227
226
  const values: Array<string> = [];
228
227
  const first = Latch.makeUnsafe();
@@ -263,7 +262,7 @@ describe("typed/router/Matcher", () => {
263
262
  it("ignores trailing slashes", () =>
264
263
  Effect.gen(function* () {
265
264
  const about = Route.Parse("about");
266
- const fx = Matcher.run(Matcher.empty.match(about, "about"));
265
+ const fx = Matcher.empty.match(about, "about");
267
266
 
268
267
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
269
268
  assert.deepStrictEqual(values, ["about"]);
@@ -276,7 +275,7 @@ describe("typed/router/Matcher", () => {
276
275
  it("is case insensitive", () =>
277
276
  Effect.gen(function* () {
278
277
  const about = Route.Parse("about");
279
- const fx = Matcher.run(Matcher.empty.match(about, "about"));
278
+ const fx = Matcher.empty.match(about, "about");
280
279
 
281
280
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
282
281
  assert.deepStrictEqual(values, ["about"]);
@@ -290,15 +289,13 @@ describe("typed/router/Matcher", () => {
290
289
  Effect.gen(function* () {
291
290
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
292
291
 
293
- const fx = Matcher.run(
294
- Matcher.empty
295
- .match(users, () => Effect.fail("guard1-error"), "never")
296
- .match(
297
- users,
298
- (input) => Effect.succeed(Option.some({ ...input, ok: true as const })),
299
- "matched",
300
- ),
301
- );
292
+ const fx = Matcher.empty
293
+ .match(users, () => Effect.fail("guard1-error"), "never")
294
+ .match(
295
+ users,
296
+ (input) => Effect.succeed(Option.some({ ...input, ok: true as const })),
297
+ "matched",
298
+ );
302
299
 
303
300
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
304
301
  assert.deepStrictEqual(values, ["matched"]);
@@ -312,11 +309,9 @@ describe("typed/router/Matcher", () => {
312
309
  Effect.gen(function* () {
313
310
  const users = Route.Join(Route.Parse("users"), Route.Param("id"));
314
311
 
315
- const fx = Matcher.run(
316
- Matcher.empty
317
- .match(users, () => Effect.succeed(Option.none()), "never1")
318
- .match(users, () => Effect.succeed(Option.none()), "never2"),
319
- );
312
+ const fx = Matcher.empty
313
+ .match(users, () => Effect.succeed(Option.none()), "never1")
314
+ .match(users, () => Effect.succeed(Option.none()), "never2");
320
315
 
321
316
  const result = yield* Fx.collectAll(Fx.take(fx, 1)).pipe(
322
317
  Effect.as("matched" as const),
@@ -338,7 +333,7 @@ describe("typed/router/Matcher", () => {
338
333
  .match(about, Fx.fail(new TestError({ message: "fail" })))
339
334
  .catch(() => Fx.succeed("recovered"));
340
335
 
341
- const fx = Matcher.run(matcher);
336
+ const fx = matcher;
342
337
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
343
338
 
344
339
  assert.deepStrictEqual(values, ["recovered"]);
@@ -356,7 +351,7 @@ describe("typed/router/Matcher", () => {
356
351
  .match(about, Fx.fail(new TestError({ message: "fail" })))
357
352
  .catchTag("TestError", () => Fx.succeed("recovered"));
358
353
 
359
- const fx = Matcher.run(matcher);
354
+ const fx = matcher;
360
355
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
361
356
 
362
357
  assert.deepStrictEqual(values, ["recovered"]);
@@ -385,14 +380,296 @@ describe("typed/router/Matcher", () => {
385
380
  ),
386
381
  );
387
382
 
388
- const fx = Matcher.run(matcher);
383
+ const fx = matcher;
389
384
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
390
385
 
391
386
  assert.deepStrictEqual(values, ["recovered"]);
392
- }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" }))));
387
+ }).pipe(
388
+ Effect.provide(ServerRouter({ url: "http://localhost/about" })),
389
+ Effect.scoped,
390
+ Effect.runPromise,
391
+ ));
393
392
 
394
- // TODO: Matcher.catchCause function test times out - may need investigation
395
- // The Matcher.catchCause() method tests pass, so basic catch functionality is verified
393
+ describe("function Matcher.catch* and error origins", () => {
394
+ it("Matcher.catchCause (function) recovers defect from Effect.sync throw in inner handler Fx", () =>
395
+ runEff(
396
+ Effect.gen(function* () {
397
+ const about = Route.Parse("about");
398
+ const inner = Matcher.empty.match(about, () =>
399
+ Fx.unwrap(
400
+ Effect.sync(() => {
401
+ throw new Error("sync-boom");
402
+ }),
403
+ ),
404
+ );
405
+ const handler = (causeRef: RefSubject.RefSubject<Cause.Cause<unknown>>) =>
406
+ Fx.unwrap(
407
+ Effect.gen(function* () {
408
+ const cause = yield* causeRef;
409
+ if (Cause.hasFails(cause)) {
410
+ return Fx.fromEffect(Effect.failCause(cause));
411
+ }
412
+ return Fx.succeed("recovered-sync");
413
+ }),
414
+ );
415
+ const wide = inner as any;
416
+ const a = yield* Fx.collectAll(Fx.take(Matcher.catchCause(handler)(wide), 1));
417
+ const b = yield* Fx.collectAll(Fx.take(Matcher.catchCause(wide, handler), 1));
418
+ assert.deepStrictEqual(a, ["recovered-sync"]);
419
+ assert.deepStrictEqual(b, ["recovered-sync"]);
420
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
421
+ ));
422
+
423
+ it("RouteGuardError from failed guards is recoverable at Effect boundary (mapEffect failure)", () =>
424
+ runEff(
425
+ Effect.gen(function* () {
426
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
427
+ const inner = Matcher.empty
428
+ .match(users, () => Effect.fail("g1"), "a")
429
+ .match(users, () => Effect.fail("g2"), "b");
430
+ const recovered = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(
431
+ Effect.as("" as const),
432
+ Effect.catchTag("RouteGuardError" as const, () =>
433
+ Effect.succeed("recovered-guard" as const),
434
+ ),
435
+ );
436
+ assert.strictEqual(recovered, "recovered-guard");
437
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
438
+ ));
439
+
440
+ it("Matcher.catchTag (function, curried) recovers inner Fx TestError", () =>
441
+ runEff(
442
+ Effect.gen(function* () {
443
+ const about = Route.Parse("about");
444
+ const inner = Matcher.empty.match(about, Fx.fail(new TestError({ message: "inner" })));
445
+ const fx = (Matcher.catchTag as any)("TestError", () => Fx.succeed("recovered-fx"))(
446
+ inner,
447
+ );
448
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
449
+ assert.deepStrictEqual(values, ["recovered-fx"]);
450
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
451
+ ));
452
+
453
+ it("Matcher.catch (function) recovers typed fail from inner stream", () =>
454
+ runEff(
455
+ Effect.gen(function* () {
456
+ const about = Route.Parse("about");
457
+ const inner = Matcher.empty.match(about, Fx.fail(new TestError({ message: "x" })));
458
+ const fx = Matcher.catch(() => Fx.succeed("recovered-catch-fn"))(inner as any);
459
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
460
+ assert.deepStrictEqual(values, ["recovered-catch-fn"]);
461
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
462
+ ));
463
+
464
+ it("function Matcher.catchCause recovers OtherError from inner Fx (typed fail path)", () =>
465
+ runEff(
466
+ Effect.gen(function* () {
467
+ const about = Route.Parse("about");
468
+ const inner = Matcher.empty.match(about, Fx.fail(new OtherError({ message: "o" })));
469
+ const fx = Matcher.catchCause((causeRef) =>
470
+ Fx.unwrap(
471
+ Effect.gen(function* () {
472
+ const cause = yield* causeRef;
473
+ const fr = Cause.findFail(cause);
474
+ if (Result.isFailure(fr)) {
475
+ return Fx.fromEffect(Effect.failCause(fr.failure));
476
+ }
477
+ const err = fr.success.error as OtherError;
478
+ assert.strictEqual(err._tag, "OtherError");
479
+ return Fx.succeed("from-outer-cause");
480
+ }),
481
+ ),
482
+ )(inner as any);
483
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
484
+ assert.deepStrictEqual(values, ["from-outer-cause"]);
485
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
486
+ ));
487
+
488
+ it("stacked function catchTag then catchTag: inner recovers TestError, outer does not see failure", () =>
489
+ runEff(
490
+ Effect.gen(function* () {
491
+ const about = Route.Parse("about");
492
+ const inner = Matcher.empty.match(about, Fx.fail(new TestError({ message: "t" })));
493
+ const wrapped = (Matcher.catchTag as any)("TestError", () => Fx.succeed("inner-ok"))(
494
+ inner,
495
+ );
496
+ const fx = (Matcher.catchTag as any)("RouteNotFound", () => Fx.succeed("should-not-run"))(
497
+ wrapped,
498
+ );
499
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
500
+ assert.deepStrictEqual(values, ["inner-ok"]);
501
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
502
+ ));
503
+
504
+ it("multiple layout calls compose outermost layout last", () =>
505
+ runEff(
506
+ Effect.gen(function* () {
507
+ const about = Route.Parse("about");
508
+ const fx = Matcher.empty
509
+ .match(about, Fx.succeed("core"))
510
+ .layout(({ content }) => Fx.map(content, (s) => `L1:${s}`))
511
+ .layout(({ content }) => Fx.map(content, (s) => `L2:${s}`));
512
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
513
+ assert.deepStrictEqual(values, ["L2:L1:core"]);
514
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
515
+ ));
516
+
517
+ it("multiple layout calls keep inner layout mounted once when params update on same route", () =>
518
+ runEff(
519
+ Effect.gen(function* () {
520
+ const l1 = yield* Ref.make(0);
521
+ const l2 = yield* Ref.make(0);
522
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
523
+ const fx = Matcher.empty
524
+ .match(users, (params) => Fx.map(params, ({ id }) => id))
525
+ .layout(({ content }) =>
526
+ Fx.unwrap(
527
+ Ref.update(l1, (n) => n + 1).pipe(Effect.as(Fx.map(content, (s) => `A:${s}`))),
528
+ ),
529
+ )
530
+ .layout(({ content }) =>
531
+ Fx.unwrap(
532
+ Ref.update(l2, (n) => n + 1).pipe(Effect.as(Fx.map(content, (s) => `B:${s}`))),
533
+ ),
534
+ );
535
+ const values: Array<string> = [];
536
+ const first = Latch.makeUnsafe();
537
+ const done = Latch.makeUnsafe();
538
+ const fiber = yield* Effect.forkChild(
539
+ Fx.observe(fx, (value) =>
540
+ Effect.sync(() => values.push(value)).pipe(
541
+ Effect.flatMap(() => {
542
+ if (values.length === 1) return first.open;
543
+ if (values.length === 2) return done.open;
544
+ return Effect.void;
545
+ }),
546
+ ),
547
+ ),
548
+ );
549
+ yield* Effect.yieldNow;
550
+ yield* first.await;
551
+ yield* Navigation.navigate("http://localhost/users/2");
552
+ yield* done.await;
553
+ yield* Fiber.interrupt(fiber);
554
+ assert.deepStrictEqual(values, ["B:A:1", "B:A:2"]);
555
+ assert.strictEqual(yield* Ref.get(l1), 1);
556
+ assert.strictEqual(yield* Ref.get(l2), 1);
557
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
558
+ ));
559
+
560
+ it("chained provide layers merge services for handler", () =>
561
+ runEff(
562
+ Effect.gen(function* () {
563
+ class SvcA extends ServiceMap.Service<SvcA, { readonly n: number }>()("SvcA") {}
564
+ class SvcB extends ServiceMap.Service<SvcB, { readonly s: string }>()("SvcB") {}
565
+ const about = Route.Parse("about");
566
+ const fx = Matcher.empty
567
+ .match(about, () =>
568
+ Fx.unwrap(
569
+ Effect.gen(function* () {
570
+ const a = yield* SvcA;
571
+ const b = yield* SvcB;
572
+ return Fx.succeed(`${a.n}:${b.s}`);
573
+ }),
574
+ ),
575
+ )
576
+ .provide(Layer.succeed(SvcA, { n: 7 }))
577
+ .provide(Layer.succeed(SvcB, { s: "z" }));
578
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
579
+ assert.deepStrictEqual(values, ["7:z"]);
580
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
581
+ ));
582
+
583
+ it("route dependencies plus matcher provide both available to handler", () =>
584
+ runEff(
585
+ Effect.gen(function* () {
586
+ class RouteSvc extends ServiceMap.Service<RouteSvc, { readonly x: number }>()(
587
+ "RouteSvc",
588
+ ) {}
589
+ class MatcherSvc extends ServiceMap.Service<MatcherSvc, { readonly y: string }>()(
590
+ "MatcherSvc",
591
+ ) {}
592
+ const about = Route.Parse("about");
593
+ const fx = Matcher.empty
594
+ .match(about, {
595
+ handler: Effect.gen(function* () {
596
+ const x = yield* RouteSvc;
597
+ const y = yield* MatcherSvc;
598
+ return `${x.x}:${y.y}`;
599
+ }),
600
+ dependencies: [Layer.succeed(RouteSvc, { x: 3 })],
601
+ } as any)
602
+ .provide(Layer.succeed(MatcherSvc, { y: "q" }));
603
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
604
+ assert.deepStrictEqual(values, ["3:q"]);
605
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
606
+ ));
607
+
608
+ it("guard fail then none then success: first succeeding guard wins, prior fail/none not surfaced", () =>
609
+ runEff(
610
+ Effect.gen(function* () {
611
+ const order = yield* Ref.make<ReadonlyArray<string>>([]);
612
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
613
+ const fx = Matcher.empty
614
+ .match(
615
+ users,
616
+ () =>
617
+ Ref.update(order, (xs) => [...xs, "fail"]).pipe(
618
+ Effect.flatMap(() => Effect.fail("g1")),
619
+ ),
620
+ "skip1",
621
+ )
622
+ .match(
623
+ users,
624
+ () => Ref.update(order, (xs) => [...xs, "none"]).pipe(Effect.as(Option.none())),
625
+ "skip2",
626
+ )
627
+ .match(
628
+ users,
629
+ () =>
630
+ Ref.update(order, (xs) => [...xs, "ok"]).pipe(
631
+ Effect.as(Option.some({ ok: true as const })),
632
+ ),
633
+ "hit",
634
+ );
635
+ const values = yield* Fx.collectAll(Fx.take(fx, 1));
636
+ assert.deepStrictEqual(values, ["hit"]);
637
+ assert.deepStrictEqual(yield* Ref.get(order), ["fail", "none", "ok"]);
638
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
639
+ ));
640
+
641
+ it("RouteGuardError with layout on matcher: recover at Effect boundary; no wrap prefix on mapEffect failure", () =>
642
+ runEff(
643
+ Effect.gen(function* () {
644
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
645
+ const about = Route.Parse("about");
646
+ const inner = Matcher.empty
647
+ .match(users, () => Effect.fail("g"), "x")
648
+ .match(about, Fx.succeed("ok"))
649
+ .layout(({ content }) => Fx.map(content, (s) => `wrap:${s}`));
650
+ const recovered = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(
651
+ Effect.as("" as const),
652
+ Effect.catchTag("RouteGuardError" as const, () =>
653
+ Effect.succeed("guard-fallback" as const),
654
+ ),
655
+ );
656
+ assert.strictEqual(recovered, "guard-fallback");
657
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
658
+ ));
659
+
660
+ it("instance catchTag only wraps handler errors; RouteGuardError from guards needs Effect.catchTag on collect", () =>
661
+ runEff(
662
+ Effect.gen(function* () {
663
+ const users = Route.Join(Route.Parse("users"), Route.Param("id"));
664
+ const inner = (Matcher.empty.match(users, () => Effect.fail("g"), "x") as any).catchTag(
665
+ "RouteGuardError",
666
+ () => Fx.succeed("never"),
667
+ );
668
+ const exited = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(Effect.exit);
669
+ assert.isTrue(Exit.isFailure(exited));
670
+ }).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
671
+ ));
672
+ });
396
673
 
397
674
  it("layout receives updated params when staying on same route", () =>
398
675
  Effect.gen(function* () {
@@ -405,7 +682,7 @@ describe("typed/router/Matcher", () => {
405
682
  Fx.unwrap(Ref.update(layoutMounts, (n) => n + 1).pipe(Effect.as(content))),
406
683
  );
407
684
 
408
- const fx = Matcher.run(matcher);
685
+ const fx = matcher;
409
686
 
410
687
  const values: Array<string> = [];
411
688
  const first = Latch.makeUnsafe();
@@ -456,7 +733,7 @@ describe("typed/router/Matcher", () => {
456
733
  dependencies: [counterLayer],
457
734
  });
458
735
 
459
- const fx = Matcher.run(matcher);
736
+ const fx = matcher;
460
737
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
461
738
 
462
739
  assert.deepStrictEqual(values, [42]);
@@ -483,7 +760,7 @@ describe("typed/router/Matcher", () => {
483
760
  })
484
761
  .match(other, "other");
485
762
 
486
- const fx = Matcher.run(matcher);
763
+ const fx = matcher;
487
764
  const values = yield* Fx.collectAll(Fx.take(fx, 1));
488
765
 
489
766
  assert.deepStrictEqual(values, ["other"]);