@richie-router/react 0.1.3 → 0.1.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.
@@ -69,6 +69,7 @@ __export(exports_router, {
69
69
  useParams: () => useParams,
70
70
  useNavigate: () => useNavigate,
71
71
  useMatches: () => useMatches,
72
+ useMatchRoute: () => useMatchRoute,
72
73
  useMatch: () => useMatch,
73
74
  useLocation: () => useLocation,
74
75
  useElementScrollRestoration: () => useElementScrollRestoration,
@@ -97,7 +98,7 @@ module.exports = __toCommonJS(exports_router);
97
98
  var import_react = __toESM(require("react"));
98
99
  var import_core = require("@richie-router/core");
99
100
  var import_history = require("./history.cjs");
100
- var jsx_dev_runtime = require("react/jsx-dev-runtime");
101
+ var jsx_runtime = require("react/jsx-runtime");
101
102
  var RouterContext = import_react.default.createContext(null);
102
103
  var RouterStateContext = import_react.default.createContext(null);
103
104
  var OutletContext = import_react.default.createContext(null);
@@ -158,6 +159,21 @@ function prependBasePathToHref(href, basePath) {
158
159
  function routeHasRecord(value) {
159
160
  return typeof value === "object" && value !== null;
160
161
  }
162
+ function isDeepInclusiveMatch(expected, actual) {
163
+ if (Array.isArray(expected)) {
164
+ if (!Array.isArray(actual) || expected.length !== actual.length) {
165
+ return false;
166
+ }
167
+ return expected.every((value, index) => isDeepInclusiveMatch(value, actual[index]));
168
+ }
169
+ if (routeHasRecord(expected)) {
170
+ if (!routeHasRecord(actual)) {
171
+ return false;
172
+ }
173
+ return Object.entries(expected).every(([key, value]) => (key in actual) && isDeepInclusiveMatch(value, actual[key]));
174
+ }
175
+ return Object.is(expected, actual);
176
+ }
161
177
  function routeHasInlineHead(route) {
162
178
  const headOption = route.options.head;
163
179
  return Boolean(headOption && typeof headOption !== "string");
@@ -802,17 +818,17 @@ function RenderMatches({ matches, index }) {
802
818
  return null;
803
819
  }
804
820
  const Component = match.route.options.component;
805
- const outlet = /* @__PURE__ */ jsx_dev_runtime.jsxDEV(RenderMatches, {
821
+ const outlet = /* @__PURE__ */ jsx_runtime.jsx(RenderMatches, {
806
822
  matches,
807
823
  index: index + 1
808
- }, undefined, false, undefined, this);
809
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(MatchContext.Provider, {
824
+ });
825
+ return /* @__PURE__ */ jsx_runtime.jsx(MatchContext.Provider, {
810
826
  value: match,
811
- children: /* @__PURE__ */ jsx_dev_runtime.jsxDEV(OutletContext.Provider, {
827
+ children: /* @__PURE__ */ jsx_runtime.jsx(OutletContext.Provider, {
812
828
  value: outlet,
813
- children: Component ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Component, {}, undefined, false, undefined, this) : outlet
814
- }, undefined, false, undefined, this)
815
- }, undefined, false, undefined, this);
829
+ children: Component ? /* @__PURE__ */ jsx_runtime.jsx(Component, {}) : outlet
830
+ })
831
+ });
816
832
  }
817
833
  function renderError(error, matches, router) {
818
834
  const reversed = [...matches].reverse();
@@ -821,9 +837,9 @@ function renderError(error, matches, router) {
821
837
  if (NotFoundComponent) {
822
838
  return import_react.default.createElement(NotFoundComponent);
823
839
  }
824
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
840
+ return /* @__PURE__ */ jsx_runtime.jsx("div", {
825
841
  children: "Not Found"
826
- }, undefined, false, undefined, this);
842
+ });
827
843
  }
828
844
  const ErrorComponent = reversed.find((match) => match.route.options.errorComponent)?.route.options.errorComponent ?? router.options.defaultErrorComponent;
829
845
  if (ErrorComponent) {
@@ -834,9 +850,9 @@ function renderError(error, matches, router) {
834
850
  }
835
851
  });
836
852
  }
837
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("pre", {
853
+ return /* @__PURE__ */ jsx_runtime.jsx("pre", {
838
854
  children: error instanceof Error ? error.message : "Unknown routing error"
839
- }, undefined, false, undefined, this);
855
+ });
840
856
  }
841
857
  function RouterProvider({ router }) {
842
858
  const snapshot = import_react.default.useSyncExternalStore(router.subscribe, router.getSnapshot, router.getSnapshot);
@@ -849,17 +865,17 @@ function RouterProvider({ router }) {
849
865
  import_react.default.useEffect(() => {
850
866
  reconcileDocumentHead(snapshot.head);
851
867
  }, [snapshot.head]);
852
- const content = snapshot.error ? renderError(snapshot.error, snapshot.matches, router) : /* @__PURE__ */ jsx_dev_runtime.jsxDEV(RenderMatches, {
868
+ const content = snapshot.error ? renderError(snapshot.error, snapshot.matches, router) : /* @__PURE__ */ jsx_runtime.jsx(RenderMatches, {
853
869
  matches: snapshot.matches,
854
870
  index: 0
855
- }, undefined, false, undefined, this);
856
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(RouterContext.Provider, {
871
+ });
872
+ return /* @__PURE__ */ jsx_runtime.jsx(RouterContext.Provider, {
857
873
  value: router,
858
- children: /* @__PURE__ */ jsx_dev_runtime.jsxDEV(RouterStateContext.Provider, {
874
+ children: /* @__PURE__ */ jsx_runtime.jsx(RouterStateContext.Provider, {
859
875
  value: snapshot,
860
876
  children: content
861
- }, undefined, false, undefined, this)
862
- }, undefined, false, undefined, this);
877
+ })
878
+ });
863
879
  }
864
880
  function Outlet() {
865
881
  return import_react.default.useContext(OutletContext);
@@ -885,6 +901,31 @@ function useNavigate() {
885
901
  await router.navigate(options);
886
902
  }, [router]);
887
903
  }
904
+ function useMatchRoute() {
905
+ const location = useLocation();
906
+ return import_react.default.useCallback((options) => {
907
+ const matched = import_core.matchPathname(options.to, location.pathname, {
908
+ partial: options.fuzzy === true
909
+ });
910
+ if (!matched) {
911
+ return false;
912
+ }
913
+ if (options.params) {
914
+ for (const [key, value] of Object.entries(options.params)) {
915
+ if (value !== undefined && matched.params[key] !== value) {
916
+ return false;
917
+ }
918
+ }
919
+ }
920
+ if (options.includeSearch) {
921
+ const expectedSearch = options.search ?? {};
922
+ if (!isDeepInclusiveMatch(expectedSearch, location.search)) {
923
+ return false;
924
+ }
925
+ }
926
+ return matched.params;
927
+ }, [location.pathname, location.search]);
928
+ }
888
929
  function useLocation() {
889
930
  return useRouterStateContext().location;
890
931
  }
@@ -928,12 +969,14 @@ function useElementScrollRestoration() {
928
969
  ref: () => {}
929
970
  };
930
971
  }
931
- function useResolvedLink(props) {
972
+ function useResolvedLink(props, activeOptions) {
932
973
  const router = useRouterContext();
933
974
  const href = router.buildHref(props);
934
975
  const location = useLocation();
935
- const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
936
- const isActive = pathOnly === location.pathname;
976
+ const targetPathname = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
977
+ const isActive = import_core.matchPathname(targetPathname, location.pathname, {
978
+ partial: activeOptions?.exact !== true
979
+ }) !== null;
937
980
  return { href, isActive, router };
938
981
  }
939
982
  var LinkComponent = import_react.default.forwardRef(function LinkInner(props, ref) {
@@ -949,6 +992,7 @@ var LinkComponent = import_react.default.forwardRef(function LinkInner(props, re
949
992
  state,
950
993
  mask,
951
994
  ignoreBlocker,
995
+ activeOptions,
952
996
  activeProps,
953
997
  children,
954
998
  onClick,
@@ -969,7 +1013,7 @@ var LinkComponent = import_react.default.forwardRef(function LinkInner(props, re
969
1013
  mask,
970
1014
  ignoreBlocker
971
1015
  };
972
- const { href, isActive, router } = useResolvedLink(navigation);
1016
+ const { href, isActive, router } = useResolvedLink(navigation, activeOptions);
973
1017
  const preloadMode = preload ?? router.options.defaultPreload;
974
1018
  const preloadDelay = router.options.defaultPreloadDelay ?? 50;
975
1019
  const preloadTimeout = import_react.default.useRef(null);
@@ -994,7 +1038,7 @@ var LinkComponent = import_react.default.forwardRef(function LinkInner(props, re
994
1038
  }
995
1039
  }, []);
996
1040
  const renderedChildren = typeof children === "function" ? children({ isActive }) : children;
997
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("a", {
1041
+ return /* @__PURE__ */ jsx_runtime.jsx("a", {
998
1042
  ...anchorProps,
999
1043
  ...isActive ? activeProps : undefined,
1000
1044
  ref,
@@ -1022,7 +1066,7 @@ var LinkComponent = import_react.default.forwardRef(function LinkInner(props, re
1022
1066
  },
1023
1067
  onBlur: cancelPreload,
1024
1068
  children: renderedChildren
1025
- }, undefined, false, undefined, this);
1069
+ });
1026
1070
  });
1027
1071
  var Link = LinkComponent;
1028
1072
  function createLink(Component) {
@@ -1039,6 +1083,7 @@ function createLink(Component) {
1039
1083
  state,
1040
1084
  mask,
1041
1085
  ignoreBlocker,
1086
+ activeOptions,
1042
1087
  activeProps,
1043
1088
  children,
1044
1089
  preload,
@@ -1056,7 +1101,7 @@ function createLink(Component) {
1056
1101
  mask,
1057
1102
  ignoreBlocker
1058
1103
  };
1059
- const { href, isActive, router } = useResolvedLink(navigation);
1104
+ const { href, isActive, router } = useResolvedLink(navigation, activeOptions);
1060
1105
  const renderedChildren = typeof children === "function" ? children({ isActive }) : children;
1061
1106
  import_react.default.useEffect(() => {
1062
1107
  if (preload !== "render") {
@@ -1064,12 +1109,12 @@ function createLink(Component) {
1064
1109
  }
1065
1110
  router.preloadRoute(navigation);
1066
1111
  }, [navigation, preload, router]);
1067
- return /* @__PURE__ */ jsx_dev_runtime.jsxDEV(Component, {
1112
+ return /* @__PURE__ */ jsx_runtime.jsx(Component, {
1068
1113
  ...componentProps,
1069
1114
  ...isActive ? activeProps : undefined,
1070
1115
  href,
1071
1116
  children: renderedChildren
1072
- }, undefined, false, undefined, this);
1117
+ });
1073
1118
  };
1074
1119
  }
1075
1120
  function linkOptions(options) {
@@ -1,5 +1,38 @@
1
+ var __create = Object.create;
2
+ var __getProtoOf = Object.getPrototypeOf;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toESMCache_node;
10
+ var __toESMCache_esm;
11
+ var __toESM = (mod, isNodeMode, target) => {
12
+ var canCache = mod != null && typeof mod === "object";
13
+ if (canCache) {
14
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
15
+ var cached = cache.get(mod);
16
+ if (cached)
17
+ return cached;
18
+ }
19
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
20
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
21
+ for (let key of __getOwnPropNames(mod))
22
+ if (!__hasOwnProp.call(to, key))
23
+ __defProp(to, key, {
24
+ get: __accessProp.bind(mod, key),
25
+ enumerable: true
26
+ });
27
+ if (canCache)
28
+ cache.set(mod, to);
29
+ return to;
30
+ };
31
+
1
32
  // packages/react/src/router.test.ts
2
33
  var import_bun_test = require("bun:test");
34
+ var import_react = __toESM(require("react"));
35
+ var import_server = require("react-dom/server");
3
36
  var import_router = require("./router.cjs");
4
37
  function createTestRouteTree(options) {
5
38
  const rootRoute = import_router.createRootRoute({
@@ -75,7 +108,67 @@ function createNestedServerHeadTree() {
75
108
  posts: postsRoute
76
109
  });
77
110
  }
111
+ function createLinkTestRouteTree(component) {
112
+ const rootRoute = import_router.createRootRoute({
113
+ component
114
+ });
115
+ const postRoute = import_router.createFileRoute("/post")({
116
+ component: () => null
117
+ });
118
+ const postsRoute = import_router.createFileRoute("/posts")({
119
+ component: () => null
120
+ });
121
+ const postDetailRoute = import_router.createFileRoute("/posts/$postId")({
122
+ component: () => null
123
+ });
124
+ postsRoute._addFileChildren({
125
+ detail: postDetailRoute
126
+ });
127
+ return rootRoute._addFileChildren({
128
+ post: postRoute,
129
+ posts: postsRoute
130
+ });
131
+ }
132
+ function renderLinkMarkup(initialEntry, component) {
133
+ const history = import_router.createMemoryHistory({
134
+ initialEntries: [initialEntry]
135
+ });
136
+ const router = import_router.createRouter({
137
+ routeTree: createLinkTestRouteTree(component),
138
+ history
139
+ });
140
+ return import_server.renderToStaticMarkup(import_react.default.createElement(import_router.RouterProvider, { router }));
141
+ }
78
142
  import_bun_test.describe("createRouter basePath", () => {
143
+ import_bun_test.test('treats "/" as the root basePath', () => {
144
+ const history = import_router.createMemoryHistory({
145
+ initialEntries: ["/about?tab=team#bio"]
146
+ });
147
+ const router = import_router.createRouter({
148
+ routeTree: createTestRouteTree(),
149
+ history,
150
+ basePath: "/"
151
+ });
152
+ import_bun_test.expect(router.state.location.pathname).toBe("/about");
153
+ import_bun_test.expect(router.state.location.href).toBe("/about?tab=team#bio");
154
+ import_bun_test.expect(router.buildHref({ to: "/about" })).toBe("/about");
155
+ });
156
+ import_bun_test.test("normalizes a trailing slash in the basePath", async () => {
157
+ const history = import_router.createMemoryHistory({
158
+ initialEntries: ["/project/about?tab=team#bio"]
159
+ });
160
+ const router = import_router.createRouter({
161
+ routeTree: createTestRouteTree(),
162
+ history,
163
+ basePath: "/project/"
164
+ });
165
+ import_bun_test.expect(router.state.location.pathname).toBe("/about");
166
+ import_bun_test.expect(router.buildHref({ to: "/about" })).toBe("/project/about");
167
+ await router.navigate({
168
+ to: "/about"
169
+ });
170
+ import_bun_test.expect(history.location.href).toBe("/project/about");
171
+ });
79
172
  import_bun_test.test("strips the basePath from the current history location", () => {
80
173
  const history = import_router.createMemoryHistory({
81
174
  initialEntries: ["/project/about?tab=team#bio"]
@@ -411,3 +504,71 @@ import_bun_test.describe("createRouter basePath", () => {
411
504
  }
412
505
  });
413
506
  });
507
+ import_bun_test.describe("Link active state", () => {
508
+ import_bun_test.test("keeps parent links active on child routes by default", () => {
509
+ const TestLink = import_router.Link;
510
+ const markup = renderLinkMarkup("/posts/alpha", () => import_react.default.createElement(TestLink, { to: "/posts", activeProps: { className: "active" } }, "Posts"));
511
+ import_bun_test.expect(markup).toContain('class="active"');
512
+ });
513
+ import_bun_test.test("supports exact-only active matching", () => {
514
+ const TestLink = import_router.Link;
515
+ const markup = renderLinkMarkup("/posts/alpha", () => import_react.default.createElement(TestLink, {
516
+ to: "/posts",
517
+ activeOptions: { exact: true },
518
+ activeProps: { className: "active" }
519
+ }, "Posts"));
520
+ import_bun_test.expect(markup).not.toContain('class="active"');
521
+ });
522
+ import_bun_test.test("matches path segments instead of raw string prefixes", () => {
523
+ const TestLink = import_router.Link;
524
+ const markup = renderLinkMarkup("/posts/alpha", () => import_react.default.createElement(TestLink, { to: "/post", activeProps: { className: "active" } }, "Post"));
525
+ import_bun_test.expect(markup).not.toContain('class="active"');
526
+ });
527
+ import_bun_test.test("applies activeOptions in custom links created with createLink", () => {
528
+ const AppLink = import_router.createLink((props) => import_react.default.createElement("a", props));
529
+ const markup = renderLinkMarkup("/posts/alpha", () => import_react.default.createElement(AppLink, {
530
+ to: "/posts",
531
+ activeOptions: { exact: true },
532
+ activeProps: { className: "active" }
533
+ }, "Posts"));
534
+ import_bun_test.expect(markup).not.toContain('class="active"');
535
+ });
536
+ });
537
+ import_bun_test.describe("useMatchRoute", () => {
538
+ import_bun_test.test("returns matched params for exact matches", () => {
539
+ const markup = renderLinkMarkup("/posts/alpha", () => {
540
+ const matchRoute = import_router.useMatchRoute();
541
+ const match = matchRoute({ to: "/posts/$postId" });
542
+ return import_react.default.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
543
+ });
544
+ import_bun_test.expect(markup).toContain("{"postId":"alpha"}");
545
+ });
546
+ import_bun_test.test("supports fuzzy parent matching", () => {
547
+ const markup = renderLinkMarkup("/posts/alpha", () => {
548
+ const matchRoute = import_router.useMatchRoute();
549
+ const match = matchRoute({ to: "/posts", fuzzy: true });
550
+ return import_react.default.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
551
+ });
552
+ import_bun_test.expect(markup).toContain("{}");
553
+ });
554
+ import_bun_test.test("supports partial param filters", () => {
555
+ const markup = renderLinkMarkup("/posts/alpha", () => {
556
+ const matchRoute = import_router.useMatchRoute();
557
+ const match = matchRoute({ to: "/posts/$postId", params: { postId: "beta" } });
558
+ return import_react.default.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
559
+ });
560
+ import_bun_test.expect(markup).toContain("false");
561
+ });
562
+ import_bun_test.test("can include search params in the match", () => {
563
+ const markup = renderLinkMarkup("/posts/alpha?tab=details&count=2", () => {
564
+ const matchRoute = import_router.useMatchRoute();
565
+ const match = matchRoute({
566
+ to: "/posts/$postId",
567
+ includeSearch: true,
568
+ search: { tab: "details" }
569
+ });
570
+ return import_react.default.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
571
+ });
572
+ import_bun_test.expect(markup).toContain("{"postId":"alpha"}");
573
+ });
574
+ });
@@ -10,6 +10,7 @@ import {
10
10
  defaultStringifySearch,
11
11
  isNotFound,
12
12
  isRedirect,
13
+ matchPathname,
13
14
  matchRouteTree,
14
15
  notFound,
15
16
  redirect,
@@ -20,7 +21,7 @@ import {
20
21
  createHashHistory,
21
22
  createMemoryHistory
22
23
  } from "./history.mjs";
23
- import { jsxDEV } from "react/jsx-dev-runtime";
24
+ import { jsx } from "react/jsx-runtime";
24
25
  var RouterContext = React.createContext(null);
25
26
  var RouterStateContext = React.createContext(null);
26
27
  var OutletContext = React.createContext(null);
@@ -81,6 +82,21 @@ function prependBasePathToHref(href, basePath) {
81
82
  function routeHasRecord(value) {
82
83
  return typeof value === "object" && value !== null;
83
84
  }
85
+ function isDeepInclusiveMatch(expected, actual) {
86
+ if (Array.isArray(expected)) {
87
+ if (!Array.isArray(actual) || expected.length !== actual.length) {
88
+ return false;
89
+ }
90
+ return expected.every((value, index) => isDeepInclusiveMatch(value, actual[index]));
91
+ }
92
+ if (routeHasRecord(expected)) {
93
+ if (!routeHasRecord(actual)) {
94
+ return false;
95
+ }
96
+ return Object.entries(expected).every(([key, value]) => (key in actual) && isDeepInclusiveMatch(value, actual[key]));
97
+ }
98
+ return Object.is(expected, actual);
99
+ }
84
100
  function routeHasInlineHead(route) {
85
101
  const headOption = route.options.head;
86
102
  return Boolean(headOption && typeof headOption !== "string");
@@ -725,17 +741,17 @@ function RenderMatches({ matches, index }) {
725
741
  return null;
726
742
  }
727
743
  const Component = match.route.options.component;
728
- const outlet = /* @__PURE__ */ jsxDEV(RenderMatches, {
744
+ const outlet = /* @__PURE__ */ jsx(RenderMatches, {
729
745
  matches,
730
746
  index: index + 1
731
- }, undefined, false, undefined, this);
732
- return /* @__PURE__ */ jsxDEV(MatchContext.Provider, {
747
+ });
748
+ return /* @__PURE__ */ jsx(MatchContext.Provider, {
733
749
  value: match,
734
- children: /* @__PURE__ */ jsxDEV(OutletContext.Provider, {
750
+ children: /* @__PURE__ */ jsx(OutletContext.Provider, {
735
751
  value: outlet,
736
- children: Component ? /* @__PURE__ */ jsxDEV(Component, {}, undefined, false, undefined, this) : outlet
737
- }, undefined, false, undefined, this)
738
- }, undefined, false, undefined, this);
752
+ children: Component ? /* @__PURE__ */ jsx(Component, {}) : outlet
753
+ })
754
+ });
739
755
  }
740
756
  function renderError(error, matches, router) {
741
757
  const reversed = [...matches].reverse();
@@ -744,9 +760,9 @@ function renderError(error, matches, router) {
744
760
  if (NotFoundComponent) {
745
761
  return React.createElement(NotFoundComponent);
746
762
  }
747
- return /* @__PURE__ */ jsxDEV("div", {
763
+ return /* @__PURE__ */ jsx("div", {
748
764
  children: "Not Found"
749
- }, undefined, false, undefined, this);
765
+ });
750
766
  }
751
767
  const ErrorComponent = reversed.find((match) => match.route.options.errorComponent)?.route.options.errorComponent ?? router.options.defaultErrorComponent;
752
768
  if (ErrorComponent) {
@@ -757,9 +773,9 @@ function renderError(error, matches, router) {
757
773
  }
758
774
  });
759
775
  }
760
- return /* @__PURE__ */ jsxDEV("pre", {
776
+ return /* @__PURE__ */ jsx("pre", {
761
777
  children: error instanceof Error ? error.message : "Unknown routing error"
762
- }, undefined, false, undefined, this);
778
+ });
763
779
  }
764
780
  function RouterProvider({ router }) {
765
781
  const snapshot = React.useSyncExternalStore(router.subscribe, router.getSnapshot, router.getSnapshot);
@@ -772,17 +788,17 @@ function RouterProvider({ router }) {
772
788
  React.useEffect(() => {
773
789
  reconcileDocumentHead(snapshot.head);
774
790
  }, [snapshot.head]);
775
- const content = snapshot.error ? renderError(snapshot.error, snapshot.matches, router) : /* @__PURE__ */ jsxDEV(RenderMatches, {
791
+ const content = snapshot.error ? renderError(snapshot.error, snapshot.matches, router) : /* @__PURE__ */ jsx(RenderMatches, {
776
792
  matches: snapshot.matches,
777
793
  index: 0
778
- }, undefined, false, undefined, this);
779
- return /* @__PURE__ */ jsxDEV(RouterContext.Provider, {
794
+ });
795
+ return /* @__PURE__ */ jsx(RouterContext.Provider, {
780
796
  value: router,
781
- children: /* @__PURE__ */ jsxDEV(RouterStateContext.Provider, {
797
+ children: /* @__PURE__ */ jsx(RouterStateContext.Provider, {
782
798
  value: snapshot,
783
799
  children: content
784
- }, undefined, false, undefined, this)
785
- }, undefined, false, undefined, this);
800
+ })
801
+ });
786
802
  }
787
803
  function Outlet() {
788
804
  return React.useContext(OutletContext);
@@ -808,6 +824,31 @@ function useNavigate() {
808
824
  await router.navigate(options);
809
825
  }, [router]);
810
826
  }
827
+ function useMatchRoute() {
828
+ const location = useLocation();
829
+ return React.useCallback((options) => {
830
+ const matched = matchPathname(options.to, location.pathname, {
831
+ partial: options.fuzzy === true
832
+ });
833
+ if (!matched) {
834
+ return false;
835
+ }
836
+ if (options.params) {
837
+ for (const [key, value] of Object.entries(options.params)) {
838
+ if (value !== undefined && matched.params[key] !== value) {
839
+ return false;
840
+ }
841
+ }
842
+ }
843
+ if (options.includeSearch) {
844
+ const expectedSearch = options.search ?? {};
845
+ if (!isDeepInclusiveMatch(expectedSearch, location.search)) {
846
+ return false;
847
+ }
848
+ }
849
+ return matched.params;
850
+ }, [location.pathname, location.search]);
851
+ }
811
852
  function useLocation() {
812
853
  return useRouterStateContext().location;
813
854
  }
@@ -851,12 +892,14 @@ function useElementScrollRestoration() {
851
892
  ref: () => {}
852
893
  };
853
894
  }
854
- function useResolvedLink(props) {
895
+ function useResolvedLink(props, activeOptions) {
855
896
  const router = useRouterContext();
856
897
  const href = router.buildHref(props);
857
898
  const location = useLocation();
858
- const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
859
- const isActive = pathOnly === location.pathname;
899
+ const targetPathname = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
900
+ const isActive = matchPathname(targetPathname, location.pathname, {
901
+ partial: activeOptions?.exact !== true
902
+ }) !== null;
860
903
  return { href, isActive, router };
861
904
  }
862
905
  var LinkComponent = React.forwardRef(function LinkInner(props, ref) {
@@ -872,6 +915,7 @@ var LinkComponent = React.forwardRef(function LinkInner(props, ref) {
872
915
  state,
873
916
  mask,
874
917
  ignoreBlocker,
918
+ activeOptions,
875
919
  activeProps,
876
920
  children,
877
921
  onClick,
@@ -892,7 +936,7 @@ var LinkComponent = React.forwardRef(function LinkInner(props, ref) {
892
936
  mask,
893
937
  ignoreBlocker
894
938
  };
895
- const { href, isActive, router } = useResolvedLink(navigation);
939
+ const { href, isActive, router } = useResolvedLink(navigation, activeOptions);
896
940
  const preloadMode = preload ?? router.options.defaultPreload;
897
941
  const preloadDelay = router.options.defaultPreloadDelay ?? 50;
898
942
  const preloadTimeout = React.useRef(null);
@@ -917,7 +961,7 @@ var LinkComponent = React.forwardRef(function LinkInner(props, ref) {
917
961
  }
918
962
  }, []);
919
963
  const renderedChildren = typeof children === "function" ? children({ isActive }) : children;
920
- return /* @__PURE__ */ jsxDEV("a", {
964
+ return /* @__PURE__ */ jsx("a", {
921
965
  ...anchorProps,
922
966
  ...isActive ? activeProps : undefined,
923
967
  ref,
@@ -945,7 +989,7 @@ var LinkComponent = React.forwardRef(function LinkInner(props, ref) {
945
989
  },
946
990
  onBlur: cancelPreload,
947
991
  children: renderedChildren
948
- }, undefined, false, undefined, this);
992
+ });
949
993
  });
950
994
  var Link = LinkComponent;
951
995
  function createLink(Component) {
@@ -962,6 +1006,7 @@ function createLink(Component) {
962
1006
  state,
963
1007
  mask,
964
1008
  ignoreBlocker,
1009
+ activeOptions,
965
1010
  activeProps,
966
1011
  children,
967
1012
  preload,
@@ -979,7 +1024,7 @@ function createLink(Component) {
979
1024
  mask,
980
1025
  ignoreBlocker
981
1026
  };
982
- const { href, isActive, router } = useResolvedLink(navigation);
1027
+ const { href, isActive, router } = useResolvedLink(navigation, activeOptions);
983
1028
  const renderedChildren = typeof children === "function" ? children({ isActive }) : children;
984
1029
  React.useEffect(() => {
985
1030
  if (preload !== "render") {
@@ -987,12 +1032,12 @@ function createLink(Component) {
987
1032
  }
988
1033
  router.preloadRoute(navigation);
989
1034
  }, [navigation, preload, router]);
990
- return /* @__PURE__ */ jsxDEV(Component, {
1035
+ return /* @__PURE__ */ jsx(Component, {
991
1036
  ...componentProps,
992
1037
  ...isActive ? activeProps : undefined,
993
1038
  href,
994
1039
  children: renderedChildren
995
- }, undefined, false, undefined, this);
1040
+ });
996
1041
  };
997
1042
  }
998
1043
  function linkOptions(options) {
@@ -1015,6 +1060,7 @@ export {
1015
1060
  useParams,
1016
1061
  useNavigate,
1017
1062
  useMatches,
1063
+ useMatchRoute,
1018
1064
  useMatch,
1019
1065
  useLocation,
1020
1066
  useElementScrollRestoration,
@@ -1,6 +1,12 @@
1
1
  // packages/react/src/router.test.ts
2
2
  import { describe, expect, test } from "bun:test";
3
+ import React from "react";
4
+ import { renderToStaticMarkup } from "react-dom/server";
3
5
  import {
6
+ Link,
7
+ useMatchRoute,
8
+ RouterProvider,
9
+ createLink,
4
10
  createFileRoute,
5
11
  createMemoryHistory,
6
12
  createRootRoute,
@@ -80,7 +86,67 @@ function createNestedServerHeadTree() {
80
86
  posts: postsRoute
81
87
  });
82
88
  }
89
+ function createLinkTestRouteTree(component) {
90
+ const rootRoute = createRootRoute({
91
+ component
92
+ });
93
+ const postRoute = createFileRoute("/post")({
94
+ component: () => null
95
+ });
96
+ const postsRoute = createFileRoute("/posts")({
97
+ component: () => null
98
+ });
99
+ const postDetailRoute = createFileRoute("/posts/$postId")({
100
+ component: () => null
101
+ });
102
+ postsRoute._addFileChildren({
103
+ detail: postDetailRoute
104
+ });
105
+ return rootRoute._addFileChildren({
106
+ post: postRoute,
107
+ posts: postsRoute
108
+ });
109
+ }
110
+ function renderLinkMarkup(initialEntry, component) {
111
+ const history = createMemoryHistory({
112
+ initialEntries: [initialEntry]
113
+ });
114
+ const router = createRouter({
115
+ routeTree: createLinkTestRouteTree(component),
116
+ history
117
+ });
118
+ return renderToStaticMarkup(React.createElement(RouterProvider, { router }));
119
+ }
83
120
  describe("createRouter basePath", () => {
121
+ test('treats "/" as the root basePath', () => {
122
+ const history = createMemoryHistory({
123
+ initialEntries: ["/about?tab=team#bio"]
124
+ });
125
+ const router = createRouter({
126
+ routeTree: createTestRouteTree(),
127
+ history,
128
+ basePath: "/"
129
+ });
130
+ expect(router.state.location.pathname).toBe("/about");
131
+ expect(router.state.location.href).toBe("/about?tab=team#bio");
132
+ expect(router.buildHref({ to: "/about" })).toBe("/about");
133
+ });
134
+ test("normalizes a trailing slash in the basePath", async () => {
135
+ const history = createMemoryHistory({
136
+ initialEntries: ["/project/about?tab=team#bio"]
137
+ });
138
+ const router = createRouter({
139
+ routeTree: createTestRouteTree(),
140
+ history,
141
+ basePath: "/project/"
142
+ });
143
+ expect(router.state.location.pathname).toBe("/about");
144
+ expect(router.buildHref({ to: "/about" })).toBe("/project/about");
145
+ await router.navigate({
146
+ to: "/about"
147
+ });
148
+ expect(history.location.href).toBe("/project/about");
149
+ });
84
150
  test("strips the basePath from the current history location", () => {
85
151
  const history = createMemoryHistory({
86
152
  initialEntries: ["/project/about?tab=team#bio"]
@@ -416,3 +482,71 @@ describe("createRouter basePath", () => {
416
482
  }
417
483
  });
418
484
  });
485
+ describe("Link active state", () => {
486
+ test("keeps parent links active on child routes by default", () => {
487
+ const TestLink = Link;
488
+ const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, { to: "/posts", activeProps: { className: "active" } }, "Posts"));
489
+ expect(markup).toContain('class="active"');
490
+ });
491
+ test("supports exact-only active matching", () => {
492
+ const TestLink = Link;
493
+ const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, {
494
+ to: "/posts",
495
+ activeOptions: { exact: true },
496
+ activeProps: { className: "active" }
497
+ }, "Posts"));
498
+ expect(markup).not.toContain('class="active"');
499
+ });
500
+ test("matches path segments instead of raw string prefixes", () => {
501
+ const TestLink = Link;
502
+ const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, { to: "/post", activeProps: { className: "active" } }, "Post"));
503
+ expect(markup).not.toContain('class="active"');
504
+ });
505
+ test("applies activeOptions in custom links created with createLink", () => {
506
+ const AppLink = createLink((props) => React.createElement("a", props));
507
+ const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(AppLink, {
508
+ to: "/posts",
509
+ activeOptions: { exact: true },
510
+ activeProps: { className: "active" }
511
+ }, "Posts"));
512
+ expect(markup).not.toContain('class="active"');
513
+ });
514
+ });
515
+ describe("useMatchRoute", () => {
516
+ test("returns matched params for exact matches", () => {
517
+ const markup = renderLinkMarkup("/posts/alpha", () => {
518
+ const matchRoute = useMatchRoute();
519
+ const match = matchRoute({ to: "/posts/$postId" });
520
+ return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
521
+ });
522
+ expect(markup).toContain("{"postId":"alpha"}");
523
+ });
524
+ test("supports fuzzy parent matching", () => {
525
+ const markup = renderLinkMarkup("/posts/alpha", () => {
526
+ const matchRoute = useMatchRoute();
527
+ const match = matchRoute({ to: "/posts", fuzzy: true });
528
+ return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
529
+ });
530
+ expect(markup).toContain("{}");
531
+ });
532
+ test("supports partial param filters", () => {
533
+ const markup = renderLinkMarkup("/posts/alpha", () => {
534
+ const matchRoute = useMatchRoute();
535
+ const match = matchRoute({ to: "/posts/$postId", params: { postId: "beta" } });
536
+ return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
537
+ });
538
+ expect(markup).toContain("false");
539
+ });
540
+ test("can include search params in the match", () => {
541
+ const markup = renderLinkMarkup("/posts/alpha?tab=details&count=2", () => {
542
+ const matchRoute = useMatchRoute();
543
+ const match = matchRoute({
544
+ to: "/posts/$postId",
545
+ includeSearch: true,
546
+ search: { tab: "details" }
547
+ });
548
+ return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
549
+ });
550
+ expect(markup).toContain("{"postId":"alpha"}");
551
+ });
552
+ });
@@ -67,6 +67,8 @@ type ParamsInput<TParams> = TParams | ((previous: TParams) => TParams);
67
67
  type SearchInput<TSearch> = TSearch | ((previous: TSearch) => TSearch) | true;
68
68
  type ParamsOption<TParams> = keyof TParams extends never ? {
69
69
  params?: never;
70
+ } : string extends keyof TParams ? {
71
+ params?: ParamsInput<TParams>;
70
72
  } : {
71
73
  params: ParamsInput<TParams>;
72
74
  };
@@ -107,8 +109,22 @@ export type NavigateOptions<TTo extends RoutePaths = RoutePaths> = {
107
109
  search?: SearchInput<SearchForTo<TTo>>;
108
110
  } & NavigateBaseOptions & ParamsOption<ParamsForTo<TTo>>;
109
111
  export type NavigateFn = <TTo extends RoutePaths>(options: NavigateOptions<TTo>) => Promise<void>;
112
+ export interface MatchRouteOptions {
113
+ fuzzy?: boolean;
114
+ includeSearch?: boolean;
115
+ }
116
+ export type UseMatchRouteOptions<TTo extends RoutePaths = RoutePaths> = {
117
+ to: TTo;
118
+ params?: Partial<ParamsForTo<TTo>>;
119
+ search?: Record<string, unknown>;
120
+ } & MatchRouteOptions;
121
+ export type MatchRouteFn = <TTo extends RoutePaths>(options: UseMatchRouteOptions<TTo>) => false | ParamsForTo<TTo>;
122
+ export interface ActiveOptions {
123
+ exact?: boolean;
124
+ }
110
125
  export type LinkOwnProps<TTo extends RoutePaths> = NavigateOptions<TTo> & {
111
126
  preload?: 'intent' | 'render' | false;
127
+ activeOptions?: ActiveOptions;
112
128
  activeProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;
113
129
  children?: React.ReactNode | ((ctx: {
114
130
  isActive: boolean;
@@ -217,8 +233,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
217
233
  private handleHistoryChange;
218
234
  }
219
235
  export declare function createRouter<TRouteTree extends AnyRoute>(options: RouterOptions<TRouteTree>): Router<TRouteTree>;
220
- export declare function RouterProvider({ router }: {
221
- router: RegisteredRouter;
236
+ export declare function RouterProvider<TRouteTree extends AnyRoute>({ router }: {
237
+ router: Router<TRouteTree>;
222
238
  }): React.ReactElement;
223
239
  export declare function Outlet(): React.ReactNode;
224
240
  export declare function useRouter(): RegisteredRouter;
@@ -233,6 +249,7 @@ export declare function useSearch<TFrom extends RoutePaths>(options?: {
233
249
  from?: TFrom;
234
250
  }): SearchOfRoute<RouteById<TFrom>>;
235
251
  export declare function useNavigate(): NavigateFn;
252
+ export declare function useMatchRoute(): MatchRouteFn;
236
253
  export declare function useLocation(): ParsedLocation;
237
254
  export declare function useRouterState<TSelection>(options: {
238
255
  select: Selector<TSelection>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richie-router/react",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "React runtime, components, and hooks for Richie Router",
5
5
  "sideEffects": false,
6
6
  "exports": {