@ovineko/react-router 0.0.1 → 0.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
@@ -16,20 +16,22 @@ Peer dependencies: `react@^19`, `react-router@^7`, `valibot@^1`.
16
16
  - ✅ **Two hook variants** - Normal hooks (auto-redirect on error) and Raw hooks (manual error handling)
17
17
  - ✅ **Smart validation** - Ignores extra query params and invalid optional params
18
18
  - ✅ **Global error handling** - Configure fallback redirect URLs globally or per-route
19
+ - ✅ **Conditional redirects** - `useRedirect` hook for declarative navigation with infinite loop prevention
20
+ - ✅ **Optional search params helper** - `optionalSearchParams` utility to avoid repetitive `v.optional()` calls
19
21
  - ✅ **Type-safe** - Full TypeScript support with inferred types
20
22
  - ✅ **Hash support** - Generate paths with hash fragments
21
23
 
22
24
  ## Quick Start
23
25
 
24
26
  ```tsx
25
- import { createRouteWithParams } from "@ovineko/react-router";
27
+ import { createRouteWithParams, optionalSearchParams } from "@ovineko/react-router";
26
28
  import * as v from "valibot";
27
29
 
28
30
  const userRoute = createRouteWithParams("/users/:id", {
29
31
  params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
30
- searchParams: v.object({
31
- tab: v.optional(v.string()),
32
- page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
32
+ searchParams: optionalSearchParams({
33
+ tab: v.string(),
34
+ page: v.pipe(v.string(), v.transform(Number), v.number()),
33
35
  }),
34
36
  errorRedirect: "/404", // Optional: redirect on validation error
35
37
  });
@@ -174,7 +176,7 @@ userRoute.path({ id: "42" }, { tab: "settings" }, "profile");
174
176
  #### Type-safe Links
175
177
 
176
178
  ```tsx
177
- <userRoute.Link params={{ id: "42" }} queryParams={{ tab: "profile" }}>
179
+ <userRoute.Link params={{ id: "42" }} searchParams={{ tab: "profile" }}>
178
180
  View Profile
179
181
  </userRoute.Link>
180
182
 
@@ -207,6 +209,95 @@ setSearchParams({ q: "vue" }, { replace: true });
207
209
 
208
210
  ### Utilities
209
211
 
212
+ #### `useRedirect(path, condition, replace?)`
213
+
214
+ Declarative hook for conditional redirects with built-in infinite loop prevention.
215
+
216
+ **Parameters:**
217
+
218
+ - `path: string` - Target redirect URL
219
+ - `condition: boolean` - Whether to trigger the redirect
220
+ - `replace?: boolean` - Use replace instead of push (default: `true`)
221
+
222
+ **Features:**
223
+
224
+ - Prevents infinite redirect loops using `useRef` tracking
225
+ - Only redirects once when condition becomes `true`
226
+ - Resets automatically when condition becomes `false`
227
+ - Uses `replace: true` by default to prevent back-button issues
228
+
229
+ ```tsx
230
+ import { useRedirect } from "@ovineko/react-router";
231
+
232
+ function ProtectedPage() {
233
+ const { isAuthenticated, isLoading } = useAuth();
234
+
235
+ // Redirect to login if not authenticated
236
+ useRedirect("/login", !isAuthenticated && !isLoading);
237
+
238
+ if (isLoading) return <Spinner />;
239
+ return <div>Protected Content</div>;
240
+ }
241
+
242
+ // Advanced usage with custom options
243
+ function UserProfile() {
244
+ const { user, error } = useUser();
245
+
246
+ // Redirect without replacing history
247
+ useRedirect("/users", !user && !error, false);
248
+
249
+ return <div>{user?.name}</div>;
250
+ }
251
+ ```
252
+
253
+ #### `optionalSearchParams(entries)`
254
+
255
+ Utility to make all search param fields optional automatically, avoiding repetitive `v.optional()` calls.
256
+
257
+ **Parameters:**
258
+
259
+ - `entries: ObjectEntries` - Valibot schema entries for search params
260
+
261
+ **Returns:** Valibot object schema with all fields wrapped in `v.optional()`
262
+
263
+ ```tsx
264
+ import { optionalSearchParams } from "@ovineko/react-router";
265
+ import * as v from "valibot";
266
+
267
+ // ❌ Before: Manual v.optional() for each field
268
+ const route = createRouteWithParams("/search", {
269
+ params: v.object({ id: v.string() }),
270
+ searchParams: v.object({
271
+ q: v.optional(v.string()),
272
+ page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
273
+ sort: v.optional(v.string()),
274
+ filter: v.optional(v.string()),
275
+ }),
276
+ });
277
+
278
+ // ✅ After: Clean and concise
279
+ const route = createRouteWithParams("/search", {
280
+ params: v.object({ id: v.string() }),
281
+ searchParams: optionalSearchParams({
282
+ q: v.string(),
283
+ page: v.pipe(v.string(), v.transform(Number), v.number()),
284
+ sort: v.string(),
285
+ filter: v.string(),
286
+ }),
287
+ });
288
+
289
+ // All fields are automatically optional!
290
+ const [searchParams] = route.useSearchParams();
291
+ // Type: Readonly<{ q?: string; page?: number; sort?: string; filter?: string }>
292
+ ```
293
+
294
+ **Benefits:**
295
+
296
+ - Cleaner, more readable code
297
+ - Less boilerplate for search params (which are typically optional)
298
+ - Full TypeScript support with proper type inference
299
+ - Works with any Valibot schema (transformations, pipes, etc.)
300
+
210
301
  #### `setGlobalErrorRedirect(url)`
211
302
 
212
303
  Set global fallback redirect URL for validation errors.
@@ -256,25 +347,6 @@ const params = route.useParams(); // Readonly<{ id: string }>
256
347
  const [searchParams] = route.useSearchParams(); // Readonly<{ page?: number }>
257
348
  ```
258
349
 
259
- ## Migration from v0.x
260
-
261
- The library now uses valibot for validation instead of TypeScript generics:
262
-
263
- **Before:**
264
-
265
- ```tsx
266
- const route = createRouteWithParams<{ id: string }, { q: string }>("/users/:id");
267
- ```
268
-
269
- **After:**
270
-
271
- ```tsx
272
- const route = createRouteWithParams("/users/:id", {
273
- params: v.object({ id: v.string() }),
274
- searchParams: v.object({ q: v.string() }),
275
- });
276
- ```
277
-
278
350
  **Benefits:**
279
351
 
280
352
  - Runtime validation
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import * as v from "valibot";
2
2
  import type { InferSearchParams, RouteWithoutParams, RouteWithParams, State } from "./types";
3
3
  export type { RouteBase, RouteWithoutParams, RouteWithParams, State, UseParamsRawResult, UseSearchParamsRawResult, } from "./types";
4
+ export { optionalSearchParams } from "./utils";
4
5
  export { URLParseError } from "./validation";
5
6
  export declare const setGlobalErrorRedirect: (url: string) => void;
7
+ export declare const useRedirect: (path: string, is: boolean, replace?: boolean) => void;
6
8
  export declare const createRouteWithParams: <TParams extends v.GenericSchema, TSearchParams extends undefined | v.GenericSchema = undefined>(pattern: string, config: {
7
9
  errorRedirect?: string;
8
10
  params: TParams;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.tsx
2
- import { memo, useCallback, useEffect, useMemo } from "react";
2
+ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import {
4
4
  generatePath,
5
5
  Link,
@@ -32,7 +32,7 @@ function safeDecodeURIComponent(value) {
32
32
  }
33
33
 
34
34
  // src/utils.ts
35
- var queryParamsToString = (params) => {
35
+ var searchParamsToString = (params) => {
36
36
  if (!params || typeof params !== "object" || Array.isArray(params)) {
37
37
  return "";
38
38
  }
@@ -141,6 +141,15 @@ var cleanOptionalParams = (obj, schema) => {
141
141
  }
142
142
  return cleaned;
143
143
  };
144
+ var optionalSearchParams = (entries) => {
145
+ const optionalEntries = {};
146
+ for (const key in entries) {
147
+ if (Object.hasOwn(entries, key)) {
148
+ optionalEntries[key] = v.optional(entries[key]);
149
+ }
150
+ }
151
+ return v.object(optionalEntries);
152
+ };
144
153
 
145
154
  // src/index.tsx
146
155
  import { jsx } from "react/jsx-runtime";
@@ -151,6 +160,18 @@ var setGlobalErrorRedirect = (url) => {
151
160
  var getErrorRedirectUrl = (routeLevel) => {
152
161
  return routeLevel ?? globalErrorRedirect;
153
162
  };
163
+ var useRedirect = (path, is, replace = true) => {
164
+ const navigate = useNavigate();
165
+ const hasRedirectedRef = useRef(false);
166
+ useEffect(() => {
167
+ if (is && !hasRedirectedRef.current) {
168
+ hasRedirectedRef.current = true;
169
+ navigate(path, { replace });
170
+ } else if (!is) {
171
+ hasRedirectedRef.current = false;
172
+ }
173
+ }, [is, navigate, path, replace]);
174
+ };
154
175
  var createSearchParamsHook = (schema) => {
155
176
  const useSearchParamsRaw = () => {
156
177
  const [rawSearchParams, setRawSearchParams] = useSearchParams();
@@ -194,26 +215,21 @@ var createSearchParamsHook = (schema) => {
194
215
  return { data, error, setter };
195
216
  };
196
217
  const useSearchParamsHook = (errorRedirectUrl) => {
197
- const navigate = useNavigate();
198
218
  const { data, error, setter } = useSearchParamsRaw();
199
- useEffect(() => {
200
- if (error) {
201
- const redirectUrl = getErrorRedirectUrl(errorRedirectUrl);
202
- navigate(redirectUrl, { replace: true });
203
- }
204
- }, [error, navigate, errorRedirectUrl]);
219
+ const redirectUrl = getErrorRedirectUrl(errorRedirectUrl);
220
+ useRedirect(redirectUrl, Boolean(error));
205
221
  return [data ?? {}, setter];
206
222
  };
207
223
  return { useSearchParamsHook, useSearchParamsRaw };
208
224
  };
209
225
  var createRouteWithParams = (pattern, config) => {
210
- const getPath = (params, searchParams, hash) => `${generatePath(pattern, params)}${queryParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
226
+ const getPath = (params, searchParams, hash) => `${generatePath(pattern, params)}${searchParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
211
227
  const searchParamsHooks = config.searchParams ? createSearchParamsHook(config.searchParams) : null;
212
228
  const LinkComponent = memo(
213
229
  ({
214
230
  children,
215
231
  params,
216
- queryParams,
232
+ searchParams,
217
233
  state,
218
234
  ...props
219
235
  }) => {
@@ -222,7 +238,7 @@ var createRouteWithParams = (pattern, config) => {
222
238
  () => ({ prevPath: pathname, ...state }),
223
239
  [pathname, state]
224
240
  );
225
- return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(params, queryParams), children });
241
+ return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(params, searchParams), children });
226
242
  }
227
243
  );
228
244
  const useParamsRaw = () => {
@@ -234,14 +250,9 @@ var createRouteWithParams = (pattern, config) => {
234
250
  return [void 0, result.issues];
235
251
  };
236
252
  const useParamsHook = () => {
237
- const navigate = useNavigate();
238
253
  const [params, error] = useParamsRaw();
239
- useEffect(() => {
240
- if (error) {
241
- const redirectUrl = getErrorRedirectUrl(config.errorRedirect);
242
- navigate(redirectUrl, { replace: true });
243
- }
244
- }, [error, navigate]);
254
+ const redirectUrl = getErrorRedirectUrl(config.errorRedirect);
255
+ useRedirect(redirectUrl, Boolean(error));
245
256
  return params ?? {};
246
257
  };
247
258
  return {
@@ -259,16 +270,16 @@ var createRouteWithParams = (pattern, config) => {
259
270
  };
260
271
  };
261
272
  var createRouteWithoutParams = (pattern, config) => {
262
- const getPath = (searchParams, hash) => `${pattern}${queryParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
273
+ const getPath = (searchParams, hash) => `${pattern}${searchParamsToString(searchParams)}${hash ? `#${hash}` : ""}`;
263
274
  const searchParamsHooks = config?.searchParams ? createSearchParamsHook(config.searchParams) : null;
264
275
  const LinkComponent = memo(
265
- ({ children, queryParams, state, ...props }) => {
276
+ ({ children, searchParams, state, ...props }) => {
266
277
  const { pathname } = useLocation();
267
278
  const mergedState = useMemo(
268
279
  () => ({ prevPath: pathname, ...state }),
269
280
  [pathname, state]
270
281
  );
271
- return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(queryParams), children });
282
+ return /* @__PURE__ */ jsx(Link, { ...props, state: mergedState, to: getPath(searchParams), children });
272
283
  }
273
284
  );
274
285
  return {
@@ -298,6 +309,8 @@ export {
298
309
  URLParseError,
299
310
  createRouteWithParams,
300
311
  createRouteWithoutParams,
312
+ optionalSearchParams,
301
313
  replaceState,
302
- setGlobalErrorRedirect
314
+ setGlobalErrorRedirect,
315
+ useRedirect
303
316
  };
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ export type InferSearchParams<T> = T extends v.GenericSchema ? v.InferOutput<T>
4
4
  export interface LinkPropsWithoutParams<SearchParams> extends Omit<LinkPropsLib, "to"> {
5
5
  children: React.ReactNode | string;
6
6
  className?: string;
7
- queryParams?: SearchParams;
7
+ searchParams?: SearchParams;
8
8
  state?: State;
9
9
  style?: React.CSSProperties;
10
10
  }
package/dist/utils.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import * as v from "valibot";
2
2
  import type { SearchParamsInput } from "./types";
3
- export declare const queryParamsToString: (params?: SearchParamsInput) => string;
3
+ export declare const searchParamsToString: (params?: SearchParamsInput) => string;
4
4
  export declare const searchParamsToObject: (params: URLSearchParams) => Record<string, string | string[]>;
5
5
  export declare const objectToSearchParams: (obj: Record<string, unknown>) => URLSearchParams;
6
6
  export declare const parseURLParamsRaw: (pattern: string, url: string) => Record<string, string>;
7
7
  /**
8
8
  * Filters an object to only include keys that exist in the valibot object schema.
9
- * Ignores extra query parameters not defined in the schema.
9
+ * Ignores extra search parameters not defined in the schema.
10
10
  */
11
11
  export declare const filterBySchemaKeys: <T extends v.ObjectSchema<any, any>>(obj: Record<string, unknown>, schema: T) => Record<string, unknown>;
12
12
  /**
@@ -15,3 +15,25 @@ export declare const filterBySchemaKeys: <T extends v.ObjectSchema<any, any>>(ob
15
15
  * Required parameters are kept as-is for subsequent validation.
16
16
  */
17
17
  export declare const cleanOptionalParams: <T extends v.ObjectSchema<any, any>>(obj: Record<string, unknown>, schema: T) => Record<string, unknown>;
18
+ /**
19
+ * Wraps all fields in a Valibot object schema with v.optional().
20
+ * Useful for search params where all fields should be optional by default.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // Instead of:
25
+ * searchParams: v.object({
26
+ * filter: v.optional(v.string()),
27
+ * sort: v.optional(v.string()),
28
+ * page: v.optional(v.number())
29
+ * })
30
+ *
31
+ * // You can write:
32
+ * searchParams: optionalSearchParams({
33
+ * filter: v.string(),
34
+ * sort: v.string(),
35
+ * page: v.number()
36
+ * })
37
+ * ```
38
+ */
39
+ export declare const optionalSearchParams: <TEntries extends v.ObjectEntries>(entries: TEntries) => v.ObjectSchema<any, undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovineko/react-router",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Type-safe wrapper for React Router v7 with typed params, query params, and Link components",
5
5
  "keywords": [
6
6
  "react",