clear-react-router 1.8.3 → 1.8.5

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 CHANGED
@@ -70,7 +70,7 @@ It provides first-class support for:
70
70
 
71
71
  ### `createRouter(routes)`
72
72
 
73
- Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic params, builds nested paths.
73
+ Normalizes route configuration. Extracts dynamic params, builds nested paths.
74
74
 
75
75
  | Property | Type | Description |
76
76
  |----------|------|-------------|
@@ -88,14 +88,26 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
88
88
 
89
89
  ### `Link`
90
90
 
91
- Component for client-side navigation with prefetch support.
91
+ Component for client-side navigation with prefetch support, active state detection, and pending state styling.
92
92
 
93
- | Prop | Type | Default |
94
- |------|------|---------|
95
- | `to` | `string` | required |
93
+ | Prop | Type | Default | Description |
94
+ |------|------|---------|-------------|
95
+ | `to` | `string` | required | Target path |
96
96
  | `prefetch` | `'hover' \| 'render' \| 'viewport' \| 'none'` | `Router` config | Override the global prefetch strategy |
97
97
  | `hoverPrefetchDelay` | `number` | `Router` config | Override the global hover delay |
98
- | `children` | `ReactElement` | required | Content to render inside the link |
98
+ | `children` | `ReactNode` | required | Content to render inside the link |
99
+ | `className` | `string \| ({ isActive, isPending }) => string` | `undefined` | CSS class name(s). Can be a function for dynamic styling |
100
+ | `style` | `CSSProperties \| ({ isActive, isPending }) => CSSProperties` | `undefined` | Inline styles. Can be a function for dynamic styling |
101
+ | `activeClassName` | `string` optional | `'active-link'` | Class name applied when the link matches the current URL |
102
+ | `pendingClassName` | `string` optional | `'pending-link'` | Class name applied when the link's target is loading |
103
+ | `onClick` | `() => void` | `undefined` | Callback fired before navigation |
104
+
105
+ **State values:**
106
+
107
+ | State | Type | Description |
108
+ |-------|------|-------------|
109
+ | `isActive` | `boolean` | `true` when the link's `to` matches the current URL |
110
+ | `isPending` | `boolean` | `true` when the target route is currently loading (loader is running) |
99
111
 
100
112
  ### Prefetch Strategies
101
113
 
@@ -123,6 +135,21 @@ import { Router, Link } from 'clear-react-router';
123
135
  <Link to="/admin" prefetch="none">
124
136
  Admin Panel
125
137
  </Link>
138
+
139
+ // With custom active/pending classes
140
+ <Link to="/settings" activeClassName="active-nav-link" pendingClassName="loading-nav-link">
141
+ Settings
142
+ </Link>
143
+
144
+ // With dynamic className
145
+ <Link to="/dashboard" className={({ isActive, isPending }) => isActive ? 'text-blue-600' : isPending ? 'text-gray-400' : 'text-gray-600'}>
146
+ Dashboard
147
+ </Link>
148
+
149
+ // With dynamic style
150
+ <Link to="/profile" style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}>
151
+ Profile
152
+ </Link>
126
153
  ```
127
154
  **Important**: prefetch="render" should be used sparingly, as it preloads data immediately when the link is rendered, which may cause unnecessary network requests.
128
155
 
@@ -1,13 +1,21 @@
1
- import { type ReactElement, type MouseEvent, type CSSProperties } from 'react';
1
+ import { type CSSProperties, ReactNode } from 'react';
2
2
  import { RouterProps } from '../types';
3
3
  type LinkProps = {
4
4
  to: string;
5
- children: ReactElement<{
6
- onClick: (e: MouseEvent) => void;
7
- style: CSSProperties;
8
- }>;
5
+ children: ReactNode;
9
6
  prefetch?: RouterProps['prefetch'];
10
7
  hoverPrefetchDelay?: number;
8
+ style?: CSSProperties | (({ isActive }: {
9
+ isActive: boolean;
10
+ isPending: boolean;
11
+ }) => CSSProperties);
12
+ className?: string | (({ isActive }: {
13
+ isActive: boolean;
14
+ isPending: boolean;
15
+ }) => string);
16
+ activeClassName?: string;
17
+ pendingClassName?: string;
18
+ onClick?(): void;
11
19
  };
12
- export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }: LinkProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName, pendingClassName, }: LinkProps) => import("react/jsx-runtime").JSX.Element;
13
21
  export {};
@@ -0,0 +1 @@
1
+ export declare const useIsRoutePending: (routePath: string) => boolean;
package/dist/index.js CHANGED
@@ -174,62 +174,45 @@ var createLazyComponent = (importFn, fallback) => {
174
174
  //#endregion
175
175
  //#region utils/utils.ts
176
176
  var isLazy = (el) => typeof el.element === "function" && el.element.toString().includes("import(");
177
- var parseClientRouteItem = (el, parentParams = [], parentPath = "") => {
178
- const segments = el.path.split("/").filter(Boolean);
179
- const staticSegments = [];
180
- const currentParams = [...parentParams];
181
- let lastStaticSegment = "";
182
- for (const segment of segments) if (segment.startsWith(":")) {
183
- if (!lastStaticSegment) throw new Error(`Route "${el.path}" cannot start with a parameter.`);
184
- currentParams.push({
185
- key: lastStaticSegment,
186
- value: segment.slice(1)
187
- });
188
- } else {
189
- lastStaticSegment = segment;
190
- staticSegments.push(segment);
191
- }
192
- const path = `${parentPath}/${staticSegments.join("/")}`.replace(/\/+/g, "/");
177
+ var parseClientRouteItem = (el, parentPattern = "") => {
178
+ const pattern = `${parentPattern}/${el.path}`.replace(/\/+/g, "/");
193
179
  const resolvedElement = isLazy(el) ? createLazyComponent(el.element, el.fallback) : el.element;
194
180
  return [{
195
181
  ...el,
196
- path,
197
- params: currentParams,
182
+ pattern,
198
183
  element: resolvedElement
199
- }, ...el.children?.flatMap((child) => parseClientRouteItem(child, currentParams, path)) ?? []];
184
+ }, ...el.children?.flatMap((child) => parseClientRouteItem(child, pattern)) ?? []];
200
185
  };
201
- var createRouter = (clientList) => clientList.flatMap((el) => parseClientRouteItem(el, []));
202
- var getParamsObject = ({ params, pathname }) => {
203
- if (!params) return {};
204
- const split = pathname.split("/");
205
- return (params || []).map((el) => ({
206
- index: split.findIndex((item) => item === el.key),
207
- value: el.value
208
- })).reduce((acc, cur) => ({
209
- ...acc,
210
- [cur.value]: split[cur.index + 1]
211
- }), {});
186
+ var createRouter = (clientList) => clientList.flatMap((el) => parseClientRouteItem(el));
187
+ var getParamsObject = (nextItem, nextPathname) => {
188
+ const { routeItem: stateItem, location: { pathname: statePathname } } = router.state.routeItemDataState.getState();
189
+ const routeItem = nextItem || stateItem;
190
+ const pathname = nextPathname || statePathname;
191
+ if (!routeItem) return {};
192
+ const pathnameSegments = pathname.split("/");
193
+ return routeItem.pattern.split("/").reduce((acc, segment, index) => {
194
+ if (segment.startsWith(":")) acc[segment.slice(1)] = pathnameSegments[index];
195
+ return acc;
196
+ }, {});
212
197
  };
213
198
  var parseWindowLocation = (location) => ({
214
199
  pathname: location.pathname,
215
200
  search: location.search
216
201
  });
217
- var comparePaths = (el, pathname) => {
218
- if (!el.params?.length) return el.path.replaceAll("/", "") === pathname.replaceAll("/", "");
219
- const splitElementPath = el.path.split("/").filter(Boolean);
220
- const paramsLength = el.params ? Object.keys(el.params).length : 0;
221
- const splitPathname = pathname.split("/").filter(Boolean);
222
- return splitElementPath.every((item, index) => item === splitPathname[2 * index]) && splitPathname.length === splitElementPath.length + paramsLength;
202
+ var comparePaths = (route, pathname) => {
203
+ const pattern = route.pattern.split("/").filter(Boolean);
204
+ const current = pathname.split("/").filter(Boolean);
205
+ return pattern.length === current.length ? pattern.every((segment, index) => segment.startsWith(":") || segment === current[index]) : false;
223
206
  };
224
207
  var findRoute = (pathname, includeAll) => {
225
- if (includeAll) return routerConfig.routes.find((el) => el.path === "/*" || comparePaths(el, pathname));
208
+ if (includeAll) return routerConfig.routes.find((el) => el.path === "*" || comparePaths(el, pathname));
226
209
  return routerConfig.routes.find((el) => comparePaths(el, pathname));
227
210
  };
228
211
  //#endregion
229
212
  //#region runtime/navigate.ts
230
213
  var navigationSeq = 0;
231
214
  var createNavigate = (routerState, revalidateCache) => {
232
- const { loaderStateRef, scrollMapState, prevPathnameRef, loaderFallbackState, isLoadingState, contextState, timestampMap } = routerState;
215
+ const { loaderStateRef, scrollMapState, prevPathnameRef, loaderFallbackState, isLoadingState, contextState, timestampMap, pendingPathRef } = routerState;
233
216
  const commitNavigation = createCommitNavigation(createCommitState(routerState), prevPathnameRef);
234
217
  const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
235
218
  const getContext = () => ({
@@ -241,10 +224,7 @@ var createNavigate = (routerState, revalidateCache) => {
241
224
  const nextItem = findRoute(location.pathname, true);
242
225
  return {
243
226
  nextItem,
244
- params: getParamsObject({
245
- params: nextItem?.params,
246
- pathname: location.pathname
247
- })
227
+ params: getParamsObject(nextItem, location.pathname)
248
228
  };
249
229
  };
250
230
  const beforeLoad = async (routeItem, params) => {
@@ -289,10 +269,12 @@ var createNavigate = (routerState, revalidateCache) => {
289
269
  const loader = async (routeItem, location) => {
290
270
  if (!routeItem?.loader) return;
291
271
  isLoadingState.setState(true);
272
+ pendingPathRef.set(location.pathname);
292
273
  await revalidateCache({
293
274
  routeItem,
294
275
  pathname: location.pathname
295
276
  });
277
+ pendingPathRef.set("");
296
278
  };
297
279
  const afterLoad = async (routeItem, params) => {
298
280
  const { afterLoad } = routerConfig;
@@ -326,10 +308,7 @@ var createInvalidate = ({ routeItemDataState, loaderStateRef, timestampMap, curr
326
308
  const invalidatePath = async (routeItem, pathname, options) => {
327
309
  const routePathname = routeItemDataState.getState().location.pathname;
328
310
  timestampMap.delete(pathname);
329
- const params = getParamsObject({
330
- params: routeItem?.params,
331
- pathname
332
- });
311
+ const params = getParamsObject();
333
312
  try {
334
313
  if (routeItem?.beforeLoad && options?.withBeforeLoad) {
335
314
  const context = contextState.getState();
@@ -406,10 +385,10 @@ var getRetry = (routeItem) => {
406
385
  };
407
386
  var sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
408
387
  var createRevalidateCache = (routerState) => {
388
+ const { loaderStateRef, timestampMap, contextState } = routerState;
409
389
  const revalidateCache = async ({ routeItem, pathname }, retried = 0) => {
410
390
  if (!routeItem?.loader) return;
411
- const isCacheItemFresh = createIsCacheItemFresh(routerState.timestampMap);
412
- const { loaderStateRef, timestampMap, contextState } = routerState;
391
+ const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
413
392
  if (loadingPromises.has(pathname)) return loadingPromises.get(pathname);
414
393
  if (isCacheItemFresh({
415
394
  routeItem,
@@ -423,10 +402,7 @@ var createRevalidateCache = (routerState) => {
423
402
  try {
424
403
  const context = contextState.getState();
425
404
  const setContext = contextState.setState;
426
- const params = getParamsObject({
427
- params: routeItem.params,
428
- pathname
429
- });
405
+ const params = getParamsObject(routeItem, pathname);
430
406
  const result = await routeItem?.loader({
431
407
  params,
432
408
  context,
@@ -497,6 +473,7 @@ var createRouterInstance = () => {
497
473
  }),
498
474
  loaderStateRef: new Cell(emptyLoaderState),
499
475
  prevPathnameRef: new Cell(""),
476
+ pendingPathRef: new Cell(""),
500
477
  timestampMap: /* @__PURE__ */ new Map()
501
478
  };
502
479
  const revalidateCache = createRevalidateCache(routerState);
@@ -504,13 +481,10 @@ var createRouterInstance = () => {
504
481
  const invalidate = createInvalidate(routerState, revalidateCache);
505
482
  const prefetch = createPrefetch(revalidateCache);
506
483
  const useGetAction = (actionKey) => {
507
- const { routeItem, location } = routerState.routeItemDataState.getState();
484
+ const { routeItem } = routerState.routeItemDataState.getState();
508
485
  const context = routerState.contextState.getState();
509
486
  const setContext = routerState.contextState.setState;
510
- const params = getParamsObject({
511
- params: routeItem?.params,
512
- pathname: location.pathname
513
- });
487
+ const params = getParamsObject();
514
488
  if (!routeItem) throw new Error("Route not found");
515
489
  if (!routeItem.actions) throw new Error("Route action creator not found");
516
490
  const action = routeItem.actions({
@@ -534,7 +508,8 @@ var createRouterInstance = () => {
534
508
  scrollMapState: routerState.scrollMapState,
535
509
  contextState: routerState.contextState,
536
510
  blockedRouteState: routerState.blockedRouteState,
537
- prevPathnameRef: routerState.prevPathnameRef
511
+ prevPathnameRef: routerState.prevPathnameRef,
512
+ pendingPathRef: routerState.pendingPathRef
538
513
  },
539
514
  runtime: {
540
515
  navigate,
@@ -549,13 +524,7 @@ var createRouterInstance = () => {
549
524
  useCurrentLoaderState: () => useGlobalState(routerState.currentLoaderState),
550
525
  useScrollMap: () => useGlobalState(routerState.scrollMapState),
551
526
  useContextState: () => useGlobalState(routerState.contextState),
552
- useParams: () => {
553
- const routeItemData = routerState.routeItemDataState.getState();
554
- return getParamsObject({
555
- params: routeItemData.routeItem?.params,
556
- pathname: routeItemData.location.pathname
557
- });
558
- },
527
+ useParams: () => getParamsObject(),
559
528
  useNavigate: () => {
560
529
  const { blockedRouteState } = routerState;
561
530
  const { location } = routerState.routeItemDataState.getState();
@@ -746,17 +715,26 @@ var Router = ({ routes, beforeLoad, afterLoad, animationDuration, isAnimated = f
746
715
  });
747
716
  };
748
717
  //#endregion
718
+ //#region hooks/useIsRoutePending.ts
719
+ var useIsRoutePending = (routePath) => {
720
+ const { hooks: { useIsLoading }, state: { pendingPathRef } } = router;
721
+ const [isPending] = useIsLoading();
722
+ return isPending && pendingPathRef.value === routePath;
723
+ };
724
+ //#endregion
749
725
  //#region hooks/useNavigate.ts
750
726
  var useNavigate = router.hooks.useNavigate;
751
727
  //#endregion
752
728
  //#region components/Link.tsx
753
- var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }) => {
754
- const { prefetch: configPrefetch, hoverPrefetchDelay: configPrefetchDelay } = routerConfig;
755
- const prefetch = prefetchLink || configPrefetch;
756
- const prefetchDelay = hoverPrefetchDelay ?? configPrefetchDelay;
729
+ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName = "active-link", pendingClassName = "pending-link" }) => {
730
+ const isPending = useIsRoutePending(to);
731
+ const { pathname } = useLocation();
757
732
  const navigate = useNavigate();
758
733
  const timeout = useRef(0);
759
734
  const ref = useRef(null);
735
+ const { prefetch: configPrefetch, hoverPrefetchDelay: configPrefetchDelay } = routerConfig;
736
+ const prefetch = prefetchLink || configPrefetch;
737
+ const prefetchDelay = hoverPrefetchDelay ?? configPrefetchDelay;
760
738
  const onMouseEnter = useCallback(() => {
761
739
  if (prefetch !== "hover" || !prefetchDelay) return;
762
740
  if (timeout.current) clearTimeout(timeout.current);
@@ -791,10 +769,31 @@ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }) => {
791
769
  if (element) observer.disconnect();
792
770
  };
793
771
  }, [prefetch, to]);
772
+ const isActive = to === pathname;
773
+ const normalizedClassName = typeof className === "function" ? className({
774
+ isActive,
775
+ isPending
776
+ }) : className;
777
+ const normalizedStyle = typeof style === "function" ? style({
778
+ isActive,
779
+ isPending
780
+ }) : style;
781
+ const resultClassName = [
782
+ isActive && activeClassName,
783
+ isPending && pendingClassName,
784
+ normalizedClassName
785
+ ].filter(Boolean).join(" ");
786
+ const clickHandler = async (event) => {
787
+ event.preventDefault();
788
+ onClick?.();
789
+ await navigate(to);
790
+ };
794
791
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", {
792
+ href: to,
795
793
  ref,
796
- style: { cursor: "pointer" },
797
- onClick: () => navigate(to),
794
+ style: normalizedStyle,
795
+ className: resultClassName,
796
+ onClick: clickHandler,
798
797
  onMouseEnter,
799
798
  onMouseLeave,
800
799
  children
package/dist/types.d.ts CHANGED
@@ -40,10 +40,7 @@ export type ClientRouteItem = {
40
40
  };
41
41
  export type RouteItem = ClientRouteItem & {
42
42
  element: RenderElement;
43
- params?: {
44
- key: string;
45
- value: string;
46
- }[];
43
+ pattern: string;
47
44
  cacheTimestamp?: number;
48
45
  };
49
46
  export type Location = {
@@ -106,6 +103,7 @@ export type RouterState = {
106
103
  }>;
107
104
  loaderStateRef: Cell<LoaderState>;
108
105
  prevPathnameRef: Cell<string>;
106
+ pendingPathRef: Cell<string>;
109
107
  timestampMap: Map<string, number>;
110
108
  };
111
109
  export type RouterType = {
@@ -1,2 +1,2 @@
1
- export declare const ALL_LOCATIONS = "/*";
1
+ export declare const NOT_FOUND = "*";
2
2
  export declare const findRoute: (pathname: string, includeAll?: boolean) => import("..").RouteItem | undefined;
@@ -1,8 +1,5 @@
1
1
  import { ClientRouteItem, Location, RouteItem } from '../types';
2
2
  export declare const createRouter: (clientList: ClientRouteItem[]) => RouteItem[];
3
- export declare const getParamsObject: ({ params, pathname }: {
4
- params?: RouteItem["params"];
5
- pathname: string;
6
- }) => {};
3
+ export declare const getParamsObject: (nextItem?: RouteItem, nextPathname?: string) => Record<string, string>;
7
4
  export declare const parseWindowLocation: (location: typeof window.location) => Location;
8
- export declare const comparePaths: (el: RouteItem, pathname: string) => boolean;
5
+ export declare const comparePaths: (route: RouteItem, pathname: string) => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {