clear-react-router 1.1.4 → 1.2.0

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
@@ -7,6 +7,7 @@ A lightweight, type-safe routing library for React applications with nested rout
7
7
  - 🧩 **Nested Routes** - Organize your UI with nested layouts and routes
8
8
  - ⚡ **Data Loading** - Built-in loaders with caching and stale-while-revalidate strategy
9
9
  - 🔒 **Navigation Blocking** - Prevent accidental navigation with `useBlocker`
10
+ - ✨ **Smooth Animations** - Page transitions with fade and slide effects (customizable type and duration)
10
11
  - 🎯 **Type-safe Redirects** - Redirect from loaders and beforeLoad hooks
11
12
  - 📦 **Prefetching** - Preload data on hover for instant navigation
12
13
  - 🚀 **Lazy Loading** - Code-split your routes with dynamic imports for optimal performance
@@ -25,7 +26,7 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
25
26
  | `path` | `string` | Route path, e.g., `/user/:userId` |
26
27
  | `element` | `ReactElement \| () => ReactElement \| LazyComponent` | Component to render |
27
28
  | `loader` | `() => Promise<unknown>` | Fetch data |
28
- | `beforeLoad` | `(context) => Promise<void>` | Auth checks, redirects |
29
+ | `beforeLoad` | `({ context, redirect }) => Promise<void>` | Auth checks and redirects. `redirect` is provided by the router |
29
30
  | `afterLoad` | `(context) => Promise<void>` | Analytics, side effects |
30
31
  | `fallback` | `ReactElement \| () => ReactElement` | Loading fallback (for lazy loading) |
31
32
  | `loaderFallback` | `ReactElement \| () => ReactElement` | Loading fallback (for loader) |
@@ -37,10 +38,21 @@ Normalizes route configuration. Handles wildcard `*` routes, extracts dynamic pa
37
38
 
38
39
  Main component that renders the application based on current URL.
39
40
 
40
- | Prop | Type | Description |
41
- |------|------|-------------|
42
- | `routeList` | `RouteItem[]` | Array of route configurations |
43
- | `context` | `object` | Optional initial context (user, theme, etc.) |
41
+ | Prop | Type | Default | Description |
42
+ |------|------|---------|-------------|
43
+ | `routeList` | `RouteItem[]` | required | Array of route configurations |
44
+ | `context` | `object` | `{}` | Optional initial context (user, theme, etc.) |
45
+ | `isAnimated` | `boolean` | `false` | Enable smooth page transitions |
46
+ | `animationOptions` | `AnimationOptions` | `{ duration: 300, name: 'fade' }` | Animation settings (only when `isAnimated` is `true`) |
47
+
48
+ **`AnimationOptions`:**
49
+
50
+ | Property | Type | Default | Description |
51
+ |----------|------|---------|-------------|
52
+ | `duration` | `number` | `300` | Animation duration in milliseconds |
53
+ | `name` | `'fade' \| 'slide-left' \| 'slide-right'` | `'fade'` | Type of transition effect |
54
+
55
+ > **Note:** When `isAnimated` is enabled, the `loaderFallback` is not displayed. Instead, a small spinner appears in the corner while data loads, ensuring smooth transitions without layout shifts.
44
56
 
45
57
  ### `Link`
46
58
 
@@ -52,13 +64,26 @@ Component for client-side navigation with prefetch support.
52
64
  | `prefetch` | `boolean` | `true` |
53
65
  | `children` | `ReactElement` | required |
54
66
 
55
- ### `redirect(url, search?)`
67
+ ### `redirect`
68
+
69
+ Function provided to `beforeLoad` for programmatic redirection.
70
+
71
+ **Type:** `(arg: Location) => Promise<void>`
56
72
 
57
- Redirects from `beforeLoad`.
58
73
  ```
59
- beforeLoad: context => {
60
- if (!context.isAuthorized) return redirect('/');
61
- }
74
+ import type { Location } from 'clear-react-router';
75
+
76
+ const routes = createRouter([
77
+ {
78
+ path: '/dashboard',
79
+ element: <Dashboard />,
80
+ beforeLoad: ({ context, redirect }) => {
81
+ if (!context.isAuthorized) {
82
+ return redirect({ pathname: '/' });
83
+ }
84
+ },
85
+ },
86
+ ]);
62
87
  ```
63
88
 
64
89
  ## Hooks
@@ -149,6 +174,14 @@ const onSave = useCallback(() => {
149
174
  useBeforeUnload(text ? onSave : undefined);
150
175
  ```
151
176
 
177
+ ### `useRouterContext()`
178
+
179
+ Handles router context.
180
+ ```
181
+ const { setContext, context } = useRouterContext();
182
+ const loginHandler = () => setContext({ ...context, user: { name: 'John' } });
183
+ ```
184
+
152
185
  ## Lazy Loading
153
186
 
154
187
  Clear Router supports code-splitting out of the box. Simply pass a function that returns a dynamic import:
@@ -159,10 +192,57 @@ Clear Router supports code-splitting out of the box. Simply pass a function that
159
192
  fallback: () => <div>Loading...</div>,
160
193
  }
161
194
  ```
195
+ ## Animations
196
+
197
+ Clear Router supports smooth page transitions using the native View Transitions API. When animations are enabled, the router waits for all data to load before starting the transition, ensuring a jank-free experience.
198
+
199
+ ### Quick Start
200
+
201
+ ```
202
+ import { Router } from 'clear-react-router';
203
+
204
+ // Enable default fade animation
205
+ <Router routeList={routes} isAnimated />
206
+
207
+ // Custom animation
208
+ <Router
209
+ routeList={routes}
210
+ isAnimated
211
+ animationOptions={{
212
+ duration: 500, // milliseconds
213
+ name: 'slide-left' // 'fade' | 'slide-left' | 'slide-right'
214
+ }}
215
+ />
216
+ ```
217
+
218
+ ## How It Works
219
+
220
+ - **Data loads first** — All `loader` and `beforeLoad` hooks complete before animation starts
221
+ - **No `loaderFallback`** — The `loaderFallback` is not shown during animated transitions
222
+ - **Subtle spinner** — A small spinner appears in the top-left corner while data is loading, so users know the app is responsive
223
+ - **Native API** — Uses `document.startViewTransition` for smooth, hardware-accelerated animations
224
+
225
+ ## Animation Types
226
+
227
+ | Name | Effect |
228
+ |------|--------|
229
+ | `fade` | Cross-fade between pages (default) |
230
+ | `slide-left` | New page slides in from right, old page slides out to left |
231
+ | `slide-right` | New page slides in from left, old page slides out to right |
232
+
233
+ ## Browser Support
234
+
235
+ View Transitions API requires modern browsers:
236
+
237
+ - Chrome/Edge 111+
238
+ - Safari 18+
239
+ - Firefox 144+
240
+
241
+ For older browsers, the router gracefully falls back to regular navigation without animation.
162
242
 
163
243
  ## Requirements
164
244
  - React 16.6+ (for React.lazy and Suspense)
165
245
  - Use `default` export for your lazy-loaded components
166
246
 
167
- ## License
168
- MIT
247
+ ## License
248
+ MIT
@@ -0,0 +1,23 @@
1
+ ._spinner_1tekw_1 {
2
+ position: fixed;
3
+ top: 5px;
4
+ left: 5px;
5
+ z-index: 9999;
6
+ width: 1rem;
7
+ height: 1rem;
8
+ border: 2px solid gray;
9
+ border-bottom-color: transparent;
10
+ border-radius: 50%;
11
+ display: inline-block;
12
+ box-sizing: border-box;
13
+ animation: _rotation_1tekw_1 1s linear infinite;
14
+ }
15
+
16
+ @keyframes _rotation_1tekw_1 {
17
+ 0% {
18
+ transform: rotate(0deg);
19
+ }
20
+ 100% {
21
+ transform: rotate(360deg);
22
+ }
23
+ }/*$vite$:1*/
@@ -1,7 +1,9 @@
1
- import type { RouteItem } from '../types/global.ts';
1
+ import { AnimationOptions, RouteItem } from '../types/global';
2
2
  type RouterProps = {
3
3
  routeList: RouteItem[];
4
4
  context?: Record<string, unknown>;
5
+ isAnimated?: boolean;
6
+ animationOptions?: AnimationOptions;
5
7
  };
6
- export declare const Router: ({ routeList, context: initialContext }: RouterProps) => import("react/jsx-runtime").JSX.Element;
8
+ export declare const Router: ({ routeList, context: initialContext, isAnimated, animationOptions, }: RouterProps) => import("react/jsx-runtime").JSX.Element;
7
9
  export {};
@@ -0,0 +1 @@
1
+ export declare const Spinner: () => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,2 @@
1
+ import { AnimationOptions } from '../types/global';
2
+ export declare const useApplyCustomAnimation: (animationOptions: AnimationOptions) => void;
@@ -4,8 +4,9 @@ type UseHandleNavigation = {
4
4
  setLocation: (arg: Location) => void;
5
5
  context: Record<string, unknown>;
6
6
  revalidateCache(routeItem?: RouteItem, isCurrentRoute?: boolean): Promise<void>;
7
+ isAnimated: boolean;
7
8
  };
8
- export declare const useHandleNavigation: ({ setLocation, routeList, context, revalidateCache }: UseHandleNavigation) => {
9
+ export declare const useHandleNavigation: ({ setLocation, routeList, context, revalidateCache, isAnimated, }: UseHandleNavigation) => {
9
10
  blockerState: BlockerState;
10
11
  updateLocation: (nextLocation: Location) => Promise<void>;
11
12
  updateBlockedRoute: ({ type, payload }: UpdateBlockedRouteProps) => void;
@@ -1 +1 @@
1
- export declare const useLocation: () => import("../types/global").Location;
1
+ export declare const useLocation: () => import("..").Location;
package/dist/index.d.ts CHANGED
@@ -8,5 +8,4 @@ export { useBlocker } from './hooks/useBlocker';
8
8
  export { useBeforeUnload } from './hooks/useBeforeUnload';
9
9
  export { useRouterContext } from './hooks/useRouterContext';
10
10
  export { createRouter } from './utils/utils';
11
- export { redirect } from './utils/redirect';
12
- export type { RouteItem, BlockerState } from './types/global';
11
+ export type { RouteItem, BlockerState, Location } from './types/global';
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ var require_react_jsx_runtime_production = /* @__PURE__ */ __commonJSMin(((expor
37
37
  };
38
38
  }
39
39
  exports.jsx = jsxProd;
40
+ exports.jsxs = jsxProd;
40
41
  }));
41
42
  //#endregion
42
43
  //#region provider/RouterProvider.tsx
@@ -129,37 +130,52 @@ var useLatest = (value) => {
129
130
  };
130
131
  //#endregion
131
132
  //#region hooks/useHandleNavigation.ts
132
- var isRedirect = (error) => typeof error === "object" && error !== null && error.cause === "redirect";
133
- var useHandleNavigation = ({ setLocation, routeList, context, revalidateCache }) => {
133
+ var useHandleNavigation = ({ setLocation, routeList, context, revalidateCache, isAnimated }) => {
134
134
  const [blockedRoute, setBlockedRoute] = useState({
135
135
  from: "",
136
136
  to: ""
137
137
  });
138
138
  const prevPathname = useRef("");
139
- const setNextLocationRef = useLatest(useCallback(async (nextLocation) => {
140
- try {
141
- const nextItem = routeList.find((el) => comparePaths(el, nextLocation.pathname));
142
- if (nextItem?.beforeLoad) await nextItem?.beforeLoad(context);
143
- setLocation(nextLocation);
144
- prevPathname.current = nextLocation.pathname;
145
- if (nextLocation.pathname !== window.location.pathname) history.pushState(null, "", nextLocation.pathname);
146
- await revalidateCache(nextItem, true);
147
- if (nextItem?.afterLoad) await nextItem?.afterLoad(context);
148
- } catch (error) {
149
- const redirect = error;
150
- if (!isRedirect(redirect)) return redirect;
151
- history.replaceState(null, "", redirect.url);
152
- setLocation({
153
- pathname: redirect.url,
154
- search: redirect.search
155
- });
139
+ const navigationSeq = useRef(0);
140
+ const navigation = useCallback((nextLocation) => {
141
+ setLocation(nextLocation);
142
+ prevPathname.current = nextLocation.pathname;
143
+ if (nextLocation.pathname === window.location.pathname) return;
144
+ history.pushState(null, "", nextLocation.pathname);
145
+ }, [setLocation]);
146
+ const transitionedNavigation = useCallback(({ nextLocation, isFirstCall, isAnimated }) => {
147
+ if (isAnimated && !isFirstCall) try {
148
+ document.startViewTransition(() => navigation(nextLocation));
149
+ } catch {
150
+ navigation(nextLocation);
156
151
  }
152
+ else navigation(nextLocation);
153
+ }, [navigation]);
154
+ const setNextLocation = useCallback(async (nextLocation, isFirstCall) => {
155
+ navigationSeq.current = navigationSeq.current + 1;
156
+ const seq = navigationSeq.current;
157
+ const nextItem = routeList.find((el) => comparePaths(el, nextLocation.pathname));
158
+ if (nextItem?.beforeLoad) await nextItem.beforeLoad({
159
+ context,
160
+ redirect: setNextLocation
161
+ });
162
+ if (seq !== navigationSeq.current) return;
163
+ await revalidateCache(nextItem, true);
164
+ if (seq !== navigationSeq.current) return;
165
+ transitionedNavigation({
166
+ nextLocation,
167
+ isAnimated,
168
+ isFirstCall
169
+ });
170
+ if (nextItem?.afterLoad) await nextItem.afterLoad(context);
157
171
  }, [
158
172
  context,
173
+ isAnimated,
159
174
  revalidateCache,
160
175
  routeList,
161
- setLocation
162
- ]));
176
+ transitionedNavigation
177
+ ]);
178
+ const setNextLocationRef = useLatest(setNextLocation);
163
179
  const updateBlockedRoute = useCallback(({ type, payload = "" }) => setBlockedRoute((prevState) => {
164
180
  if (prevState.from === payload && type === "charge") return prevState;
165
181
  if (payload && prevState.from !== payload && type === "charge") return {
@@ -200,7 +216,7 @@ var useHandleNavigation = ({ setLocation, routeList, context, revalidateCache })
200
216
  }, [blockedRoute.from, setNextLocationRef]);
201
217
  useEffect(() => {
202
218
  const currentLocation = parseWindowLocation(window.location);
203
- setNextLocationRef.current(currentLocation);
219
+ setNextLocationRef.current(currentLocation, true);
204
220
  prevPathname.current = currentLocation.pathname;
205
221
  }, [setNextLocationRef]);
206
222
  return {
@@ -265,6 +281,63 @@ var useLoader = (routeList) => {
265
281
  };
266
282
  };
267
283
  //#endregion
284
+ //#region hooks/useApplyCustomAnimation.ts
285
+ var useApplyCustomAnimation = (animationOptions) => {
286
+ useEffect(() => {
287
+ if (!animationOptions?.duration) return;
288
+ const style = document.createElement("style");
289
+ style.id = "dynamic-view-transition-duration-style";
290
+ style.textContent = `::view-transition-group(root) { animation-duration: ${animationOptions.duration}ms; }`;
291
+ document.head.appendChild(style);
292
+ return () => style.remove();
293
+ }, [animationOptions]);
294
+ useEffect(() => {
295
+ if (!animationOptions.name) return;
296
+ const style = document.createElement("style");
297
+ style.id = "dynamic-view-transition-duration-name";
298
+ style.textContent = `
299
+ @keyframes fade-out {
300
+ from { opacity: 1; }
301
+ to { opacity: 0; }
302
+ }
303
+ @keyframes fade-in {
304
+ from { opacity: 0; }
305
+ to { opacity: 1; }
306
+ }
307
+ @keyframes slide-left-out {
308
+ from { transform: translateX(0); }
309
+ to { transform: translateX(-100%); }
310
+ }
311
+ @keyframes slide-left-in {
312
+ from { transform: translateX(100%); }
313
+ to { transform: translateX(0); }
314
+ }
315
+ @keyframes slide-right-out {
316
+ from { transform: translateX(0); }
317
+ to { transform: translateX(100%); }
318
+ }
319
+ @keyframes slide-right-in {
320
+ from { transform: translateX(-100%); }
321
+ to { transform: translateX(0); }
322
+ }
323
+ ::view-transition-old(root) {
324
+ animation: ${animationOptions.name}-out ${animationOptions.duration ?? 800}ms cubic-bezier(0.4, 0, 0.2, 1);
325
+ }
326
+ ::view-transition-new(root) {
327
+ animation: ${animationOptions.name}-in ${animationOptions.duration ?? 800}ms cubic-bezier(0.4, 0, 0.2, 1);
328
+ }`;
329
+ document.head.appendChild(style);
330
+ return () => style.remove();
331
+ }, [animationOptions]);
332
+ };
333
+ var Spinner_module_default = {
334
+ spinner: "_spinner_1tekw_1",
335
+ rotation: "_rotation_1tekw_1"
336
+ };
337
+ //#endregion
338
+ //#region components/Spinner/Spinner.tsx
339
+ var Spinner = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: Spinner_module_default.spinner });
340
+ //#endregion
268
341
  //#region utils/renderElement.tsx
269
342
  var renderElement = (Component) => {
270
343
  if (!Component) return null;
@@ -274,16 +347,18 @@ var renderElement = (Component) => {
274
347
  //#region components/Router.tsx
275
348
  var PAGE_NOT_FOUND = "error 404. Page not found";
276
349
  var ALL_LOCATIONS = "*";
277
- var Router = ({ routeList, context: initialContext = {} }) => {
350
+ var Router = ({ routeList, context: initialContext = {}, isAnimated = false, animationOptions = {} }) => {
278
351
  const [location, setLocation] = useState(parseWindowLocation(window.location));
279
352
  const [context, setContext] = useState(initialContext);
353
+ useApplyCustomAnimation(animationOptions);
280
354
  const routeItem = useMemo(() => routeList.find((el) => el.path === ALL_LOCATIONS || comparePaths(el, location.pathname)), [location.pathname, routeList]);
281
355
  const { loaderError, loaderCache, prefetchLoader, revalidateCache, isLoading } = useLoader(routeList);
282
356
  const { blockerState, updateLocation, updateBlockedRoute } = useHandleNavigation({
283
357
  setLocation,
284
358
  routeList,
285
359
  context,
286
- revalidateCache
360
+ revalidateCache,
361
+ isAnimated
287
362
  });
288
363
  const params = useMemo(() => routeItem?.params ? getParamsObject(routeItem.params) : {}, [routeItem]);
289
364
  const providerProps = useMemo(() => ({
@@ -306,7 +381,7 @@ var Router = ({ routeList, context: initialContext = {} }) => {
306
381
  updateBlockedRoute,
307
382
  updateLocation
308
383
  ]);
309
- if (routeItem?.loader && !loaderError && isLoading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RouterProvider, {
384
+ if (!isAnimated && routeItem?.loader && !loaderError && isLoading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RouterProvider, {
310
385
  ...providerProps,
311
386
  children: renderElement(routeItem?.loaderFallback)
312
387
  });
@@ -314,9 +389,9 @@ var Router = ({ routeList, context: initialContext = {} }) => {
314
389
  ...providerProps,
315
390
  children: renderElement(routeItem?.errorElement)
316
391
  });
317
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RouterProvider, {
392
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(RouterProvider, {
318
393
  ...providerProps,
319
- children: renderElement(routeItem?.element) || PAGE_NOT_FOUND
394
+ children: [renderElement(routeItem?.element) || PAGE_NOT_FOUND, isAnimated && isLoading && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {})]
320
395
  });
321
396
  };
322
397
  //#endregion
@@ -415,55 +490,4 @@ var useRouterContext = () => {
415
490
  };
416
491
  };
417
492
  //#endregion
418
- //#region \0@oxc-project+runtime@0.132.0/helpers/typeof.js
419
- function _typeof(o) {
420
- "@babel/helpers - typeof";
421
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
422
- return typeof o;
423
- } : function(o) {
424
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
425
- }, _typeof(o);
426
- }
427
- //#endregion
428
- //#region \0@oxc-project+runtime@0.132.0/helpers/toPrimitive.js
429
- function toPrimitive(t, r) {
430
- if ("object" != _typeof(t) || !t) return t;
431
- var e = t[Symbol.toPrimitive];
432
- if (void 0 !== e) {
433
- var i = e.call(t, r || "default");
434
- if ("object" != _typeof(i)) return i;
435
- throw new TypeError("@@toPrimitive must return a primitive value.");
436
- }
437
- return ("string" === r ? String : Number)(t);
438
- }
439
- //#endregion
440
- //#region \0@oxc-project+runtime@0.132.0/helpers/toPropertyKey.js
441
- function toPropertyKey(t) {
442
- var i = toPrimitive(t, "string");
443
- return "symbol" == _typeof(i) ? i : i + "";
444
- }
445
- //#endregion
446
- //#region \0@oxc-project+runtime@0.132.0/helpers/defineProperty.js
447
- function _defineProperty(e, r, t) {
448
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
449
- value: t,
450
- enumerable: !0,
451
- configurable: !0,
452
- writable: !0
453
- }) : e[r] = t, e;
454
- }
455
- //#endregion
456
- //#region utils/redirect.ts
457
- var Redirect = class {
458
- constructor(url, search) {
459
- _defineProperty(this, "url", void 0);
460
- _defineProperty(this, "search", void 0);
461
- _defineProperty(this, "cause", void 0);
462
- this.url = url;
463
- this.search = search;
464
- this.cause = "redirect";
465
- }
466
- };
467
- var redirect = (url, search) => Promise.reject(new Redirect(url, search));
468
- //#endregion
469
- export { Link, Router, createRouter, redirect, useBeforeUnload, useBlocker, useLoaderState, useLocation, useNavigate, useParams, useRouterContext };
493
+ export { Link, Router, createRouter, useBeforeUnload, useBlocker, useLoaderState, useLocation, useNavigate, useParams, useRouterContext };
@@ -11,7 +11,10 @@ export type ClientRouteItem = {
11
11
  fallback?: (() => ReactElement) | ReactElement;
12
12
  children?: ClientRouteItem[];
13
13
  staleTime?: number;
14
- beforeLoad?: (context: Record<string, unknown>) => Promise<unknown> | undefined;
14
+ beforeLoad?: (arg: {
15
+ context: Record<string, unknown>;
16
+ redirect: (arg: Location) => Promise<void>;
17
+ }) => Promise<unknown> | undefined;
15
18
  afterLoad?: (context: Record<string, unknown>) => Promise<unknown> | undefined;
16
19
  };
17
20
  export type RouteItem = ClientRouteItem & {
@@ -32,3 +35,7 @@ export type UpdateBlockedRouteProps = {
32
35
  type: 'process' | 'reset' | 'charge' | 'unblock';
33
36
  payload?: string;
34
37
  };
38
+ export type AnimationOptions = {
39
+ duration?: number;
40
+ name?: 'fade' | 'slide-left' | 'slide-right';
41
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clear-react-router",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "A lightweight, type-safe routing library for React applications",
5
5
  "author": "Andrew Bubnov",
6
6
  "scripts": {
@@ -1,7 +0,0 @@
1
- export declare class Redirect {
2
- url: string;
3
- search?: string;
4
- cause: 'redirect';
5
- constructor(url: string, search?: string);
6
- }
7
- export declare const redirect: (url: string, search?: string) => Promise<never>;