@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 +96 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +36 -23
- package/dist/types.d.ts +1 -1
- package/dist/utils.d.ts +24 -2
- package/package.json +1 -1
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:
|
|
31
|
-
tab: v.
|
|
32
|
-
page: v.
|
|
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" }}
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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)}${
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
240
|
-
|
|
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}${
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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>;
|