@firtoz/router-toolkit 0.1.4 → 0.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/package.json +13 -4
- package/src/index.ts +5 -0
- package/src/types/Func.ts +1 -1
- package/src/types/HrefArgs.ts +8 -17
- package/src/types/MaybeError.ts +45 -0
- package/src/types/RegisterPages.ts +15 -0
- package/src/types/RoutePath.ts +3 -0
- package/src/types/RouteWithLoaderModule.ts +7 -0
- package/src/types/index.ts +3 -2
- package/src/useCachedFetch.ts +75 -0
- package/src/useDynamicFetcher.ts +8 -92
- package/src/useDynamicSubmitter.tsx +15 -7
- package/src/useFetcherStateChanged.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"import": "./src/index.ts",
|
|
12
12
|
"require": "./src/index.ts"
|
|
13
13
|
},
|
|
14
|
-
"./*": [
|
|
14
|
+
"./*": [
|
|
15
|
+
"./src/*.ts",
|
|
16
|
+
"./src/*.tsx"
|
|
17
|
+
]
|
|
15
18
|
},
|
|
16
19
|
"files": [
|
|
17
20
|
"src/**/*",
|
|
@@ -20,8 +23,12 @@
|
|
|
20
23
|
"scripts": {
|
|
21
24
|
"build": "echo 'No build step - using TypeScript source directly'",
|
|
22
25
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"lint": "
|
|
24
|
-
"
|
|
26
|
+
"lint": "biome check src",
|
|
27
|
+
"format": "biome format src --write",
|
|
28
|
+
"test": "echo 'No tests yet'",
|
|
29
|
+
"changeset": "changeset",
|
|
30
|
+
"version": "changeset version",
|
|
31
|
+
"release": "changeset publish"
|
|
25
32
|
},
|
|
26
33
|
"keywords": [
|
|
27
34
|
"react-router",
|
|
@@ -49,6 +56,8 @@
|
|
|
49
56
|
"zod": "^3.25.69"
|
|
50
57
|
},
|
|
51
58
|
"devDependencies": {
|
|
59
|
+
"@biomejs/biome": "^2.0.6",
|
|
60
|
+
"@changesets/cli": "^2.29.5",
|
|
52
61
|
"@types/react": "^19.1.8",
|
|
53
62
|
"react": "^19.1.0",
|
|
54
63
|
"react-router": "^7.6.3",
|
package/src/index.ts
ADDED
package/src/types/Func.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// biome-ignore lint/suspicious/noExplicitAny: We really want to use any here
|
|
2
|
-
export type Func = (...args: any[]) => unknown;
|
|
2
|
+
export type Func = (...args: any[]) => unknown;
|
package/src/types/HrefArgs.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import type { href
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
pages: infer Registered extends AnyPages;
|
|
10
|
-
} ? Registered : AnyPages;
|
|
11
|
-
|
|
12
|
-
export type HrefArgs<T extends keyof RegisterPages> = Parameters<typeof href<T>> extends [
|
|
13
|
-
string,
|
|
14
|
-
...infer Rest,
|
|
15
|
-
] ? Rest : [];
|
|
16
|
-
|
|
17
|
-
export type RoutePath<TPath extends keyof RegisterPages> = TPath;
|
|
1
|
+
import type { href } from "react-router";
|
|
2
|
+
import type { RegisterPages } from "./RegisterPages";
|
|
3
|
+
|
|
4
|
+
export type HrefArgs<T extends keyof RegisterPages> = Parameters<
|
|
5
|
+
typeof href<T>
|
|
6
|
+
> extends [string, ...infer Rest]
|
|
7
|
+
? Rest
|
|
8
|
+
: [];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type DefiniteError<TError = string> = {
|
|
2
|
+
success: false;
|
|
3
|
+
error: TError;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type DefiniteSuccess<T = undefined> = {
|
|
7
|
+
success: true;
|
|
8
|
+
} & (T extends undefined
|
|
9
|
+
? {
|
|
10
|
+
result?: T;
|
|
11
|
+
}
|
|
12
|
+
: {
|
|
13
|
+
result: T;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type MaybeError<T = undefined, TError = string> =
|
|
17
|
+
| DefiniteSuccess<T>
|
|
18
|
+
| DefiniteError<TError>;
|
|
19
|
+
|
|
20
|
+
export type AssumeSuccess<T extends MaybeError<unknown>> = Exclude<
|
|
21
|
+
T,
|
|
22
|
+
undefined
|
|
23
|
+
> extends MaybeError<infer U>
|
|
24
|
+
? U
|
|
25
|
+
: never;
|
|
26
|
+
|
|
27
|
+
export const success = <T = undefined>(
|
|
28
|
+
...params: T extends undefined ? [] : [T]
|
|
29
|
+
): DefiniteSuccess<T> => {
|
|
30
|
+
if (params.length === 0) {
|
|
31
|
+
return { success: true } as unknown as DefiniteSuccess<T>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
success: true,
|
|
36
|
+
result: params[0],
|
|
37
|
+
} as unknown as DefiniteSuccess<T>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const fail = <TError = string>(error: TError): DefiniteError<TError> => {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Register } from "react-router";
|
|
2
|
+
|
|
3
|
+
type AnyParams = Record<string, string | undefined>;
|
|
4
|
+
type AnyPages = Record<
|
|
5
|
+
string,
|
|
6
|
+
{
|
|
7
|
+
params: AnyParams;
|
|
8
|
+
}
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
export type RegisterPages = Register extends {
|
|
12
|
+
pages: infer Registered extends AnyPages;
|
|
13
|
+
}
|
|
14
|
+
? Registered
|
|
15
|
+
: AnyPages;
|
package/src/types/index.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
1
|
+
export * from "./HrefArgs";
|
|
2
|
+
export * from "./MaybeError";
|
|
3
|
+
export * from "./RoutePath";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { href, type useLoaderData } from "react-router";
|
|
3
|
+
import type { HrefArgs } from "./types/HrefArgs";
|
|
4
|
+
import type { RouteWithLoaderModule } from "./types/RouteWithLoaderModule";
|
|
5
|
+
|
|
6
|
+
// Cache for the useCachedFetch hook (regular fetch, not useFetcher)
|
|
7
|
+
const fetchCache = new Map<string, unknown>();
|
|
8
|
+
|
|
9
|
+
// Hook that uses regular fetch instead of useFetcher to avoid route invalidation
|
|
10
|
+
export const useCachedFetch = <TInfo extends RouteWithLoaderModule>(
|
|
11
|
+
path: TInfo["file"],
|
|
12
|
+
...args: TInfo["file"] extends "undefined"
|
|
13
|
+
? HrefArgs<"/">
|
|
14
|
+
: HrefArgs<TInfo["file"]>
|
|
15
|
+
): {
|
|
16
|
+
data: ReturnType<typeof useLoaderData<TInfo["loader"]>> | undefined;
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
error: Error | undefined;
|
|
19
|
+
} => {
|
|
20
|
+
// Generate URL using href, same as useDynamicFetcher
|
|
21
|
+
const url = useMemo(() => {
|
|
22
|
+
return href<typeof path>(path, ...args);
|
|
23
|
+
}, [path, args]);
|
|
24
|
+
|
|
25
|
+
// Use the generated URL as the cache key
|
|
26
|
+
const cacheKey = url;
|
|
27
|
+
|
|
28
|
+
// Local state
|
|
29
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
30
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
31
|
+
const [data, setData] = useState<
|
|
32
|
+
ReturnType<typeof useLoaderData<TInfo["loader"]>> | undefined
|
|
33
|
+
>(() =>
|
|
34
|
+
fetchCache.has(cacheKey)
|
|
35
|
+
? (fetchCache.get(cacheKey) as ReturnType<
|
|
36
|
+
typeof useLoaderData<TInfo["loader"]>
|
|
37
|
+
>)
|
|
38
|
+
: undefined,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Auto-fetch on mount or when URL changes, if not in cache
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const fetchData = async () => {
|
|
44
|
+
// Skip fetch if data is already cached
|
|
45
|
+
if (fetchCache.has(cacheKey)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setIsLoading(true);
|
|
50
|
+
setError(undefined);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(url);
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = await response.json();
|
|
60
|
+
|
|
61
|
+
// Update cache and state
|
|
62
|
+
fetchCache.set(cacheKey, result);
|
|
63
|
+
setData(result as ReturnType<typeof useLoaderData<TInfo["loader"]>>);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
66
|
+
} finally {
|
|
67
|
+
setIsLoading(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
fetchData();
|
|
72
|
+
}, [url, cacheKey]);
|
|
73
|
+
|
|
74
|
+
return { data, isLoading, error };
|
|
75
|
+
};
|
package/src/useDynamicFetcher.ts
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import { useCallback,
|
|
2
|
-
import { href, useFetcher
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { href, useFetcher } from "react-router";
|
|
3
|
+
import type { HrefArgs } from "./types/HrefArgs";
|
|
4
|
+
import type { RouteWithLoaderModule } from "./types/RouteWithLoaderModule";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
file: keyof RegisterPages;
|
|
8
|
-
loader: Func;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export const useDynamicFetcher = <TInfo extends RouteModule>(
|
|
6
|
+
export const useDynamicFetcher = <TInfo extends RouteWithLoaderModule>(
|
|
12
7
|
path: TInfo["file"],
|
|
13
|
-
...args: TInfo["file"] extends "undefined"
|
|
8
|
+
...args: TInfo["file"] extends "undefined"
|
|
9
|
+
? HrefArgs<"/">
|
|
10
|
+
: HrefArgs<TInfo["file"]>
|
|
14
11
|
): Omit<ReturnType<typeof useFetcher<TInfo["loader"]>>, "load" | "submit"> & {
|
|
15
12
|
load: (queryParams?: Record<string, string>) => Promise<void>;
|
|
16
13
|
} => {
|
|
@@ -44,84 +41,3 @@ export const useDynamicFetcher = <TInfo extends RouteModule>(
|
|
|
44
41
|
load,
|
|
45
42
|
};
|
|
46
43
|
};
|
|
47
|
-
|
|
48
|
-
// Cache for the useCachedFetch hook (regular fetch, not useFetcher)
|
|
49
|
-
const fetchCache = new Map<string, unknown>();
|
|
50
|
-
|
|
51
|
-
// Hook that uses regular fetch instead of useFetcher to avoid route invalidation
|
|
52
|
-
export const useCachedFetch = <
|
|
53
|
-
TInfo extends {
|
|
54
|
-
file: string;
|
|
55
|
-
module: RouteModule;
|
|
56
|
-
},
|
|
57
|
-
>(
|
|
58
|
-
path: TInfo["file"] extends "undefined"
|
|
59
|
-
? "/"
|
|
60
|
-
: `/${TInfo["file"]}` extends keyof RegisterPages
|
|
61
|
-
? `/${TInfo["file"]}`
|
|
62
|
-
: never,
|
|
63
|
-
...args: TInfo["file"] extends "undefined"
|
|
64
|
-
? HrefArgs<"/">
|
|
65
|
-
: `/${TInfo["file"]}` extends keyof RegisterPages
|
|
66
|
-
? HrefArgs<`/${TInfo["file"]}`>
|
|
67
|
-
: never
|
|
68
|
-
): {
|
|
69
|
-
data: ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>> | undefined;
|
|
70
|
-
isLoading: boolean;
|
|
71
|
-
error: Error | undefined;
|
|
72
|
-
} => {
|
|
73
|
-
// Generate URL using href, same as useDynamicFetcher
|
|
74
|
-
const url = useMemo(() => {
|
|
75
|
-
// biome-ignore lint/suspicious/noExplicitAny: Complex conditional typing prevents TypeScript from inferring args when spreading
|
|
76
|
-
return href<typeof path>(path, ...(args as any));
|
|
77
|
-
}, [path, args]);
|
|
78
|
-
|
|
79
|
-
// Use the generated URL as the cache key
|
|
80
|
-
const cacheKey = url;
|
|
81
|
-
|
|
82
|
-
// Local state
|
|
83
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
84
|
-
const [error, setError] = useState<Error | undefined>(undefined);
|
|
85
|
-
const [data, setData] = useState<
|
|
86
|
-
ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>> | undefined
|
|
87
|
-
>(() =>
|
|
88
|
-
fetchCache.has(cacheKey)
|
|
89
|
-
? (fetchCache.get(cacheKey) as ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>>)
|
|
90
|
-
: undefined,
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
// Auto-fetch on mount or when URL changes, if not in cache
|
|
94
|
-
useEffect(() => {
|
|
95
|
-
const fetchData = async () => {
|
|
96
|
-
// Skip fetch if data is already cached
|
|
97
|
-
if (fetchCache.has(cacheKey)) {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
setIsLoading(true);
|
|
102
|
-
setError(undefined);
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const response = await fetch(url);
|
|
106
|
-
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const result = await response.json();
|
|
112
|
-
|
|
113
|
-
// Update cache and state
|
|
114
|
-
fetchCache.set(cacheKey, result);
|
|
115
|
-
setData(result as ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>>);
|
|
116
|
-
} catch (err) {
|
|
117
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
118
|
-
} finally {
|
|
119
|
-
setIsLoading(false);
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
fetchData();
|
|
124
|
-
}, [url, cacheKey]);
|
|
125
|
-
|
|
126
|
-
return { data, isLoading, error };
|
|
127
|
-
};
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
useFetcher,
|
|
8
8
|
} from "react-router";
|
|
9
9
|
import type { z } from "zod/v4";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import type { Func } from "./types/Func";
|
|
11
|
+
import type { HrefArgs } from "./types/HrefArgs";
|
|
12
|
+
import type { RegisterPages } from "./types/RegisterPages";
|
|
12
13
|
|
|
13
14
|
type RouteModule = {
|
|
14
15
|
file: keyof RegisterPages;
|
|
@@ -24,21 +25,28 @@ type SubmitFunc<TModule extends RouteModule> = (
|
|
|
24
25
|
) => Promise<void>;
|
|
25
26
|
|
|
26
27
|
type SubmitForm = (
|
|
27
|
-
props: Omit<
|
|
28
|
+
props: Omit<
|
|
29
|
+
FetcherFormProps & React.RefAttributes<HTMLFormElement>,
|
|
30
|
+
"action" | "method"
|
|
31
|
+
> & {
|
|
28
32
|
method: Exclude<SubmitOptions["method"], "GET">;
|
|
29
33
|
},
|
|
30
34
|
) => React.ReactElement;
|
|
31
35
|
|
|
32
36
|
export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
33
37
|
path: TInfo["file"],
|
|
34
|
-
...args: TInfo["file"] extends "undefined"
|
|
35
|
-
|
|
38
|
+
...args: TInfo["file"] extends "undefined"
|
|
39
|
+
? HrefArgs<"/">
|
|
40
|
+
: HrefArgs<TInfo["file"]>
|
|
41
|
+
): Omit<
|
|
42
|
+
ReturnType<typeof useFetcher<TInfo["action"]>>,
|
|
43
|
+
"load" | "submit" | "Form"
|
|
44
|
+
> & {
|
|
36
45
|
submit: SubmitFunc<TInfo>;
|
|
37
46
|
Form: SubmitForm;
|
|
38
47
|
} => {
|
|
39
48
|
const url = useMemo(() => {
|
|
40
|
-
|
|
41
|
-
return href<typeof path>(path, ...(args as any));
|
|
49
|
+
return href<typeof path>(path, ...args);
|
|
42
50
|
}, [path, args]);
|
|
43
51
|
|
|
44
52
|
const fetcher = useFetcher<TInfo["action"]>({
|
|
@@ -8,7 +8,10 @@ import type { useFetcher } from "react-router";
|
|
|
8
8
|
*/
|
|
9
9
|
export const useFetcherStateChanged = (
|
|
10
10
|
fetcher: Pick<ReturnType<typeof useFetcher>, "state">,
|
|
11
|
-
onChange: (
|
|
11
|
+
onChange: (
|
|
12
|
+
lastState: typeof fetcher.state | undefined,
|
|
13
|
+
newState: typeof fetcher.state,
|
|
14
|
+
) => void,
|
|
12
15
|
) => {
|
|
13
16
|
const lastStateRef = useRef<typeof fetcher.state>(fetcher.state);
|
|
14
17
|
|
|
@@ -18,4 +21,4 @@ export const useFetcherStateChanged = (
|
|
|
18
21
|
lastStateRef.current = fetcher.state;
|
|
19
22
|
}
|
|
20
23
|
}, [fetcher.state, onChange]);
|
|
21
|
-
};
|
|
24
|
+
};
|