clear-react-router 1.7.6 → 1.7.8

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
@@ -64,6 +64,8 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
64
64
  | `animationDuration` | `number` | `optional` | Animation duration in milliseconds (browser default is used if not set) |
65
65
  | `defaultLoaderFallback` | `ReactElement \| () => ReactElement` | `optional` | Default loading fallback for every route loader |
66
66
  | `defaultErrorElement` | `ReactElement \| () => ReactElement` | `optional` | Default error fallback for every route |
67
+ | `beforeLoad` | `({ params, context, redirect, setContext }) => Promise<unknown> \| undefined \| void` | `undefined` | Runs before every navigation. Useful for authentication, analytics, or updating shared context. |
68
+ | `afterLoad` | `({ params, context, setContext }) => Promise<void>` | `undefined` | Runs after every successful navigation. Useful for analytics, page tracking, or other global side effects. |
67
69
  | `spinner` | `boolean \| undefined` | `true` | Show a small spinner in the corner while loading data (only when `isAnimated` is enabled) |
68
70
  | `preserveScroll` | `boolean \| undefined` | `true` | Save and restore scroll position when navigating between pages |
69
71
  | `showFallbackOnAnimation` | `boolean \| undefined` | `false` | Show `loaderFallback` even when `isAnimated` is `true` (instead of spinner) |
@@ -72,6 +74,8 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
72
74
  | `context` | `object` | `{}` | Initial context (user, theme, etc.) |
73
75
  | `errorBoundary` | `ComponentType<{ children: ReactNode }>` | `undefined` | Custom error boundary component for catching render errors in route components |
74
76
 
77
+ > **Note:** Global lifecycle hooks wrap every route navigation. The global beforeLoad runs **before** the route-specific beforeLoad, while the global afterLoad runs **after** the route-specific afterLoad.
78
+
75
79
  ```tsx
76
80
  <div>
77
81
  <Navbar />
@@ -396,13 +400,13 @@ const UserProfile = () => {
396
400
 
397
401
  ### `useInvalidate()`
398
402
 
399
- Returns a function that marks route data as stale and re-runs the route lifecycle.
403
+ Returns a function that revalidates cached route data by executing the route lifecycle again.
400
404
 
401
- 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.
405
+ Calling `invalidate()` clears the cached loader result and immediately runs both `beforeLoad` and `loader` for the specified route. This is useful after mutations or any operation that changes data used by the route.
402
406
 
403
407
  #### Current route
404
408
 
405
- Invalidate the currently active route:
409
+ Revalidate the currently active route:
406
410
 
407
411
  ```tsx
408
412
  const invalidate = useInvalidate();
@@ -412,41 +416,80 @@ await invalidate();
412
416
 
413
417
  #### Specific route
414
418
 
415
- You can also invalidate any registered route by passing its pathname:
419
+ Revalidate any registered route by passing its pathname:
416
420
 
417
421
  ```tsx
418
- const invalidate = useInvalidate();
419
-
420
422
  await invalidate('/posts');
421
423
  ```
422
424
 
423
- 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.
425
+ #### Multiple routes
426
+
427
+ You can revalidate several routes at once by passing an array of pathnames:
428
+
429
+ ```tsx
430
+ await invalidate([ '/posts', '/profile', '/settings' ]);
431
+ ```
424
432
 
425
- #### Why use it?
433
+ #### Dynamic routes
426
434
 
427
- A common use case is refreshing route data after a mutation.
435
+ When a dynamic route pattern is provided, every cached route matching that pattern will be revalidated.
428
436
 
429
- 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`:
437
+ For example:
430
438
 
431
439
  ```tsx
432
- const invalidate = useInvalidate();
440
+ await invalidate('/post/[id]');
441
+ ```
433
442
 
434
- await deletePost(id);
435
- await invalidate('/posts');
443
+ will revalidate all cached routes such as:
444
+
445
+ ```text
446
+ /post/1
447
+ /post/17
448
+ /post/42
436
449
  ```
437
450
 
438
- Likewise, after updating the current page, you can immediately refresh its data:
451
+ This also works for nested dynamic routes:
439
452
 
440
453
  ```tsx
441
- const invalidate = useInvalidate();
454
+ await invalidate('/post/[id]/comment/[id]');
455
+ ```
442
456
 
443
- await updateProfile(data);
444
- await invalidate();
457
+ #### Including child routes
458
+
459
+ To revalidate a single route together with its cached child routes, pass the `withChildren` option:
460
+
461
+ ```tsx
462
+ await invalidate('/posts', { withChildren: true }); // a route, not a route list
463
+ ```
464
+
465
+ This will recursively revalidate cached routes inside the route tree.
466
+
467
+ For example, if the following routes have been visited:
468
+
469
+ ```text
470
+ /posts
471
+ /post/17
472
+ /post/23
473
+ /post/42/comments
445
474
  ```
446
475
 
476
+ then:
477
+
478
+ ```tsx
479
+ await invalidate('/posts', { withChildren: true });
480
+ ```
481
+
482
+ will revalidate the cached child routes:
483
+
484
+ ```text
485
+ /post/17
486
+ /post/23
487
+ /post/42/comments
488
+ ```
447
489
  #### Notes
448
490
 
449
491
  * `invalidate()` re-executes both `beforeLoad` and `loader` for the invalidated route.
492
+ * Only routes that already have cached data are revalidated.
450
493
  * Cached data is discarded before the new loader starts.
451
494
  * When used as an event handler, wrap the call in an arrow function:
452
495
 
@@ -1,2 +1,2 @@
1
1
  import { RouterProps } from '../types/global';
2
- export declare const Router: ({ routes, animationDuration, isAnimated, spinner, preserveScroll, showFallbackOnAnimation, prefetch, hoverPrefetchDelay, errorBoundary: ErrorBoundary, context: initialContext, defaultLoaderFallback, defaultErrorElement, }: RouterProps) => import("react/jsx-runtime").JSX.Element | null;
2
+ export declare const Router: ({ routes, beforeLoad, afterLoad, animationDuration, isAnimated, spinner, preserveScroll, showFallbackOnAnimation, prefetch, hoverPrefetchDelay, errorBoundary: ErrorBoundary, context: initialContext, defaultLoaderFallback, defaultErrorElement, }: RouterProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -1,10 +1,12 @@
1
- import { RouterProps } from '../types/global';
1
+ import { ClientRouteItem, RouterProps } from '../types/global';
2
2
  declare class RouterConfig {
3
3
  routes: RouterProps['routes'];
4
4
  prefetch: RouterProps['prefetch'];
5
5
  isAnimated: RouterProps['isAnimated'];
6
6
  showFallbackOnAnimation: RouterProps['showFallbackOnAnimation'];
7
7
  hoverPrefetchDelay: number;
8
+ beforeLoad?: ClientRouteItem['beforeLoad'];
9
+ afterLoad?: ClientRouteItem['afterLoad'];
8
10
  configure(config: Partial<RouterConfig>): void;
9
11
  }
10
12
  export declare const routerConfig: RouterConfig;
@@ -1,4 +1,4 @@
1
1
  export declare const useGetAction: (actionKey: string) => {
2
2
  currentAction: (arg: FormData) => Promise<unknown> | Promise<void> | void | unknown;
3
- invalidate: (path?: string) => Promise<void>;
3
+ invalidate: typeof import("../runtime/invalidate").invalidate;
4
4
  };
@@ -1 +1,2 @@
1
- export declare const useInvalidate: () => (path?: string) => Promise<void>;
1
+ import { invalidate } from '../runtime/invalidate';
2
+ export declare const useInvalidate: () => typeof invalidate;
package/dist/index.js CHANGED
@@ -170,21 +170,28 @@ var createLazyComponent = (importFn, fallback) => {
170
170
  //#region utils/utils.ts
171
171
  var isLazy = (el) => typeof el.element === "function" && el.element.toString().includes("import(");
172
172
  var parseClientRouteItem = (el, parentParams = [], parentPath = "") => {
173
- const currentParamsList = el.path.match(/:[^/]+/g);
174
- const normalizedSplitPath = el.path.replaceAll(/:[^/]+(\/|$)/g, "").split("/").filter(Boolean);
175
- const splitPath = el.path.split("/");
176
- const currentParams = currentParamsList ? [...parentParams, ...currentParamsList.map((param, index) => ({
177
- key: normalizedSplitPath[index],
178
- value: param.slice(1)
179
- }))] : parentParams;
180
- const path = currentParams.length ? `${parentPath}${splitPath.slice(0, splitPath.length - 1).join("/")}` : el.path;
173
+ const segments = el.path.split("/").filter(Boolean);
174
+ const staticSegments = [];
175
+ const currentParams = [...parentParams];
176
+ let lastStaticSegment = "";
177
+ for (const segment of segments) if (segment.startsWith(":")) {
178
+ if (!lastStaticSegment) throw new Error(`Route "${el.path}" cannot start with a parameter.`);
179
+ currentParams.push({
180
+ key: lastStaticSegment,
181
+ value: segment.slice(1)
182
+ });
183
+ } else {
184
+ lastStaticSegment = segment;
185
+ staticSegments.push(segment);
186
+ }
187
+ const path = `${parentPath}/${staticSegments.join("/")}`.replace(/\/+/g, "/");
181
188
  const resolvedElement = isLazy(el) ? createLazyComponent(el.element, el.fallback) : el.element;
182
189
  return [{
183
190
  ...el,
184
191
  path,
185
192
  params: currentParams,
186
193
  element: resolvedElement
187
- }, ...el.children?.flatMap((child) => parseClientRouteItem(child, currentParams, path)) || []];
194
+ }, ...el.children?.flatMap((child) => parseClientRouteItem(child, currentParams, path)) ?? []];
188
195
  };
189
196
  var createRouter = (clientList) => clientList.flatMap((el) => parseClientRouteItem(el, []));
190
197
  var getParamsObject = ({ params, pathname }) => {
@@ -264,8 +271,8 @@ var revalidateCache = ({ routeItem, pathname }) => {
264
271
  return promise;
265
272
  };
266
273
  //#endregion
267
- //#region utils/navigationExecutor.ts
268
- var navigationExecutor = (nextLocation, routeItem) => {
274
+ //#region utils/commitState.ts
275
+ var commitState = (nextLocation, routeItem) => {
269
276
  routeItemDataState.setState({
270
277
  routeItem,
271
278
  location: nextLocation
@@ -287,6 +294,8 @@ var RouterConfig = class {
287
294
  _defineProperty(this, "isAnimated", false);
288
295
  _defineProperty(this, "showFallbackOnAnimation", false);
289
296
  _defineProperty(this, "hoverPrefetchDelay", 150);
297
+ _defineProperty(this, "beforeLoad", void 0);
298
+ _defineProperty(this, "afterLoad", void 0);
290
299
  }
291
300
  configure(config) {
292
301
  Object.assign(this, config);
@@ -294,17 +303,17 @@ var RouterConfig = class {
294
303
  };
295
304
  var routerConfig = new RouterConfig();
296
305
  //#endregion
297
- //#region utils/transitionedNavigation.ts
298
- var transitionedNavigation = (nextLocation, routeItem) => {
306
+ //#region utils/commitNavigation.ts
307
+ var commitNavigation = (nextLocation, routeItem) => {
299
308
  const { isAnimated } = routerConfig;
300
309
  if (!isAnimated || !prevPathnameRef.value) {
301
- navigationExecutor(nextLocation, routeItem);
310
+ commitState(nextLocation, routeItem);
302
311
  return;
303
312
  }
304
313
  try {
305
- document.startViewTransition(() => navigationExecutor(nextLocation, routeItem));
314
+ document.startViewTransition(() => commitState(nextLocation, routeItem));
306
315
  } catch {
307
- navigationExecutor(nextLocation, routeItem);
316
+ commitState(nextLocation, routeItem);
308
317
  }
309
318
  };
310
319
  var findRoute = (pathname, includeAll) => {
@@ -314,21 +323,24 @@ var findRoute = (pathname, includeAll) => {
314
323
  //#endregion
315
324
  //#region runtime/navigate.ts
316
325
  var navigationSeq = 0;
317
- var navigate = async (nextLocation) => {
318
- navigationSeq = navigationSeq + 1;
319
- const seq = navigationSeq;
326
+ var routeResolve = (location) => {
320
327
  loaderStateRef.set(emptyLoaderState);
321
- const { isAnimated, showFallbackOnAnimation: showFallback } = routerConfig;
322
- const nextItem = findRoute(nextLocation.pathname, true);
323
- const params = getParamsObject({
324
- params: nextItem?.params,
325
- pathname: nextLocation.pathname
326
- });
327
- const { context, setContext } = getContext();
328
- if (nextItem?.beforeLoad) {
329
- const redirect = async (location) => await navigate(typeof location === "string" ? { pathname: location } : location);
328
+ const nextItem = findRoute(location.pathname, true);
329
+ return {
330
+ nextItem,
331
+ params: getParamsObject({
332
+ params: nextItem?.params,
333
+ pathname: location.pathname
334
+ })
335
+ };
336
+ };
337
+ var beforeLoad = async (routeItem, params) => {
338
+ const { beforeLoad } = routerConfig;
339
+ const runBeforeLoad = async (loaderFn) => {
340
+ const redirect = async (redirected) => await navigate(typeof redirected === "string" ? { pathname: redirected } : redirected);
341
+ const { context, setContext } = getContext();
330
342
  try {
331
- await nextItem.beforeLoad({
343
+ await loaderFn({
332
344
  context,
333
345
  redirect,
334
346
  params,
@@ -343,10 +355,13 @@ var navigate = async (nextLocation) => {
343
355
  ...prev,
344
356
  beforeLoadError: error
345
357
  }));
346
- return transitionedNavigation(nextLocation, nextItem);
347
358
  }
348
- }
349
- if (seq !== navigationSeq) return;
359
+ };
360
+ if (beforeLoad) await runBeforeLoad(beforeLoad);
361
+ if (routeItem?.beforeLoad) await runBeforeLoad(routeItem?.beforeLoad);
362
+ };
363
+ var prepareNavigation = (routeItem, location) => {
364
+ const { isAnimated, showFallbackOnAnimation: showFallback } = routerConfig;
350
365
  scrollMapState.setState((prevState) => {
351
366
  const scrollPosition = document.scrollingElement?.scrollTop ?? 0;
352
367
  if (!scrollPosition || prevState[prevPathnameRef.value] === scrollPosition) return prevState;
@@ -356,24 +371,44 @@ var navigate = async (nextLocation) => {
356
371
  };
357
372
  });
358
373
  loaderFallbackState.setState(isCacheItemFresh({
359
- routeItem: nextItem,
360
- pathname: nextLocation.pathname
361
- }) || isAnimated && !showFallback ? void 0 : nextItem?.loaderFallback);
362
- if (nextItem?.loader) {
363
- isLoadingState.setState(true);
364
- await revalidateCache({
365
- routeItem: nextItem,
366
- pathname: nextLocation.pathname
367
- });
368
- }
369
- if (seq !== navigationSeq) return;
370
- transitionedNavigation(nextLocation, nextItem);
371
- if (nextItem?.afterLoad) await nextItem.afterLoad({
374
+ routeItem,
375
+ pathname: location.pathname
376
+ }) || isAnimated && !showFallback ? void 0 : routeItem?.loaderFallback);
377
+ };
378
+ var loader = async (routeItem, location) => {
379
+ if (!routeItem?.loader) return;
380
+ isLoadingState.setState(true);
381
+ await revalidateCache({
382
+ routeItem,
383
+ pathname: location.pathname
384
+ });
385
+ };
386
+ var afterLoad = async (routeItem, params) => {
387
+ const { afterLoad } = routerConfig;
388
+ const { context, setContext } = getContext();
389
+ if (routeItem?.afterLoad) await routeItem.afterLoad({
390
+ context,
391
+ params,
392
+ setContext
393
+ });
394
+ if (afterLoad) await afterLoad({
372
395
  context,
373
396
  params,
374
397
  setContext
375
398
  });
376
399
  };
400
+ var navigate = async (nextLocation) => {
401
+ navigationSeq = navigationSeq + 1;
402
+ const seq = navigationSeq;
403
+ const { nextItem, params } = routeResolve(nextLocation);
404
+ await beforeLoad(nextItem, params);
405
+ if (seq !== navigationSeq) return;
406
+ prepareNavigation(nextItem, nextLocation);
407
+ await loader(nextItem, nextLocation);
408
+ if (seq !== navigationSeq) return;
409
+ commitNavigation(nextLocation, nextItem);
410
+ await afterLoad(nextItem, params);
411
+ };
377
412
  //#endregion
378
413
  //#region hooks/useNavigation.ts
379
414
  var useNavigation = () => {
@@ -483,7 +518,7 @@ var renderElement = (Component) => {
483
518
  //#endregion
484
519
  //#region components/Router.tsx
485
520
  var EmptyBoundary = ({ children }) => children;
486
- var Router = ({ routes, animationDuration, isAnimated = false, spinner = true, preserveScroll = true, showFallbackOnAnimation = false, prefetch = "hover", hoverPrefetchDelay = 150, errorBoundary: ErrorBoundary = EmptyBoundary, context: initialContext, defaultLoaderFallback, defaultErrorElement }) => {
521
+ var Router = ({ routes, beforeLoad, afterLoad, animationDuration, isAnimated = false, spinner = true, preserveScroll = true, showFallbackOnAnimation = false, prefetch = "hover", hoverPrefetchDelay = 150, errorBoundary: ErrorBoundary = EmptyBoundary, context: initialContext, defaultLoaderFallback, defaultErrorElement }) => {
487
522
  const [isLoading] = useIsLoading();
488
523
  const [currentLoaderFallback] = useLoaderFallback();
489
524
  const [routeItemData] = useRouteItemData();
@@ -494,7 +529,9 @@ var Router = ({ routes, animationDuration, isAnimated = false, spinner = true, p
494
529
  isAnimated,
495
530
  prefetch,
496
531
  hoverPrefetchDelay,
497
- showFallbackOnAnimation
532
+ showFallbackOnAnimation,
533
+ beforeLoad,
534
+ afterLoad
498
535
  });
499
536
  useApplyCustomAnimation(animationDuration);
500
537
  useSetInitialContext(initialContext);
@@ -625,22 +662,21 @@ var FormProvider = ({ children, isSubmitting }) => /* @__PURE__ */ (0, import_js
625
662
  });
626
663
  //#endregion
627
664
  //#region runtime/invalidate.ts
628
- var invalidate = async (path) => {
665
+ var redirect = () => Promise.resolve();
666
+ var invalidatePath = async (routeItem, pathname) => {
629
667
  const routePathname = routeItemDataState.getState().location.pathname;
630
- const pathname = path || routePathname;
631
- const routeItem = findRoute(pathname);
632
- const resultParams = getParamsObject({
668
+ timestampMap.delete(pathname);
669
+ const params = getParamsObject({
633
670
  params: routeItem?.params,
634
671
  pathname
635
672
  });
636
- timestampMap.delete(pathname);
637
673
  try {
638
674
  if (routeItem?.beforeLoad) {
639
675
  const { context, setContext } = getContext();
640
676
  await routeItem.beforeLoad({
641
677
  context,
642
- redirect: () => Promise.resolve(),
643
- params: resultParams,
678
+ redirect,
679
+ params,
644
680
  setContext
645
681
  });
646
682
  }
@@ -660,6 +696,22 @@ var invalidate = async (path) => {
660
696
  });
661
697
  if (pathname === routePathname) currentLoaderState.setState(loaderStateRef.value);
662
698
  };
699
+ var invalidateItem = async (pathname, withChildren) => {
700
+ const routeItem = findRoute(pathname);
701
+ if (!routeItem) return;
702
+ const pathnameArray = [];
703
+ for (const [key] of timestampMap) if (comparePaths(routeItem, key)) pathnameArray.push(key);
704
+ await Promise.all(pathnameArray.map((pathname) => invalidatePath(routeItem, pathname)));
705
+ if (withChildren && routeItem.children?.length) {
706
+ const childPathList = routeItem.children.map((el) => el.path);
707
+ await Promise.all(childPathList.map((el) => invalidateItem(`${pathname}${el}`, withChildren)));
708
+ }
709
+ };
710
+ async function invalidate(pathList, options) {
711
+ const routePathname = routeItemDataState.getState().location.pathname;
712
+ const pathnameList = Array.isArray(pathList) ? pathList : pathList ? [pathList] : [routePathname];
713
+ await Promise.all(pathnameList.map((pathname) => invalidateItem(pathname, options?.withChildren)));
714
+ }
663
715
  //#endregion
664
716
  //#region hooks/useInvalidate.ts
665
717
  var useInvalidate = () => invalidate;
@@ -1 +1,6 @@
1
- export declare const invalidate: (path?: string) => Promise<void>;
1
+ type Options = {
2
+ withChildren?: boolean;
3
+ };
4
+ export declare function invalidate(path?: string[]): Promise<void>;
5
+ export declare function invalidate(path?: string, options?: Options): Promise<void>;
6
+ export {};
@@ -3,6 +3,17 @@ export type LazyComponent = () => Promise<{
3
3
  default: ComponentType<unknown>;
4
4
  }>;
5
5
  export type RenderElement = (() => ReactElement) | ReactElement;
6
+ export type BeforeLoad = (arg: {
7
+ context: Record<string, unknown>;
8
+ redirect: (arg: Location | string) => Promise<void>;
9
+ params: Record<string, string>;
10
+ setContext: Dispatch<SetStateAction<Record<string, unknown>>>;
11
+ }) => Promise<unknown> | undefined | void;
12
+ export type AfterLoad = (arg: {
13
+ context: Record<string, unknown>;
14
+ params: Record<string, string>;
15
+ setContext: Dispatch<SetStateAction<Record<string, unknown>>>;
16
+ }) => Promise<void>;
6
17
  export type ClientRouteItem = {
7
18
  path: string;
8
19
  element: RenderElement | LazyComponent;
@@ -16,12 +27,7 @@ export type ClientRouteItem = {
16
27
  fallback?: RenderElement;
17
28
  children?: ClientRouteItem[];
18
29
  staleTime?: number;
19
- beforeLoad?: (arg: {
20
- context: Record<string, unknown>;
21
- redirect: (arg: Location | string) => Promise<void>;
22
- params: Record<string, string>;
23
- setContext: Dispatch<SetStateAction<Record<string, unknown>>>;
24
- }) => Promise<unknown> | undefined | void;
30
+ beforeLoad?: BeforeLoad;
25
31
  afterLoad?: (arg: {
26
32
  context: Record<string, unknown>;
27
33
  params: Record<string, string>;
@@ -79,5 +85,7 @@ export type RouterProps = {
79
85
  errorBoundary?: ComponentType<{
80
86
  children: ReactNode;
81
87
  }>;
88
+ beforeLoad?: ClientRouteItem['beforeLoad'];
89
+ afterLoad?: ClientRouteItem['afterLoad'];
82
90
  context?: Record<string, unknown>;
83
91
  };
@@ -0,0 +1,2 @@
1
+ import { Location, RouteItem } from '../types/global';
2
+ export declare const commitNavigation: (nextLocation: Location, routeItem: RouteItem | undefined) => void;
@@ -0,0 +1,2 @@
1
+ import { Location, RouteItem } from '../types/global';
2
+ export declare const commitState: (nextLocation: Location, routeItem: RouteItem | undefined) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {
@@ -1,2 +0,0 @@
1
- import { Location, RouteItem } from '../types/global';
2
- export declare const navigationExecutor: (nextLocation: Location, routeItem: RouteItem | undefined) => void;
@@ -1,2 +0,0 @@
1
- import { Location, RouteItem } from '../types/global';
2
- export declare const transitionedNavigation: (nextLocation: Location, routeItem: RouteItem | undefined) => void;