@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.
- package/README.md +1 -1
- package/dist/CurrentRoute.d.ts +2 -2
- package/dist/CurrentRoute.d.ts.map +1 -1
- package/dist/CurrentRoute.js +4 -4
- package/dist/Matcher.d.ts +18 -19
- package/dist/Matcher.d.ts.map +1 -1
- package/dist/Matcher.js +112 -109
- package/dist/MatcherV2.d.ts +3 -0
- package/dist/MatcherV2.d.ts.map +1 -0
- package/dist/MatcherV2.js +1 -0
- 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/CurrentRoute.ts +5 -5
- package/src/Matcher.browser.test.ts +767 -0
- package/src/Matcher.test.ts +348 -73
- package/src/Matcher.ts +170 -166
- 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
package/src/Matcher.test.ts
CHANGED
|
@@ -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";
|
|
10
|
-
import * as
|
|
11
|
+
import * as Result from "effect/Result";
|
|
12
|
+
import * as Context from "effect/Context";
|
|
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
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
101
|
+
const fx = Matcher.empty.match(route, () => "about");
|
|
97
102
|
|
|
98
|
-
const
|
|
99
|
-
Effect.as("
|
|
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(
|
|
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 =
|
|
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.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
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),
|
|
@@ -207,22 +208,20 @@ describe("typed/router/Matcher", () => {
|
|
|
207
208
|
const mounts = yield* Ref.make(0);
|
|
208
209
|
const layouts = yield* Ref.make(0);
|
|
209
210
|
|
|
210
|
-
const sharedLayer = Layer.
|
|
211
|
-
Ref.update(mounts, (n) => n + 1).pipe(Effect.as(
|
|
211
|
+
const sharedLayer = Layer.effectContext(
|
|
212
|
+
Ref.update(mounts, (n) => n + 1).pipe(Effect.as(Context.empty())),
|
|
212
213
|
);
|
|
213
214
|
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.
|
|
223
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.
|
|
316
|
-
|
|
317
|
-
|
|
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 =
|
|
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 =
|
|
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,294 @@ describe("typed/router/Matcher", () => {
|
|
|
385
380
|
),
|
|
386
381
|
);
|
|
387
382
|
|
|
388
|
-
const fx =
|
|
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(
|
|
387
|
+
}).pipe(
|
|
388
|
+
Effect.provide(ServerRouter({ url: "http://localhost/about" })),
|
|
389
|
+
Effect.scoped,
|
|
390
|
+
Effect.runPromise,
|
|
391
|
+
));
|
|
393
392
|
|
|
394
|
-
|
|
395
|
-
|
|
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 Context.Service<SvcA, { readonly n: number }>()("SvcA") {}
|
|
564
|
+
class SvcB extends Context.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 Context.Service<RouteSvc, { readonly x: number }>()("RouteSvc") {}
|
|
587
|
+
class MatcherSvc extends Context.Service<MatcherSvc, { readonly y: string }>()(
|
|
588
|
+
"MatcherSvc",
|
|
589
|
+
) {}
|
|
590
|
+
const about = Route.Parse("about");
|
|
591
|
+
const fx = Matcher.empty
|
|
592
|
+
.match(about, {
|
|
593
|
+
handler: Effect.gen(function* () {
|
|
594
|
+
const x = yield* RouteSvc;
|
|
595
|
+
const y = yield* MatcherSvc;
|
|
596
|
+
return `${x.x}:${y.y}`;
|
|
597
|
+
}),
|
|
598
|
+
dependencies: [Layer.succeed(RouteSvc, { x: 3 })],
|
|
599
|
+
} as any)
|
|
600
|
+
.provide(Layer.succeed(MatcherSvc, { y: "q" }));
|
|
601
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
602
|
+
assert.deepStrictEqual(values, ["3:q"]);
|
|
603
|
+
}).pipe(Effect.provide(ServerRouter({ url: "http://localhost/about" })), Effect.scoped),
|
|
604
|
+
));
|
|
605
|
+
|
|
606
|
+
it("guard fail then none then success: first succeeding guard wins, prior fail/none not surfaced", () =>
|
|
607
|
+
runEff(
|
|
608
|
+
Effect.gen(function* () {
|
|
609
|
+
const order = yield* Ref.make<ReadonlyArray<string>>([]);
|
|
610
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
611
|
+
const fx = Matcher.empty
|
|
612
|
+
.match(
|
|
613
|
+
users,
|
|
614
|
+
() =>
|
|
615
|
+
Ref.update(order, (xs) => [...xs, "fail"]).pipe(
|
|
616
|
+
Effect.flatMap(() => Effect.fail("g1")),
|
|
617
|
+
),
|
|
618
|
+
"skip1",
|
|
619
|
+
)
|
|
620
|
+
.match(
|
|
621
|
+
users,
|
|
622
|
+
() => Ref.update(order, (xs) => [...xs, "none"]).pipe(Effect.as(Option.none())),
|
|
623
|
+
"skip2",
|
|
624
|
+
)
|
|
625
|
+
.match(
|
|
626
|
+
users,
|
|
627
|
+
() =>
|
|
628
|
+
Ref.update(order, (xs) => [...xs, "ok"]).pipe(
|
|
629
|
+
Effect.as(Option.some({ ok: true as const })),
|
|
630
|
+
),
|
|
631
|
+
"hit",
|
|
632
|
+
);
|
|
633
|
+
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
634
|
+
assert.deepStrictEqual(values, ["hit"]);
|
|
635
|
+
assert.deepStrictEqual(yield* Ref.get(order), ["fail", "none", "ok"]);
|
|
636
|
+
}).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
|
|
637
|
+
));
|
|
638
|
+
|
|
639
|
+
it("RouteGuardError with layout on matcher: recover at Effect boundary; no wrap prefix on mapEffect failure", () =>
|
|
640
|
+
runEff(
|
|
641
|
+
Effect.gen(function* () {
|
|
642
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
643
|
+
const about = Route.Parse("about");
|
|
644
|
+
const inner = Matcher.empty
|
|
645
|
+
.match(users, () => Effect.fail("g"), "x")
|
|
646
|
+
.match(about, Fx.succeed("ok"))
|
|
647
|
+
.layout(({ content }) => Fx.map(content, (s) => `wrap:${s}`));
|
|
648
|
+
const recovered = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(
|
|
649
|
+
Effect.as("" as const),
|
|
650
|
+
Effect.catchTag("RouteGuardError" as const, () =>
|
|
651
|
+
Effect.succeed("guard-fallback" as const),
|
|
652
|
+
),
|
|
653
|
+
);
|
|
654
|
+
assert.strictEqual(recovered, "guard-fallback");
|
|
655
|
+
}).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
|
|
656
|
+
));
|
|
657
|
+
|
|
658
|
+
it("instance catchTag only wraps handler errors; RouteGuardError from guards needs Effect.catchTag on collect", () =>
|
|
659
|
+
runEff(
|
|
660
|
+
Effect.gen(function* () {
|
|
661
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
662
|
+
const inner = (Matcher.empty.match(users, () => Effect.fail("g"), "x") as any).catchTag(
|
|
663
|
+
"RouteGuardError",
|
|
664
|
+
() => Fx.succeed("never"),
|
|
665
|
+
);
|
|
666
|
+
const exited = yield* Fx.collectAll(Fx.take(inner, 1)).pipe(Effect.exit);
|
|
667
|
+
assert.isTrue(Exit.isFailure(exited));
|
|
668
|
+
}).pipe(Effect.provide(ServerRouter({ url: "http://localhost/users/1" })), Effect.scoped),
|
|
669
|
+
));
|
|
670
|
+
});
|
|
396
671
|
|
|
397
672
|
it("layout receives updated params when staying on same route", () =>
|
|
398
673
|
Effect.gen(function* () {
|
|
@@ -405,7 +680,7 @@ describe("typed/router/Matcher", () => {
|
|
|
405
680
|
Fx.unwrap(Ref.update(layoutMounts, (n) => n + 1).pipe(Effect.as(content))),
|
|
406
681
|
);
|
|
407
682
|
|
|
408
|
-
const fx =
|
|
683
|
+
const fx = matcher;
|
|
409
684
|
|
|
410
685
|
const values: Array<string> = [];
|
|
411
686
|
const first = Latch.makeUnsafe();
|
|
@@ -441,7 +716,7 @@ describe("typed/router/Matcher", () => {
|
|
|
441
716
|
|
|
442
717
|
it("per-route dependencies option provides services to handler", () =>
|
|
443
718
|
Effect.gen(function* () {
|
|
444
|
-
class Counter extends
|
|
719
|
+
class Counter extends Context.Service<Counter, { readonly value: number }>()("Counter") {}
|
|
445
720
|
|
|
446
721
|
const counterLayer = Layer.succeed(Counter, { value: 42 });
|
|
447
722
|
const about = Route.Parse("about");
|
|
@@ -456,7 +731,7 @@ describe("typed/router/Matcher", () => {
|
|
|
456
731
|
dependencies: [counterLayer],
|
|
457
732
|
});
|
|
458
733
|
|
|
459
|
-
const fx =
|
|
734
|
+
const fx = matcher;
|
|
460
735
|
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
461
736
|
|
|
462
737
|
assert.deepStrictEqual(values, [42]);
|
|
@@ -472,8 +747,8 @@ describe("typed/router/Matcher", () => {
|
|
|
472
747
|
const about = Route.Parse("about");
|
|
473
748
|
const other = Route.Parse("other");
|
|
474
749
|
|
|
475
|
-
const layerWithFinalizer = Layer.
|
|
476
|
-
Effect.acquireRelease(Effect.succeed(
|
|
750
|
+
const layerWithFinalizer = Layer.effectContext(
|
|
751
|
+
Effect.acquireRelease(Effect.succeed(Context.empty()), () => Ref.set(finalized, true)),
|
|
477
752
|
);
|
|
478
753
|
|
|
479
754
|
const matcher = Matcher.empty
|
|
@@ -483,7 +758,7 @@ describe("typed/router/Matcher", () => {
|
|
|
483
758
|
})
|
|
484
759
|
.match(other, "other");
|
|
485
760
|
|
|
486
|
-
const fx =
|
|
761
|
+
const fx = matcher;
|
|
487
762
|
const values = yield* Fx.collectAll(Fx.take(fx, 1));
|
|
488
763
|
|
|
489
764
|
assert.deepStrictEqual(values, ["other"]);
|