clear-react-router 1.9.1 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +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
- - **Typed Query Param** — Type-safe reading and writing of URL query parameters with built-in parsers for strings, numbers, booleans, arrays, and Zod schemas
34
+ - **Optimistic navigation** — Instantly renders stale cached data while fresh data is loaded in the background.
35
35
  - **Flexible API** - Use components or hooks as you prefer
36
36
  - **Browser History** - Full support for browser back/forward buttons
37
37
  - **Context-aware** - Pass and update context through routes
@@ -82,12 +82,13 @@ Normalizes route configuration. Extracts dynamic params, builds nested paths.
82
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` |
83
83
  | `fallback` | `ReactElement \| () => ReactElement` | Loading fallback (for lazy loading) |
84
84
  | `loaderFallback` | `ReactElement \| () => ReactElement` | Loading fallback for the route's `loader`. Overrides the global `defaultLoaderFallback` set in `Router` |
85
- | `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 |
86
87
  | `errorElement` | `ReactElement \| () => ReactElement` | Error fallback for the route. Overrides the global `defaultErrorElement` set in `Router` |
87
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 |
88
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` |
89
- | `pollingInterval` | `number \| undefined` | `undefined` | Polling interval (in milliseconds) for automatically revalidating data while the route is active |
90
- | `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 |
91
92
 
92
93
  Loader arguments:
93
94
  ```ts
@@ -113,7 +114,7 @@ Component for client-side navigation with prefetch support, active state detecti
113
114
  | `style` | `CSSProperties \| ({ isActive, isPending }) => CSSProperties` | `undefined` | Inline styles. Can be a function for dynamic styling |
114
115
  | `activeClassName` | `string` optional | `'active-link'` | Class name applied when the link matches the current URL |
115
116
  | `pendingClassName` | `string` optional | `'pending-link'` | Class name applied when the link's target is loading |
116
- | `onClick` | `() => void` | `undefined` | Callback fired before navigation |
117
+ | `beforeNavigate` | `() => Promise<void> \| undefined` | `undefined` | Callback fired before navigation |
117
118
 
118
119
  **State values:**
119
120
 
@@ -163,6 +164,11 @@ import { Router, Link } from 'clear-react-router';
163
164
  <Link to="/profile" style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}>
164
165
  Profile
165
166
  </Link>
167
+
168
+ // Use `beforeNavigate`
169
+ <Link to="/details" beforeNavigate={saveDashboardData}>
170
+ Admin Panel
171
+ </Link>
166
172
  ```
167
173
  **Important**: prefetch="render" should be used sparingly, as it preloads data immediately when the link is rendered, which may cause unnecessary network requests.
168
174
 
@@ -13,7 +13,7 @@ type LinkProps = Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'className' | 'sty
13
13
  className?: string | ((arg: States) => string);
14
14
  activeClassName?: string;
15
15
  pendingClassName?: string;
16
- onClick?(): void;
16
+ beforeNavigate?(): Promise<void>;
17
17
  };
18
- export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, onClick, activeClassName, pendingClassName, }: LinkProps) => import("react/jsx-runtime").JSX.Element;
18
+ export declare const Link: ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, beforeNavigate, activeClassName, pendingClassName, }: LinkProps) => import("react/jsx-runtime").JSX.Element;
19
19
  export {};
package/dist/index.js CHANGED
@@ -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,7 +756,7 @@ 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 Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, className, style, beforeNavigate, activeClassName = "active-link", pendingClassName = "pending-link" }) => {
749
760
  const isPending = useIsRoutePending(to);
750
761
  const { pathname } = useLocation();
751
762
  const navigate = useNavigate();
@@ -804,7 +815,7 @@ var Link = ({ children, to, prefetch: prefetchLink, hoverPrefetchDelay, classNam
804
815
  ].filter(Boolean).join(" ");
805
816
  const clickHandler = async (event) => {
806
817
  event.preventDefault();
807
- onClick?.();
818
+ await beforeNavigate?.();
808
819
  await navigate(to);
809
820
  };
810
821
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", {
@@ -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.1",
3
+ "version": "1.9.3",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {