clear-react-router 1.6.2 → 1.6.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
@@ -2,21 +2,33 @@
2
2
 
3
3
  A lightweight, type-safe routing library for React applications with nested routes, data loading, navigation blocking, and prefetching.
4
4
 
5
+ ## Why Clear Router?
6
+
7
+ Most React routers focus on flexibility and ecosystem integrations.
8
+ Clear Router focuses on predictable navigation with a small, explicit API.
9
+
10
+ It provides first-class support for:
11
+
12
+ - Predictable routing
13
+ - Built-in data loading
14
+ - Small API
15
+
5
16
  ## Features
6
17
 
7
- - 🧩 **Nested Routes** - Organize your UI with nested layouts and routes
8
- - **Data Loading** - Built-in loaders with caching and stale-while-revalidate strategy
9
- - 🔒 **Navigation Blocking** - Prevent accidental navigation with `useBlocker`
10
- - **Smooth Animations** - Page transitions with fade effect (customizable duration)
11
- - 🏗️ **Static Layout** — Keep navbar, footer, and other elements outside the router to avoid unnecessary re-renders
12
- - 🎯 **Type-safe Redirects** - Redirect from beforeLoad hook
13
- - 📦 **Prefetching** - Preload data on hover for instant navigation
14
- - 🚀 **Lazy Loading** - Code-split your routes with dynamic imports for optimal performance
15
- - 📍 **Scroll Restoration** Automatically saves and restores scroll position when navigating back to a page (preserves user's scroll position)
16
- - 🔍 **Typed Query Param** — Type-safe reading and writing of URL query parameters with built-in parsers for strings, numbers, booleans, arrays, and Zod schemas
17
- - 🎨 **Flexible API** - Use components or hooks as you prefer
18
- - 📱 **Browser History** - Full support for browser back/forward buttons
19
- - 🧠 **Context-aware** - Pass and update context through routes
18
+ - **Nested Routes** - Organize your UI with nested layouts and routes
19
+ - **Data Loading** - Built-in loaders with caching and stale-while-revalidate strategy
20
+ - **Navigation Blocking** - Prevent accidental navigation with `useBlocker`
21
+ - **Smooth Animations** - Page transitions with fade effect (customizable duration)
22
+ - **Static Layout** — Keep navbar, footer, and other elements outside the router to avoid unnecessary re-renders
23
+ - **Type-safe Redirects** - Redirect from beforeLoad hook
24
+ - **Cache invalidation** - Manual route invalidation
25
+ - **Prefetching** - Preload data on hover for instant navigation
26
+ - **Lazy Loading** - Code-split your routes with dynamic imports for optimal performance
27
+ - **Scroll Restoration** — Automatically saves and restores scroll position when navigating back to a page (preserves user's scroll position)
28
+ - **Typed Query Param** — Type-safe reading and writing of URL query parameters with built-in parsers for strings, numbers, booleans, arrays, and Zod schemas
29
+ - **Flexible API** - Use components or hooks as you prefer
30
+ - **Browser History** - Full support for browser back/forward buttons
31
+ - **Context-aware** - Pass and update context through routes
20
32
 
21
33
  ## API
22
34
 
@@ -28,8 +40,8 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
28
40
  |----------|------|-------------|
29
41
  | `path` | `string` | Route path, e.g., `/user/:userId` |
30
42
  | `element` | `ReactElement \| () => ReactElement \| LazyComponent` | Component to render |
31
- | `loader` | `({ params, context, setContext }) => Promise<unknown>` | Fetch data using route params and context. Can update context via `setContext` |
32
43
  | `beforeLoad` | `({ params, context, redirect, setContext }) => Promise<unknown> \| undefined \| void` | Auth checks and redirects. Can update context via `setContext`. `redirect` is provided by the router |
44
+ | `loader` | `({ params, context, setContext }) => Promise<unknown>` | Fetch data using route params and context. Can update context via `setContext` |
33
45
  | `afterLoad` | `({ params, context, setContext }) => Promise<void>` | Analytics, side effects after data is loaded. Can update context via `setContext` |
34
46
  | `fallback` | `ReactElement \| () => ReactElement` | Loading fallback (for lazy loading) |
35
47
  | `loaderFallback` | `ReactElement \| () => ReactElement` | Loading fallback (for loader) |
@@ -245,21 +257,21 @@ Returns current location `{ pathname, search, state }`.
245
257
  const { pathname, search, state } = useLocation();
246
258
  ```
247
259
 
248
- ### `useLoaderState()`
260
+ ### `useLoaderState<T>()`
249
261
 
250
262
  Returns the cached data loaded by the current route's `loader`, along with any errors from `loader` or `beforeLoad`. Data is automatically cached and reused when navigating back to the same route.
251
263
 
252
264
  **Returns:**
253
265
 
254
266
  | Property | Type | Description |
255
- |----------|------|-------------|
256
- | `data` | `unknown` | The data returned from the route's `loader` |
267
+ |----------|:----:|-------------|
268
+ | `data` | `T` | The data returned from the route's `loader` |
257
269
  | `loaderError` | `Error \| null` | Error from the `loader` (if any) |
258
270
  | `beforeLoadError` | `Error \| null` | Error from the `beforeLoad` hook (if any) |
259
271
 
260
272
  ```
261
- function UserProfile() {
262
- const { data, loaderError, beforeLoadError } = useLoaderState();
273
+ const UserProfile = () => {
274
+ const { data, loaderError, beforeLoadError } = useLoaderState<User>();
263
275
  ```
264
276
 
265
277
  ### Caching behavior:
@@ -273,6 +285,71 @@ function UserProfile() {
273
285
  }
274
286
  ```
275
287
 
288
+ ### `useInvalidate`
289
+
290
+ Returns a function that marks route data as stale and re-runs the route lifecycle.
291
+
292
+ Calling `invalidate()` clears the cached loader result for a route and executes both `beforeLoad` and `loader` again. This is useful after mutations or any operation that changes data used by the route.
293
+
294
+ #### Current route
295
+
296
+ Invalidate the currently active route:
297
+
298
+ ```tsx
299
+ const invalidate = useInvalidate();
300
+
301
+ await invalidate();
302
+ ```
303
+
304
+ #### Specific route
305
+
306
+ You can also invalidate any registered route by passing its pathname:
307
+
308
+ ```tsx
309
+ const invalidate = useInvalidate();
310
+
311
+ await invalidate('/posts');
312
+ ```
313
+
314
+ The route does not need to be currently active. Its cache will be marked as stale, and the next time it is visited, `beforeLoad` and `loader` will run again.
315
+
316
+ #### Why use it?
317
+
318
+ A common use case is refreshing route data after a mutation.
319
+
320
+ For example, after deleting a post while viewing `/posts/42`, you may want the posts list to be reloaded the next time the user navigates to `/posts`:
321
+
322
+ ```tsx
323
+ const invalidate = useInvalidate();
324
+
325
+ await deletePost(id);
326
+ await invalidate('/posts');
327
+ ```
328
+
329
+ Likewise, after updating the current page, you can immediately refresh its data:
330
+
331
+ ```tsx
332
+ const invalidate = useInvalidate();
333
+
334
+ await updateProfile(data);
335
+ await invalidate();
336
+ ```
337
+
338
+ #### Notes
339
+
340
+ * `invalidate()` re-executes both `beforeLoad` and `loader` for the invalidated route.
341
+ * Cached data is discarded before the new loader starts.
342
+ * When used as an event handler, wrap the call in an arrow function:
343
+
344
+ ```tsx
345
+ <button onClick={() => invalidate()}>
346
+ Refresh
347
+ </button>
348
+ ```
349
+
350
+ Passing `invalidate` directly (`onClick={invalidate}`) is not supported because React passes a `MouseEvent` object to event handlers.
351
+
352
+
276
353
  ### `useBlocker(callback)`
277
354
 
278
355
  Blocks navigation when callback returns `true`.
@@ -370,7 +447,7 @@ type Adapter<T> = {
370
447
  | Element | Type | Description |
371
448
  |---------|------|-------------|
372
449
  | `value` | `T` | The parsed value from the query parameter |
373
- | `setValue` | `(arg: T | null) => void` | Function to update the query parameter. Null is passed to remove the parameter. |
450
+ | `setValue` | `(arg: T \| null) => void` | Function to update the query parameter. Null is passed to remove the parameter. |
374
451
 
375
452
  ### Built-in Adapters
376
453
 
@@ -12,6 +12,7 @@ export type ActionsContextValue = {
12
12
  prefetchLoader(arg: string): Promise<void>;
13
13
  setContext(arg: object): void;
14
14
  restoreScroll(): void;
15
+ invalidate(path?: string): void;
15
16
  };
16
17
  export type DataContextValue = {
17
18
  context: Record<string, unknown>;
@@ -10,8 +10,9 @@ type UseHandleNavigation = {
10
10
  pathname: string;
11
11
  }): boolean;
12
12
  loaderStateRef: RefObject<LoaderState>;
13
+ clearTimestamp(path: string): void;
13
14
  };
14
- export declare const useHandleNavigation: ({ routeList, context, revalidateCache, setContext, isCacheItemFresh, loaderStateRef, }: UseHandleNavigation) => {
15
+ export declare const useHandleNavigation: ({ routeList, context, revalidateCache, setContext, isCacheItemFresh, loaderStateRef, clearTimestamp, }: UseHandleNavigation) => {
15
16
  blockerState: BlockerState;
16
17
  updateLocation: (nextLocation: Location) => Promise<void>;
17
18
  updateBlockedRoute: ({ type, payload }: UpdateBlockedRouteProps) => void;
@@ -20,5 +21,6 @@ export declare const useHandleNavigation: ({ routeList, context, revalidateCache
20
21
  currentLoaderFallback: import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | (() => import("react").ReactElement) | undefined;
21
22
  isLoading: boolean;
22
23
  loaderState: LoaderState;
24
+ invalidate: (pathname?: string) => Promise<void>;
23
25
  };
24
26
  export {};
@@ -0,0 +1 @@
1
+ export declare const useInvalidate: () => (path?: string) => void;
@@ -13,5 +13,6 @@ export declare const useLoader: ({ routeList, context, setContext }: UseLoaderPa
13
13
  pathname: string;
14
14
  }) => boolean;
15
15
  loaderStateRef: import("react").RefObject<LoaderState>;
16
+ clearTimestamp: (pathname: string) => void;
16
17
  };
17
18
  export {};
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { useNavigate } from './hooks/useNavigate';
5
5
  export { useParams } from './hooks/useParams';
6
6
  export { useLocation } from './hooks/useLocation';
7
7
  export { useLoaderState } from './hooks/useLoaderState';
8
+ export { useInvalidate } from './hooks/useInvalidate';
8
9
  export { useBlocker } from './hooks/useBlocker';
9
10
  export { useBeforeUnload } from './hooks/useBeforeUnload';
10
11
  export { useRouterContext } from './hooks/useRouterContext';
package/dist/index.js CHANGED
@@ -45,14 +45,15 @@ var require_react_jsx_runtime_production = /* @__PURE__ */ __commonJSMin(((expor
45
45
  var import_jsx_runtime = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
46
46
  module.exports = require_react_jsx_runtime_production();
47
47
  })))();
48
- var Provider = ({ children, setContext, context, updateBlockedRoute, updateLocation, prefetchLoader, blockerState, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState }) => {
48
+ var Provider = ({ children, setContext, context, updateBlockedRoute, updateLocation, prefetchLoader, blockerState, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState, invalidate }) => {
49
49
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ActionsContext.Provider, {
50
50
  value: {
51
51
  updateLocation,
52
52
  updateBlockedRoute,
53
53
  prefetchLoader,
54
54
  setContext,
55
- restoreScroll
55
+ restoreScroll,
56
+ invalidate
56
57
  },
57
58
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DataContext.Provider, {
58
59
  value: { context },
@@ -187,7 +188,7 @@ var comparePaths = (el, pathname) => {
187
188
  //#endregion
188
189
  //#region hooks/useHandleNavigation.ts
189
190
  var ALL_LOCATIONS = "*";
190
- var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, isCacheItemFresh, loaderStateRef }) => {
191
+ var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, isCacheItemFresh, loaderStateRef, clearTimestamp }) => {
191
192
  const { isAnimated, showFallbackIfAnimated: showFallback } = routerConfig;
192
193
  const [isLoading, setIsLoading] = useState(false);
193
194
  const [blockedRoute, setBlockedRoute] = useState({
@@ -203,16 +204,16 @@ var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, is
203
204
  const [loaderState, setLoaderState] = useState(emptyLoaderState);
204
205
  const prevPathname = useRef("");
205
206
  const navigationSeq = useRef(0);
206
- const scrollMapRef = useLatest(scrollMap);
207
+ const scrollMapLatest = useLatest(scrollMap);
207
208
  const restoreScroll = useCallback(() => {
208
- if (!prevPathname.current || !scrollMapRef.current[prevPathname.current]) return;
209
+ if (!prevPathname.current || !scrollMapLatest.current[prevPathname.current]) return;
209
210
  requestAnimationFrame(() => {
210
211
  window.scrollTo({
211
- top: scrollMapRef.current[prevPathname.current],
212
+ top: scrollMapLatest.current[prevPathname.current],
212
213
  behavior: "smooth"
213
214
  });
214
215
  });
215
- }, [scrollMapRef]);
216
+ }, [scrollMapLatest]);
216
217
  const navigation = useCallback((nextLocation, routeItem) => {
217
218
  setRouteItemData({
218
219
  routeItem,
@@ -237,21 +238,19 @@ var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, is
237
238
  navigation(nextLocation, routeItem);
238
239
  }
239
240
  }, [navigation, isAnimated]);
240
- const navigationHandler = useCallback(async (nextLocation) => {
241
- navigationSeq.current = navigationSeq.current + 1;
242
- const seq = navigationSeq.current;
243
- loaderStateRef.current = emptyLoaderState;
244
- const nextItem = routeList.find((el) => el.path === ALL_LOCATIONS || comparePaths(el, nextLocation.pathname));
245
- const params = getParamsObject({
246
- params: nextItem?.params,
247
- pathname: nextLocation.pathname
241
+ const invalidate = useCallback(async (pathname = routeItemData.location.pathname) => {
242
+ if (typeof pathname !== "string") return;
243
+ const routeItem = routeList.find((el) => comparePaths(el, pathname));
244
+ const resultParams = getParamsObject({
245
+ params: routeItem?.params,
246
+ pathname
248
247
  });
249
- if (nextItem?.beforeLoad) try {
250
- const redirect = async (location) => await navigationHandler(typeof location === "string" ? { pathname: location } : location);
251
- await nextItem.beforeLoad({
248
+ clearTimestamp(pathname);
249
+ try {
250
+ if (routeItem?.beforeLoad) await routeItem.beforeLoad({
252
251
  context,
253
- redirect,
254
- params,
252
+ redirect: () => Promise.resolve(),
253
+ params: resultParams,
255
254
  setContext
256
255
  });
257
256
  loaderStateRef.current = {
@@ -263,7 +262,50 @@ var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, is
263
262
  ...loaderStateRef.current,
264
263
  beforeLoadError: error
265
264
  };
266
- return transitionedNavigation(nextLocation, nextItem);
265
+ }
266
+ await revalidateCache({
267
+ routeItem,
268
+ pathname
269
+ });
270
+ if (pathname === routeItemData.location.pathname) setLoaderState(loaderStateRef.current);
271
+ }, [
272
+ clearTimestamp,
273
+ context,
274
+ loaderStateRef,
275
+ revalidateCache,
276
+ routeItemData.location.pathname,
277
+ routeList,
278
+ setContext
279
+ ]);
280
+ const navigationHandler = useCallback(async (nextLocation) => {
281
+ navigationSeq.current = navigationSeq.current + 1;
282
+ const seq = navigationSeq.current;
283
+ loaderStateRef.current = emptyLoaderState;
284
+ const nextItem = routeList.find((el) => el.path === ALL_LOCATIONS || comparePaths(el, nextLocation.pathname));
285
+ const params = getParamsObject({
286
+ params: nextItem?.params,
287
+ pathname: nextLocation.pathname
288
+ });
289
+ if (nextItem?.beforeLoad) {
290
+ const redirect = async (location) => await navigationHandler(typeof location === "string" ? { pathname: location } : location);
291
+ try {
292
+ await nextItem.beforeLoad({
293
+ context,
294
+ redirect,
295
+ params,
296
+ setContext
297
+ });
298
+ loaderStateRef.current = {
299
+ ...loaderStateRef.current,
300
+ beforeLoadError: null
301
+ };
302
+ } catch (error) {
303
+ loaderStateRef.current = {
304
+ ...loaderStateRef.current,
305
+ beforeLoadError: error
306
+ };
307
+ return transitionedNavigation(nextLocation, nextItem);
308
+ }
267
309
  }
268
310
  if (seq !== navigationSeq.current) return;
269
311
  setScrollMap((prevState) => {
@@ -359,19 +401,20 @@ var useHandleNavigation = ({ routeList, context, revalidateCache, setContext, is
359
401
  restoreScroll,
360
402
  currentLoaderFallback,
361
403
  isLoading,
362
- loaderState
404
+ loaderState,
405
+ invalidate
363
406
  };
364
407
  };
365
408
  //#endregion
366
409
  //#region hooks/useLoader.ts
367
410
  var useLoader = ({ routeList, context, setContext }) => {
368
- const timestampMapRef = useRef({});
411
+ const timestampMapRef = useRef(/* @__PURE__ */ new Map());
369
412
  const loaderMapRef = useRef({});
370
413
  const loaderStateRef = useRef(emptyLoaderState);
371
414
  const loadingPromises = useRef(/* @__PURE__ */ new Map());
372
415
  const isCacheItemFresh = useCallback(({ routeItem, pathname }) => {
373
416
  if (!routeItem) return true;
374
- const currentCacheTimestamp = timestampMapRef.current[pathname];
417
+ const currentCacheTimestamp = timestampMapRef.current.get(pathname);
375
418
  if (!currentCacheTimestamp) return false;
376
419
  if (!routeItem.staleTime) return true;
377
420
  return Date.now() - currentCacheTimestamp < routeItem.staleTime;
@@ -398,20 +441,13 @@ var useLoader = ({ routeList, context, setContext }) => {
398
441
  context,
399
442
  setContext
400
443
  });
401
- timestampMapRef.current = {
402
- ...timestampMapRef.current,
403
- [pathname]: Date.now()
404
- };
405
- loaderMapRef.current[pathname] = {
406
- data: result,
407
- loaderError: null,
408
- beforeLoadError: null
409
- };
444
+ timestampMapRef.current.set(pathname, Date.now());
410
445
  loaderStateRef.current = {
411
446
  ...loaderStateRef?.current,
412
447
  data: result,
413
448
  loaderError: null
414
449
  };
450
+ loaderMapRef.current[pathname] = loaderStateRef.current;
415
451
  } catch (error) {
416
452
  loaderStateRef.current = {
417
453
  ...loaderStateRef?.current,
@@ -439,25 +475,29 @@ var useLoader = ({ routeList, context, setContext }) => {
439
475
  }, [revalidateCache, routeList]),
440
476
  revalidateCache,
441
477
  isCacheItemFresh,
442
- loaderStateRef
478
+ loaderStateRef,
479
+ clearTimestamp: useCallback((pathname) => {
480
+ timestampMapRef.current.delete(pathname);
481
+ }, [])
443
482
  };
444
483
  };
445
484
  //#endregion
446
485
  //#region components/RouterProvider.tsx
447
486
  var RouterProvider = ({ children, routeList, context: initialContext = {} }) => {
448
487
  const [context, setContext] = useState(initialContext);
449
- const { prefetchLoader, revalidateCache, isCacheItemFresh, loaderStateRef } = useLoader({
488
+ const { prefetchLoader, revalidateCache, isCacheItemFresh, loaderStateRef, clearTimestamp } = useLoader({
450
489
  routeList,
451
490
  context,
452
491
  setContext
453
492
  });
454
- const { blockerState, updateLocation, updateBlockedRoute, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState } = useHandleNavigation({
493
+ const { blockerState, updateLocation, updateBlockedRoute, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState, invalidate } = useHandleNavigation({
455
494
  routeList,
456
495
  context,
457
496
  setContext,
458
497
  revalidateCache,
459
498
  isCacheItemFresh,
460
- loaderStateRef
499
+ loaderStateRef,
500
+ clearTimestamp
461
501
  });
462
502
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Provider, {
463
503
  ...useMemo(() => ({
@@ -471,7 +511,8 @@ var RouterProvider = ({ children, routeList, context: initialContext = {} }) =>
471
511
  restoreScroll,
472
512
  currentLoaderFallback,
473
513
  isLoading,
474
- loaderState
514
+ loaderState,
515
+ invalidate
475
516
  }), [
476
517
  blockerState,
477
518
  context,
@@ -482,7 +523,8 @@ var RouterProvider = ({ children, routeList, context: initialContext = {} }) =>
482
523
  currentLoaderFallback,
483
524
  restoreScroll,
484
525
  isLoading,
485
- loaderState
526
+ loaderState,
527
+ invalidate
486
528
  ]),
487
529
  children
488
530
  });
@@ -682,6 +724,12 @@ var useLoaderState = () => {
682
724
  return loaderState;
683
725
  };
684
726
  //#endregion
727
+ //#region hooks/useInvalidate.ts
728
+ var useInvalidate = () => {
729
+ const { invalidate } = useRouterActions();
730
+ return invalidate;
731
+ };
732
+ //#endregion
685
733
  //#region hooks/useBlocker.ts
686
734
  var useBlocker = (blockerFn) => {
687
735
  const { blockerState } = useNavigationState();
@@ -887,4 +935,4 @@ var adapter = {
887
935
  })
888
936
  };
889
937
  //#endregion
890
- export { Link, Router, RouterProvider, adapter, createRouter, useBeforeUnload, useBlocker, useHistoricalTrail, useLoaderState, useLocation, useNavigate, useParams, useQueryParam, useRouterContext, useSearchParams };
938
+ export { Link, Router, RouterProvider, adapter, createRouter, useBeforeUnload, useBlocker, useHistoricalTrail, useInvalidate, useLoaderState, useLocation, useNavigate, useParams, useQueryParam, useRouterContext, useSearchParams };
@@ -3,5 +3,5 @@ import { type ActionsContextValue, type DataContextValue, type NavigationContext
3
3
  type ProviderProps = NavigationContextValue & ActionsContextValue & DataContextValue & {
4
4
  children: ReactNode;
5
5
  };
6
- export declare const Provider: ({ children, setContext, context, updateBlockedRoute, updateLocation, prefetchLoader, blockerState, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState, }: ProviderProps) => import("react/jsx-runtime").JSX.Element;
6
+ export declare const Provider: ({ children, setContext, context, updateBlockedRoute, updateLocation, prefetchLoader, blockerState, routeItemData, restoreScroll, currentLoaderFallback, isLoading, loaderState, invalidate, }: ProviderProps) => import("react/jsx-runtime").JSX.Element;
7
7
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {