clear-react-router 2.0.1 → 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);
@@ -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
@@ -290,20 +297,25 @@ var createNavigate = (routerState, revalidateCache) => {
290
297
  };
291
298
  const polling = (routeItem, location) => {
292
299
  if (!routeItem?.pollingInterval) return;
300
+ const signal = createSignal();
293
301
  interval = window.setInterval(() => revalidateCache({
294
302
  routeItem,
295
303
  pathname: location.pathname,
296
- search: location.search
304
+ search: location.search,
305
+ signal
297
306
  }), routeItem.pollingInterval);
298
307
  };
299
- const loader = async (routeItem, location) => {
308
+ const loader = async (routeItem, location, seq) => {
300
309
  if (!routeItem?.loader) return;
301
310
  window.clearInterval(interval);
311
+ const signal = createSignal();
302
312
  await revalidateCache({
303
313
  routeItem,
304
314
  pathname: location.pathname,
305
- search: location.search
315
+ search: location.search,
316
+ signal
306
317
  });
318
+ if (seq !== navigationSeq) return;
307
319
  polling(routeItem, location);
308
320
  };
309
321
  const afterLoad = async (routeItem, params) => {
@@ -324,7 +336,7 @@ var createNavigate = (routerState, revalidateCache) => {
324
336
  await beforeLoad(nextItem, params);
325
337
  if (seq !== navigationSeq) return;
326
338
  prepareNavigation(nextItem, nextLocation);
327
- await loader(nextItem, nextLocation);
339
+ await loader(nextItem, nextLocation, seq);
328
340
  if (seq !== navigationSeq) return;
329
341
  commitNavigation(nextLocation, nextItem);
330
342
  await afterLoad(nextItem, params);
@@ -333,7 +345,7 @@ var createNavigate = (routerState, revalidateCache) => {
333
345
  };
334
346
  //#endregion
335
347
  //#region runtime/invalidate.ts
336
- var redirect = () => Promise.resolve();
348
+ var redirect = Promise.resolve;
337
349
  var createInvalidate = ({ routeItemDataState, loaderStateRef, loaderMap, currentLoaderState, contextState }, revalidateCache) => {
338
350
  const invalidatePath = async (routeItem, pathname, options) => {
339
351
  const routePathname = routeItemDataState.getState().location.pathname;
@@ -400,7 +412,6 @@ var createPrefetch = (revalidateCache) => async (pathname) => {
400
412
  };
401
413
  //#endregion
402
414
  //#region utils/revalidateCache.ts
403
- var loadingPromises = /* @__PURE__ */ new Map();
404
415
  var isObjectRetry = (arg) => typeof arg === "object";
405
416
  var createRetry = (arg) => {
406
417
  if (arg === void 0) return null;
@@ -420,27 +431,44 @@ var getRetry = (routeItem) => {
420
431
  };
421
432
  var sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
422
433
  var createRevalidateCache = (routerState) => {
423
- const { loaderStateRef, contextState, loaderMap } = routerState;
424
- const removeFirstStaleItem = () => {
425
- const deletedItem = [...loaderMap.entries()].find(([, item]) => {
434
+ const { loaderStateRef, contextState, loaderMap, loadingPromises } = routerState;
435
+ const removeStaleItems = () => {
436
+ const deletedItems = [...loaderMap.entries()].filter(([, item]) => {
426
437
  const staleTime = item.staleTime ?? routerConfig.defaultStaleTime;
427
438
  return staleTime && staleTime + item.timestamp < Date.now();
428
439
  });
429
- if (deletedItem) loaderMap.delete(deletedItem[0]);
440
+ if (deletedItems.length) deletedItems.forEach((item) => loaderMap.delete(item[0]));
441
+ };
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;
430
454
  };
431
- const revalidateCache = async ({ routeItem, pathname, search = "" }, retried = 0) => {
455
+ const revalidateCache = async ({ routeItem, pathname, search = "", signal }, retried = 0) => {
432
456
  if (!routeItem?.loader) return;
433
457
  const isCacheItemFresh = createIsCacheItemFresh(loaderMap);
434
- removeFirstStaleItem();
458
+ removeStaleItems();
435
459
  const path = `${pathname}${search}`;
436
- if (loadingPromises.has(path)) return loadingPromises.get(path);
460
+ if (loadingPromises.has(path)) {
461
+ moveItemToLastPosition(path);
462
+ return loadingPromises.get(path);
463
+ }
437
464
  if (isCacheItemFresh(path)) {
438
- const item = loaderMap.get(path);
465
+ const item = moveItemToLastPosition(path);
439
466
  if (item?.state) loaderStateRef.set(item.state);
440
467
  return;
441
468
  }
442
469
  const promise = (async () => {
443
470
  if (!routeItem?.loader) return;
471
+ const effectiveSignal = signal ?? new AbortController().signal;
444
472
  try {
445
473
  const context = contextState.getState();
446
474
  const setContext = contextState.setState;
@@ -450,7 +478,8 @@ var createRevalidateCache = (routerState) => {
450
478
  params,
451
479
  context,
452
480
  setContext,
453
- searchParams
481
+ searchParams,
482
+ signal: effectiveSignal
454
483
  });
455
484
  loaderStateRef.set((prev) => ({
456
485
  ...prev,
@@ -462,11 +491,16 @@ var createRevalidateCache = (routerState) => {
462
491
  timestamp: Date.now(),
463
492
  staleTime: routeItem.staleTime
464
493
  });
494
+ evict();
465
495
  return {
466
496
  data: result,
467
497
  error: null
468
498
  };
469
499
  } catch (error) {
500
+ if (effectiveSignal.aborted) return {
501
+ data: null,
502
+ error: null
503
+ };
470
504
  const retry = getRetry(routeItem);
471
505
  if (retry && retry.count > retried) {
472
506
  loadingPromises.delete(path);
@@ -474,7 +508,8 @@ var createRevalidateCache = (routerState) => {
474
508
  await revalidateCache({
475
509
  routeItem,
476
510
  pathname,
477
- search
511
+ search,
512
+ signal
478
513
  }, retried + 1);
479
514
  return {
480
515
  data: null,
@@ -531,7 +566,8 @@ var createRouterInstance = () => {
531
566
  to: ""
532
567
  }),
533
568
  loaderStateRef: new Cell(emptyLoaderState),
534
- loaderMap: /* @__PURE__ */ new Map()
569
+ loaderMap: /* @__PURE__ */ new Map(),
570
+ loadingPromises: /* @__PURE__ */ new Map()
535
571
  };
536
572
  const revalidateCache = createRevalidateCache(routerState);
537
573
  const invalidate = createInvalidate(routerState, revalidateCache);
@@ -719,7 +755,9 @@ var renderElement = (Component) => {
719
755
  //#region components/Router.tsx
720
756
  var EmptyBoundary = ({ children }) => children;
721
757
  var IS_MOBILE = isMobile();
722
- 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 }) => {
723
761
  const { useRouteItemData, usePendingState } = router.hooks;
724
762
  const [routeItemData] = useRouteItemData();
725
763
  const [pendingState] = usePendingState();
@@ -735,7 +773,8 @@ var Router = ({ routes, defaultBeforeLoad, defaultAfterLoad, animationDuration,
735
773
  defaultAfterLoad,
736
774
  defaultRetry,
737
775
  defaultStaleTime,
738
- defaultPreserveScroll
776
+ defaultPreserveScroll,
777
+ maxCacheSize
739
778
  });
740
779
  useApplyCustomAnimation(animationDuration);
741
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 = (args: 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,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.1",
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": {