clear-react-router 2.0.0 → 2.0.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
@@ -22,17 +22,17 @@ It provides first-class support for:
22
22
  ## Features
23
23
 
24
24
  - **Nested Routes** - Organize your UI with nested layouts and routes
25
- - **Data Loading** - Built-in loaders with caching and stale-while-revalidate strategy
25
+ - **Data Loading** - Built-in loaders with TTL-based caching (`staleTime`)
26
26
  - **Navigation Blocking** - Prevent accidental navigation with `useBlocker`
27
27
  - **Smooth Animations** - Page transitions with fade effect (customizable duration)
28
28
  - **Static Layout** — Keep navbar, footer, and other elements outside the router to avoid unnecessary re-renders
29
29
  - **Programmatic Redirects** - Redirect from beforeLoad hook
30
30
  - **Cache invalidation** - Manual route invalidation
31
+ - **Bounded Cache** - Automatically evicts least recently used entries once `maxCacheSize` is reached, keeping memory usage predictable in long sessions
31
32
  - **Prefetching** - Preload data on hover for instant navigation
32
33
  - **Lazy Loading** - Code-split your routes with dynamic imports for optimal performance
33
34
  - **Scroll Restoration** — Automatically saves and restores scroll position when navigating back to a page (preserves user's scroll position)
34
35
  - **Optimistic navigation** — Instantly renders stale cached data while fresh data is loaded in the background.
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
38
38
 
@@ -43,6 +43,7 @@ It provides first-class support for:
43
43
  | Prop | Type | Default | Description |
44
44
  |------|------|---------|-------------|
45
45
  | `routes` | `RouteItem[]` | required | Array of route configurations |
46
+ | `maxCacheSize` | `number \| undefined` | 60 for mobile, 150 for desktop | Maximum number of cached loader entries. Once the limit is reached, the least recently used entries are evicted |
46
47
  | `isAnimated` | `boolean \| undefined` | `false` | Enable smooth page fade transitions |
47
48
  | `animationDuration` | `number` | `optional` | Animation duration in milliseconds (browser default is used if not set) |
48
49
  | `spinner` | `boolean \| undefined` | `true` | Show a small spinner in the corner while loading data (only when `isAnimated` is enabled) |
@@ -79,7 +80,7 @@ Normalizes route configuration. Extracts dynamic params, builds nested paths.
79
80
  | `path` | `string` | Route path, e.g., `/user/:userId` |
80
81
  | `element` | `ReactElement \| () => ReactElement \| LazyComponent` | Component to render |
81
82
  | `beforeLoad` | `({ params, context, redirect, setContext }) => Promise<unknown> \| undefined \| void` | Runs before every route navigation. Auth checks and redirects. Can update context via `setContext`. `redirect` is provided by the router |
82
- | `loader` | `({ params, context, setContext, searchParams }) => Promise<unknown>` | Fetch data using route params, search params, and context. Can update context via `setContext` |
83
+ | `loader` | `({ params, context, setContext, searchParams, signal }) => Promise<unknown>` | Fetch data using route params, search params, abort controller signal and context. Can update context via `setContext` |
83
84
  | `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` |
84
85
  | `fallback` | `ReactElement \| () => ReactElement` | Loading fallback (for lazy loading) |
85
86
  | `loaderFallback` | `ReactElement \| () => ReactElement` | Loading fallback for the route's `loader`. Overrides the global `defaultLoaderFallback` set in `Router` |
@@ -98,6 +99,7 @@ Loader arguments:
98
99
  context: Record<string, unknown>; // Router context
99
100
  setContext: Dispatch<SetStateAction<Record<string, unknown>>>; // Updates the router context
100
101
  searchParams: Record<string, string>; // URL search parameters
102
+ signal: AbortSignal; // AbortController signal
101
103
  }
102
104
  ```
103
105
 
@@ -563,16 +565,24 @@ const UserProfile = () => {
563
565
  ```
564
566
 
565
567
  ### Caching behavior:
568
+
566
569
  - The loader result is cached and reused when navigating back to the same route (e.g., from /user/123 back to /user/456 it will be a new request because different params, but from /user/456 to /user/456 — cache hit).
567
- - Use staleTime in route config to control how long cache is considered fresh:
568
- ```
570
+ - Use `staleTime` in route config to control how long cache is considered fresh:
571
+
572
+ ```tsx
569
573
  {
570
574
  path: '/user/:userId',
571
575
  loader: async ({ params }) => fetchUser(params.userId),
572
576
  staleTime: 60000, // 1 minute — cache is fresh for 60 seconds
573
577
  }
574
578
  ```
575
- - Stale cache entries are cleaned up incrementally on every navigation, keeping the loader cache from growing unbounded over long sessions.
579
+
580
+ - Stale entries are cleaned up on every navigation, so cache growth stays tied to how often you actually revisit stale data — not to how long the session lasts.
581
+ - On top of that, the cache is bounded by `maxCacheSize` — once the limit is reached, the least recently used entry is evicted to make room for a new one, regardless of whether it's still fresh. This caps memory usage for apps with many high-cardinality dynamic routes (e.g. `/product/:id` across a large catalog). It defaults to a device-aware value (lower on mobile) and can be overridden on the `Router`:
582
+
583
+ ```tsx
584
+ <Router routes={routes} maxCacheSize={200} />
585
+ ```
576
586
 
577
587
 
578
588
  ### `useInvalidate()`
@@ -1,2 +1,2 @@
1
1
  import { RouterProps } from '../types';
2
- export declare const Router: ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration, defaultLoaderFallback, defaultErrorElement, defaultRetry, defaultStaleTime, context: initialContext, isAnimated, spinner, defaultPreserveScroll, showFallbackOnAnimation, defaultPrefetch, defaultHoverPrefetchDelay, errorBoundary: ErrorBoundary, }: RouterProps) => import("react/jsx-runtime").JSX.Element | null;
2
+ export declare const Router: ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration, defaultLoaderFallback, defaultErrorElement, defaultRetry, defaultStaleTime, context: initialContext, isAnimated, spinner, defaultPreserveScroll, showFallbackOnAnimation, maxCacheSize, defaultPrefetch, defaultHoverPrefetchDelay, errorBoundary: ErrorBoundary, }: RouterProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,7 @@
1
1
  import { ClientRouteItem, RouterProps } from '../types';
2
2
  declare class RouterConfig {
3
3
  routes: RouterProps['routes'];
4
+ maxCacheSize: number;
4
5
  defaultPrefetch: RouterProps['defaultPrefetch'];
5
6
  isAnimated: RouterProps['isAnimated'];
6
7
  defaultHoverPrefetchDelay: number;
package/dist/index.js CHANGED
@@ -86,6 +86,7 @@ function _defineProperty(e, r, t) {
86
86
  var RouterConfig = class {
87
87
  constructor() {
88
88
  _defineProperty(this, "routes", []);
89
+ _defineProperty(this, "maxCacheSize", 0);
89
90
  _defineProperty(this, "defaultPrefetch", "hover");
90
91
  _defineProperty(this, "isAnimated", false);
91
92
  _defineProperty(this, "defaultHoverPrefetchDelay", 150);
@@ -113,8 +114,8 @@ var createCommitNavigation = (navigationExecutor, routeItemDataState) => (nextLo
113
114
  };
114
115
  //#endregion
115
116
  //#region utils/isCacheItemFresh.ts
116
- var createIsCacheItemFresh = (loaderMap) => (pathname, search) => {
117
- const item = loaderMap.get(`${pathname}${search}`);
117
+ var createIsCacheItemFresh = (loaderMap) => (path) => {
118
+ const item = loaderMap.get(path);
118
119
  if (item === void 0) return false;
119
120
  const resolvedStaleTime = item.staleTime ?? routerConfig.defaultStaleTime;
120
121
  if (resolvedStaleTime === void 0) return true;
@@ -220,12 +221,18 @@ var findRoute = (pathname, includeAll) => {
220
221
  };
221
222
  //#endregion
222
223
  //#region runtime/navigate.ts
223
- var navigationSeq = 0;
224
- var interval = 0;
225
224
  var createNavigate = (routerState, revalidateCache) => {
225
+ let navigationSeq = 0;
226
+ let interval = 0;
227
+ let abortController = null;
226
228
  const { loaderStateRef, scrollMapState, pendingState, contextState, loaderMap, routeItemDataState } = routerState;
227
229
  const commitNavigation = createCommitNavigation(createCommitState(routerState), routeItemDataState);
228
230
  const isCacheItemFresh = createIsCacheItemFresh(loaderMap);
231
+ const createSignal = () => {
232
+ abortController?.abort();
233
+ abortController = new AbortController();
234
+ return abortController.signal;
235
+ };
229
236
  const getContext = () => ({
230
237
  context: contextState.getState(),
231
238
  setContext: contextState.setState
@@ -272,37 +279,44 @@ var createNavigate = (routerState, revalidateCache) => {
272
279
  [prevPathname]: scrollPosition
273
280
  };
274
281
  });
275
- if (routeItem?.optimistic && loaderMap.has(location.pathname)) {
282
+ const path = `${location.pathname}${location.search}`;
283
+ if (routeItem?.optimistic && loaderMap.has(path)) {
276
284
  routeItemDataState.setState({
277
285
  routeItem,
278
286
  location
279
287
  });
280
- const currentLoaderState = loaderMap.get(location.pathname)?.state;
288
+ const currentLoaderState = loaderMap.get(path)?.state;
281
289
  if (currentLoaderState) loaderStateRef.set(currentLoaderState);
282
290
  } else {
283
- const pendingShouldExist = routeItem?.loader && !isCacheItemFresh(location.pathname, location.search);
291
+ const pendingShouldExist = routeItem?.loader && !isCacheItemFresh(path);
284
292
  pendingState.setState(pendingShouldExist ? {
285
293
  routeItem,
286
294
  location
287
295
  } : void 0);
288
296
  }
289
297
  };
290
- const afterEachLoad = (routeItem, location) => {
298
+ const polling = (routeItem, location) => {
291
299
  if (!routeItem?.pollingInterval) return;
300
+ const signal = createSignal();
292
301
  interval = window.setInterval(() => revalidateCache({
293
302
  routeItem,
294
- pathname: location.pathname
303
+ pathname: location.pathname,
304
+ search: location.search,
305
+ signal
295
306
  }), routeItem.pollingInterval);
296
307
  };
297
- const loader = async (routeItem, location) => {
308
+ const loader = async (routeItem, location, seq) => {
298
309
  if (!routeItem?.loader) return;
299
310
  window.clearInterval(interval);
311
+ const signal = createSignal();
300
312
  await revalidateCache({
301
313
  routeItem,
302
314
  pathname: location.pathname,
303
- search: location.search
315
+ search: location.search,
316
+ signal
304
317
  });
305
- afterEachLoad(routeItem, location);
318
+ if (seq !== navigationSeq) return;
319
+ polling(routeItem, location);
306
320
  };
307
321
  const afterLoad = async (routeItem, params) => {
308
322
  const { defaultAfterLoad } = routerConfig;
@@ -322,7 +336,7 @@ var createNavigate = (routerState, revalidateCache) => {
322
336
  await beforeLoad(nextItem, params);
323
337
  if (seq !== navigationSeq) return;
324
338
  prepareNavigation(nextItem, nextLocation);
325
- await loader(nextItem, nextLocation);
339
+ await loader(nextItem, nextLocation, seq);
326
340
  if (seq !== navigationSeq) return;
327
341
  commitNavigation(nextLocation, nextItem);
328
342
  await afterLoad(nextItem, params);
@@ -331,7 +345,7 @@ var createNavigate = (routerState, revalidateCache) => {
331
345
  };
332
346
  //#endregion
333
347
  //#region runtime/invalidate.ts
334
- var redirect = () => Promise.resolve();
348
+ var redirect = Promise.resolve;
335
349
  var createInvalidate = ({ routeItemDataState, loaderStateRef, loaderMap, currentLoaderState, contextState }, revalidateCache) => {
336
350
  const invalidatePath = async (routeItem, pathname, options) => {
337
351
  const routePathname = routeItemDataState.getState().location.pathname;
@@ -398,7 +412,6 @@ var createPrefetch = (revalidateCache) => async (pathname) => {
398
412
  };
399
413
  //#endregion
400
414
  //#region utils/revalidateCache.ts
401
- var loadingPromises = /* @__PURE__ */ new Map();
402
415
  var isObjectRetry = (arg) => typeof arg === "object";
403
416
  var createRetry = (arg) => {
404
417
  if (arg === void 0) return null;
@@ -418,26 +431,44 @@ var getRetry = (routeItem) => {
418
431
  };
419
432
  var sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
420
433
  var createRevalidateCache = (routerState) => {
421
- const { loaderStateRef, contextState, loaderMap } = routerState;
422
- const removeFirstStaleItem = () => {
423
- const deletedItem = [...loaderMap.entries()].find(([, item]) => {
434
+ const { loaderStateRef, contextState, loaderMap, loadingPromises } = routerState;
435
+ const removeStaleItems = () => {
436
+ const deletedItems = [...loaderMap.entries()].filter(([, item]) => {
424
437
  const staleTime = item.staleTime ?? routerConfig.defaultStaleTime;
425
438
  return staleTime && staleTime + item.timestamp < Date.now();
426
439
  });
427
- if (deletedItem) loaderMap.delete(deletedItem[0]);
440
+ if (deletedItems.length) deletedItems.forEach((item) => loaderMap.delete(item[0]));
428
441
  };
429
- const revalidateCache = async ({ routeItem, pathname, search = "" }, retried = 0) => {
442
+ const evict = () => {
443
+ if (loaderMap.size <= routerConfig.maxCacheSize) return;
444
+ const oldestKey = loaderMap.keys().next().value;
445
+ if (oldestKey) loaderMap.delete(oldestKey);
446
+ };
447
+ const moveItemToLastPosition = (path) => {
448
+ const item = loaderMap.get(path);
449
+ if (item) {
450
+ loaderMap.delete(path);
451
+ loaderMap.set(path, item);
452
+ }
453
+ return item;
454
+ };
455
+ const revalidateCache = async ({ routeItem, pathname, search = "", signal }, retried = 0) => {
430
456
  if (!routeItem?.loader) return;
431
457
  const isCacheItemFresh = createIsCacheItemFresh(loaderMap);
432
- removeFirstStaleItem();
433
- if (loadingPromises.has(pathname)) return loadingPromises.get(`${pathname}${search}`);
434
- if (isCacheItemFresh(pathname, search)) {
435
- const item = loaderMap.get(`${pathname}${search}`);
458
+ removeStaleItems();
459
+ const path = `${pathname}${search}`;
460
+ if (loadingPromises.has(path)) {
461
+ moveItemToLastPosition(path);
462
+ return loadingPromises.get(path);
463
+ }
464
+ if (isCacheItemFresh(path)) {
465
+ const item = moveItemToLastPosition(path);
436
466
  if (item?.state) loaderStateRef.set(item.state);
437
467
  return;
438
468
  }
439
469
  const promise = (async () => {
440
470
  if (!routeItem?.loader) return;
471
+ const effectiveSignal = signal ?? new AbortController().signal;
441
472
  try {
442
473
  const context = contextState.getState();
443
474
  const setContext = contextState.setState;
@@ -447,31 +478,38 @@ var createRevalidateCache = (routerState) => {
447
478
  params,
448
479
  context,
449
480
  setContext,
450
- searchParams
481
+ searchParams,
482
+ signal: effectiveSignal
451
483
  });
452
484
  loaderStateRef.set((prev) => ({
453
485
  ...prev,
454
486
  data: result,
455
487
  loaderError: null
456
488
  }));
457
- loaderMap.set(`${pathname}${search}`, {
489
+ loaderMap.set(path, {
458
490
  state: loaderStateRef.value,
459
491
  timestamp: Date.now(),
460
492
  staleTime: routeItem.staleTime
461
493
  });
494
+ evict();
462
495
  return {
463
496
  data: result,
464
497
  error: null
465
498
  };
466
499
  } catch (error) {
500
+ if (effectiveSignal.aborted) return {
501
+ data: null,
502
+ error: null
503
+ };
467
504
  const retry = getRetry(routeItem);
468
505
  if (retry && retry.count > retried) {
469
- loadingPromises.delete(`${pathname}${search}`);
506
+ loadingPromises.delete(path);
470
507
  if (retry.delay) await sleep(retry.delay);
471
508
  await revalidateCache({
472
509
  routeItem,
473
510
  pathname,
474
- search
511
+ search,
512
+ signal
475
513
  }, retried + 1);
476
514
  return {
477
515
  data: null,
@@ -489,10 +527,10 @@ var createRevalidateCache = (routerState) => {
489
527
  };
490
528
  }
491
529
  } finally {
492
- loadingPromises.delete(`${pathname}${search}`);
530
+ loadingPromises.delete(path);
493
531
  }
494
532
  })();
495
- loadingPromises.set(`${pathname}${search}`, promise);
533
+ loadingPromises.set(path, promise);
496
534
  return promise;
497
535
  };
498
536
  return revalidateCache;
@@ -528,7 +566,8 @@ var createRouterInstance = () => {
528
566
  to: ""
529
567
  }),
530
568
  loaderStateRef: new Cell(emptyLoaderState),
531
- loaderMap: /* @__PURE__ */ new Map()
569
+ loaderMap: /* @__PURE__ */ new Map(),
570
+ loadingPromises: /* @__PURE__ */ new Map()
532
571
  };
533
572
  const revalidateCache = createRevalidateCache(routerState);
534
573
  const invalidate = createInvalidate(routerState, revalidateCache);
@@ -716,7 +755,9 @@ var renderElement = (Component) => {
716
755
  //#region components/Router.tsx
717
756
  var EmptyBoundary = ({ children }) => children;
718
757
  var IS_MOBILE = isMobile();
719
- var Router = ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration, defaultLoaderFallback, defaultErrorElement, defaultRetry, defaultStaleTime, context: initialContext, isAnimated = false, spinner = true, defaultPreserveScroll = true, showFallbackOnAnimation = false, defaultPrefetch = IS_MOBILE ? "viewport" : "hover", defaultHoverPrefetchDelay = 150, errorBoundary: ErrorBoundary = EmptyBoundary }) => {
758
+ var MOBILE_CACHE_SIZE = 60;
759
+ var DESKTOP_CACHE_SIZE = 150;
760
+ var Router = ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration, defaultLoaderFallback, defaultErrorElement, defaultRetry, defaultStaleTime, context: initialContext, isAnimated = false, spinner = true, defaultPreserveScroll = true, showFallbackOnAnimation = false, maxCacheSize = IS_MOBILE ? MOBILE_CACHE_SIZE : DESKTOP_CACHE_SIZE, defaultPrefetch = IS_MOBILE ? "viewport" : "hover", defaultHoverPrefetchDelay = 150, errorBoundary: ErrorBoundary = EmptyBoundary }) => {
720
761
  const { useRouteItemData, usePendingState } = router.hooks;
721
762
  const [routeItemData] = useRouteItemData();
722
763
  const [pendingState] = usePendingState();
@@ -732,7 +773,8 @@ var Router = ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration,
732
773
  defaultAfterLoad,
733
774
  defaultRetry,
734
775
  defaultStaleTime,
735
- defaultPreserveScroll
776
+ defaultPreserveScroll,
777
+ maxCacheSize
736
778
  });
737
779
  useApplyCustomAnimation(animationDuration);
738
780
  useSetInitialContext(initialContext);
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export type ClientRouteItem = {
23
23
  context: Record<string, unknown>;
24
24
  setContext: Dispatch<SetStateAction<Record<string, unknown>>>;
25
25
  searchParams: Record<string, string>;
26
+ signal: AbortSignal;
26
27
  }): Promise<unknown>;
27
28
  loaderFallback?: RenderElement;
28
29
  errorElement?: RenderElement;
@@ -64,6 +65,7 @@ export type RevalidateCacheArgs = {
64
65
  pathname: string;
65
66
  routeItem?: RouteItem;
66
67
  search?: string;
68
+ signal?: AbortSignal;
67
69
  };
68
70
  export type LoaderState<T = unknown> = {
69
71
  data: T;
@@ -92,6 +94,7 @@ export type RouterProps = {
92
94
  showFallbackOnAnimation?: boolean;
93
95
  defaultPrefetch?: 'hover' | 'render' | 'viewport' | 'none';
94
96
  defaultHoverPrefetchDelay?: number;
97
+ maxCacheSize?: number;
95
98
  errorBoundary?: ComponentType<{
96
99
  children: ReactNode;
97
100
  }>;
@@ -104,6 +107,13 @@ export type LoaderStateItem = {
104
107
  timestamp: number;
105
108
  staleTime: number | undefined;
106
109
  };
110
+ export type LoadingPromise = Promise<{
111
+ data: unknown;
112
+ error: null;
113
+ } | {
114
+ data: null;
115
+ error: unknown;
116
+ } | undefined>;
107
117
  export type RouterState = {
108
118
  routeItemDataState: Store<RouteItemData>;
109
119
  pendingState: Store<RouteItemData | undefined>;
@@ -116,6 +126,7 @@ export type RouterState = {
116
126
  }>;
117
127
  loaderStateRef: Cell<LoaderState>;
118
128
  loaderMap: Map<string, LoaderStateItem>;
129
+ loadingPromises: Map<string, LoadingPromise>;
119
130
  };
120
131
  export type RouterType = {
121
132
  state: Omit<RouterState, 'timestampMap'>;
@@ -147,10 +158,7 @@ export type InvalidateOptions = {
147
158
  withChildren?: boolean;
148
159
  withBeforeLoad?: boolean;
149
160
  };
150
- export type RevalidateCache = ({ routeItem, pathname, }: RevalidateCacheArgs) => Promise<{
151
- data: unknown;
152
- error: unknown;
153
- }>;
161
+ export type RevalidateCache = (args: RevalidateCacheArgs) => LoadingPromise;
154
162
  export type Options = Partial<{
155
163
  onSuccess: (args: unknown) => void;
156
164
  onError: (args: unknown) => void;
@@ -1,2 +1,2 @@
1
1
  import { LoaderStateItem } from '../types';
2
- export declare const createIsCacheItemFresh: (loaderMap: Map<string, LoaderStateItem>) => (pathname: string, search?: string) => boolean;
2
+ export declare const createIsCacheItemFresh: (loaderMap: Map<string, LoaderStateItem>) => (path: string) => boolean;
@@ -1,2 +1,8 @@
1
1
  import { RevalidateCacheArgs, RouterState } from '../types';
2
- export declare const createRevalidateCache: (routerState: RouterState) => ({ routeItem, pathname, search }: RevalidateCacheArgs, retried?: number) => Promise<any>;
2
+ export declare const createRevalidateCache: (routerState: RouterState) => ({ routeItem, pathname, search, signal }: RevalidateCacheArgs, retried?: number) => Promise<{
3
+ data: unknown;
4
+ error: null;
5
+ } | {
6
+ data: null;
7
+ error: unknown;
8
+ } | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {