@typed/router 1.0.0-beta.2 → 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 (26) hide show
  1. package/dist/Matcher.d.ts +6 -7
  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/test-utils/matcherBrowserHarness.d.ts +10 -0
  8. package/dist/test-utils/matcherBrowserHarness.d.ts.map +1 -0
  9. package/dist/test-utils/matcherBrowserHarness.js +13 -0
  10. package/package.json +16 -13
  11. package/src/Matcher.browser.test.ts +771 -0
  12. package/src/Matcher.test.ts +344 -67
  13. package/src/Matcher.ts +146 -134
  14. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--back---restores-previous-match-1.png +0 -0
  15. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--catchTag-RouteNotFound-can-navigate-and-re-match--browser-history--1.png +0 -0
  16. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--decodes-query-params-from-pathname-search-1.png +0 -0
  17. 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
  18. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--history-back-restores-previous-match--popstate-sync--1.png +0 -0
  19. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--path-and-query-params-both-decode--distinct-names--1.png +0 -0
  20. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--prefix-scopes-routes-under-a-path-segment-1.png +0 -0
  21. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--provideService-supplies-a-service-to-handlers-1.png +0 -0
  22. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--query-param-wins-over-path-param-when-names-collide-1.png +0 -0
  23. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-away-from-unmatched-path--side-effect--1.png +0 -0
  24. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--redirectTo-navigates-then-matches-target-route-1.png +0 -0
  25. package/src/__screenshots__/Matcher.browser.test.ts/typed-router-Matcher--browser--reuses-shared-layers-and-layouts-across-route-changes-1.png +0 -0
  26. package/src/test-utils/matcherBrowserHarness.ts +22 -0
package/src/Matcher.ts CHANGED
@@ -3,7 +3,7 @@ import type * as Arr from "effect/Array";
3
3
  import * as Cause from "effect/Cause";
4
4
  import * as Effect from "effect/Effect";
5
5
  import * as Exit from "effect/Exit";
6
- import { interrupt, isSuccess } from "effect/Exit";
6
+ import { interrupt } from "effect/Exit";
7
7
  import { dual, identity } from "effect/Function";
8
8
  import * as Result from "effect/Result";
9
9
  import * as Layer from "effect/Layer";
@@ -25,7 +25,7 @@ import { fromEffect, never } from "@typed/fx/Fx/constructors/fromEffect";
25
25
  import { succeed } from "@typed/fx/Fx/constructors/succeed";
26
26
  import type * as Fx from "@typed/fx/Fx/Fx";
27
27
  import { fromStream } from "@typed/fx/Fx/stream";
28
- import { isFx } from "@typed/fx/Fx/TypeId";
28
+ import { FxTypeId, isFx } from "@typed/fx/Fx/TypeId";
29
29
  import { RefSubject } from "@typed/fx/RefSubject";
30
30
  import { CurrentPath, Navigation } from "@typed/navigation/Navigation";
31
31
  import type { MatchAst, RouteAst } from "./AST.js";
@@ -33,6 +33,7 @@ import * as AST from "./AST.js";
33
33
  import { CurrentRoute } from "./CurrentRoute.js";
34
34
  import { Join, make as makeRoute, type Route } from "./Route.js";
35
35
  import type { Router } from "./Router.js";
36
+ import { Sink } from "@typed/fx";
36
37
 
37
38
  export type Layout<Params, A, E, R, B, E2, R2> = (
38
39
  params: LayoutParams<Params, A, E, R>,
@@ -149,7 +150,10 @@ type ComputeMatchResult<E2, R2, D, LB, LE2, LR2, C, GE, GR> = ApplyCatch<
149
150
  C
150
151
  >;
151
152
 
152
- export interface Matcher<A, E = never, R = never> extends Pipeable {
153
+ export interface Matcher<A, E = never, R = never>
154
+ extends
155
+ Fx.Fx<A, E | RouteNotFound | RouteDecodeError | RouteGuardError, R | Router | Scope.Scope>,
156
+ Pipeable {
153
157
  readonly cases: ReadonlyArray<MatchAst>;
154
158
 
155
159
  // Overload 1: match(route, handler) - function handler (must be first for inference)
@@ -431,6 +435,15 @@ function parseMatchArgs(args: [unknown, ...Array<unknown>]): ParsedMatch {
431
435
  }
432
436
 
433
437
  class MatcherImpl<A, E, R> implements Matcher<A, E, R> {
438
+ readonly [FxTypeId]: Fx.Fx.Variance<
439
+ A,
440
+ E | RouteNotFound | RouteDecodeError | RouteGuardError,
441
+ R | Scope.Scope | Router
442
+ > = {
443
+ _A: identity,
444
+ _E: identity,
445
+ _R: identity,
446
+ };
434
447
  readonly cases: ReadonlyArray<MatchAst>;
435
448
  constructor(cases: ReadonlyArray<MatchAst>) {
436
449
  this.cases = cases;
@@ -660,118 +673,13 @@ class MatcherImpl<A, E, R> implements Matcher<A, E, R> {
660
673
  pipe() {
661
674
  return pipeArguments(this, arguments);
662
675
  }
663
- }
664
-
665
- function normalizeHandler<Params, B, E2 = never, R2 = never>(
666
- handler: MatchHandler<Params, B, E2, R2>,
667
- ): (params: RefSubject.RefSubject<Params>) => Fx.Fx<B, E2, R2> {
668
- if (isMatchHandlerFn(handler)) return (params) => toFx(handler(params));
669
- return () => toFx(handler);
670
- }
671
-
672
- function toFx<A, E, R>(
673
- value: Fx.Fx<A, E, R> | Stream.Stream<A, E, R> | Effect.Effect<A, E, R> | A,
674
- ): Fx.Fx<A, E, R> {
675
- if (isFx(value)) return value;
676
- if (Stream.isStream(value)) return fromStream(value);
677
- if (Effect.isEffect(value)) return fromEffect(value);
678
- return succeed(value);
679
- }
680
-
681
- export const empty: Matcher<never> = new MatcherImpl([]);
682
- export const match = empty.match.bind(empty);
683
-
684
- /**
685
- * Merge multiple matchers into one. Each matcher's layouts and provide apply only to its own routes.
686
- * Use this so directory layouts (e.g. api/_layout) and directory dependencies apply only to routes under that directory.
687
- */
688
- export function merge<const Matchers extends ReadonlyArray<Matcher.Any>>(
689
- ...matchers: Matchers
690
- ): Matcher<
691
- Matcher.MergeSuccess<Matchers>,
692
- Matcher.MergeError<Matchers>,
693
- Matcher.MergeServices<Matchers>
694
- > {
695
- if (matchers.length === 0) {
696
- return empty as unknown as Matcher<
697
- Matcher.MergeSuccess<Matchers>,
698
- Matcher.MergeError<Matchers>,
699
- Matcher.MergeServices<Matchers>
700
- >;
701
- }
702
- if (matchers.length === 1) {
703
- return matchers[0] as unknown as Matcher<
704
- Matcher.MergeSuccess<Matchers>,
705
- Matcher.MergeError<Matchers>,
706
- Matcher.MergeServices<Matchers>
707
- >;
708
- }
709
- const first = matchers[0] as MatcherImpl<
710
- Matcher.MergeSuccess<Matchers>,
711
- Matcher.MergeError<Matchers>,
712
- Matcher.MergeServices<Matchers>
713
- >;
714
- const rest = matchers.slice(1) as ReadonlyArray<
715
- Matcher<
716
- Matcher.MergeSuccess<Matchers>,
717
- Matcher.MergeError<Matchers>,
718
- Matcher.MergeServices<Matchers>
719
- >
720
- >;
721
- return first.merge(...rest) as unknown as Matcher<
722
- Matcher.MergeSuccess<Matchers>,
723
- Matcher.MergeError<Matchers>,
724
- Matcher.MergeServices<Matchers>
725
- >;
726
- }
727
676
 
728
- export class RouteGuardError extends Schema.ErrorClass<RouteGuardError>(
729
- "@typed/router/RouteGuardError",
730
- )({
731
- _tag: Schema.tag("RouteGuardError"),
732
- path: Schema.String,
733
- causes: Schema.Array(Schema.Unknown),
734
- }) {}
735
-
736
- export class RouteNotFound extends Schema.ErrorClass<RouteNotFound>("@typed/router/RouteNotFound")({
737
- _tag: Schema.tag("RouteNotFound"),
738
- path: Schema.String,
739
- }) {}
740
-
741
- export class RouteDecodeError extends Schema.ErrorClass<RouteDecodeError>(
742
- "@typed/router/RouteDecodeError",
743
- )({
744
- _tag: Schema.tag("RouteDecodeError"),
745
- path: Schema.String,
746
- cause: Schema.String,
747
- }) {}
748
-
749
- /**
750
- * @internal
751
- */
752
- export type CompiledEntry = {
753
- readonly route: Route.Any;
754
- readonly guard: AnyGuard;
755
- readonly handler: AnyMatchHandler;
756
- readonly layers: ReadonlyArray<AnyLayer>;
757
- readonly layouts: ReadonlyArray<AnyLayout>;
758
- readonly catches: ReadonlyArray<AnyCatch>;
759
- readonly decode: (input: unknown) => Effect.Effect<any, Schema.SchemaError, any>;
760
- };
761
-
762
- export function run<M extends Matcher.Any>(
763
- matcher: M,
764
- ): Fx.Fx<
765
- Matcher.Success<M>,
766
- Matcher.Error<M> | RouteNotFound | RouteDecodeError | RouteGuardError,
767
- Matcher.Services<M> | Router | CurrentRoute | Scope.Scope
768
- > {
769
- return unwrap(
770
- Effect.gen(function* () {
677
+ run<RSink>(sink: Sink.Sink<A, E | RouteNotFound | RouteDecodeError | RouteGuardError, RSink>) {
678
+ return Effect.gen({ self: this }, function* () {
771
679
  const fiberId = yield* Effect.fiberId;
772
680
  const rootScope = yield* Effect.scope;
773
681
  const current = yield* CurrentRoute;
774
- const prefixed = matcher.prefix(current.route);
682
+ const prefixed = this.prefix(current.route);
775
683
  const entries = compile(prefixed.cases);
776
684
  const router = findMyWay.make<ReadonlyArray<CompiledEntry>>({
777
685
  ignoreTrailingSlash: true,
@@ -798,11 +706,11 @@ export function run<M extends Matcher.Any>(
798
706
  let currentState: {
799
707
  entry: CompiledEntry;
800
708
  params: RefSubject.RefSubject<any>;
801
- fx: Fx.Fx<Matcher.Success<M>, Matcher.Error<M>, Matcher.Services<M> | Scope.Scope | Router>;
709
+ fx: Fx.Fx<A, E, R | Scope.Scope | Router>;
802
710
  scope: Scope.Closeable;
803
711
  } | null = null;
804
712
 
805
- return CurrentPath.pipe(
713
+ const stream = CurrentPath.pipe(
806
714
  mapEffect(
807
715
  Effect.fn(function* (path) {
808
716
  const result = router.find("GET", path);
@@ -905,10 +813,109 @@ export function run<M extends Matcher.Any>(
905
813
  skipRepeats,
906
814
  switchMap(identity),
907
815
  );
908
- }),
909
- );
816
+
817
+ return yield* stream.run(sink);
818
+ });
819
+ }
910
820
  }
911
821
 
822
+ function normalizeHandler<Params, B, E2 = never, R2 = never>(
823
+ handler: MatchHandler<Params, B, E2, R2>,
824
+ ): (params: RefSubject.RefSubject<Params>) => Fx.Fx<B, E2, R2> {
825
+ if (isMatchHandlerFn(handler)) return (params) => toFx(handler(params));
826
+ return () => toFx(handler);
827
+ }
828
+
829
+ function toFx<A, E, R>(
830
+ value: Fx.Fx<A, E, R> | Stream.Stream<A, E, R> | Effect.Effect<A, E, R> | A,
831
+ ): Fx.Fx<A, E, R> {
832
+ if (isFx(value)) return value;
833
+ if (Stream.isStream(value)) return fromStream(value);
834
+ if (Effect.isEffect(value)) return fromEffect(value);
835
+ return succeed(value);
836
+ }
837
+
838
+ export const empty: Matcher<never> = new MatcherImpl([]);
839
+ export const match = empty.match.bind(empty);
840
+
841
+ /**
842
+ * Merge multiple matchers into one. Each matcher's layouts and provide apply only to its own routes.
843
+ * Use this so directory layouts (e.g. api/_layout) and directory dependencies apply only to routes under that directory.
844
+ */
845
+ export function merge<const Matchers extends ReadonlyArray<Matcher.Any>>(
846
+ ...matchers: Matchers
847
+ ): Matcher<
848
+ Matcher.MergeSuccess<Matchers>,
849
+ Matcher.MergeError<Matchers>,
850
+ Matcher.MergeServices<Matchers>
851
+ > {
852
+ if (matchers.length === 0) {
853
+ return empty as unknown as Matcher<
854
+ Matcher.MergeSuccess<Matchers>,
855
+ Matcher.MergeError<Matchers>,
856
+ Matcher.MergeServices<Matchers>
857
+ >;
858
+ }
859
+ if (matchers.length === 1) {
860
+ return matchers[0] as unknown as Matcher<
861
+ Matcher.MergeSuccess<Matchers>,
862
+ Matcher.MergeError<Matchers>,
863
+ Matcher.MergeServices<Matchers>
864
+ >;
865
+ }
866
+ const first = matchers[0] as MatcherImpl<
867
+ Matcher.MergeSuccess<Matchers>,
868
+ Matcher.MergeError<Matchers>,
869
+ Matcher.MergeServices<Matchers>
870
+ >;
871
+ const rest = matchers.slice(1) as ReadonlyArray<
872
+ Matcher<
873
+ Matcher.MergeSuccess<Matchers>,
874
+ Matcher.MergeError<Matchers>,
875
+ Matcher.MergeServices<Matchers>
876
+ >
877
+ >;
878
+ return first.merge(...rest) as unknown as Matcher<
879
+ Matcher.MergeSuccess<Matchers>,
880
+ Matcher.MergeError<Matchers>,
881
+ Matcher.MergeServices<Matchers>
882
+ >;
883
+ }
884
+
885
+ export class RouteGuardError extends Schema.ErrorClass<RouteGuardError>(
886
+ "@typed/router/RouteGuardError",
887
+ )({
888
+ _tag: Schema.tag("RouteGuardError"),
889
+ path: Schema.String,
890
+ causes: Schema.Array(Schema.Unknown),
891
+ }) {}
892
+
893
+ export class RouteNotFound extends Schema.ErrorClass<RouteNotFound>("@typed/router/RouteNotFound")({
894
+ _tag: Schema.tag("RouteNotFound"),
895
+ path: Schema.String,
896
+ }) {}
897
+
898
+ export class RouteDecodeError extends Schema.ErrorClass<RouteDecodeError>(
899
+ "@typed/router/RouteDecodeError",
900
+ )({
901
+ _tag: Schema.tag("RouteDecodeError"),
902
+ path: Schema.String,
903
+ cause: Schema.String,
904
+ }) {}
905
+
906
+ /**
907
+ * @internal
908
+ */
909
+ export type CompiledEntry = {
910
+ readonly route: Route.Any;
911
+ readonly guard: AnyGuard;
912
+ readonly handler: AnyMatchHandler;
913
+ readonly layers: ReadonlyArray<AnyLayer>;
914
+ readonly layouts: ReadonlyArray<AnyLayout>;
915
+ readonly catches: ReadonlyArray<AnyCatch>;
916
+ readonly decode: (input: unknown) => Effect.Effect<any, Schema.SchemaError, any>;
917
+ };
918
+
912
919
  type InputSucces<T> = [Matcher.Success<T> | Fx.Fx.Success<T>] extends [infer A] ? A : never;
913
920
  type InputError<T> = [Matcher.Error<T> | Fx.Fx.Error<T>] extends [infer E] ? E : never;
914
921
  type InputServices<T> = [Matcher.Services<T> | Fx.Fx.Services<T>] extends [infer R] ? R : never;
@@ -943,11 +950,10 @@ export const catchCause: {
943
950
  const eff = Effect.gen(function* () {
944
951
  const fiberId = yield* Effect.fiberId;
945
952
  const rootScope = yield* Effect.scope;
946
- const fx = isFx(input) ? input : run(input);
947
953
  const manager = makeCatchManager(rootScope, fiberId);
948
954
  const result = yield* manager.apply(
949
955
  [f],
950
- fx,
956
+ input,
951
957
  ServiceMap.empty() as ServiceMap.ServiceMap<any>,
952
958
  );
953
959
  return result as Fx.Fx<A | B, E2, R | R2 | Router | Scope.Scope>;
@@ -960,6 +966,11 @@ export const catch_: {
960
966
  <I extends Fx.Fx.Any | Matcher.Any, B, E2, R2>(
961
967
  f: (e: InputError<I>) => Fx.Fx<B, E2, R2>,
962
968
  ): (input: I) => Fx.Fx<InputSucces<I> | B, E2, InputServices<I> | R2 | Router | Scope.Scope>;
969
+
970
+ <I extends Fx.Fx.Any | Matcher.Any, B, E2, R2>(
971
+ input: I,
972
+ f: (e: InputError<I>) => Fx.Fx<B, E2, R2>,
973
+ ): Fx.Fx<InputSucces<I> | B, E2, InputServices<I> | R2 | Router | Scope.Scope>;
963
974
  } = dual(
964
975
  2,
965
976
  <I extends Fx.Fx.Any | Matcher.Any, B, E2, R2>(
@@ -1092,26 +1103,27 @@ function normalizeDependencies(
1092
1103
  return dependencies.map(toSingleLayer);
1093
1104
  }
1094
1105
 
1095
- type NormalizeLayer<T extends AnyDependency> =
1096
- T extends Layer.Layer<infer A, infer E, infer R> ? Layer.Layer<A, E, R> : T extends ServiceMap.ServiceMap<infer R> ? Layer.Layer<R> : never
1097
-
1098
- type NormalizeLayers<T extends ReadonlyArray<AnyDependency>> ={
1099
- [K in keyof T]: NormalizeLayer<T[K]>
1100
- }
1106
+ type NormalizeLayer<T extends AnyDependency> =
1107
+ T extends Layer.Layer<infer A, infer E, infer R>
1108
+ ? Layer.Layer<A, E, R>
1109
+ : T extends ServiceMap.ServiceMap<infer R>
1110
+ ? Layer.Layer<R>
1111
+ : never;
1112
+
1113
+ type NormalizeLayers<T extends ReadonlyArray<AnyDependency>> = {
1114
+ [K in keyof T]: NormalizeLayer<T[K]>;
1115
+ };
1101
1116
 
1102
- type ToLayer<T> =
1103
- T extends ReadonlyArray<AnyLayer> ? Layer.Layer<
1104
- Layer.Success<T[number]>,
1105
- Layer.Error<T[number]>,
1106
- Layer.Services<T[number]>
1107
- > : never
1117
+ type ToLayer<T> =
1118
+ T extends ReadonlyArray<AnyLayer>
1119
+ ? Layer.Layer<Layer.Success<T[number]>, Layer.Error<T[number]>, Layer.Services<T[number]>>
1120
+ : never;
1108
1121
 
1109
- type NormalizeDeps<T extends AnyDependency | ReadonlyArray<AnyDependency>> =
1110
- T extends AnyDependency
1122
+ type NormalizeDeps<T extends AnyDependency | ReadonlyArray<AnyDependency>> = T extends AnyDependency
1111
1123
  ? NormalizeLayer<T>
1112
1124
  : T extends ReadonlyArray<AnyDependency>
1113
- ? ToLayer<NormalizeLayers<T>>
1114
- : never
1125
+ ? ToLayer<NormalizeLayers<T>>
1126
+ : never;
1115
1127
 
1116
1128
  /**
1117
1129
  * Normalize dependency input (ServiceMap | Layer | Array of either) into a single Layer.
@@ -1432,7 +1444,7 @@ export function makeCatchManager(rootScope: Scope.Scope, fiberId: number) {
1432
1444
  exit,
1433
1445
  mapEffect(
1434
1446
  Effect.fn(function* (e) {
1435
- if (isSuccess(e)) return succeed(e.value);
1447
+ if (Exit.isSuccess(e)) return succeed(e.value);
1436
1448
  yield* RefSubject.set(causes, e.cause);
1437
1449
  return fallback;
1438
1450
  }),
@@ -0,0 +1,22 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { BrowserRouter, Router } from "../Router.js";
3
+ import { Scope } from "effect";
4
+
5
+ /**
6
+ * Build an absolute URL for the Vitest browser page origin (same pattern as in-memory tests using http://localhost/...).
7
+ */
8
+ export const absoluteUrl = (
9
+ path: string,
10
+ win: Window & typeof globalThis = globalThis.window,
11
+ ): string => {
12
+ const normalized = path.startsWith("/") ? path : `/${path}`;
13
+ return new URL(normalized, win.location.origin).href;
14
+ };
15
+
16
+ /**
17
+ * Run an effect with {@link BrowserRouter} scoped over the real (or test) `window`.
18
+ */
19
+ export const runWithBrowserRouter = <A, E>(
20
+ effect: Effect.Effect<A, E, Router | Scope.Scope>,
21
+ win: Window & typeof globalThis = globalThis.window,
22
+ ): Promise<A> => effect.pipe(Effect.provide(BrowserRouter(win)), Effect.scoped, Effect.runPromise);