clear-react-router 1.8.4 → 1.8.6

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, 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, extracts dynamic params, builds nested paths.
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
 
@@ -557,6 +584,14 @@ await invalidate('/posts', { withBeforeLoad: true });
557
584
  await invalidate(['/posts', '/users'], { withBeforeLoad: true });
558
585
  ```
559
586
 
587
+ #### Returns
588
+
589
+ An array of objects with the following structure:
590
+ ```ts
591
+ { path: string; data: unknown; error: unknown }
592
+ ```
593
+ Each object represents a revalidated route, where `path` is the route pathname, `data` is the revalidated loader result, and `error` is the loader error, if any.
594
+
560
595
  #### Notes
561
596
 
562
597
  * **Only routes that already have cached data are revalidated.**
@@ -1,13 +1,19 @@
1
- import { type ReactElement, type MouseEvent, type CSSProperties } from 'react';
1
+ import { type CSSProperties, ReactNode, ComponentPropsWithoutRef } from 'react';
2
2
  import { RouterProps } from '../types';
3
- type LinkProps = {
3
+ type States = {
4
+ isActive: boolean;
5
+ isPending: boolean;
6
+ };
7
+ type LinkProps = Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'className' | 'style'> & {
4
8
  to: string;
5
- children: ReactElement<{
6
- onClick: (e: MouseEvent) => void;
7
- style: CSSProperties;
8
- }>;
9
+ children: ReactNode;
9
10
  prefetch?: RouterProps['prefetch'];
10
11
  hoverPrefetchDelay?: number;
12
+ style?: CSSProperties | ((arg: States) => CSSProperties);
13
+ className?: string | ((arg: States) => string);
14
+ activeClassName?: string;
15
+ pendingClassName?: string;
16
+ onClick?(): void;
11
17
  };
12
- export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }: LinkProps) => import("react/jsx-runtime").JSX.Element;
18
+ export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName, pendingClassName, }: LinkProps) => import("react/jsx-runtime").JSX.Element;
13
19
  export {};
@@ -1 +1 @@
1
- export declare const useInvalidate: () => (pathList?: string | string[], options?: import("../types").InvalidateOptions) => Promise<void>;
1
+ export declare const useInvalidate: () => (pathList?: string | string[], options?: import("../types").InvalidateOptions) => Promise<import("../types").InvalidateResult[]>;
@@ -0,0 +1 @@
1
+ export declare const useIsRoutePending: (routePath: string) => boolean;
package/dist/index.js CHANGED
@@ -212,7 +212,7 @@ var findRoute = (pathname, includeAll) => {
212
212
  //#region runtime/navigate.ts
213
213
  var navigationSeq = 0;
214
214
  var createNavigate = (routerState, revalidateCache) => {
215
- const { loaderStateRef, scrollMapState, prevPathnameRef, loaderFallbackState, isLoadingState, contextState, timestampMap } = routerState;
215
+ const { loaderStateRef, scrollMapState, prevPathnameRef, loaderFallbackState, isLoadingState, contextState, timestampMap, pendingPathRef } = routerState;
216
216
  const commitNavigation = createCommitNavigation(createCommitState(routerState), prevPathnameRef);
217
217
  const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
218
218
  const getContext = () => ({
@@ -269,10 +269,12 @@ var createNavigate = (routerState, revalidateCache) => {
269
269
  const loader = async (routeItem, location) => {
270
270
  if (!routeItem?.loader) return;
271
271
  isLoadingState.setState(true);
272
+ pendingPathRef.set(location.pathname);
272
273
  await revalidateCache({
273
274
  routeItem,
274
275
  pathname: location.pathname
275
276
  });
277
+ pendingPathRef.set("");
276
278
  };
277
279
  const afterLoad = async (routeItem, params) => {
278
280
  const { afterLoad } = routerConfig;
@@ -328,27 +330,30 @@ var createInvalidate = ({ routeItemDataState, loaderStateRef, timestampMap, curr
328
330
  beforeLoadError: error
329
331
  }));
330
332
  }
331
- await revalidateCache({
333
+ const result = await revalidateCache({
332
334
  routeItem,
333
335
  pathname
334
336
  });
335
337
  if (pathname === routePathname) currentLoaderState.setState(loaderStateRef.value);
338
+ return {
339
+ path: pathname,
340
+ ...result
341
+ };
336
342
  };
337
343
  const invalidateItem = async (pathname, options) => {
338
344
  const routeItem = findRoute(pathname);
339
- if (!routeItem) return;
345
+ if (!routeItem) return [];
340
346
  const pathnameArray = [];
341
347
  for (const [key] of timestampMap) if (comparePaths(routeItem, key)) pathnameArray.push(key);
342
- await Promise.all(pathnameArray.map((pathname) => invalidatePath(routeItem, pathname, options)));
343
- if (options?.withChildren && routeItem.children?.length) {
344
- const childPathList = routeItem.children.map((el) => el.path);
345
- await Promise.all(childPathList.map((el) => invalidateItem(`${pathname}${el}`, options)));
346
- }
348
+ const currentResults = await Promise.all(pathnameArray.map((pathname) => invalidatePath(routeItem, pathname, options)));
349
+ if (!options?.withChildren || !routeItem.children?.length) return currentResults;
350
+ const childResults = await Promise.all(routeItem.children.map((child) => invalidateItem(`${pathname}${child.path}`, options)));
351
+ return [...currentResults, ...childResults.flat()];
347
352
  };
348
353
  return async (pathList, options) => {
349
354
  const routePathname = routeItemDataState.getState().location.pathname;
350
355
  const pathnameList = Array.isArray(pathList) ? pathList : pathList ? [pathList] : [routePathname];
351
- await Promise.all(pathnameList.map((pathname) => invalidateItem(pathname, options)));
356
+ return (await Promise.all(pathnameList.map((pathname) => invalidateItem(pathname, options)))).flat();
352
357
  };
353
358
  };
354
359
  //#endregion
@@ -383,18 +388,15 @@ var getRetry = (routeItem) => {
383
388
  };
384
389
  var sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
385
390
  var createRevalidateCache = (routerState) => {
391
+ const { loaderStateRef, timestampMap, contextState } = routerState;
386
392
  const revalidateCache = async ({ routeItem, pathname }, retried = 0) => {
387
393
  if (!routeItem?.loader) return;
388
- const isCacheItemFresh = createIsCacheItemFresh(routerState.timestampMap);
389
- const { loaderStateRef, timestampMap, contextState } = routerState;
394
+ const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
390
395
  if (loadingPromises.has(pathname)) return loadingPromises.get(pathname);
391
396
  if (isCacheItemFresh({
392
397
  routeItem,
393
398
  pathname
394
- })) {
395
- loaderStateRef.set(loaderMapRef[pathname]);
396
- return;
397
- }
399
+ })) loaderStateRef.set(loaderMapRef[pathname]);
398
400
  const promise = (async () => {
399
401
  if (!routeItem?.loader) return;
400
402
  try {
@@ -413,6 +415,10 @@ var createRevalidateCache = (routerState) => {
413
415
  loaderError: null
414
416
  }));
415
417
  loaderMapRef[pathname] = loaderStateRef.value;
418
+ return {
419
+ data: result,
420
+ error: null
421
+ };
416
422
  } catch (error) {
417
423
  const retry = getRetry(routeItem);
418
424
  if (retry && retry.count > retried) {
@@ -422,11 +428,21 @@ var createRevalidateCache = (routerState) => {
422
428
  routeItem,
423
429
  pathname
424
430
  }, retried + 1);
425
- } else loaderStateRef.set((prev) => ({
426
- ...prev,
427
- data: null,
428
- loaderError: error
429
- }));
431
+ return {
432
+ data: null,
433
+ error
434
+ };
435
+ } else {
436
+ loaderStateRef.set((prev) => ({
437
+ ...prev,
438
+ data: null,
439
+ loaderError: error
440
+ }));
441
+ return {
442
+ data: null,
443
+ error
444
+ };
445
+ }
430
446
  } finally {
431
447
  loadingPromises.delete(pathname);
432
448
  }
@@ -471,6 +487,7 @@ var createRouterInstance = () => {
471
487
  }),
472
488
  loaderStateRef: new Cell(emptyLoaderState),
473
489
  prevPathnameRef: new Cell(""),
490
+ pendingPathRef: new Cell(""),
474
491
  timestampMap: /* @__PURE__ */ new Map()
475
492
  };
476
493
  const revalidateCache = createRevalidateCache(routerState);
@@ -505,7 +522,8 @@ var createRouterInstance = () => {
505
522
  scrollMapState: routerState.scrollMapState,
506
523
  contextState: routerState.contextState,
507
524
  blockedRouteState: routerState.blockedRouteState,
508
- prevPathnameRef: routerState.prevPathnameRef
525
+ prevPathnameRef: routerState.prevPathnameRef,
526
+ pendingPathRef: routerState.pendingPathRef
509
527
  },
510
528
  runtime: {
511
529
  navigate,
@@ -711,17 +729,26 @@ var Router = ({ routes, beforeLoad, afterLoad, animationDuration, isAnimated = f
711
729
  });
712
730
  };
713
731
  //#endregion
732
+ //#region hooks/useIsRoutePending.ts
733
+ var useIsRoutePending = (routePath) => {
734
+ const { hooks: { useIsLoading }, state: { pendingPathRef } } = router;
735
+ const [isPending] = useIsLoading();
736
+ return isPending && pendingPathRef.value === routePath;
737
+ };
738
+ //#endregion
714
739
  //#region hooks/useNavigate.ts
715
740
  var useNavigate = router.hooks.useNavigate;
716
741
  //#endregion
717
742
  //#region components/Link.tsx
718
- var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }) => {
719
- const { prefetch: configPrefetch, hoverPrefetchDelay: configPrefetchDelay } = routerConfig;
720
- const prefetch = prefetchLink || configPrefetch;
721
- const prefetchDelay = hoverPrefetchDelay ?? configPrefetchDelay;
743
+ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName = "active-link", pendingClassName = "pending-link" }) => {
744
+ const isPending = useIsRoutePending(to);
745
+ const { pathname } = useLocation();
722
746
  const navigate = useNavigate();
723
747
  const timeout = useRef(0);
724
748
  const ref = useRef(null);
749
+ const { prefetch: configPrefetch, hoverPrefetchDelay: configPrefetchDelay } = routerConfig;
750
+ const prefetch = prefetchLink || configPrefetch;
751
+ const prefetchDelay = hoverPrefetchDelay ?? configPrefetchDelay;
725
752
  const onMouseEnter = useCallback(() => {
726
753
  if (prefetch !== "hover" || !prefetchDelay) return;
727
754
  if (timeout.current) clearTimeout(timeout.current);
@@ -756,10 +783,31 @@ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay }) => {
756
783
  if (element) observer.disconnect();
757
784
  };
758
785
  }, [prefetch, to]);
786
+ const isActive = to === pathname;
787
+ const normalizedClassName = typeof className === "function" ? className({
788
+ isActive,
789
+ isPending
790
+ }) : className;
791
+ const normalizedStyle = typeof style === "function" ? style({
792
+ isActive,
793
+ isPending
794
+ }) : style;
795
+ const resultClassName = [
796
+ isActive && activeClassName,
797
+ isPending && pendingClassName,
798
+ normalizedClassName
799
+ ].filter(Boolean).join(" ");
800
+ const clickHandler = async (event) => {
801
+ event.preventDefault();
802
+ onClick?.();
803
+ await navigate(to);
804
+ };
759
805
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", {
806
+ href: to,
760
807
  ref,
761
- style: { cursor: "pointer" },
762
- onClick: () => navigate(to),
808
+ style: normalizedStyle,
809
+ className: resultClassName,
810
+ onClick: clickHandler,
763
811
  onMouseEnter,
764
812
  onMouseLeave,
765
813
  children
@@ -1,2 +1,2 @@
1
- import { type InvalidateOptions, RevalidateCache, RouterState } from '../types';
2
- export declare const createInvalidate: ({ routeItemDataState, loaderStateRef, timestampMap, currentLoaderState, contextState }: RouterState, revalidateCache: RevalidateCache) => (pathList?: string | string[], options?: InvalidateOptions) => Promise<void>;
1
+ import { type InvalidateOptions, InvalidateResult, RevalidateCache, RouterState } from '../types';
2
+ export declare const createInvalidate: ({ routeItemDataState, loaderStateRef, timestampMap, currentLoaderState, contextState }: RouterState, revalidateCache: RevalidateCache) => (pathList?: string | string[], options?: InvalidateOptions) => Promise<InvalidateResult[]>;
package/dist/types.d.ts CHANGED
@@ -34,7 +34,7 @@ export type ClientRouteItem = {
34
34
  actions?: (arg: {
35
35
  context: Record<string, unknown>;
36
36
  params: Record<string, string>;
37
- invalidate: (path?: string) => Promise<void>;
37
+ invalidate: (path?: string) => Promise<InvalidateResult[]>;
38
38
  setContext: Dispatch<SetStateAction<Record<string, unknown>>>;
39
39
  }) => Record<string, (arg: FormData) => Promise<unknown> | Promise<void> | void | unknown>;
40
40
  };
@@ -103,13 +103,14 @@ export type RouterState = {
103
103
  }>;
104
104
  loaderStateRef: Cell<LoaderState>;
105
105
  prevPathnameRef: Cell<string>;
106
+ pendingPathRef: Cell<string>;
106
107
  timestampMap: Map<string, number>;
107
108
  };
108
109
  export type RouterType = {
109
110
  state: Omit<RouterState, 'loaderStateRef' | 'timestampMap'>;
110
111
  runtime: {
111
112
  navigate(arg: Location): Promise<void>;
112
- invalidate(pathList?: string | string[], options?: InvalidateOptions): Promise<void>;
113
+ invalidate(pathList?: string | string[], options?: InvalidateOptions): Promise<InvalidateResult[]>;
113
114
  prefetch(pathname: string): Promise<void>;
114
115
  };
115
116
  hooks: {
@@ -127,7 +128,7 @@ export type RouterType = {
127
128
  useNavigate: () => (arg: Location | string | -1) => Promise<void>;
128
129
  useGetAction: (actionKey: string) => {
129
130
  currentAction: (arg: FormData) => Promise<unknown> | Promise<void> | void | unknown;
130
- invalidate: (pathList?: string | string[], options?: InvalidateOptions) => Promise<void>;
131
+ invalidate: (pathList?: string | string[], options?: InvalidateOptions) => Promise<InvalidateResult[]>;
131
132
  };
132
133
  useRestoreScroll: () => () => void;
133
134
  useAction: (action: string, options?: Options) => (arg: FormData) => Promise<void>;
@@ -137,9 +138,16 @@ export type InvalidateOptions = {
137
138
  withChildren?: boolean;
138
139
  withBeforeLoad?: boolean;
139
140
  };
140
- export type RevalidateCache = ({ routeItem, pathname }: RevalidateCacheArgs) => Promise<unknown> | undefined;
141
+ export type RevalidateCache = ({ routeItem, pathname, }: RevalidateCacheArgs) => Promise<{
142
+ data: unknown;
143
+ error: unknown;
144
+ }>;
141
145
  export type Options = Partial<{
142
146
  onSuccess: (args: unknown) => void;
143
147
  onError: (args: unknown) => void;
144
148
  }> | undefined;
149
+ export type InvalidateResult = {
150
+ path: string;
151
+ data: unknown;
152
+ };
145
153
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {