@firtoz/router-toolkit 8.0.1 → 9.0.1
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 +127 -71
- package/dist/{chunk-HX57TC2S.js → chunk-F4324Q33.js} +2 -2
- package/dist/chunk-F4324Q33.js.map +1 -0
- package/dist/chunk-SBJFTOWW.js +3 -0
- package/dist/{chunk-2RLEUOSR.js.map → chunk-SBJFTOWW.js.map} +1 -1
- package/dist/{chunk-5MOCOBGV.js → chunk-UF5QHE5K.js} +2 -2
- package/dist/chunk-UF5QHE5K.js.map +1 -0
- package/dist/chunk-XMGRKSHM.js +183 -0
- package/dist/chunk-XMGRKSHM.js.map +1 -0
- package/dist/formAction.d.ts +41 -15
- package/dist/formAction.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +1 -1
- package/dist/useDynamicFetcher.d.ts +18 -8
- package/dist/useDynamicFetcher.js +1 -1
- package/dist/useDynamicSubmitter.d.ts +300 -133
- package/dist/useDynamicSubmitter.js +1 -1
- package/package.json +4 -4
- package/src/formAction.ts +41 -15
- package/src/types/index.ts +0 -1
- package/src/useDynamicFetcher.ts +18 -8
- package/src/useDynamicSubmitter.tsx +323 -101
- package/dist/chunk-2RLEUOSR.js +0 -3
- package/dist/chunk-5MOCOBGV.js.map +0 -1
- package/dist/chunk-HX57TC2S.js.map +0 -1
- package/dist/chunk-JJN6GBJL.js +0 -55
- package/dist/chunk-JJN6GBJL.js.map +0 -1
package/src/useDynamicFetcher.ts
CHANGED
|
@@ -97,28 +97,33 @@
|
|
|
97
97
|
*
|
|
98
98
|
* ```tsx
|
|
99
99
|
* import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
100
|
+
* import { useEffect, useState } from "react";
|
|
100
101
|
*
|
|
101
102
|
* function PostEditor({ postId }: { postId: string }) {
|
|
102
|
-
* // Fetch post data
|
|
103
103
|
* const fetcher = useDynamicFetcher<typeof import("./api.posts.$postId")>(
|
|
104
104
|
* "/api/posts/:postId",
|
|
105
105
|
* { postId }
|
|
106
106
|
* );
|
|
107
107
|
*
|
|
108
|
-
* // Submit updates
|
|
109
108
|
* const submitter = useDynamicSubmitter<typeof import("./api.posts.$postId")>(
|
|
110
109
|
* "/api/posts/:postId",
|
|
111
110
|
* { postId }
|
|
112
111
|
* );
|
|
113
112
|
*
|
|
113
|
+
* const [saving, setSaving] = useState(false);
|
|
114
|
+
*
|
|
114
115
|
* useEffect(() => {
|
|
115
116
|
* fetcher.load();
|
|
116
117
|
* }, [fetcher.load]);
|
|
117
118
|
*
|
|
118
119
|
* const handleSave = async (title: string, content: string) => {
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
120
|
+
* setSaving(true);
|
|
121
|
+
* try {
|
|
122
|
+
* await submitter.submitJson({ title, content }, { method: "PUT" });
|
|
123
|
+
* fetcher.load();
|
|
124
|
+
* } finally {
|
|
125
|
+
* setSaving(false);
|
|
126
|
+
* }
|
|
122
127
|
* };
|
|
123
128
|
*
|
|
124
129
|
* if (!fetcher.data) return <div>Loading...</div>;
|
|
@@ -127,17 +132,22 @@
|
|
|
127
132
|
* <form onSubmit={(e) => {
|
|
128
133
|
* e.preventDefault();
|
|
129
134
|
* const form = new FormData(e.currentTarget);
|
|
130
|
-
* handleSave(form.get("title") as string, form.get("content") as string);
|
|
135
|
+
* void handleSave(form.get("title") as string, form.get("content") as string);
|
|
131
136
|
* }}>
|
|
132
137
|
* <input name="title" defaultValue={fetcher.data.post.title} />
|
|
133
138
|
* <textarea name="content" defaultValue={fetcher.data.post.content} />
|
|
134
|
-
* <button disabled={
|
|
135
|
-
* {
|
|
139
|
+
* <button type="submit" disabled={saving}>
|
|
140
|
+
* {saving ? "Saving..." : "Save"}
|
|
136
141
|
* </button>
|
|
137
142
|
* </form>
|
|
138
143
|
* );
|
|
139
144
|
* }
|
|
140
145
|
* ```
|
|
146
|
+
*
|
|
147
|
+
* **Submitter UX:** Local `saving` around `await submitter.submitJson` fits a promise-first save +
|
|
148
|
+
* reload flow. For declarative `fetcher.state` / `fetcher.data` in JSX (e.g. with `submitter.Form`),
|
|
149
|
+
* use {@link useDynamicSubmitterFetcher} instead. The package README documents trade-offs under
|
|
150
|
+
* **useDynamicSubmitter** (heading “Local useState vs useDynamicSubmitterFetcher”).
|
|
141
151
|
*/
|
|
142
152
|
|
|
143
153
|
import { useCallback, useMemo } from "react";
|
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
* This module provides a hook that creates a type-safe fetcher for submitting forms
|
|
5
5
|
* to dynamic routes with full TypeScript inference for the form schema and route params.
|
|
6
6
|
*
|
|
7
|
+
* **Awaiting results:** `submit` and `submitJson` return a `Promise` that resolves with the
|
|
8
|
+
* action payload after the submission completes (fetcher leaves `submitting` / `loading` for
|
|
9
|
+
* `idle`). The hook does **not** expose `data` or `state`—use the promise result (and local
|
|
10
|
+
* React state) for outcomes and loading UX. For declarative UI tied to the same fetcher, use
|
|
11
|
+
* {@link useDynamicSubmitterFetcher} (or {@link dynamicSubmitterFetcherKey} with `useFetcher`).
|
|
12
|
+
* Use **one awaited submit at
|
|
13
|
+
* a time** per hook instance; React Router’s
|
|
14
|
+
* single fetcher replaces an in-flight request when you submit again. If you call `submit` or
|
|
15
|
+
* `submitJson` again before the previous promise settles, the previous promise is **rejected**
|
|
16
|
+
* with {@link SubmitterSupersededError}. That applies **per React Router fetcher key** (same
|
|
17
|
+
* resolved URL and {@link UseDynamicSubmitterOptions.keySuffix}): two hook instances that share
|
|
18
|
+
* the same key also supersede one another’s in-flight promise. Use distinct `keySuffix` values
|
|
19
|
+
* when you need independent overlapping submissions to the same route. For many overlapping
|
|
20
|
+
* operations, use `ConcurrentSubmitterProvider` / `useConcurrentSubmitter` instead.
|
|
21
|
+
*
|
|
22
|
+
* **Unmount:** If the component unmounts while a returned promise is still pending, that
|
|
23
|
+
* promise is **rejected** with {@link SubmitterUnmountedError}.
|
|
24
|
+
*
|
|
25
|
+
* **Local `useState` vs {@link useDynamicSubmitterFetcher}:** For programmatic
|
|
26
|
+
* `await submitter.submitJson`, a local pending flag and `try` / `finally` is often enough for
|
|
27
|
+
* disabled buttons and matches the promise-first API. Use {@link useDynamicSubmitterFetcher} when
|
|
28
|
+
* you want declarative `fetcher.state` / `fetcher.data` in JSX (especially with `submitter.Form`
|
|
29
|
+
* or inline errors). The package README documents trade-offs in more detail.
|
|
30
|
+
*
|
|
7
31
|
* @example
|
|
8
32
|
* ### Route Setup (`app/routes/admin.posts.$id.tsx`)
|
|
9
33
|
*
|
|
@@ -51,17 +75,24 @@
|
|
|
51
75
|
* { id: postId }
|
|
52
76
|
* );
|
|
53
77
|
*
|
|
54
|
-
*
|
|
55
|
-
* // submitter.state is "idle" | "loading" | "submitting"
|
|
78
|
+
* const [pending, setPending] = useState(false);
|
|
56
79
|
*
|
|
57
80
|
* // Option 1: Submit as JSON (recommended for programmatic submissions)
|
|
58
81
|
* // Defaults to POST if no options provided
|
|
59
82
|
* const handleSubmitJson = async () => {
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
83
|
+
* setPending(true);
|
|
84
|
+
* try {
|
|
85
|
+
* const data = await submitter.submitJson({
|
|
86
|
+
* title: "My Post",
|
|
87
|
+
* content: "Post content here",
|
|
88
|
+
* published: true,
|
|
89
|
+
* });
|
|
90
|
+
* if (data.success) {
|
|
91
|
+
* console.log("Saved");
|
|
92
|
+
* }
|
|
93
|
+
* } finally {
|
|
94
|
+
* setPending(false);
|
|
95
|
+
* }
|
|
65
96
|
* };
|
|
66
97
|
*
|
|
67
98
|
* // Option 2: Submit with FormData or SubmitTarget
|
|
@@ -69,43 +100,12 @@
|
|
|
69
100
|
* await submitter.submit(formData, { method: "POST" });
|
|
70
101
|
* };
|
|
71
102
|
*
|
|
72
|
-
* // Option 3: Use the Form component (defaults to POST)
|
|
103
|
+
* // Option 3: Use the Form component (defaults to POST); pair with useDynamicSubmitterFetcher(submitter) if you need reactive state
|
|
73
104
|
* return (
|
|
74
105
|
* <submitter.Form>
|
|
75
106
|
* <input name="title" />
|
|
76
107
|
* <textarea name="content" />
|
|
77
|
-
* <button type="submit">Save</button>
|
|
78
|
-
* </submitter.Form>
|
|
79
|
-
* );
|
|
80
|
-
* }
|
|
81
|
-
* ```
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* ### Handling responses
|
|
85
|
-
*
|
|
86
|
-
* ```tsx
|
|
87
|
-
* function LoginForm() {
|
|
88
|
-
* const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
|
|
89
|
-
*
|
|
90
|
-
* useEffect(() => {
|
|
91
|
-
* if (submitter.data?.success) {
|
|
92
|
-
* // Handle success
|
|
93
|
-
* console.log("Logged in as:", submitter.data.value.user.email);
|
|
94
|
-
* } else if (submitter.data && !submitter.data.success) {
|
|
95
|
-
* // Handle error
|
|
96
|
-
* if (submitter.data.error.type === "validation") {
|
|
97
|
-
* console.log("Validation errors:", submitter.data.error.error);
|
|
98
|
-
* }
|
|
99
|
-
* }
|
|
100
|
-
* }, [submitter.data]);
|
|
101
|
-
*
|
|
102
|
-
* return (
|
|
103
|
-
* <submitter.Form>
|
|
104
|
-
* <input name="email" type="email" />
|
|
105
|
-
* <input name="password" type="password" />
|
|
106
|
-
* <button disabled={submitter.state !== "idle"}>
|
|
107
|
-
* {submitter.state === "submitting" ? "Logging in..." : "Login"}
|
|
108
|
-
* </button>
|
|
108
|
+
* <button type="submit" disabled={pending}>Save</button>
|
|
109
109
|
* </submitter.Form>
|
|
110
110
|
* );
|
|
111
111
|
* }
|
|
@@ -113,9 +113,10 @@
|
|
|
113
113
|
*/
|
|
114
114
|
|
|
115
115
|
// biome-ignore lint/style/useImportType: We need to import React here.
|
|
116
|
-
import React, { useCallback, useMemo } from "react";
|
|
116
|
+
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
117
117
|
import {
|
|
118
118
|
type FetcherFormProps,
|
|
119
|
+
type HTMLFormMethod,
|
|
119
120
|
href,
|
|
120
121
|
type SubmitOptions,
|
|
121
122
|
type SubmitTarget,
|
|
@@ -125,6 +126,138 @@ import type { z } from "zod";
|
|
|
125
126
|
import type { HrefArgs } from "./types/HrefArgs";
|
|
126
127
|
import type { RouteWithActionModule } from "./types/RouteWithActionModule";
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Thrown when a new `submit` or `submitJson` runs before a prior returned promise has settled.
|
|
131
|
+
* The new submission proceeds; catch this error if overlapping calls are expected.
|
|
132
|
+
*/
|
|
133
|
+
export class SubmitterSupersededError extends Error {
|
|
134
|
+
override readonly name = "SubmitterSupersededError";
|
|
135
|
+
constructor(
|
|
136
|
+
message = "This submission was superseded by a newer submit before it completed.",
|
|
137
|
+
) {
|
|
138
|
+
super(message);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Thrown when the component that owns the submitter unmounts before a `submit` /
|
|
144
|
+
* `submitJson` promise has settled.
|
|
145
|
+
*/
|
|
146
|
+
export class SubmitterUnmountedError extends Error {
|
|
147
|
+
override readonly name = "SubmitterUnmountedError";
|
|
148
|
+
constructor(
|
|
149
|
+
message = "The submitter was unmounted before this submission completed.",
|
|
150
|
+
) {
|
|
151
|
+
super(message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Action payload type on the fetcher (same shape React Router puts on `fetcher.data` after the action runs).
|
|
157
|
+
* Includes `undefined` while idle or in flight—use {@link SubmitterSettledData} for the value after
|
|
158
|
+
* `await submitter.submit` / `await submitter.submitJson`.
|
|
159
|
+
*/
|
|
160
|
+
export type DynamicSubmitterData<TInfo extends RouteWithActionModule> =
|
|
161
|
+
ReturnType<typeof useFetcher<TInfo["action"]>>["data"];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Payload type after a successful `await submitter.submit` / `await submitter.submitJson`.
|
|
165
|
+
* Omits `undefined` from {@link DynamicSubmitterData}: the promise only resolves when `fetcher.data`
|
|
166
|
+
* is defined (otherwise it rejects). Inner success values may still be void / optional `result` for
|
|
167
|
+
* `MaybeError<undefined>` from `formAction` + `success()`.
|
|
168
|
+
*/
|
|
169
|
+
export type SubmitterSettledData<TInfo extends RouteWithActionModule> =
|
|
170
|
+
NonNullable<DynamicSubmitterData<TInfo>>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Options for {@link useDynamicSubmitter}.
|
|
174
|
+
*/
|
|
175
|
+
export type UseDynamicSubmitterOptions = {
|
|
176
|
+
/**
|
|
177
|
+
* Appended to the default fetcher key so multiple submitters can target the same resolved URL
|
|
178
|
+
* without sharing React Router fetcher state. Omit to use the default key for that URL.
|
|
179
|
+
*/
|
|
180
|
+
keySuffix?: string;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* React Router `useFetcher` key used by {@link useDynamicSubmitter} for a resolved href.
|
|
185
|
+
* Pass the same string as {@link UseDynamicSubmitterResult.fetcherKey} (or call with the same
|
|
186
|
+
* `resolvedHref` and `keySuffix` as the submitter) so a parallel `useFetcher({ key })` observes
|
|
187
|
+
* the same submission lifecycle.
|
|
188
|
+
*
|
|
189
|
+
* When `keySuffix` is set, it is encoded and joined with a fixed delimiter so arbitrary strings
|
|
190
|
+
* are safe in the key.
|
|
191
|
+
*/
|
|
192
|
+
export function dynamicSubmitterFetcherKey(
|
|
193
|
+
resolvedHref: string,
|
|
194
|
+
keySuffix?: string,
|
|
195
|
+
): string {
|
|
196
|
+
const base = `submitter-${resolvedHref}`;
|
|
197
|
+
if (keySuffix === undefined || keySuffix === "") {
|
|
198
|
+
return base;
|
|
199
|
+
}
|
|
200
|
+
return `${base}::${encodeURIComponent(keySuffix)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isSubmitterOptions(x: unknown): x is UseDynamicSubmitterOptions {
|
|
204
|
+
if (x === null || typeof x !== "object") return false;
|
|
205
|
+
const keys = Object.keys(x as object);
|
|
206
|
+
if (keys.length === 0) return false;
|
|
207
|
+
return keys.every((k) => k === "keySuffix");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseUseDynamicSubmitterRestArgs(args: readonly unknown[]): {
|
|
211
|
+
hrefArgs: unknown[];
|
|
212
|
+
options: UseDynamicSubmitterOptions;
|
|
213
|
+
} {
|
|
214
|
+
if (args.length === 0) {
|
|
215
|
+
return { hrefArgs: [], options: {} };
|
|
216
|
+
}
|
|
217
|
+
const last = args[args.length - 1];
|
|
218
|
+
if (args.length >= 2 && isSubmitterOptions(last)) {
|
|
219
|
+
return { hrefArgs: [...args.slice(0, -1)], options: last };
|
|
220
|
+
}
|
|
221
|
+
if (args.length === 1 && isSubmitterOptions(args[0])) {
|
|
222
|
+
return { hrefArgs: [], options: args[0] };
|
|
223
|
+
}
|
|
224
|
+
return { hrefArgs: [...args], options: {} };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type UseDynamicSubmitterRest<R extends RouteWithActionModule["route"]> =
|
|
228
|
+
HrefArgs<R> extends readonly []
|
|
229
|
+
? [options?: UseDynamicSubmitterOptions]
|
|
230
|
+
: [...hrefArgs: HrefArgs<R>, options?: UseDynamicSubmitterOptions];
|
|
231
|
+
|
|
232
|
+
type PendingAwait = {
|
|
233
|
+
gen: number;
|
|
234
|
+
ownerId: number;
|
|
235
|
+
reject: (reason: unknown) => void;
|
|
236
|
+
/** Called when the shared fetcher reaches `idle` for this submission generation. */
|
|
237
|
+
finishIdle: (data: unknown, error: unknown | undefined) => void;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type SubmitterKeyBucket = {
|
|
241
|
+
submitGen: number;
|
|
242
|
+
pending: PendingAwait | null;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const submitterKeyBuckets = new Map<string, SubmitterKeyBucket>();
|
|
246
|
+
|
|
247
|
+
function getSubmitterKeyBucket(key: string): SubmitterKeyBucket {
|
|
248
|
+
let b = submitterKeyBuckets.get(key);
|
|
249
|
+
if (!b) {
|
|
250
|
+
b = { submitGen: 0, pending: null };
|
|
251
|
+
submitterKeyBuckets.set(key, b);
|
|
252
|
+
}
|
|
253
|
+
return b;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let nextSubmitterOwnerId = 1;
|
|
257
|
+
function allocateSubmitterOwnerId(): number {
|
|
258
|
+
return nextSubmitterOwnerId++;
|
|
259
|
+
}
|
|
260
|
+
|
|
128
261
|
/**
|
|
129
262
|
* Function type for submitting form data with a SubmitTarget.
|
|
130
263
|
*
|
|
@@ -145,7 +278,7 @@ type SubmitFunc<TModule extends RouteWithActionModule> = (
|
|
|
145
278
|
options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
146
279
|
method: Exclude<SubmitOptions["method"], "GET">;
|
|
147
280
|
},
|
|
148
|
-
) => Promise<
|
|
281
|
+
) => Promise<SubmitterSettledData<TModule>>;
|
|
149
282
|
|
|
150
283
|
/**
|
|
151
284
|
* Options for submitJson function.
|
|
@@ -183,7 +316,7 @@ type SubmitJsonOptions = Omit<
|
|
|
183
316
|
type SubmitJsonFunc<TModule extends RouteWithActionModule> = (
|
|
184
317
|
data: z.infer<TModule["formSchema"]>,
|
|
185
318
|
options?: SubmitJsonOptions,
|
|
186
|
-
) => Promise<
|
|
319
|
+
) => Promise<SubmitterSettledData<TModule>>;
|
|
187
320
|
|
|
188
321
|
/**
|
|
189
322
|
* Form component type with pre-bound action URL.
|
|
@@ -214,6 +347,19 @@ type SubmitForm = (
|
|
|
214
347
|
},
|
|
215
348
|
) => React.ReactElement;
|
|
216
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Stable object returned by {@link useDynamicSubmitter}: `submit`, `submitJson`, `Form`, and
|
|
352
|
+
* `fetcherKey`. The reference is memoized and does not change when the internal fetcher’s
|
|
353
|
+
* `state` / `data` update.
|
|
354
|
+
*/
|
|
355
|
+
export type UseDynamicSubmitterResult<TInfo extends RouteWithActionModule> = {
|
|
356
|
+
submit: SubmitFunc<TInfo>;
|
|
357
|
+
submitJson: SubmitJsonFunc<TInfo>;
|
|
358
|
+
Form: SubmitForm;
|
|
359
|
+
/** Pass to {@link useDynamicSubmitterFetcher} or `useFetcher({ key })` for reactive `state` / `data`. */
|
|
360
|
+
fetcherKey: string;
|
|
361
|
+
};
|
|
362
|
+
|
|
217
363
|
/**
|
|
218
364
|
* Creates a type-safe fetcher for submitting forms to dynamic routes.
|
|
219
365
|
*
|
|
@@ -225,15 +371,13 @@ type SubmitForm = (
|
|
|
225
371
|
* @template TInfo - The route module type (use `typeof import("./route-file")`)
|
|
226
372
|
*
|
|
227
373
|
* @param path - The route path (must match the route's `route` export)
|
|
228
|
-
* @param
|
|
374
|
+
* @param rest - Route parameters (if any), then optional {@link UseDynamicSubmitterOptions}. For
|
|
375
|
+
* static routes, you may pass only options as the second argument (e.g. `{ keySuffix: "a" }`).
|
|
376
|
+
* Options are recognized only when the object contains exclusively the `keySuffix` key (do not use
|
|
377
|
+
* a route param object whose only field is named `keySuffix` unless it is meant as options).
|
|
229
378
|
*
|
|
230
|
-
* @returns
|
|
231
|
-
*
|
|
232
|
-
* - `submitJson` - Submit a plain object as JSON (schema type only)
|
|
233
|
-
* - `Form` - Pre-bound form component
|
|
234
|
-
* - `data` - Response data from the action (typed)
|
|
235
|
-
* - `state` - Fetcher state ("idle" | "loading" | "submitting")
|
|
236
|
-
* - All other useFetcher properties
|
|
379
|
+
* @returns Stable `{ submit, submitJson, Form, fetcherKey }`. Await the promises for action results;
|
|
380
|
+
* use {@link useDynamicSubmitterFetcher} or local state for reactive loading/data.
|
|
237
381
|
*
|
|
238
382
|
* @example
|
|
239
383
|
* ### Basic usage with route parameters
|
|
@@ -254,86 +398,164 @@ type SubmitForm = (
|
|
|
254
398
|
* { userId: "123" }
|
|
255
399
|
* );
|
|
256
400
|
*
|
|
257
|
-
*
|
|
258
|
-
* await submitter.submitJson({
|
|
401
|
+
* const data = await submitter.submitJson({
|
|
259
402
|
* displayName: "John Doe",
|
|
260
403
|
* email: "john@example.com",
|
|
261
404
|
* notifications: true,
|
|
262
405
|
* });
|
|
263
406
|
*
|
|
264
|
-
*
|
|
265
|
-
* if (submitter.data?.success) {
|
|
407
|
+
* if (data.success) {
|
|
266
408
|
* console.log("Settings updated!");
|
|
267
409
|
* }
|
|
268
410
|
* ```
|
|
269
411
|
*/
|
|
270
|
-
export
|
|
412
|
+
export function useDynamicSubmitter<TInfo extends RouteWithActionModule>(
|
|
271
413
|
path: TInfo["route"],
|
|
272
|
-
...
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
"load" | "submit" | "Form"
|
|
278
|
-
> & {
|
|
279
|
-
/** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
|
|
280
|
-
submit: SubmitFunc<TInfo>;
|
|
281
|
-
/** Submit a plain object as JSON (schema type only, defaults to POST) */
|
|
282
|
-
submitJson: SubmitJsonFunc<TInfo>;
|
|
283
|
-
/** Pre-bound Form component with action URL already set (defaults to POST) */
|
|
284
|
-
Form: SubmitForm;
|
|
285
|
-
} => {
|
|
414
|
+
...rest: UseDynamicSubmitterRest<TInfo["route"]>
|
|
415
|
+
): UseDynamicSubmitterResult<TInfo> {
|
|
416
|
+
const { hrefArgs, options } = parseUseDynamicSubmitterRestArgs(rest);
|
|
417
|
+
const keySuffix = options.keySuffix;
|
|
418
|
+
|
|
286
419
|
const url = useMemo(() => {
|
|
287
420
|
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
288
|
-
return href(path, ...(
|
|
289
|
-
}, [path,
|
|
421
|
+
return href(path, ...(hrefArgs as any));
|
|
422
|
+
}, [path, keySuffix, ...(hrefArgs as unknown[])]);
|
|
423
|
+
|
|
424
|
+
const fetcherKey = useMemo(
|
|
425
|
+
() => dynamicSubmitterFetcherKey(url, keySuffix),
|
|
426
|
+
[url, keySuffix],
|
|
427
|
+
);
|
|
290
428
|
|
|
291
429
|
const fetcher = useFetcher<TInfo["action"]>({
|
|
292
|
-
key:
|
|
430
|
+
key: fetcherKey,
|
|
293
431
|
});
|
|
294
432
|
|
|
433
|
+
const fetcherRef = useRef(fetcher);
|
|
434
|
+
fetcherRef.current = fetcher;
|
|
435
|
+
|
|
436
|
+
const ownerIdRef = useRef(allocateSubmitterOwnerId());
|
|
437
|
+
const prevStateRef = useRef(fetcher.state);
|
|
438
|
+
|
|
439
|
+
const beginSubmit = useCallback(
|
|
440
|
+
(runSubmit: () => void) => {
|
|
441
|
+
return new Promise<SubmitterSettledData<TInfo>>((resolve, reject) => {
|
|
442
|
+
const bucket = getSubmitterKeyBucket(fetcherKey);
|
|
443
|
+
const prevPending = bucket.pending;
|
|
444
|
+
if (prevPending) {
|
|
445
|
+
prevPending.reject(new SubmitterSupersededError());
|
|
446
|
+
}
|
|
447
|
+
bucket.submitGen += 1;
|
|
448
|
+
const gen = bucket.submitGen;
|
|
449
|
+
bucket.pending = {
|
|
450
|
+
gen,
|
|
451
|
+
ownerId: ownerIdRef.current,
|
|
452
|
+
reject,
|
|
453
|
+
finishIdle: (data, error) => {
|
|
454
|
+
if (data !== undefined) {
|
|
455
|
+
resolve(data as SubmitterSettledData<TInfo>);
|
|
456
|
+
} else {
|
|
457
|
+
reject(error ?? new Error("Submission failed"));
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
runSubmit();
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
[fetcherKey],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
return () => {
|
|
469
|
+
const bucket = getSubmitterKeyBucket(fetcherKey);
|
|
470
|
+
const pending = bucket.pending;
|
|
471
|
+
if (pending && pending.ownerId === ownerIdRef.current) {
|
|
472
|
+
bucket.pending = null;
|
|
473
|
+
pending.reject(new SubmitterUnmountedError());
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}, [fetcherKey]);
|
|
477
|
+
|
|
478
|
+
const fetcherError = (fetcher as { error?: unknown }).error;
|
|
479
|
+
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
const prev = prevStateRef.current;
|
|
482
|
+
prevStateRef.current = fetcher.state;
|
|
483
|
+
const wasWorking = prev === "submitting" || prev === "loading";
|
|
484
|
+
if (!wasWorking || fetcher.state !== "idle") {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const bucket = getSubmitterKeyBucket(fetcherKey);
|
|
488
|
+
const p = bucket.pending;
|
|
489
|
+
if (!p || p.gen !== bucket.submitGen) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
bucket.pending = null;
|
|
493
|
+
p.finishIdle(fetcher.data, fetcherError);
|
|
494
|
+
}, [fetcherKey, fetcher.state, fetcher.data, fetcherError]);
|
|
495
|
+
|
|
295
496
|
const submit: SubmitFunc<TInfo> = useCallback(
|
|
296
497
|
(target, options) => {
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
498
|
+
return beginSubmit(() => {
|
|
499
|
+
const f = fetcherRef.current;
|
|
500
|
+
void f.submit(target, {
|
|
501
|
+
...options,
|
|
502
|
+
method: (options?.method ?? "POST") as HTMLFormMethod,
|
|
503
|
+
action: url,
|
|
504
|
+
encType: "multipart/form-data",
|
|
505
|
+
} as Parameters<typeof f.submit>[1]);
|
|
506
|
+
});
|
|
304
507
|
},
|
|
305
|
-
[
|
|
508
|
+
[beginSubmit, url],
|
|
306
509
|
);
|
|
307
510
|
|
|
308
511
|
const submitJson: SubmitJsonFunc<TInfo> = useCallback(
|
|
309
512
|
(data, options = {}) => {
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
513
|
+
return beginSubmit(() => {
|
|
514
|
+
const f = fetcherRef.current;
|
|
515
|
+
void f.submit(
|
|
516
|
+
data as SubmitTarget,
|
|
517
|
+
{
|
|
518
|
+
...options,
|
|
519
|
+
method: (options.method ?? "POST") as HTMLFormMethod,
|
|
520
|
+
action: url,
|
|
521
|
+
encType: "application/json",
|
|
522
|
+
} as Parameters<typeof f.submit>[1],
|
|
523
|
+
);
|
|
524
|
+
});
|
|
320
525
|
},
|
|
321
|
-
[
|
|
526
|
+
[beginSubmit, url],
|
|
322
527
|
);
|
|
323
528
|
|
|
324
|
-
const
|
|
529
|
+
const fetcherFormRef = useRef(fetcher.Form);
|
|
530
|
+
fetcherFormRef.current = fetcher.Form;
|
|
325
531
|
|
|
326
532
|
const Form: SubmitForm = useCallback(
|
|
327
533
|
({ method = "POST", ...props }) => {
|
|
534
|
+
const OriginalForm = fetcherFormRef.current;
|
|
328
535
|
return <OriginalForm action={url} method={method} {...props} />;
|
|
329
536
|
},
|
|
330
|
-
[url
|
|
537
|
+
[url],
|
|
331
538
|
);
|
|
332
539
|
|
|
333
|
-
return
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
540
|
+
return useMemo(
|
|
541
|
+
() => ({
|
|
542
|
+
submit,
|
|
543
|
+
submitJson,
|
|
544
|
+
Form,
|
|
545
|
+
fetcherKey,
|
|
546
|
+
}),
|
|
547
|
+
[submit, submitJson, Form, fetcherKey],
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* React Router `useFetcher` bound to the same key as {@link useDynamicSubmitter}, so `state` /
|
|
553
|
+
* `data` reflect the same submissions as `submitter.submit` / `submitter.Form`.
|
|
554
|
+
*
|
|
555
|
+
* Call at component top level next to `useDynamicSubmitter`.
|
|
556
|
+
*/
|
|
557
|
+
export function useDynamicSubmitterFetcher<TInfo extends RouteWithActionModule>(
|
|
558
|
+
submitter: UseDynamicSubmitterResult<TInfo>,
|
|
559
|
+
) {
|
|
560
|
+
return useFetcher<TInfo["action"]>({ key: submitter.fetcherKey });
|
|
561
|
+
}
|
package/dist/chunk-2RLEUOSR.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/formAction.ts"],"names":[],"mappings":";;;;;AA6SO,IAAM,aAAa,CAKxB;AAAA,EACD,MAAA;AAAA,EACA;AACD,CAAA,KAA8D;AAC7D,EAAA,OAAO,OACN,IAAA,KACoE;AACpE,IAAA,IAAI;AACH,MAAA,MAAM,WAAA,GAAc,IAAA,CAAK,OAAA,CAAQ,OAAA,CAAQ,IAAI,cAAc,CAAA;AAC3D,MAAA,MAAM,MAAA,GAAS,WAAA,EAAa,QAAA,CAAS,kBAAkB,CAAA,IAAK,KAAA;AAE5D,MAAA,MAAM,WAAA,GAAc,SACjB,MAAM,MAAA,CAAO,eAAe,MAAM,IAAA,CAAK,QAAQ,IAAA,EAAM,IACrD,MAAM,GAAA,CACL,SAAS,MAAM,CAAA,CACf,eAAe,MAAM,IAAA,CAAK,OAAA,CAAQ,QAAA,EAAU,CAAA;AAEhD,MAAA,IAAI,CAAC,YAAY,OAAA,EAAS;AACzB,QAAA,OAAO,IAAA,CAAK;AAAA,UACX,IAAA,EAAM,YAAA;AAAA,UACN,OAAO,CAAA,CAAE,YAAA;AAAA,YACR,WAAA,CAAY;AAAA;AACb,SACA,CAAA;AAAA,MACF;AAEA,MAAA,MAAM,aAAA,GAAgB,MAAM,OAAA,CAAQ,IAAA,EAAM,YAAY,IAAI,CAAA;AAC1D,MAAA,IAAI,CAAC,cAAc,OAAA,EAAS;AAC3B,QAAA,OAAO,IAAA,CAAK;AAAA,UACX,IAAA,EAAM,SAAA;AAAA,UACN,OAAO,aAAA,CAAc;AAAA,SACrB,CAAA;AAAA,MACF;AAEA,MAAA,OAAO,aAAA;AAAA,IACR,SAAS,KAAA,EAAO;AAEf,MAAA,IAAI,iBAAiB,QAAA,EAAU;AAC9B,QAAA,MAAM,KAAA;AAAA,MACP;AAEA,MAAA,OAAA,CAAQ,KAAA,CAAM,mCAAmC,KAAK,CAAA;AACtD,MAAA,OAAO,IAAA,CAAK;AAAA,QACX,IAAA,EAAM;AAAA,OACN,CAAA;AAAA,IACF;AAAA,EACD,CAAA;AACD","file":"chunk-5MOCOBGV.js","sourcesContent":["/**\n * @fileoverview Type-safe form action utility for React Router 7\n *\n * This module provides a wrapper for React Router actions that handles form data and JSON\n * validation using Zod schemas and provides structured error handling with MaybeError.\n *\n * Supports both:\n * - **JSON requests** (`Content-Type: application/json`) - parsed with `request.json()` and validated directly\n * - **FormData requests** (`multipart/form-data` or `application/x-www-form-urlencoded`) - parsed with `request.formData()` and validated with zod-form-data\n *\n * ## Overview\n *\n * `formAction` is designed to work seamlessly with `useDynamicSubmitter` and `useDynamicFetcher`\n * to provide end-to-end type safety for your React Router forms.\n *\n * @example\n * ### Basic Route Setup (`app/routes/auth.login.tsx`)\n *\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction, type RoutePath } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n *\n * // 1. Export the route path for type inference\n * export const route: RoutePath<\"/auth/login\"> = \"/auth/login\";\n *\n * // 2. Define your form schema with Zod\n * export const formSchema = z.object({\n * email: z.string().email(\"Please enter a valid email\"),\n * password: z.string().min(8, \"Password must be at least 8 characters\"),\n * rememberMe: z.boolean().optional().default(false),\n * });\n *\n * // 3. Create the action with formAction\n * export const action = formAction({\n * schema: formSchema,\n * handler: async ({ request }, data) => {\n * // data is fully typed: { email: string, password: string, rememberMe: boolean }\n * try {\n * const user = await authenticateUser(data.email, data.password);\n * if (data.rememberMe) {\n * await createPersistentSession(user.id);\n * }\n * return success({ user });\n * } catch (error) {\n * return fail(\"Invalid email or password\");\n * }\n * },\n * });\n * ```\n *\n * @example\n * ### Using with useDynamicSubmitter\n *\n * The route above can be used with `useDynamicSubmitter` for type-safe form submissions:\n *\n * ```tsx\n * import { useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n *\n * function LoginForm() {\n * const submitter = useDynamicSubmitter<typeof import(\"./auth.login\")>(\"/auth/login\");\n *\n * // Option 1: Submit as JSON (defaults to POST)\n * const handleLoginJson = async () => {\n * await submitter.submitJson({\n * email: \"user@example.com\",\n * password: \"secret123\",\n * rememberMe: true,\n * });\n * };\n *\n * // Option 2: Use the Form component (defaults to POST)\n * return (\n * <submitter.Form>\n * <input name=\"email\" type=\"email\" placeholder=\"Email\" />\n * <input name=\"password\" type=\"password\" placeholder=\"Password\" />\n * <label>\n * <input name=\"rememberMe\" type=\"checkbox\" /> Remember me\n * </label>\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Logging in...\" : \"Login\"}\n * </button>\n *\n * {submitter.data && !submitter.data.success && (\n * <div className=\"error\">\n * {submitter.data.error.type === \"validation\"\n * ? \"Please check your inputs\"\n * : submitter.data.error.type === \"handler\"\n * ? submitter.data.error.error // \"Invalid email or password\"\n * : \"An unexpected error occurred\"}\n * </div>\n * )}\n * </submitter.Form>\n * );\n * }\n * ```\n *\n * @example\n * ### Combined loader + action route (`app/routes/admin.posts.$id.tsx`)\n *\n * You can combine `formAction` with a loader for full CRUD operations:\n *\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction, type RoutePath } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n * import type { LoaderFunctionArgs } from \"react-router\";\n *\n * export const route: RoutePath<\"/admin/posts/:id\"> = \"/admin/posts/:id\";\n *\n * // Loader for fetching data (used with useDynamicFetcher)\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * const post = await db.posts.findUnique({ where: { id: params.id } });\n * return { post };\n * };\n *\n * // Form schema for updates\n * export const formSchema = z.object({\n * title: z.string().min(1, \"Title is required\"),\n * content: z.string().min(10, \"Content must be at least 10 characters\"),\n * published: z.boolean().optional().default(false),\n * });\n *\n * // Action for handling form submissions (used with useDynamicSubmitter)\n * export const action = formAction({\n * schema: formSchema,\n * handler: async ({ params }, data) => {\n * const updated = await db.posts.update({\n * where: { id: params.id },\n * data,\n * });\n * return success({ post: updated });\n * },\n * });\n * ```\n *\n * @example\n * ### Full CRUD component using both hooks\n *\n * ```tsx\n * import { useDynamicFetcher, useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n * import { useEffect } from \"react\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * // Fetch post data\n * const fetcher = useDynamicFetcher<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * // Submit updates\n * const submitter = useDynamicSubmitter<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * if (fetcher.state === \"loading\" && !fetcher.data) {\n * return <div>Loading...</div>;\n * }\n *\n * const post = fetcher.data?.post;\n *\n * return (\n * <submitter.Form method=\"PUT\">\n * <input name=\"title\" defaultValue={post?.title} />\n * <textarea name=\"content\" defaultValue={post?.content} />\n * <label>\n * <input name=\"published\" type=\"checkbox\" defaultChecked={post?.published} />\n * Published\n * </label>\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Saving...\" : \"Save\"}\n * </button>\n * </submitter.Form>\n * );\n * }\n * ```\n */\n\nimport { fail, type MaybeError } from \"@firtoz/maybe-error\";\nimport type { ActionFunctionArgs } from \"react-router\";\nimport { z } from \"zod\";\nimport { zfd } from \"zod-form-data\";\n\n/**\n * Error types that can be returned by formAction\n */\nexport type FormActionError<TError, TSchema extends z.ZodTypeAny> =\n\t| {\n\t\t\ttype: \"validation\";\n\t\t\terror: ReturnType<typeof z.treeifyError<z.infer<TSchema>>>;\n\t }\n\t| {\n\t\t\ttype: \"handler\";\n\t\t\terror: TError;\n\t }\n\t| {\n\t\t\ttype: \"unknown\";\n\t };\n\n/**\n * Configuration object for formAction\n *\n * @template TSchema - The Zod schema type for form validation\n * @template TResult - The success result type from the handler\n * @template TError - The error type that the handler can return\n * @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)\n */\nexport interface FormActionConfig<\n\tTSchema extends z.ZodTypeAny,\n\tTResult = undefined,\n\tTError = string,\n\tActionArgs extends ActionFunctionArgs = ActionFunctionArgs,\n> {\n\t/**\n\t * Zod schema to validate the form data against\n\t */\n\tschema: TSchema;\n\t/**\n\t * Handler function that processes the validated form data\n\t *\n\t * @param args - The original action function arguments\n\t * @param data - The validated form data (typed according to the schema)\n\t * @returns A promise that resolves to a MaybeError with the result or error\n\t */\n\thandler: (\n\t\targs: ActionArgs,\n\t\tdata: z.infer<TSchema>,\n\t) => Promise<MaybeError<TResult, TError>>;\n}\n\n/**\n * Creates a type-safe form action handler that validates form data or JSON and provides structured error handling.\n *\n * This function wraps a React Router action to:\n * 1. Detect content type (JSON vs FormData) from the request headers\n * 2. Parse and validate the request body using a Zod schema\n * 3. Call the provided handler with validated data\n * 4. Return structured errors for validation failures, handler errors, or unknown errors\n * 5. Preserve React Router Response objects (redirects, etc.) by re-throwing them\n *\n * **Content-Type handling:**\n * - `application/json`: Uses `request.json()` and validates directly with the schema\n * - `multipart/form-data` or `application/x-www-form-urlencoded`: Uses `request.formData()` and validates with zod-form-data\n *\n * @template TSchema - The Zod schema type for form validation\n * @template TResult - The success result type from the handler (defaults to undefined)\n * @template TError - The error type that the handler can return (defaults to string)\n * @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)\n *\n * @param config - Configuration object containing schema and handler\n * @returns An action function that can be used with React Router\n *\n * @example\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n *\n * const loginSchema = z.object({\n * email: z.string().email(\"Invalid email format\"),\n * password: z.string().min(8, \"Password must be at least 8 characters\"),\n * });\n *\n * export const action = formAction({\n * schema: loginSchema,\n * handler: async (args, data) => {\n * try {\n * const user = await authenticateUser(data.email, data.password);\n * return success(user);\n * } catch (error) {\n * return fail(\"Invalid credentials\");\n * }\n * },\n * });\n * ```\n *\n * @example\n * ```typescript\n * // In your component, handle the different error types:\n * const actionData = useActionData<typeof action>();\n *\n * if (actionData && !actionData.success) {\n * switch (actionData.error.type) {\n * case \"validation\":\n * // Handle validation errors - actionData.error.error contains field-specific errors\n * break;\n * case \"handler\":\n * // Handle business logic errors - actionData.error.error contains your custom error\n * break;\n * case \"unknown\":\n * // Handle unexpected errors\n * break;\n * }\n * }\n * ```\n */\nexport const formAction = <\n\tTSchema extends z.ZodTypeAny,\n\tTResult = undefined,\n\tTError = string,\n\tActionArgs extends ActionFunctionArgs = ActionFunctionArgs,\n>({\n\tschema,\n\thandler,\n}: FormActionConfig<TSchema, TResult, TError, ActionArgs>) => {\n\treturn async (\n\t\targs: ActionArgs,\n\t): Promise<MaybeError<TResult, FormActionError<TError, TSchema>>> => {\n\t\ttry {\n\t\t\tconst contentType = args.request.headers.get(\"Content-Type\");\n\t\t\tconst isJson = contentType?.includes(\"application/json\") ?? false;\n\n\t\t\tconst parseResult = isJson\n\t\t\t\t? await schema.safeParseAsync(await args.request.json())\n\t\t\t\t: await zfd\n\t\t\t\t\t\t.formData(schema)\n\t\t\t\t\t\t.safeParseAsync(await args.request.formData());\n\n\t\t\tif (!parseResult.success) {\n\t\t\t\treturn fail({\n\t\t\t\t\ttype: \"validation\" as const,\n\t\t\t\t\terror: z.treeifyError<z.infer<TSchema>>(\n\t\t\t\t\t\tparseResult.error as z.core.$ZodError<z.infer<TSchema>>,\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst handlerResult = await handler(args, parseResult.data);\n\t\t\tif (!handlerResult.success) {\n\t\t\t\treturn fail({\n\t\t\t\t\ttype: \"handler\" as const,\n\t\t\t\t\terror: handlerResult.error,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn handlerResult;\n\t\t} catch (error) {\n\t\t\t// Re-throw Response objects (redirects, etc.) to preserve React Router behavior\n\t\t\tif (error instanceof Response) {\n\t\t\t\tthrow error;\n\t\t\t}\n\n\t\t\tconsole.error(\"Unexpected error in formAction:\", error);\n\t\t\treturn fail({\n\t\t\t\ttype: \"unknown\" as const,\n\t\t\t});\n\t\t}\n\t};\n};\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/useDynamicFetcher.ts"],"names":[],"mappings":";;;;AAsMO,IAAM,iBAAA,GAAoB,CAChC,IAAA,EAAA,GACG,IAAA,KAoBC;AACJ,EAAA,MAAM,GAAA,GAAM,QAAQ,MAAM;AAEzB,IAAA,OAAO,IAAA,CAAK,IAAA,EAAM,GAAI,IAAY,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,IAAA,EAAM,IAAI,CAAC,CAAA;AAEf,EAAA,MAAM,UAAU,UAAA,CAA4B;AAAA,IAC3C,GAAA,EAAK,WAAW,GAAG,CAAA;AAAA,GACnB,CAAA;AAED,EAAA,MAAM,IAAA,GAAO,WAAA;AAAA,IACZ,CAAC,WAAA,KAAyC;AACzC,MAAA,IAAI,CAAC,WAAA,IAAe,MAAA,CAAO,KAAK,WAAW,CAAA,CAAE,WAAW,CAAA,EAAG;AAC1D,QAAA,OAAO,OAAA,CAAQ,KAAK,GAAG,CAAA;AAAA,MACxB;AAGA,MAAA,MAAM,SAAS,IAAI,GAAA,CAAI,GAAA,EAAK,MAAA,CAAO,SAAS,MAAM,CAAA;AAClD,MAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,WAAW,CAAA,EAAG;AACvD,QAAA,MAAA,CAAO,YAAA,CAAa,GAAA,CAAI,GAAA,EAAK,KAAK,CAAA;AAAA,MACnC;AAEA,MAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAA,CAAO,QAAA,GAAW,OAAO,MAAM,CAAA;AAAA,IACpD,CAAA;AAAA,IACA,CAAC,OAAA,CAAQ,IAAA,EAAM,GAAG;AAAA,GACnB;AAEA,EAAA,OAAO;AAAA,IACN,GAAG,OAAA;AAAA,IACH;AAAA,GACD;AACD","file":"chunk-HX57TC2S.js","sourcesContent":["/**\n * @fileoverview Type-safe dynamic data fetching hook for React Router 7\n *\n * This module provides a hook that creates a type-safe fetcher for loading data\n * from dynamic routes with full TypeScript inference for the loader response and route params.\n *\n * @example\n * ### Route Setup (`app/routes/api.users.$userId.ts`)\n *\n * First, set up your route with the required exports:\n *\n * ```typescript\n * import type { RoutePath } from \"@firtoz/router-toolkit\";\n *\n * // Export the route path for type inference\n * export const route: RoutePath<\"/api/users/:userId\"> = \"/api/users/:userId\";\n *\n * // Define the loader with a typed return value\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * const user = await db.users.findUnique({ where: { id: params.userId } });\n * return {\n * user: {\n * id: user.id,\n * email: user.email,\n * displayName: user.displayName,\n * createdAt: user.createdAt.toISOString(),\n * },\n * };\n * };\n * ```\n *\n * @example\n * ### Using the hook in a component\n *\n * ```tsx\n * import { useDynamicFetcher } from \"@firtoz/router-toolkit\";\n * import { useEffect } from \"react\";\n *\n * function UserProfile({ userId }: { userId: string }) {\n * // Type-safe fetcher with full inference\n * const fetcher = useDynamicFetcher<typeof import(\"./api.users.$userId\")>(\n * \"/api/users/:userId\",\n * { userId }\n * );\n *\n * // Load data on mount\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * // fetcher.data is fully typed: { user: { id, email, displayName, createdAt } } | undefined\n * if (fetcher.state === \"loading\") {\n * return <div>Loading...</div>;\n * }\n *\n * if (!fetcher.data) {\n * return <div>No user found</div>;\n * }\n *\n * return (\n * <div>\n * <h1>{fetcher.data.user.displayName}</h1>\n * <p>{fetcher.data.user.email}</p>\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ### Loading with query parameters\n *\n * ```tsx\n * function SearchResults() {\n * const fetcher = useDynamicFetcher<typeof import(\"./api.search\")>(\"/api/search\");\n *\n * const handleSearch = (query: string, page: number) => {\n * // Pass query params to the load function\n * fetcher.load({ q: query, page: String(page) });\n * };\n *\n * return (\n * <div>\n * <input onChange={(e) => handleSearch(e.target.value, 1)} />\n * {fetcher.data?.results.map((result) => (\n * <div key={result.id}>{result.title}</div>\n * ))}\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ### Combining with useDynamicSubmitter for full CRUD\n *\n * You can use `useDynamicFetcher` alongside `useDynamicSubmitter` to create\n * complete CRUD interfaces with type safety:\n *\n * ```tsx\n * import { useDynamicFetcher, useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * // Fetch post data\n * const fetcher = useDynamicFetcher<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * // Submit updates\n * const submitter = useDynamicSubmitter<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * const handleSave = async (title: string, content: string) => {\n * await submitter.submitJson({ title, content }, { method: \"PUT\" });\n * // Reload after save\n * fetcher.load();\n * };\n *\n * if (!fetcher.data) return <div>Loading...</div>;\n *\n * return (\n * <form onSubmit={(e) => {\n * e.preventDefault();\n * const form = new FormData(e.currentTarget);\n * handleSave(form.get(\"title\") as string, form.get(\"content\") as string);\n * }}>\n * <input name=\"title\" defaultValue={fetcher.data.post.title} />\n * <textarea name=\"content\" defaultValue={fetcher.data.post.content} />\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Saving...\" : \"Save\"}\n * </button>\n * </form>\n * );\n * }\n * ```\n */\n\nimport { useCallback, useMemo } from \"react\";\nimport { href, useFetcher } from \"react-router\";\nimport type { HrefArgs } from \"./types/HrefArgs\";\nimport type { RouteWithLoaderModule } from \"./types/RouteWithLoaderModule\";\n\n/**\n * Creates a type-safe fetcher for loading data from dynamic routes.\n *\n * This hook provides full TypeScript inference for:\n * - Route parameters (from the route path)\n * - Loader response type (from the route's loader export)\n *\n * @template TInfo - The route module type (use `typeof import(\"./route-file\")`)\n *\n * @param path - The route path (must match the route's `route` export)\n * @param args - Route parameters (if the route has dynamic segments like `:id`)\n *\n * @returns An extended fetcher object with:\n * - `load` - Function to load data, optionally with query parameters\n * - `data` - Response data from the loader (typed)\n * - `state` - Fetcher state (\"idle\" | \"loading\" | \"submitting\")\n * - All other useFetcher properties (except `submit`)\n *\n * @example\n * ### Basic usage\n *\n * ```typescript\n * // In your route file (app/routes/api.products.$productId.ts):\n * export const route: RoutePath<\"/api/products/:productId\"> = \"/api/products/:productId\";\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * return { product: await getProduct(params.productId) };\n * };\n *\n * // In your component:\n * const fetcher = useDynamicFetcher<typeof import(\"./api.products.$productId\")>(\n * \"/api/products/:productId\",\n * { productId: \"abc123\" }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * // fetcher.data is typed as { product: Product } | undefined\n * ```\n *\n * @example\n * ### With query parameters\n *\n * ```typescript\n * const fetcher = useDynamicFetcher<typeof import(\"./api.search\")>(\"/api/search\");\n *\n * // Load with query params: /api/search?q=hello&limit=10\n * fetcher.load({ q: \"hello\", limit: \"10\" });\n * ```\n */\nexport const useDynamicFetcher = <TInfo extends RouteWithLoaderModule>(\n\tpath: TInfo[\"route\"],\n\t...args: TInfo[\"route\"] extends \"undefined\"\n\t\t? HrefArgs<\"/\">\n\t\t: HrefArgs<TInfo[\"route\"]>\n): Omit<ReturnType<typeof useFetcher<TInfo[\"loader\"]>>, \"load\" | \"submit\"> & {\n\t/**\n\t * Load data from the route's loader.\n\t *\n\t * @param queryParams - Optional query parameters to append to the URL\n\t * @returns A promise that resolves when the load is complete\n\t *\n\t * @example\n\t * ```typescript\n\t * // Load without query params\n\t * fetcher.load();\n\t *\n\t * // Load with query params\n\t * fetcher.load({ page: \"2\", sort: \"name\" });\n\t * ```\n\t */\n\tload: (queryParams?: Record<string, string>) => Promise<void>;\n} => {\n\tconst url = useMemo(() => {\n\t\t// biome-ignore lint/suspicious/noExplicitAny: Intentional\n\t\treturn href(path, ...(args as any));\n\t}, [path, args]);\n\n\tconst fetcher = useFetcher<TInfo[\"loader\"]>({\n\t\tkey: `fetcher-${url}`,\n\t});\n\n\tconst load = useCallback(\n\t\t(queryParams?: Record<string, string>) => {\n\t\t\tif (!queryParams || Object.keys(queryParams).length === 0) {\n\t\t\t\treturn fetcher.load(url);\n\t\t\t}\n\n\t\t\t// Build URL with query parameters\n\t\t\tconst urlObj = new URL(url, window.location.origin);\n\t\t\tfor (const [key, value] of Object.entries(queryParams)) {\n\t\t\t\turlObj.searchParams.set(key, value);\n\t\t\t}\n\n\t\t\treturn fetcher.load(urlObj.pathname + urlObj.search);\n\t\t},\n\t\t[fetcher.load, url],\n\t);\n\n\treturn {\n\t\t...fetcher,\n\t\tload,\n\t};\n};\n"]}
|