clear-react-router 1.9.2 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@ It provides first-class support for:
31
31
  - **Prefetching** - Preload data on hover for instant navigation
32
32
  - **Lazy Loading** - Code-split your routes with dynamic imports for optimal performance
33
33
  - **Scroll Restoration** — Automatically saves and restores scroll position when navigating back to a page (preserves user's scroll position)
34
+ - **Optimistic navigation** — Instantly renders stale cached data while fresh data is loaded in the background.
34
35
  - **Flexible API** - Use components or hooks as you prefer
35
36
  - **Browser History** - Full support for browser back/forward buttons
36
37
  - **Context-aware** - Pass and update context through routes
@@ -81,12 +82,13 @@ Normalizes route configuration. Extracts dynamic params, builds nested paths.
81
82
  | `afterLoad` | `({ params, context, setContext }) => Promise<void>` | Runs after a successful navigation once the route has finished loading. Analytics, side effects after data is loaded. Can update context via `setContext` |
82
83
  | `fallback` | `ReactElement \| () => ReactElement` | Loading fallback (for lazy loading) |
83
84
  | `loaderFallback` | `ReactElement \| () => ReactElement` | Loading fallback for the route's `loader`. Overrides the global `defaultLoaderFallback` set in `Router` |
84
- | `retry` | `number \| { count: number; delay: number }` | `undefined` | Overrides the global cache revalidation retry policy for this route |
85
+ | `retry` | `number \| { count: number; delay: number }` | Overrides the global cache revalidation retry policy for this route |
86
+ | `optimistic` | `boolean \| undefined` | Instant navigation using stale data while fresh data is loaded in the background |
85
87
  | `errorElement` | `ReactElement \| () => ReactElement` | Error fallback for the route. Overrides the global `defaultErrorElement` set in `Router` |
86
88
  | `staleTime` | `number` | Time in milliseconds before cached loader data is considered stale. Overrides Router.defaultStaleTime. If neither value is provided, cached data never expires |
87
89
  | `actions` | `({ params, context, invalidate, setContext }) => Record<string, (formData: FormData) => unknown \| Promise<unknown>>` | Defines route actions for data mutations. Actions receive `FormData`, can update context via `setContext`, and can invalidate cached loader data using the router-provided `invalidate` |
88
- | `pollingInterval` | `number \| undefined` | `undefined` | Polling interval (in milliseconds) for automatically revalidating data while the route is active |
89
- | `preserveScroll` | `boolean \| undefined` | `undefined` | Save and restore route scroll position when navigating between pages |
90
+ | `pollingInterval` | `number \| undefined` | Polling interval (in milliseconds) for automatically revalidating data while the route is active |
91
+ | `preserveScroll` | `boolean \| undefined` | Save and restore route scroll position when navigating between pages |
90
92
 
91
93
  Loader arguments:
92
94
  ```ts
@@ -105,6 +107,8 @@ Component for client-side navigation with prefetch support, active state detecti
105
107
  | Prop | Type | Default | Description |
106
108
  |------|------|---------|-------------|
107
109
  | `to` | `string` | required | Target path |
110
+ | `as` | `(props: ElementProps<T>) => ReactElement` | renders `<a>` | Render prop for using a custom element/component instead of the default `<a>`. Receives the computed isActive and isPending values, event handlers, and ref to attach to your own element |
111
+ | `exact` | `boolean` | `false` | When `false`, the link is also considered active if the current URL starts with `to` (useful for nested routes) |
108
112
  | `prefetch` | `'hover' \| 'render' \| 'viewport' \| 'none'` | `Router` config | Override the global prefetch strategy |
109
113
  | `hoverPrefetchDelay` | `number` | `Router` config | Override the global hover delay |
110
114
  | `children` | `ReactNode` | required | Content to render inside the link |
@@ -112,13 +116,13 @@ Component for client-side navigation with prefetch support, active state detecti
112
116
  | `style` | `CSSProperties \| ({ isActive, isPending }) => CSSProperties` | `undefined` | Inline styles. Can be a function for dynamic styling |
113
117
  | `activeClassName` | `string` optional | `'active-link'` | Class name applied when the link matches the current URL |
114
118
  | `pendingClassName` | `string` optional | `'pending-link'` | Class name applied when the link's target is loading |
115
- | `onClick` | `() => void` | `undefined` | Callback fired before navigation |
119
+ | `beforeNavigate` | `() => Promise<void> \| undefined` | `undefined` | Callback fired before navigation |
116
120
 
117
121
  **State values:**
118
122
 
119
123
  | State | Type | Description |
120
124
  |-------|------|-------------|
121
- | `isActive` | `boolean` | `true` when the link's `to` matches the current URL |
125
+ | `isActive` | `boolean` | `true` when the link's `to` matches the current URL considering `exact` value |
122
126
  | `isPending` | `boolean` | `true` when the target route is currently loading (loader is running) |
123
127
 
124
128
  ### Prefetch Strategies
@@ -135,6 +139,22 @@ Component for client-side navigation with prefetch support, active state detecti
135
139
  ```tsx
136
140
  import { Router, Link } from 'clear-react-router';
137
141
 
142
+ // Render a custom element/component via `as`. The function receives ref, event handlers, isActive/isPending and must render them itself
143
+ import { Button } from '@mui/material';
144
+
145
+ <Link
146
+ to="/dashboard"
147
+ as={({ isActive, isPending, ...props }) => (
148
+ <Button
149
+ {...props}
150
+ variant={isActive ? 'contained' : 'outlined'}
151
+ sx={{ opacity: isPending ? 0.5 : 1 }}
152
+ />
153
+ )}
154
+ >
155
+ Dashboard
156
+ </Link>
157
+
138
158
  // Global prefetch: hover with 100ms delay
139
159
  <Router routes={routes} prefetch="hover" hoverPrefetchDelay={100} />
140
160
 
@@ -162,6 +182,17 @@ import { Router, Link } from 'clear-react-router';
162
182
  <Link to="/profile" style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}>
163
183
  Profile
164
184
  </Link>
185
+
186
+ // Use `beforeNavigate`
187
+ <Link to="/details" beforeNavigate={saveDashboardData}>
188
+ Admin Panel
189
+ </Link>
190
+
191
+ // `exact={false}` — active for nested routes too
192
+ // e.g. active when current URL is "/settings" or "/settings/profile"
193
+ <Link to="/settings" exact={false}>
194
+ Settings
195
+ </Link>
165
196
  ```
166
197
  **Important**: prefetch="render" should be used sparingly, as it preloads data immediately when the link is rendered, which may cause unnecessary network requests.
167
198
 
@@ -1,19 +1,33 @@
1
- import { type CSSProperties, ReactNode, ComponentPropsWithoutRef } from 'react';
1
+ import { type CSSProperties, ReactNode, MouseEvent, ReactElement, Ref } from 'react';
2
2
  import { RouterProps } from '../types';
3
3
  type States = {
4
4
  isActive: boolean;
5
5
  isPending: boolean;
6
6
  };
7
- type LinkProps = Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'className' | 'style'> & {
7
+ type ElementProps<T extends HTMLElement = HTMLElement> = {
8
+ ref: Ref<T>;
9
+ href: string;
10
+ isActive: boolean;
11
+ isPending: boolean;
12
+ onClick(event: MouseEvent): void;
13
+ onMouseEnter(event: MouseEvent): void;
14
+ onMouseLeave(event: MouseEvent): void;
15
+ className?: string;
16
+ style?: CSSProperties;
17
+ children?: ReactNode;
18
+ };
19
+ type LinkProps<T extends HTMLElement = HTMLAnchorElement> = {
8
20
  to: string;
9
- children: ReactNode;
21
+ children?: ReactNode;
22
+ as?: (props: ElementProps<T>) => ReactElement;
10
23
  prefetch?: RouterProps['prefetch'];
11
24
  hoverPrefetchDelay?: number;
12
- style?: CSSProperties | ((arg: States) => CSSProperties);
13
25
  className?: string | ((arg: States) => string);
14
26
  activeClassName?: string;
15
27
  pendingClassName?: string;
16
- onClick?(): void;
28
+ beforeNavigate?(): Promise<void>;
29
+ style?: CSSProperties | ((arg: States) => CSSProperties);
30
+ exact?: boolean;
17
31
  };
18
- export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName, pendingClassName, }: LinkProps) => import("react/jsx-runtime").JSX.Element;
32
+ export declare const Link: <T extends HTMLElement = HTMLAnchorElement>({ children, to, as, prefetch: prefetchLink, hoverPrefetchDelay, className, style, beforeNavigate, exact, activeClassName, pendingClassName, }: LinkProps<T>) => ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
19
33
  export {};
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ var createCommitState = ({ routeItemDataState, pendingState }) => (nextLocation,
44
44
  //#region constants.ts
45
45
  var emptyLoaderState = {};
46
46
  //#endregion
47
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/typeof.js
47
+ //#region \0@oxc-project+runtime@0.132.0/helpers/typeof.js
48
48
  function _typeof(o) {
49
49
  "@babel/helpers - typeof";
50
50
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -54,7 +54,7 @@ function _typeof(o) {
54
54
  }, _typeof(o);
55
55
  }
56
56
  //#endregion
57
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/toPrimitive.js
57
+ //#region \0@oxc-project+runtime@0.132.0/helpers/toPrimitive.js
58
58
  function toPrimitive(t, r) {
59
59
  if ("object" != _typeof(t) || !t) return t;
60
60
  var e = t[Symbol.toPrimitive];
@@ -66,13 +66,13 @@ function toPrimitive(t, r) {
66
66
  return ("string" === r ? String : Number)(t);
67
67
  }
68
68
  //#endregion
69
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/toPropertyKey.js
69
+ //#region \0@oxc-project+runtime@0.132.0/helpers/toPropertyKey.js
70
70
  function toPropertyKey(t) {
71
71
  var i = toPrimitive(t, "string");
72
72
  return "symbol" == _typeof(i) ? i : i + "";
73
73
  }
74
74
  //#endregion
75
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/defineProperty.js
75
+ //#region \0@oxc-project+runtime@0.132.0/helpers/defineProperty.js
76
76
  function _defineProperty(e, r, t) {
77
77
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
78
78
  value: t,
@@ -113,13 +113,13 @@ var createCommitNavigation = (navigationExecutor, routeItemDataState) => (nextLo
113
113
  };
114
114
  //#endregion
115
115
  //#region utils/isCacheItemFresh.ts
116
- var createIsCacheItemFresh = (timestampMap) => ({ routeItem, pathname }) => {
116
+ var createIsCacheItemFresh = (loaderMap) => ({ routeItem, pathname }) => {
117
117
  if (!routeItem) return true;
118
- const timestamp = timestampMap.get(pathname);
119
- if (timestamp === void 0) return false;
118
+ const item = loaderMap.get(pathname);
119
+ if (item === void 0) return false;
120
120
  const staleTime = routeItem.staleTime ?? routerConfig.defaultStaleTime;
121
121
  if (staleTime === void 0) return true;
122
- return Date.now() - timestamp <= staleTime;
122
+ return Date.now() - item.timestamp <= staleTime;
123
123
  };
124
124
  //#endregion
125
125
  //#region ../../node_modules/react/cjs/react-jsx-runtime.production.js
@@ -216,9 +216,9 @@ var findRoute = (pathname, includeAll) => {
216
216
  var navigationSeq = 0;
217
217
  var interval = 0;
218
218
  var createNavigate = (routerState, revalidateCache) => {
219
- const { loaderStateRef, scrollMapState, pendingState, contextState, timestampMap, routeItemDataState } = routerState;
219
+ const { loaderStateRef, scrollMapState, pendingState, contextState, loaderMap, routeItemDataState } = routerState;
220
220
  const commitNavigation = createCommitNavigation(createCommitState(routerState), routeItemDataState);
221
- const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
221
+ const isCacheItemFresh = createIsCacheItemFresh(loaderMap);
222
222
  const getContext = () => ({
223
223
  context: contextState.getState(),
224
224
  setContext: contextState.setState
@@ -265,14 +265,23 @@ var createNavigate = (routerState, revalidateCache) => {
265
265
  [prevPathname]: scrollPosition
266
266
  };
267
267
  });
268
- const pendingShouldExist = routeItem?.loader && !isCacheItemFresh({
269
- routeItem,
270
- pathname: location.pathname
271
- });
272
- pendingState.setState(pendingShouldExist ? {
273
- routeItem,
274
- location
275
- } : void 0);
268
+ if (routeItem?.optimistic && loaderMap.has(location.pathname)) {
269
+ routeItemDataState.setState({
270
+ routeItem,
271
+ location
272
+ });
273
+ const currentLoaderState = loaderMap.get(location.pathname)?.state;
274
+ if (currentLoaderState) loaderStateRef.set(currentLoaderState);
275
+ } else {
276
+ const pendingShouldExist = routeItem?.loader && !isCacheItemFresh({
277
+ routeItem,
278
+ pathname: location.pathname
279
+ });
280
+ pendingState.setState(pendingShouldExist ? {
281
+ routeItem,
282
+ location
283
+ } : void 0);
284
+ }
276
285
  };
277
286
  const afterEachLoad = (routeItem) => {
278
287
  if (!routeItem?.pollingInterval) return;
@@ -319,10 +328,10 @@ var createNavigate = (routerState, revalidateCache) => {
319
328
  //#endregion
320
329
  //#region runtime/invalidate.ts
321
330
  var redirect = () => Promise.resolve();
322
- var createInvalidate = ({ routeItemDataState, loaderStateRef, timestampMap, currentLoaderState, contextState }, revalidateCache) => {
331
+ var createInvalidate = ({ routeItemDataState, loaderStateRef, loaderMap, currentLoaderState, contextState }, revalidateCache) => {
323
332
  const invalidatePath = async (routeItem, pathname, options) => {
324
333
  const routePathname = routeItemDataState.getState().location.pathname;
325
- timestampMap.delete(pathname);
334
+ loaderMap.delete(pathname);
326
335
  const params = getParamsObject();
327
336
  try {
328
337
  if (routeItem?.beforeLoad && options?.withBeforeLoad) {
@@ -359,7 +368,7 @@ var createInvalidate = ({ routeItemDataState, loaderStateRef, timestampMap, curr
359
368
  const routeItem = findRoute(pathname);
360
369
  if (!routeItem) return [];
361
370
  const pathnameArray = [];
362
- for (const [key] of timestampMap) if (comparePaths(routeItem, key)) pathnameArray.push(key);
371
+ for (const [key] of loaderMap) if (comparePaths(routeItem, key)) pathnameArray.push(key);
363
372
  const currentResults = await Promise.all(pathnameArray.map((pathname) => invalidatePath(routeItem, pathname, options)));
364
373
  if (!options?.withChildren || !routeItem.children?.length) return currentResults;
365
374
  const childResults = await Promise.all(routeItem.children.map((child) => invalidateItem(`${pathname}${child.path}`, options)));
@@ -385,7 +394,6 @@ var createPrefetch = (revalidateCache) => async (pathname) => {
385
394
  };
386
395
  //#endregion
387
396
  //#region utils/revalidateCache.ts
388
- var loaderMapRef = {};
389
397
  var loadingPromises = /* @__PURE__ */ new Map();
390
398
  var isObjectRetry = (arg) => typeof arg === "object";
391
399
  var createRetry = (arg) => {
@@ -406,16 +414,17 @@ var getRetry = (routeItem) => {
406
414
  };
407
415
  var sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
408
416
  var createRevalidateCache = (routerState) => {
409
- const { loaderStateRef, timestampMap, contextState } = routerState;
417
+ const { loaderStateRef, contextState, loaderMap } = routerState;
410
418
  const revalidateCache = async ({ routeItem, pathname, search = "" }, retried = 0) => {
411
419
  if (!routeItem?.loader) return;
412
- const isCacheItemFresh = createIsCacheItemFresh(timestampMap);
420
+ const isCacheItemFresh = createIsCacheItemFresh(loaderMap);
413
421
  if (loadingPromises.has(pathname)) return loadingPromises.get(pathname);
414
422
  if (isCacheItemFresh({
415
423
  routeItem,
416
424
  pathname
417
425
  })) {
418
- loaderStateRef.set(loaderMapRef[pathname]);
426
+ const item = loaderMap.get(pathname);
427
+ if (item?.state) loaderStateRef.set(item.state);
419
428
  return;
420
429
  }
421
430
  const promise = (async () => {
@@ -431,13 +440,15 @@ var createRevalidateCache = (routerState) => {
431
440
  setContext,
432
441
  searchParams
433
442
  });
434
- timestampMap.set(`${pathname}${search}`, Date.now());
435
443
  loaderStateRef.set((prev) => ({
436
444
  ...prev,
437
445
  data: result,
438
446
  loaderError: null
439
447
  }));
440
- loaderMapRef[`${pathname}${search}`] = loaderStateRef.value;
448
+ loaderMap.set(`${pathname}${search}`, {
449
+ state: loaderStateRef.value,
450
+ timestamp: Date.now()
451
+ });
441
452
  return {
442
453
  data: result,
443
454
  error: null
@@ -506,7 +517,7 @@ var createRouterInstance = () => {
506
517
  to: ""
507
518
  }),
508
519
  loaderStateRef: new Cell(emptyLoaderState),
509
- timestampMap: /* @__PURE__ */ new Map()
520
+ loaderMap: /* @__PURE__ */ new Map()
510
521
  };
511
522
  const revalidateCache = createRevalidateCache(routerState);
512
523
  const invalidate = createInvalidate(routerState, revalidateCache);
@@ -745,12 +756,13 @@ var useLocation = () => {
745
756
  };
746
757
  //#endregion
747
758
  //#region components/Link.tsx
748
- var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName = "active-link", pendingClassName = "pending-link" }) => {
759
+ var defaultAs = ({ isActive, isPending, ...props }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", { ...props });
760
+ var Link = ({ children, to, as = defaultAs, prefetch: prefetchLink, hoverPrefetchDelay, className, style, beforeNavigate, exact = false, activeClassName = "active-link", pendingClassName = "pending-link" }) => {
749
761
  const isPending = useIsRoutePending(to);
750
762
  const { pathname } = useLocation();
751
763
  const navigate = useNavigate();
752
764
  const timeout = useRef(0);
753
- const ref = useRef(null);
765
+ const elementRef = useRef(null);
754
766
  const { prefetch: configPrefetch, hoverPrefetchDelay: configPrefetchDelay } = routerConfig;
755
767
  const prefetch = prefetchLink || configPrefetch;
756
768
  const prefetchDelay = hoverPrefetchDelay ?? configPrefetchDelay;
@@ -778,17 +790,19 @@ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, classNam
778
790
  }, [prefetch, to]);
779
791
  useEffect(() => {
780
792
  if (prefetch !== "viewport") return;
781
- const element = ref.current;
793
+ const element = elementRef.current;
794
+ if (!element) return;
782
795
  const observer = new IntersectionObserver(async () => {
783
796
  await router.runtime.prefetch(to);
784
797
  observer.disconnect();
785
798
  });
786
- if (element) observer.observe(element);
787
- return () => {
788
- if (element) observer.disconnect();
789
- };
799
+ observer.observe(element);
800
+ return () => observer.disconnect();
790
801
  }, [prefetch, to]);
791
- const isActive = to === pathname;
802
+ useEffect(() => () => {
803
+ if (timeout.current) clearTimeout(timeout.current);
804
+ }, []);
805
+ const isActive = to === "/" ? pathname === "/" : exact ? pathname === to : pathname === to || pathname?.startsWith(`${to}/`);
792
806
  const normalizedClassName = typeof className === "function" ? className({
793
807
  isActive,
794
808
  isPending
@@ -803,18 +817,21 @@ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, classNam
803
817
  normalizedClassName
804
818
  ].filter(Boolean).join(" ");
805
819
  const clickHandler = async (event) => {
820
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
806
821
  event.preventDefault();
807
- onClick?.();
822
+ await beforeNavigate?.();
808
823
  await navigate(to);
809
824
  };
810
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", {
825
+ return as({
826
+ ref: elementRef,
811
827
  href: to,
812
- ref,
813
828
  style: normalizedStyle,
814
829
  className: resultClassName,
815
830
  onClick: clickHandler,
816
831
  onMouseEnter,
817
832
  onMouseLeave,
833
+ isActive,
834
+ isPending,
818
835
  children
819
836
  });
820
837
  };
@@ -1,2 +1,2 @@
1
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[]>;
2
+ export declare const createInvalidate: ({ routeItemDataState, loaderStateRef, loaderMap, currentLoaderState, contextState }: RouterState, revalidateCache: RevalidateCache) => (pathList?: string | string[], options?: InvalidateOptions) => Promise<InvalidateResult[]>;
package/dist/types.d.ts CHANGED
@@ -25,6 +25,7 @@ export type ClientRouteItem = {
25
25
  fallback?: RenderElement;
26
26
  children?: ClientRouteItem[];
27
27
  staleTime?: number;
28
+ optimistic?: boolean;
28
29
  pollingInterval?: number;
29
30
  retry?: Retry;
30
31
  preserveScroll?: boolean;
@@ -94,6 +95,10 @@ export type RouterProps = {
94
95
  afterLoad?: ClientRouteItem['afterLoad'];
95
96
  context?: Record<string, unknown>;
96
97
  };
98
+ export type LoaderStateItem = {
99
+ state: LoaderState;
100
+ timestamp: number;
101
+ };
97
102
  export type RouterState = {
98
103
  routeItemDataState: Store<RouteItemData>;
99
104
  pendingState: Store<RouteItemData | undefined>;
@@ -105,7 +110,7 @@ export type RouterState = {
105
110
  to: string;
106
111
  }>;
107
112
  loaderStateRef: Cell<LoaderState>;
108
- timestampMap: Map<string, number>;
113
+ loaderMap: Map<string, LoaderStateItem>;
109
114
  };
110
115
  export type RouterType = {
111
116
  state: Omit<RouterState, 'timestampMap'>;
@@ -1,5 +1,5 @@
1
- import type { RouteItem } from '../types';
2
- export declare const createIsCacheItemFresh: (timestampMap: Map<string, number>) => ({ routeItem, pathname }: {
1
+ import { LoaderStateItem, RouteItem } from '../types';
2
+ export declare const createIsCacheItemFresh: (loaderMap: Map<string, LoaderStateItem>) => ({ routeItem, pathname }: {
3
3
  routeItem?: RouteItem;
4
4
  pathname: string;
5
5
  }) => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {