@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/README.md
CHANGED
|
@@ -106,9 +106,15 @@ export default function Dashboard() {
|
|
|
106
106
|
|
|
107
107
|
### 3. Forms with Actions
|
|
108
108
|
|
|
109
|
+
`useDynamicSubmitter` returns a **stable** `{ submit, submitJson, Form, fetcherKey }` and does not expose `state` / `data`. Use `useDynamicSubmitterFetcher(submitter)` when you need reactive loading or action results in JSX (or `submitter.fetcherKey` with `useFetcher` for advanced cases).
|
|
110
|
+
|
|
109
111
|
```tsx
|
|
110
112
|
// app/routes/create-user.tsx
|
|
111
|
-
import {
|
|
113
|
+
import {
|
|
114
|
+
useDynamicSubmitter,
|
|
115
|
+
useDynamicSubmitterFetcher,
|
|
116
|
+
type RoutePath,
|
|
117
|
+
} from '@firtoz/router-toolkit';
|
|
112
118
|
|
|
113
119
|
export const route: RoutePath<"/create-user"> = "/create-user";
|
|
114
120
|
|
|
@@ -119,15 +125,17 @@ export async function action({ request }) {
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
export default function CreateUser() {
|
|
122
|
-
const
|
|
123
|
-
|
|
128
|
+
const path = "/create-user" as const;
|
|
129
|
+
const submitter = useDynamicSubmitter<typeof import("./create-user")>(path);
|
|
130
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
131
|
+
|
|
124
132
|
return (
|
|
125
133
|
<submitter.Form method="post">
|
|
126
134
|
<input name="name" placeholder="User name" required />
|
|
127
135
|
<button type="submit">
|
|
128
|
-
{
|
|
136
|
+
{fetcher.state === "submitting" ? "Creating..." : "Create"}
|
|
129
137
|
</button>
|
|
130
|
-
{
|
|
138
|
+
{fetcher.data?.success && <p>✅ User created!</p>}
|
|
131
139
|
</submitter.Form>
|
|
132
140
|
);
|
|
133
141
|
}
|
|
@@ -136,8 +144,8 @@ export default function CreateUser() {
|
|
|
136
144
|
**Key Points:**
|
|
137
145
|
- Export `route: RoutePath<"your-path">` in every route file
|
|
138
146
|
- Use `useDynamicFetcher<typeof import("./route-file")>` for type-safe data fetching
|
|
139
|
-
- Use `useDynamicSubmitter<typeof import("./route-file")>` for type-safe
|
|
140
|
-
-
|
|
147
|
+
- Use `useDynamicSubmitter<typeof import("./route-file")>` for type-safe `submit` / `submitJson` / `Form`; use `useDynamicSubmitterFetcher(submitter)` for reactive `state` / `data` on the same submission (optional `keySuffix` when two submitters share one URL)
|
|
148
|
+
- Await `submit` / `submitJson` for programmatic flows; full TypeScript inference on the resolved payload
|
|
141
149
|
|
|
142
150
|
> **💡 Tip**: Start with `useDynamicFetcher` for data loading, then add `useDynamicSubmitter` for forms. The `useFetcherStateChanged` hook is great for notifications and side effects.
|
|
143
151
|
|
|
@@ -196,11 +204,15 @@ export default function UsersPage() {
|
|
|
196
204
|
|
|
197
205
|
### `useDynamicSubmitter`
|
|
198
206
|
|
|
199
|
-
Type-safe
|
|
207
|
+
Type-safe `submit`, `submitJson`, and `Form` with a **stable** return object (`fetcherKey` included). Use `await submitJson(...)` (or `await submit(...)`) for action payloads; use `useDynamicSubmitterFetcher(submitter)` when you need `fetcher.state` / `fetcher.data` in the UI.
|
|
200
208
|
|
|
201
209
|
```tsx
|
|
202
210
|
// app/routes/contact.tsx
|
|
203
|
-
import {
|
|
211
|
+
import {
|
|
212
|
+
useDynamicSubmitter,
|
|
213
|
+
useDynamicSubmitterFetcher,
|
|
214
|
+
type RoutePath,
|
|
215
|
+
} from '@firtoz/router-toolkit';
|
|
204
216
|
import { z } from 'zod';
|
|
205
217
|
import type { Route } from './+types/contact';
|
|
206
218
|
|
|
@@ -234,9 +246,11 @@ export async function action({ request }: Route.ActionArgs) {
|
|
|
234
246
|
};
|
|
235
247
|
}
|
|
236
248
|
|
|
237
|
-
// 4.
|
|
249
|
+
// 4. Submitter + matching fetcher for reactive UI
|
|
238
250
|
export default function ContactForm() {
|
|
239
|
-
const
|
|
251
|
+
const path = "/contact" as const;
|
|
252
|
+
const submitter = useDynamicSubmitter<typeof import("./contact")>(path);
|
|
253
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
240
254
|
|
|
241
255
|
return (
|
|
242
256
|
<div>
|
|
@@ -263,18 +277,18 @@ export default function ContactForm() {
|
|
|
263
277
|
|
|
264
278
|
<button
|
|
265
279
|
type="submit"
|
|
266
|
-
disabled={
|
|
280
|
+
disabled={fetcher.state === "submitting"}
|
|
267
281
|
>
|
|
268
|
-
{
|
|
282
|
+
{fetcher.state === "submitting" ? "Submitting..." : "Submit"}
|
|
269
283
|
</button>
|
|
270
284
|
</submitter.Form>
|
|
271
285
|
|
|
272
|
-
{
|
|
286
|
+
{fetcher.data && (
|
|
273
287
|
<div>
|
|
274
|
-
{
|
|
275
|
-
<p>✅ {
|
|
288
|
+
{fetcher.data.success ? (
|
|
289
|
+
<p>✅ {fetcher.data.message}</p>
|
|
276
290
|
) : (
|
|
277
|
-
<p>❌ {
|
|
291
|
+
<p>❌ {fetcher.data.message}</p>
|
|
278
292
|
)}
|
|
279
293
|
</div>
|
|
280
294
|
)}
|
|
@@ -283,6 +297,22 @@ export default function ContactForm() {
|
|
|
283
297
|
}
|
|
284
298
|
```
|
|
285
299
|
|
|
300
|
+
#### Local `useState` vs `useDynamicSubmitterFetcher`
|
|
301
|
+
|
|
302
|
+
Both are valid; pick based on how you want loading and action feedback to show up.
|
|
303
|
+
|
|
304
|
+
**Local state around `await` (e.g. `saving` + `try` / `finally`)**
|
|
305
|
+
|
|
306
|
+
- **Pros:** Matches the promise-first API; no extra hook; the pending flag tracks exactly your async handler (including extra `await`s in the same function); easy to reason about when you reload data after save (e.g. `useDynamicFetcher.load()`).
|
|
307
|
+
- **Cons:** You must remember `finally` (or equivalent); one flag is awkward if several overlapping operations need distinct UX; you do not get declarative `fetcher.data` for the action unless you store the awaited value yourself.
|
|
308
|
+
|
|
309
|
+
**`useDynamicSubmitterFetcher(submitter)`**
|
|
310
|
+
|
|
311
|
+
- **Pros:** Declarative `fetcher.state` / `fetcher.data` / `fetcher.error` in JSX—good for `<submitter.Form>`, inline validation or handler errors, and staying aligned with React Router’s fetcher lifecycle on `submitter.fetcherKey`.
|
|
312
|
+
- **Cons:** Second `useFetcher` subscription; UI follows RR’s state machine, not only “my handler,” unless you isolate keys with `keySuffix` when two widgets share one URL.
|
|
313
|
+
|
|
314
|
+
**Rule of thumb:** Use **local pending state** when the flow is “run this async function, disable until it finishes.” Use **`useDynamicSubmitterFetcher`** when you want **reactive** fetcher fields in render without mirroring them into state.
|
|
315
|
+
|
|
286
316
|
### `ConcurrentSubmitterProvider` + `useConcurrentSubmitter`
|
|
287
317
|
|
|
288
318
|
Run multiple submissions in parallel via the framework fetcher; each is tracked in `operations` with `submittedData` (for optimistic UI) and `data` when done. Wrap your app (or subtree) with `ConcurrentSubmitterProvider`, then use `useConcurrentSubmitter<TInfo>()` for typed `submitJson` / `submitFormData` with path and args per call.
|
|
@@ -345,7 +375,12 @@ Track changes in fetcher state and react to them. Perfect for triggering side ef
|
|
|
345
375
|
|
|
346
376
|
```tsx
|
|
347
377
|
// app/routes/notification-form.tsx
|
|
348
|
-
import {
|
|
378
|
+
import {
|
|
379
|
+
useDynamicSubmitter,
|
|
380
|
+
useDynamicSubmitterFetcher,
|
|
381
|
+
useFetcherStateChanged,
|
|
382
|
+
type RoutePath,
|
|
383
|
+
} from '@firtoz/router-toolkit';
|
|
349
384
|
import { useState } from 'react';
|
|
350
385
|
import { z } from 'zod';
|
|
351
386
|
import type { Route } from './+types/notification-form';
|
|
@@ -373,17 +408,19 @@ export async function action({ request }: Route.ActionArgs) {
|
|
|
373
408
|
}
|
|
374
409
|
|
|
375
410
|
export default function NotificationForm() {
|
|
376
|
-
const
|
|
411
|
+
const path = "/notification-form" as const;
|
|
412
|
+
const submitter = useDynamicSubmitter<typeof import("./notification-form")>(path);
|
|
413
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
377
414
|
const [notifications, setNotifications] = useState<string[]>([]);
|
|
378
415
|
|
|
379
|
-
// Track fetcher state changes for side effects
|
|
380
|
-
useFetcherStateChanged(
|
|
416
|
+
// Track fetcher state changes for side effects (pass the parallel fetcher, not submitter)
|
|
417
|
+
useFetcherStateChanged(fetcher, (lastState, newState) => {
|
|
381
418
|
console.log(`Fetcher state changed from ${lastState} to ${newState}`);
|
|
382
419
|
|
|
383
420
|
// Show success notification when form submission completes
|
|
384
421
|
if (newState === 'idle' && lastState === 'submitting') {
|
|
385
|
-
if (
|
|
386
|
-
setNotifications(prev => [...prev, `✅ ${
|
|
422
|
+
if (fetcher.data?.success) {
|
|
423
|
+
setNotifications(prev => [...prev, `✅ ${fetcher.data.message}`]);
|
|
387
424
|
} else {
|
|
388
425
|
setNotifications(prev => [...prev, `❌ Submission failed`]);
|
|
389
426
|
}
|
|
@@ -421,12 +458,12 @@ export default function NotificationForm() {
|
|
|
421
458
|
|
|
422
459
|
<button
|
|
423
460
|
type="submit"
|
|
424
|
-
disabled={
|
|
461
|
+
disabled={fetcher.state === 'submitting'}
|
|
425
462
|
>
|
|
426
|
-
{
|
|
463
|
+
{fetcher.state === 'submitting' ? 'Sending...' : 'Send Notification'}
|
|
427
464
|
</button>
|
|
428
465
|
|
|
429
|
-
<p>Current state: <strong>{
|
|
466
|
+
<p>Current state: <strong>{fetcher.state}</strong></p>
|
|
430
467
|
</submitter.Form>
|
|
431
468
|
|
|
432
469
|
{/* Show notifications triggered by state changes */}
|
|
@@ -520,18 +557,23 @@ The `formAction` utility works seamlessly with `useDynamicSubmitter` when you ex
|
|
|
520
557
|
|
|
521
558
|
```tsx
|
|
522
559
|
// app/routes/register.tsx (component)
|
|
523
|
-
import {
|
|
560
|
+
import {
|
|
561
|
+
useDynamicSubmitter,
|
|
562
|
+
useDynamicSubmitterFetcher,
|
|
563
|
+
} from "@firtoz/router-toolkit";
|
|
524
564
|
|
|
525
565
|
export default function Register() {
|
|
526
|
-
const
|
|
566
|
+
const path = "/register" as const;
|
|
567
|
+
const submitter = useDynamicSubmitter<typeof import("./register")>(path);
|
|
568
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
527
569
|
|
|
528
570
|
return (
|
|
529
571
|
<submitter.Form method="post">
|
|
530
572
|
<input name="email" type="email" required />
|
|
531
573
|
<input name="password" type="password" required />
|
|
532
574
|
<input name="confirmPassword" type="password" required />
|
|
533
|
-
<button type="submit" disabled={
|
|
534
|
-
{
|
|
575
|
+
<button type="submit" disabled={fetcher.state === "submitting"}>
|
|
576
|
+
{fetcher.state === "submitting" ? "Registering..." : "Register"}
|
|
535
577
|
</button>
|
|
536
578
|
</submitter.Form>
|
|
537
579
|
);
|
|
@@ -540,26 +582,25 @@ export default function Register() {
|
|
|
540
582
|
|
|
541
583
|
#### Error Handling
|
|
542
584
|
|
|
543
|
-
The `formAction` utility returns structured errors
|
|
585
|
+
The `formAction` utility returns structured errors. Inspect `fetcher.data` from `useDynamicSubmitterFetcher(submitter)`, or `await submitter.submitJson(...)` in an async handler:
|
|
544
586
|
|
|
545
587
|
```tsx
|
|
546
588
|
export default function Register() {
|
|
547
|
-
const
|
|
589
|
+
const path = "/register" as const;
|
|
590
|
+
const submitter = useDynamicSubmitter<typeof import("./register")>(path);
|
|
591
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
592
|
+
|
|
593
|
+
if (fetcher.data && !fetcher.data.success) {
|
|
594
|
+
const error = fetcher.data.error;
|
|
548
595
|
|
|
549
|
-
if (submitter.data && !submitter.data.success) {
|
|
550
|
-
const error = submitter.data.error;
|
|
551
|
-
|
|
552
596
|
switch (error.type) {
|
|
553
597
|
case "validation":
|
|
554
|
-
// Handle Zod validation errors
|
|
555
598
|
console.log("Validation errors:", error.error);
|
|
556
599
|
break;
|
|
557
600
|
case "handler":
|
|
558
|
-
// Handle business logic errors
|
|
559
601
|
console.log("Handler error:", error.error);
|
|
560
602
|
break;
|
|
561
603
|
case "unknown":
|
|
562
|
-
// Handle unexpected errors
|
|
563
604
|
console.log("Unknown error occurred");
|
|
564
605
|
break;
|
|
565
606
|
}
|
|
@@ -843,7 +884,11 @@ export default function LoaderTest() {
|
|
|
843
884
|
|
|
844
885
|
```tsx
|
|
845
886
|
// app/routes/action-test.tsx
|
|
846
|
-
import {
|
|
887
|
+
import {
|
|
888
|
+
useDynamicSubmitter,
|
|
889
|
+
useDynamicSubmitterFetcher,
|
|
890
|
+
type RoutePath,
|
|
891
|
+
} from '@firtoz/router-toolkit';
|
|
847
892
|
import { z } from 'zod';
|
|
848
893
|
import type { Route } from './+types/action-test';
|
|
849
894
|
|
|
@@ -887,7 +932,9 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
|
|
887
932
|
}
|
|
888
933
|
|
|
889
934
|
export default function ActionTest() {
|
|
890
|
-
const
|
|
935
|
+
const path = "/action-test" as const;
|
|
936
|
+
const submitter = useDynamicSubmitter<typeof import("./action-test")>(path);
|
|
937
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
891
938
|
|
|
892
939
|
return (
|
|
893
940
|
<div className="p-6">
|
|
@@ -923,27 +970,27 @@ export default function ActionTest() {
|
|
|
923
970
|
|
|
924
971
|
<button
|
|
925
972
|
type="submit"
|
|
926
|
-
disabled={
|
|
973
|
+
disabled={fetcher.state === "submitting"}
|
|
927
974
|
className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
|
|
928
975
|
>
|
|
929
|
-
{
|
|
976
|
+
{fetcher.state === "submitting" ? "Submitting..." : "Submit"}
|
|
930
977
|
</button>
|
|
931
978
|
</submitter.Form>
|
|
932
979
|
|
|
933
|
-
{
|
|
980
|
+
{fetcher.data && (
|
|
934
981
|
<div className="mt-6">
|
|
935
982
|
<h2 className="text-lg font-semibold mb-2">Action Result:</h2>
|
|
936
983
|
<pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
|
|
937
|
-
{JSON.stringify(
|
|
984
|
+
{JSON.stringify(fetcher.data, null, 2)}
|
|
938
985
|
</pre>
|
|
939
986
|
|
|
940
|
-
{
|
|
987
|
+
{fetcher.data.success ? (
|
|
941
988
|
<div className="mt-4 p-3 bg-green-100 rounded">
|
|
942
|
-
<p className="text-green-800">✅ {
|
|
989
|
+
<p className="text-green-800">✅ {fetcher.data.message}</p>
|
|
943
990
|
</div>
|
|
944
991
|
) : (
|
|
945
992
|
<div className="mt-4 p-3 bg-red-100 rounded">
|
|
946
|
-
<p className="text-red-800">❌ {
|
|
993
|
+
<p className="text-red-800">❌ {fetcher.data.message}</p>
|
|
947
994
|
</div>
|
|
948
995
|
)}
|
|
949
996
|
</div>
|
|
@@ -958,8 +1005,8 @@ export default function ActionTest() {
|
|
|
958
1005
|
```tsx
|
|
959
1006
|
// app/routes/combined-test.tsx
|
|
960
1007
|
import {
|
|
961
|
-
useDynamicFetcher,
|
|
962
1008
|
useDynamicSubmitter,
|
|
1009
|
+
useDynamicSubmitterFetcher,
|
|
963
1010
|
type RoutePath,
|
|
964
1011
|
} from '@firtoz/router-toolkit';
|
|
965
1012
|
import { useLoaderData } from 'react-router';
|
|
@@ -1033,8 +1080,9 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
|
|
1033
1080
|
|
|
1034
1081
|
export default function CombinedTest() {
|
|
1035
1082
|
const loaderData = useLoaderData<LoaderData>();
|
|
1036
|
-
const
|
|
1037
|
-
const submitter = useDynamicSubmitter<typeof import("./combined-test")>(
|
|
1083
|
+
const path = "/combined-test" as const;
|
|
1084
|
+
const submitter = useDynamicSubmitter<typeof import("./combined-test")>(path);
|
|
1085
|
+
const actionFetcher = useDynamicSubmitterFetcher(submitter);
|
|
1038
1086
|
|
|
1039
1087
|
return (
|
|
1040
1088
|
<div className="p-6">
|
|
@@ -1087,10 +1135,10 @@ export default function CombinedTest() {
|
|
|
1087
1135
|
|
|
1088
1136
|
<button
|
|
1089
1137
|
type="submit"
|
|
1090
|
-
disabled={
|
|
1138
|
+
disabled={actionFetcher.state === "submitting"}
|
|
1091
1139
|
className="bg-purple-500 text-white px-4 py-2 rounded disabled:opacity-50"
|
|
1092
1140
|
>
|
|
1093
|
-
{
|
|
1141
|
+
{actionFetcher.state === "submitting" ? "Updating..." : "Update User"}
|
|
1094
1142
|
</button>
|
|
1095
1143
|
</submitter.Form>
|
|
1096
1144
|
</div>
|
|
@@ -1100,21 +1148,21 @@ export default function CombinedTest() {
|
|
|
1100
1148
|
<div className="mt-6">
|
|
1101
1149
|
<h2 className="text-lg font-semibold mb-2">Action Status:</h2>
|
|
1102
1150
|
<pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
|
|
1103
|
-
{JSON.stringify({ state:
|
|
1151
|
+
{JSON.stringify({ state: actionFetcher.state }, null, 2)}
|
|
1104
1152
|
</pre>
|
|
1105
1153
|
</div>
|
|
1106
1154
|
|
|
1107
|
-
{
|
|
1155
|
+
{actionFetcher.data && (
|
|
1108
1156
|
<div className="mt-6">
|
|
1109
1157
|
<h2 className="text-lg font-semibold mb-2">Action Result:</h2>
|
|
1110
1158
|
<pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
|
|
1111
|
-
{JSON.stringify(
|
|
1159
|
+
{JSON.stringify(actionFetcher.data, null, 2)}
|
|
1112
1160
|
</pre>
|
|
1113
1161
|
|
|
1114
|
-
{
|
|
1162
|
+
{actionFetcher.data.success ? (
|
|
1115
1163
|
<div className="mt-4 p-3 bg-green-100 rounded">
|
|
1116
|
-
<p className="text-green-800">✅ {
|
|
1117
|
-
{
|
|
1164
|
+
<p className="text-green-800">✅ {actionFetcher.data.message}</p>
|
|
1165
|
+
{actionFetcher.data.updatedUser && (
|
|
1118
1166
|
<p className="text-sm text-green-700 mt-1">
|
|
1119
1167
|
Tip: Reload the page to see if data persists (it won't in this demo)
|
|
1120
1168
|
</p>
|
|
@@ -1122,7 +1170,7 @@ export default function CombinedTest() {
|
|
|
1122
1170
|
</div>
|
|
1123
1171
|
) : (
|
|
1124
1172
|
<div className="mt-4 p-3 bg-red-100 rounded">
|
|
1125
|
-
<p className="text-red-800">❌ {
|
|
1173
|
+
<p className="text-red-800">❌ {actionFetcher.data.message}</p>
|
|
1126
1174
|
</div>
|
|
1127
1175
|
)}
|
|
1128
1176
|
</div>
|
|
@@ -1134,12 +1182,12 @@ export default function CombinedTest() {
|
|
|
1134
1182
|
|
|
1135
1183
|
## MaybeError Utility
|
|
1136
1184
|
|
|
1137
|
-
|
|
1185
|
+
`@firtoz/maybe-error` is a **dependency** of router-toolkit (it is installed with this package), but it is **not** re-exported from `@firtoz/router-toolkit`. Import `success`, `fail`, `MaybeError`, `exhaustiveGuard`, and related symbols **from `@firtoz/maybe-error`** so your imports match runtime and dependency boundaries stay clear.
|
|
1138
1186
|
|
|
1139
1187
|
### Basic Usage
|
|
1140
1188
|
|
|
1141
1189
|
```tsx
|
|
1142
|
-
import { success, fail, type MaybeError } from '@firtoz/
|
|
1190
|
+
import { success, fail, type MaybeError } from '@firtoz/maybe-error';
|
|
1143
1191
|
|
|
1144
1192
|
// Define a function that may fail
|
|
1145
1193
|
function divide(a: number, b: number): MaybeError<number> {
|
|
@@ -1162,7 +1210,8 @@ if (result.success) {
|
|
|
1162
1210
|
|
|
1163
1211
|
```tsx
|
|
1164
1212
|
// app/routes/user-profile.tsx
|
|
1165
|
-
import { success, fail, type MaybeError
|
|
1213
|
+
import { success, fail, type MaybeError } from '@firtoz/maybe-error';
|
|
1214
|
+
import { type RoutePath } from '@firtoz/router-toolkit';
|
|
1166
1215
|
import type { Route } from './+types/user-profile';
|
|
1167
1216
|
|
|
1168
1217
|
interface User {
|
|
@@ -1230,7 +1279,12 @@ export default function UserProfile() {
|
|
|
1230
1279
|
|
|
1231
1280
|
```tsx
|
|
1232
1281
|
// app/routes/create-user.tsx
|
|
1233
|
-
import { success, fail, type MaybeError
|
|
1282
|
+
import { success, fail, type MaybeError } from '@firtoz/maybe-error';
|
|
1283
|
+
import {
|
|
1284
|
+
useDynamicSubmitter,
|
|
1285
|
+
useDynamicSubmitterFetcher,
|
|
1286
|
+
type RoutePath,
|
|
1287
|
+
} from '@firtoz/router-toolkit';
|
|
1234
1288
|
import { z } from 'zod';
|
|
1235
1289
|
import type { Route } from './+types/create-user';
|
|
1236
1290
|
|
|
@@ -1280,7 +1334,9 @@ export async function action({ request }: Route.ActionArgs): Promise<MaybeError<
|
|
|
1280
1334
|
}
|
|
1281
1335
|
|
|
1282
1336
|
export default function CreateUser() {
|
|
1283
|
-
const
|
|
1337
|
+
const path = "/create-user" as const;
|
|
1338
|
+
const submitter = useDynamicSubmitter<typeof import("./create-user")>(path);
|
|
1339
|
+
const fetcher = useDynamicSubmitterFetcher(submitter);
|
|
1284
1340
|
|
|
1285
1341
|
return (
|
|
1286
1342
|
<div>
|
|
@@ -1297,24 +1353,24 @@ export default function CreateUser() {
|
|
|
1297
1353
|
<input id="email" name="email" type="email" required />
|
|
1298
1354
|
</div>
|
|
1299
1355
|
|
|
1300
|
-
<button type="submit" disabled={
|
|
1301
|
-
{
|
|
1356
|
+
<button type="submit" disabled={fetcher.state === "submitting"}>
|
|
1357
|
+
{fetcher.state === "submitting" ? "Creating..." : "Create User"}
|
|
1302
1358
|
</button>
|
|
1303
1359
|
</submitter.Form>
|
|
1304
1360
|
|
|
1305
|
-
{
|
|
1361
|
+
{fetcher.data && (
|
|
1306
1362
|
<div>
|
|
1307
|
-
{
|
|
1363
|
+
{fetcher.data.success ? (
|
|
1308
1364
|
<div className="success">
|
|
1309
1365
|
<h3>User Created!</h3>
|
|
1310
|
-
<p>Name: {
|
|
1311
|
-
<p>Email: {
|
|
1366
|
+
<p>Name: {fetcher.data.result.name}</p>
|
|
1367
|
+
<p>Email: {fetcher.data.result.email}</p>
|
|
1312
1368
|
</div>
|
|
1313
1369
|
) : (
|
|
1314
1370
|
<div className="errors">
|
|
1315
1371
|
<h3>Validation Errors:</h3>
|
|
1316
1372
|
<ul>
|
|
1317
|
-
{
|
|
1373
|
+
{fetcher.data.error.map((error, index) => (
|
|
1318
1374
|
<li key={index}>
|
|
1319
1375
|
<strong>{error.field}:</strong> {error.message}
|
|
1320
1376
|
</li>
|
|
@@ -29,5 +29,5 @@ var useDynamicFetcher = (path, ...args) => {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export { useDynamicFetcher };
|
|
32
|
-
//# sourceMappingURL=chunk-
|
|
33
|
-
//# sourceMappingURL=chunk-
|
|
32
|
+
//# sourceMappingURL=chunk-F4324Q33.js.map
|
|
33
|
+
//# sourceMappingURL=chunk-F4324Q33.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useDynamicFetcher.ts"],"names":[],"mappings":";;;;AAgNO,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-F4324Q33.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 * import { useEffect, useState } from \"react\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * const fetcher = useDynamicFetcher<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * const submitter = useDynamicSubmitter<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * const [saving, setSaving] = useState(false);\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * const handleSave = async (title: string, content: string) => {\n * setSaving(true);\n * try {\n * await submitter.submitJson({ title, content }, { method: \"PUT\" });\n * fetcher.load();\n * } finally {\n * setSaving(false);\n * }\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 * void 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 type=\"submit\" disabled={saving}>\n * {saving ? \"Saving...\" : \"Save\"}\n * </button>\n * </form>\n * );\n * }\n * ```\n *\n * **Submitter UX:** Local `saving` around `await submitter.submitJson` fits a promise-first save +\n * reload flow. For declarative `fetcher.state` / `fetcher.data` in JSX (e.g. with `submitter.Form`),\n * use {@link useDynamicSubmitterFetcher} instead. The package README documents trade-offs under\n * **useDynamicSubmitter** (heading “Local useState vs useDynamicSubmitterFetcher”).\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"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-SBJFTOWW.js"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/formAction.ts"],"names":[],"mappings":";;;;;AAuUO,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-UF5QHE5K.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 * The hook exposes {@link UseDynamicSubmitterResult.fetcherKey} (built with\n * {@link dynamicSubmitterFetcherKey}) so a parallel `useFetcher` stays aligned; prefer\n * {@link useDynamicSubmitterFetcher} instead of hand-rolling the key.\n *\n * The **optional** {@link useDynamicSubmitterFetcher} below is only for declarative UI that reads\n * `fetcher.state` / `fetcher.data` in render (same submission as `submitter`). For promise-first\n * flows, omit it and use `await submitter.submitJson(...)` plus local `useState` for pending.\n * Use {@link UseDynamicSubmitterOptions.keySuffix} when two submitters target the same URL and\n * must not share fetcher state.\n *\n * ```tsx\n * import {\n * useDynamicSubmitter,\n * useDynamicSubmitterFetcher,\n * } from \"@firtoz/router-toolkit\";\n *\n * function LoginForm() {\n * const submitter = useDynamicSubmitter<typeof import(\"./auth.login\")>(\"/auth/login\");\n * const fetcher = useDynamicSubmitterFetcher(submitter);\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: Form + useDynamicSubmitterFetcher for reactive state/data\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 type=\"submit\" disabled={fetcher.state === \"submitting\"}>\n * {fetcher.state === \"submitting\" ? \"Logging in...\" : \"Login\"}\n * </button>\n *\n * {fetcher.data && !fetcher.data.success && (\n * <div className=\"error\">\n * {fetcher.data.error.type === \"validation\"\n * ? \"Please check your inputs\"\n * : fetcher.data.error.type === \"handler\"\n * ? fetcher.data.error.error\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, useState } from \"react\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * const fetcher = useDynamicFetcher<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * const submitter = useDynamicSubmitter<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * const [saving, setSaving] = useState(false);\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\n * method=\"PUT\"\n * onSubmit={async (e) => {\n * e.preventDefault();\n * setSaving(true);\n * try {\n * const fd = new FormData(e.currentTarget);\n * await submitter.submit(fd, { method: \"PUT\" });\n * fetcher.load();\n * } finally {\n * setSaving(false);\n * }\n * }}\n * >\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 type=\"submit\" disabled={saving}>\n * {saving ? \"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"]}
|