@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/router-toolkit",
3
- "version": "0.1.4",
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
- "./*": ["./src/*.ts", "./src/*.tsx"]
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": "eslint src",
24
- "test": "echo 'No tests yet'"
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
@@ -0,0 +1,5 @@
1
+ export * from "./types/index";
2
+ export * from "./useCachedFetch";
3
+ export * from "./useDynamicSubmitter";
4
+ export * from "./useDynamicSubmitter";
5
+ export * from "./useFetcherStateChanged";
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;
@@ -1,17 +1,8 @@
1
- import type { href, Register } from "react-router";
2
-
3
- type AnyParams = Record<string, string | undefined>;
4
- type AnyPages = Record<string, {
5
- params: AnyParams;
6
- }>;
7
-
8
- export type RegisterPages = Register extends {
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;
@@ -0,0 +1,3 @@
1
+ import type { RegisterPages } from "./RegisterPages";
2
+
3
+ export type RoutePath<TPath extends keyof RegisterPages> = TPath;
@@ -0,0 +1,7 @@
1
+ import type { Func } from "./Func";
2
+ import type { RegisterPages } from "./RegisterPages";
3
+
4
+ export type RouteWithLoaderModule = {
5
+ file: keyof RegisterPages;
6
+ loader: Func;
7
+ };
@@ -1,2 +1,3 @@
1
- export type { Func } from "./Func";
2
- export type { HrefArgs,RoutePath } from "./HrefArgs";
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
+ };
@@ -1,16 +1,13 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { href, useFetcher, type useLoaderData } from "react-router";
3
- import type { Func } from "./types/Func";
4
- import type { HrefArgs, RegisterPages } from "./types/HrefArgs";
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
- type RouteModule = {
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" ? HrefArgs<"/"> : HrefArgs<TInfo["file"]>
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 { HrefArgs, RegisterPages } from "./types/HrefArgs";
11
- import { Func } from "./types";
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<FetcherFormProps & React.RefAttributes<HTMLFormElement>, "action" | "method"> & {
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" ? HrefArgs<"/"> : HrefArgs<TInfo["file"]>
35
- ): Omit<ReturnType<typeof useFetcher<TInfo["action"]>>, "load" | "submit" | "Form"> & {
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
- // biome-ignore lint/suspicious/noExplicitAny: We are sure the args are correct
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: (lastState: typeof fetcher.state | undefined, newState: typeof fetcher.state) => void,
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
+ };