@firtoz/router-toolkit 5.5.2 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/package.json +1 -1
- package/src/ConcurrentSubmitterProvider.tsx +329 -0
- package/src/index.ts +2 -2
- package/src/types/RouteWithActionModule.ts +18 -0
- package/src/types/index.ts +1 -0
- package/src/useConcurrentSubmitter.tsx +86 -0
- package/src/useDynamicSubmitter.tsx +4 -19
- package/src/useConcurrentDynamicSubmitter.tsx +0 -264
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Type-safe React Router 7 framework mode helpers with enhanced fetching, form sub
|
|
|
13
13
|
- ✅ **Type-safe routing** - Full TypeScript support with React Router 7 framework mode
|
|
14
14
|
- 🚀 **Enhanced fetching** - Dynamic fetchers with caching and query parameter support
|
|
15
15
|
- 📝 **Form submission** - Type-safe form handling with Zod validation
|
|
16
|
-
- 📤 **Concurrent submissions** - Multiple parallel submissions per action with per-operation tracking and optimistic UI (`
|
|
16
|
+
- 📤 **Concurrent submissions** - Multiple parallel submissions per action with per-operation tracking and optimistic UI (`ConcurrentSubmitterProvider` + `useConcurrentSubmitter`)
|
|
17
17
|
- 🔄 **State tracking** - Monitor fetcher state changes with ease
|
|
18
18
|
- 🎯 **Zero configuration** - Works out of the box with React Router 7
|
|
19
19
|
- 📦 **Tree-shakeable** - Import only what you need
|
|
@@ -279,25 +279,37 @@ export default function ContactForm() {
|
|
|
279
279
|
}
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
### `
|
|
282
|
+
### `ConcurrentSubmitterProvider` + `useConcurrentSubmitter`
|
|
283
283
|
|
|
284
|
-
Run multiple submissions
|
|
284
|
+
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.
|
|
285
285
|
|
|
286
|
-
- **`submitJson(data)`** — POST JSON
|
|
287
|
-
- **`submitFormData(formData, submittedData?)`** — POST multipart/form-data
|
|
286
|
+
- **`submitJson(path, args, data, options?)`** — POST JSON to the given route; path/args are per call.
|
|
287
|
+
- **`submitFormData(path, args, formData, submittedData?, options?)`** — POST multipart/form-data. Optional `submittedData` is a serializable object for the operations list (e.g. `{ type: "upload", label: "photo.jpg" }`); FormData/File are not stored in state.
|
|
288
288
|
|
|
289
289
|
```tsx
|
|
290
|
-
|
|
290
|
+
// Root (e.g. root.tsx)
|
|
291
|
+
import { ConcurrentSubmitterProvider } from '@firtoz/router-toolkit';
|
|
292
|
+
|
|
293
|
+
export default function App() {
|
|
294
|
+
return (
|
|
295
|
+
<ConcurrentSubmitterProvider>
|
|
296
|
+
<Outlet />
|
|
297
|
+
</ConcurrentSubmitterProvider>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Any route or component
|
|
302
|
+
import { useConcurrentSubmitter } from '@firtoz/router-toolkit';
|
|
291
303
|
|
|
292
304
|
function UploadList() {
|
|
293
|
-
const { operations, submitFormData } =
|
|
305
|
+
const { operations, submitFormData } = useConcurrentSubmitter<
|
|
294
306
|
typeof import("./api.upload")
|
|
295
|
-
>(
|
|
307
|
+
>();
|
|
296
308
|
|
|
297
309
|
const handleUpload = (file: File) => {
|
|
298
310
|
const fd = new FormData();
|
|
299
311
|
fd.set("file", file);
|
|
300
|
-
submitFormData(fd, { type: "upload", label: file.name });
|
|
312
|
+
submitFormData("/api/upload", undefined, fd, { type: "upload", label: file.name });
|
|
301
313
|
};
|
|
302
314
|
|
|
303
315
|
return (
|
|
@@ -321,8 +333,7 @@ function UploadList() {
|
|
|
321
333
|
```
|
|
322
334
|
|
|
323
335
|
- **`operations`**: `Record<string, Operation<T>>` — each operation has `id`, `status` (`"pending"` | `"done"` | `"error"`), `submittedData` (payload or display object), and when done `data` (action response).
|
|
324
|
-
- **`submitJson(data)`**: returns `{ id, promise }`.
|
|
325
|
-
- **`submitFormData(formData, submittedData?)`**: returns `{ id, promise }`; `submittedData` defaults to `{}` and is used only for display in the operations list.
|
|
336
|
+
- **`submitJson(path, args, data, options?)`** / **`submitFormData(path, args, formData, submittedData?, options?)`**: each returns `{ id, promise }`. `submittedData` defaults to `{}` and is used only for display in the operations list.
|
|
326
337
|
|
|
327
338
|
### `useFetcherStateChanged`
|
|
328
339
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Global provider for concurrent form submissions via React Router fetchers.
|
|
3
|
+
*
|
|
4
|
+
* Mounts components that use useFetcher with unique keys so each submission goes through
|
|
5
|
+
* the framework (correct .data URL and turbo-stream decoding). Path/args are per submission.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Root:
|
|
10
|
+
* <ConcurrentSubmitterProvider>
|
|
11
|
+
* <Outlet />
|
|
12
|
+
* </ConcurrentSubmitterProvider>
|
|
13
|
+
*
|
|
14
|
+
* // Anywhere: use useConcurrentSubmitter() from "./useConcurrentSubmitter"
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React, {
|
|
19
|
+
useCallback,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
createContext,
|
|
24
|
+
} from "react";
|
|
25
|
+
import { href, useFetcher } from "react-router";
|
|
26
|
+
import type { SubmitTarget } from "react-router";
|
|
27
|
+
|
|
28
|
+
/** Status of a single concurrent submission */
|
|
29
|
+
export type OperationStatus = "pending" | "done" | "error";
|
|
30
|
+
|
|
31
|
+
/** One tracked submission: pending → done (or error). Includes submitted payload for optimistic UI. */
|
|
32
|
+
export type Operation<TResponse = unknown, TFormData = unknown> = {
|
|
33
|
+
id: string;
|
|
34
|
+
status: OperationStatus;
|
|
35
|
+
/** Data that was sent (for optimistic display while pending, or to show what was submitted) */
|
|
36
|
+
submittedData: TFormData;
|
|
37
|
+
/** Response from the action when status is "done" */
|
|
38
|
+
data?: TResponse;
|
|
39
|
+
error?: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Result of submitJson / submitFormData: id to look up in operations, promise to await */
|
|
43
|
+
export type SubmitJsonResult<T> = {
|
|
44
|
+
id: string;
|
|
45
|
+
promise: Promise<T>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Optional serializable payload for FormData submissions (for display in operations list). */
|
|
49
|
+
export type FormDataSubmittedData = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
export type SubmitJsonOptions = {
|
|
52
|
+
method?: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SubmitFormDataOptions = {
|
|
56
|
+
headers?: HeadersInit;
|
|
57
|
+
method?: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type PendingJson = {
|
|
61
|
+
kind: "json";
|
|
62
|
+
actionUrl: string;
|
|
63
|
+
method: string;
|
|
64
|
+
encType: "application/json";
|
|
65
|
+
data: unknown;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type PendingForm = {
|
|
69
|
+
kind: "form";
|
|
70
|
+
actionUrl: string;
|
|
71
|
+
method: string;
|
|
72
|
+
encType: "multipart/form-data";
|
|
73
|
+
formData: FormData;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type PendingSubmit = PendingJson | PendingForm;
|
|
77
|
+
|
|
78
|
+
type OperationState = Operation<unknown, unknown> & {
|
|
79
|
+
resolve: (value: unknown) => void;
|
|
80
|
+
reject: (error: unknown) => void;
|
|
81
|
+
pendingSubmit?: PendingSubmit;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function buildActionUrl(path: string, args?: Record<string, string>): string {
|
|
85
|
+
// biome-ignore lint/suspicious/noExplicitAny: path is dynamic from caller
|
|
86
|
+
return args ? href(path as any, args as any) : (href(path as any) as string);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type ContextValue = {
|
|
90
|
+
operations: Record<string, Operation<unknown, unknown>>;
|
|
91
|
+
addJsonSubmission: (
|
|
92
|
+
path: string,
|
|
93
|
+
args: Record<string, string> | undefined,
|
|
94
|
+
data: unknown,
|
|
95
|
+
options?: SubmitJsonOptions,
|
|
96
|
+
) => SubmitJsonResult<unknown>;
|
|
97
|
+
addFormSubmission: (
|
|
98
|
+
path: string,
|
|
99
|
+
args: Record<string, string> | undefined,
|
|
100
|
+
formData: FormData,
|
|
101
|
+
submittedData: FormDataSubmittedData,
|
|
102
|
+
options?: SubmitFormDataOptions,
|
|
103
|
+
) => SubmitJsonResult<unknown>;
|
|
104
|
+
onSettle: (id: string, data?: unknown, error?: unknown) => void;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const ConcurrentSubmitterContext = createContext<ContextValue | null>(
|
|
108
|
+
null,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
function FetcherRunner({
|
|
112
|
+
id,
|
|
113
|
+
pendingSubmit,
|
|
114
|
+
onSettle,
|
|
115
|
+
}: {
|
|
116
|
+
id: string;
|
|
117
|
+
pendingSubmit: PendingSubmit;
|
|
118
|
+
onSettle: (id: string, data?: unknown, error?: unknown) => void;
|
|
119
|
+
}) {
|
|
120
|
+
const fetcher = useFetcher({ key: id });
|
|
121
|
+
const submittedRef = useRef(false);
|
|
122
|
+
const settledRef = useRef(false);
|
|
123
|
+
const prevStateRef = useRef(fetcher.state);
|
|
124
|
+
|
|
125
|
+
React.useEffect(() => {
|
|
126
|
+
if (!pendingSubmit || submittedRef.current) return;
|
|
127
|
+
submittedRef.current = true;
|
|
128
|
+
|
|
129
|
+
if (pendingSubmit.kind === "json") {
|
|
130
|
+
fetcher.submit(pendingSubmit.data as SubmitTarget, {
|
|
131
|
+
action: pendingSubmit.actionUrl,
|
|
132
|
+
method: pendingSubmit.method as "POST" | "PUT" | "PATCH" | "DELETE",
|
|
133
|
+
encType: "application/json",
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
fetcher.submit(pendingSubmit.formData, {
|
|
137
|
+
action: pendingSubmit.actionUrl,
|
|
138
|
+
method: pendingSubmit.method as "POST" | "PUT" | "PATCH" | "DELETE",
|
|
139
|
+
encType: "multipart/form-data",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}, [pendingSubmit, fetcher.submit]);
|
|
143
|
+
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
const wasSubmitting = prevStateRef.current === "submitting";
|
|
146
|
+
prevStateRef.current = fetcher.state;
|
|
147
|
+
|
|
148
|
+
if (wasSubmitting && fetcher.state === "idle" && !settledRef.current) {
|
|
149
|
+
settledRef.current = true;
|
|
150
|
+
if (fetcher.data !== undefined) {
|
|
151
|
+
onSettle(id, fetcher.data, undefined);
|
|
152
|
+
} else {
|
|
153
|
+
const err =
|
|
154
|
+
(fetcher as { error?: unknown }).error ??
|
|
155
|
+
new Error("Submission failed");
|
|
156
|
+
onSettle(id, undefined, err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, [id, fetcher.state, fetcher.data, onSettle]);
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function ConcurrentSubmitterProvider({
|
|
165
|
+
children,
|
|
166
|
+
}: {
|
|
167
|
+
children: React.ReactNode;
|
|
168
|
+
}) {
|
|
169
|
+
const nextIdRef = useRef(0);
|
|
170
|
+
const [operationsState, setOperationsState] = useState<
|
|
171
|
+
Record<string, OperationState>
|
|
172
|
+
>({});
|
|
173
|
+
|
|
174
|
+
const onSettle = useCallback(
|
|
175
|
+
(id: string, data?: unknown, error?: unknown) => {
|
|
176
|
+
setOperationsState((prev) => {
|
|
177
|
+
const op = prev[id];
|
|
178
|
+
if (!op) return prev;
|
|
179
|
+
const { resolve, reject, ...rest } = op;
|
|
180
|
+
if (error !== undefined) {
|
|
181
|
+
reject(error);
|
|
182
|
+
return {
|
|
183
|
+
...prev,
|
|
184
|
+
[id]: {
|
|
185
|
+
...rest,
|
|
186
|
+
status: "error" as OperationStatus,
|
|
187
|
+
error,
|
|
188
|
+
resolve,
|
|
189
|
+
reject,
|
|
190
|
+
pendingSubmit: undefined,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
resolve(data);
|
|
195
|
+
return {
|
|
196
|
+
...prev,
|
|
197
|
+
[id]: {
|
|
198
|
+
...rest,
|
|
199
|
+
status: "done" as OperationStatus,
|
|
200
|
+
data,
|
|
201
|
+
resolve,
|
|
202
|
+
reject,
|
|
203
|
+
pendingSubmit: undefined,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
[],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const addJsonSubmission = useCallback(
|
|
212
|
+
(
|
|
213
|
+
path: string,
|
|
214
|
+
args: Record<string, string> | undefined,
|
|
215
|
+
data: unknown,
|
|
216
|
+
options: SubmitJsonOptions = {},
|
|
217
|
+
): SubmitJsonResult<unknown> => {
|
|
218
|
+
const id = `op-${++nextIdRef.current}`;
|
|
219
|
+
const method = options.method ?? "POST";
|
|
220
|
+
const actionUrl = buildActionUrl(path, args);
|
|
221
|
+
|
|
222
|
+
let resolve!: (value: unknown) => void;
|
|
223
|
+
let reject!: (error: unknown) => void;
|
|
224
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
225
|
+
resolve = res;
|
|
226
|
+
reject = rej;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const pendingSubmit: PendingJson = {
|
|
230
|
+
kind: "json",
|
|
231
|
+
actionUrl,
|
|
232
|
+
method,
|
|
233
|
+
encType: "application/json",
|
|
234
|
+
data,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const op: OperationState = {
|
|
238
|
+
id,
|
|
239
|
+
status: "pending",
|
|
240
|
+
submittedData: data as Record<string, unknown>,
|
|
241
|
+
resolve,
|
|
242
|
+
reject,
|
|
243
|
+
pendingSubmit,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
setOperationsState((prev) => ({ ...prev, [id]: op }));
|
|
247
|
+
return { id, promise };
|
|
248
|
+
},
|
|
249
|
+
[],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const addFormSubmission = useCallback(
|
|
253
|
+
(
|
|
254
|
+
path: string,
|
|
255
|
+
args: Record<string, string> | undefined,
|
|
256
|
+
formData: FormData,
|
|
257
|
+
submittedData: FormDataSubmittedData,
|
|
258
|
+
options: SubmitFormDataOptions = {},
|
|
259
|
+
): SubmitJsonResult<unknown> => {
|
|
260
|
+
const id = `op-${++nextIdRef.current}`;
|
|
261
|
+
const method = options.method ?? "POST";
|
|
262
|
+
const actionUrl = buildActionUrl(path, args);
|
|
263
|
+
|
|
264
|
+
let resolve!: (value: unknown) => void;
|
|
265
|
+
let reject!: (error: unknown) => void;
|
|
266
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
267
|
+
resolve = res;
|
|
268
|
+
reject = rej;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const pendingSubmit: PendingForm = {
|
|
272
|
+
kind: "form",
|
|
273
|
+
actionUrl,
|
|
274
|
+
method,
|
|
275
|
+
encType: "multipart/form-data",
|
|
276
|
+
formData,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const op: OperationState = {
|
|
280
|
+
id,
|
|
281
|
+
status: "pending",
|
|
282
|
+
submittedData,
|
|
283
|
+
resolve,
|
|
284
|
+
reject,
|
|
285
|
+
pendingSubmit,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
setOperationsState((prev) => ({ ...prev, [id]: op }));
|
|
289
|
+
return { id, promise };
|
|
290
|
+
},
|
|
291
|
+
[],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const operations = useMemo(() => {
|
|
295
|
+
const result: Record<string, Operation<unknown, unknown>> = {};
|
|
296
|
+
for (const [k, v] of Object.entries(operationsState)) {
|
|
297
|
+
const { resolve: _r, reject: _j, pendingSubmit: _p, ...rest } = v;
|
|
298
|
+
result[k] = rest;
|
|
299
|
+
}
|
|
300
|
+
return result;
|
|
301
|
+
}, [operationsState]);
|
|
302
|
+
|
|
303
|
+
const value = useMemo<ContextValue>(
|
|
304
|
+
() => ({
|
|
305
|
+
operations,
|
|
306
|
+
addJsonSubmission,
|
|
307
|
+
addFormSubmission,
|
|
308
|
+
onSettle,
|
|
309
|
+
}),
|
|
310
|
+
[operations, addJsonSubmission, addFormSubmission, onSettle],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<ConcurrentSubmitterContext.Provider value={value}>
|
|
315
|
+
{children}
|
|
316
|
+
{Object.entries(operationsState).map(
|
|
317
|
+
([id, op]) =>
|
|
318
|
+
op.pendingSubmit && (
|
|
319
|
+
<FetcherRunner
|
|
320
|
+
key={id}
|
|
321
|
+
id={id}
|
|
322
|
+
pendingSubmit={op.pendingSubmit}
|
|
323
|
+
onSettle={onSettle}
|
|
324
|
+
/>
|
|
325
|
+
),
|
|
326
|
+
)}
|
|
327
|
+
</ConcurrentSubmitterContext.Provider>
|
|
328
|
+
);
|
|
329
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
export * from "./ConcurrentSubmitterProvider";
|
|
1
2
|
export * from "./formAction";
|
|
2
3
|
export * from "./types/index";
|
|
3
4
|
export * from "./useCachedFetch";
|
|
5
|
+
export * from "./useConcurrentSubmitter";
|
|
4
6
|
export * from "./useDynamicFetcher";
|
|
5
7
|
export * from "./useDynamicSubmitter";
|
|
6
|
-
export * from "./useConcurrentDynamicSubmitter";
|
|
7
8
|
export * from "./useFetcherStateChanged";
|
|
8
|
-
// Test comment to trigger release
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Func } from "./Func";
|
|
2
|
+
import type { RegisterPages } from "./RegisterPages";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route module shape for type-safe form actions: route path, action handler, and form schema.
|
|
7
|
+
* Use with useDynamicSubmitter and useConcurrentSubmitter.
|
|
8
|
+
*/
|
|
9
|
+
export type RouteWithActionModule = {
|
|
10
|
+
route: keyof RegisterPages;
|
|
11
|
+
action: Func;
|
|
12
|
+
formSchema: z.ZodType;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Action result type inferred from the route module's action */
|
|
16
|
+
export type ActionResult<TModule extends RouteWithActionModule> = Awaited<
|
|
17
|
+
ReturnType<TModule["action"]>
|
|
18
|
+
>;
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Typed hook for concurrent form submissions. Use within ConcurrentSubmitterProvider.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useCallback, useContext } from "react";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import type { RegisterPages } from "./types/RegisterPages";
|
|
8
|
+
import type {
|
|
9
|
+
ActionResult,
|
|
10
|
+
RouteWithActionModule,
|
|
11
|
+
} from "./types/RouteWithActionModule";
|
|
12
|
+
import { ConcurrentSubmitterContext } from "./ConcurrentSubmitterProvider";
|
|
13
|
+
import type {
|
|
14
|
+
FormDataSubmittedData,
|
|
15
|
+
Operation,
|
|
16
|
+
SubmitFormDataOptions,
|
|
17
|
+
SubmitJsonOptions,
|
|
18
|
+
SubmitJsonResult,
|
|
19
|
+
} from "./ConcurrentSubmitterProvider";
|
|
20
|
+
|
|
21
|
+
export type { ActionResult };
|
|
22
|
+
|
|
23
|
+
type RouteParams<R extends keyof RegisterPages> = RegisterPages[R]["params"];
|
|
24
|
+
|
|
25
|
+
export type UseConcurrentSubmitterReturn<TInfo extends RouteWithActionModule> =
|
|
26
|
+
{
|
|
27
|
+
operations: Record<
|
|
28
|
+
string,
|
|
29
|
+
Operation<
|
|
30
|
+
ActionResult<TInfo>,
|
|
31
|
+
z.infer<TInfo["formSchema"]> | FormDataSubmittedData
|
|
32
|
+
>
|
|
33
|
+
>;
|
|
34
|
+
submitJson: (
|
|
35
|
+
path: TInfo["route"],
|
|
36
|
+
args: RouteParams<TInfo["route"]> | undefined,
|
|
37
|
+
data: z.infer<TInfo["formSchema"]>,
|
|
38
|
+
options?: SubmitJsonOptions,
|
|
39
|
+
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
40
|
+
submitFormData: (
|
|
41
|
+
path: TInfo["route"],
|
|
42
|
+
args: RouteParams<TInfo["route"]> | undefined,
|
|
43
|
+
formData: FormData,
|
|
44
|
+
submittedData?: FormDataSubmittedData,
|
|
45
|
+
options?: SubmitFormDataOptions,
|
|
46
|
+
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function useConcurrentSubmitter<
|
|
50
|
+
TInfo extends RouteWithActionModule,
|
|
51
|
+
>(): UseConcurrentSubmitterReturn<TInfo> {
|
|
52
|
+
const ctx = useContext(ConcurrentSubmitterContext);
|
|
53
|
+
if (!ctx) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"useConcurrentSubmitter must be used within a ConcurrentSubmitterProvider",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const submitJson = useCallback(
|
|
60
|
+
(
|
|
61
|
+
path: string,
|
|
62
|
+
args: Record<string, string> | undefined,
|
|
63
|
+
data: unknown,
|
|
64
|
+
options?: SubmitJsonOptions,
|
|
65
|
+
) => ctx.addJsonSubmission(path, args, data, options),
|
|
66
|
+
[ctx],
|
|
67
|
+
) as UseConcurrentSubmitterReturn<TInfo>["submitJson"];
|
|
68
|
+
|
|
69
|
+
const submitFormData = useCallback(
|
|
70
|
+
(
|
|
71
|
+
path: string,
|
|
72
|
+
args: Record<string, string> | undefined,
|
|
73
|
+
formData: FormData,
|
|
74
|
+
submittedData: FormDataSubmittedData = {},
|
|
75
|
+
options?: SubmitFormDataOptions,
|
|
76
|
+
) => ctx.addFormSubmission(path, args, formData, submittedData, options),
|
|
77
|
+
[ctx],
|
|
78
|
+
) as UseConcurrentSubmitterReturn<TInfo>["submitFormData"];
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
operations:
|
|
82
|
+
ctx.operations as UseConcurrentSubmitterReturn<TInfo>["operations"],
|
|
83
|
+
submitJson,
|
|
84
|
+
submitFormData,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -122,23 +122,8 @@ import {
|
|
|
122
122
|
useFetcher,
|
|
123
123
|
} from "react-router";
|
|
124
124
|
import type { z } from "zod";
|
|
125
|
-
import type { Func } from "./types/Func";
|
|
126
125
|
import type { HrefArgs } from "./types/HrefArgs";
|
|
127
|
-
import type {
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Represents a route module with the required exports for useDynamicSubmitter.
|
|
131
|
-
*
|
|
132
|
-
* A valid route module must export:
|
|
133
|
-
* - `route`: The route path (e.g., "/admin/posts/:id")
|
|
134
|
-
* - `action`: The form action handler created with `formAction`
|
|
135
|
-
* - `formSchema`: The Zod schema for form validation
|
|
136
|
-
*/
|
|
137
|
-
type RouteModule = {
|
|
138
|
-
route: keyof RegisterPages;
|
|
139
|
-
action: Func;
|
|
140
|
-
formSchema: z.ZodType;
|
|
141
|
-
};
|
|
126
|
+
import type { RouteWithActionModule } from "./types/RouteWithActionModule";
|
|
142
127
|
|
|
143
128
|
/**
|
|
144
129
|
* Function type for submitting form data with a SubmitTarget.
|
|
@@ -155,7 +140,7 @@ type RouteModule = {
|
|
|
155
140
|
* submitter.submit(formRef.current, { method: "POST" });
|
|
156
141
|
* ```
|
|
157
142
|
*/
|
|
158
|
-
type SubmitFunc<TModule extends
|
|
143
|
+
type SubmitFunc<TModule extends RouteWithActionModule> = (
|
|
159
144
|
target: z.infer<TModule["formSchema"]> & SubmitTarget,
|
|
160
145
|
options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
161
146
|
method: Exclude<SubmitOptions["method"], "GET">;
|
|
@@ -195,7 +180,7 @@ type SubmitJsonOptions = Omit<
|
|
|
195
180
|
* await submitter.submitJson(data, { method: "PUT" });
|
|
196
181
|
* ```
|
|
197
182
|
*/
|
|
198
|
-
type SubmitJsonFunc<TModule extends
|
|
183
|
+
type SubmitJsonFunc<TModule extends RouteWithActionModule> = (
|
|
199
184
|
data: z.infer<TModule["formSchema"]>,
|
|
200
185
|
options?: SubmitJsonOptions,
|
|
201
186
|
) => Promise<void>;
|
|
@@ -282,7 +267,7 @@ type SubmitForm = (
|
|
|
282
267
|
* }
|
|
283
268
|
* ```
|
|
284
269
|
*/
|
|
285
|
-
export const useDynamicSubmitter = <TInfo extends
|
|
270
|
+
export const useDynamicSubmitter = <TInfo extends RouteWithActionModule>(
|
|
286
271
|
path: TInfo["route"],
|
|
287
272
|
...args: TInfo["route"] extends "undefined"
|
|
288
273
|
? HrefArgs<"/">
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Concurrent type-safe form submissions for React Router 7
|
|
3
|
-
*
|
|
4
|
-
* Lets you run multiple submissions to the same (or logical) action in parallel,
|
|
5
|
-
* each tracked independently with pending → done state. No single fetcher;
|
|
6
|
-
* each submission is a promise and an entry in `operations`.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```tsx
|
|
10
|
-
* const { operations, submitJson } = useConcurrentDynamicSubmitter<typeof import("./api.upload")>("/api/upload");
|
|
11
|
-
*
|
|
12
|
-
* const a = submitJson({ fileId: "1", name: "a.pdf" });
|
|
13
|
-
* const b = submitJson({ fileId: "2", name: "b.pdf" });
|
|
14
|
-
*
|
|
15
|
-
* // FormData: pass serializable submittedData for display (no File/FormData in state)
|
|
16
|
-
* submitFormData(formData, { type: "upload", label: "photo.jpg" });
|
|
17
|
-
* submitFormData(convertFormData, { type: "convert", label: "anim.gif" });
|
|
18
|
-
* // Map operations: op.submittedData for labels, op.data when done
|
|
19
|
-
* {Object.values(operations).map((op) => (
|
|
20
|
-
* <div key={op.id}>
|
|
21
|
-
* {op.status === "pending" && <Skeleton>{op.submittedData.label}</Skeleton>}
|
|
22
|
-
* {op.status === "done" && <Item data={op.data} />}
|
|
23
|
-
* </div>
|
|
24
|
-
* ))}
|
|
25
|
-
* ```
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { useCallback, useMemo, useRef, useState } from "react";
|
|
29
|
-
import { href } from "react-router";
|
|
30
|
-
import type { z } from "zod";
|
|
31
|
-
import type { HrefArgs } from "./types/HrefArgs";
|
|
32
|
-
import type { RegisterPages } from "./types/RegisterPages";
|
|
33
|
-
|
|
34
|
-
type RouteModule = {
|
|
35
|
-
route: keyof RegisterPages;
|
|
36
|
-
action: (...args: unknown[]) => unknown;
|
|
37
|
-
formSchema: z.ZodType;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/** Action result type inferred from the route module's action */
|
|
41
|
-
export type ActionResult<TModule extends RouteModule> = Awaited<
|
|
42
|
-
ReturnType<TModule["action"]>
|
|
43
|
-
>;
|
|
44
|
-
|
|
45
|
-
/** Status of a single concurrent submission */
|
|
46
|
-
export type OperationStatus = "pending" | "done" | "error";
|
|
47
|
-
|
|
48
|
-
/** One tracked submission: pending → done (or error). Includes submitted payload for optimistic UI. */
|
|
49
|
-
export type Operation<TResponse, TFormData = unknown> = {
|
|
50
|
-
id: string;
|
|
51
|
-
status: OperationStatus;
|
|
52
|
-
/** Data that was sent (for optimistic display while pending, or to show what was submitted) */
|
|
53
|
-
submittedData: TFormData;
|
|
54
|
-
/** Response from the action when status is "done" */
|
|
55
|
-
data?: TResponse;
|
|
56
|
-
error?: unknown;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/** Result of submitJson / submitFormData: id to look up in operations, promise to await */
|
|
60
|
-
export type SubmitJsonResult<T> = {
|
|
61
|
-
id: string;
|
|
62
|
-
promise: Promise<T>;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
type SubmitJsonOptions = {
|
|
66
|
-
method?: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/** Optional serializable payload for FormData submissions (for display in operations list; FormData/File are not stored in state). */
|
|
70
|
-
export type FormDataSubmittedData = Record<string, unknown>;
|
|
71
|
-
|
|
72
|
-
/** Options for submitFormData (e.g. Accept: application/json so the action returns JSON instead of redirecting). */
|
|
73
|
-
export type SubmitFormDataOptions = {
|
|
74
|
-
headers?: HeadersInit;
|
|
75
|
-
method?: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Submits JSON to the action URL via fetch and returns the parsed JSON.
|
|
80
|
-
*/
|
|
81
|
-
async function submitJsonToAction<T>(
|
|
82
|
-
actionUrl: string,
|
|
83
|
-
data: unknown,
|
|
84
|
-
options: SubmitJsonOptions & { fetchFn?: typeof fetch },
|
|
85
|
-
): Promise<T> {
|
|
86
|
-
const { method = "POST", fetchFn = fetch } = options;
|
|
87
|
-
const res = await fetchFn(actionUrl, {
|
|
88
|
-
method,
|
|
89
|
-
headers: { "Content-Type": "application/json" },
|
|
90
|
-
body: JSON.stringify(data),
|
|
91
|
-
});
|
|
92
|
-
if (!res.ok) {
|
|
93
|
-
const text = await res.text();
|
|
94
|
-
throw new Error(`Action failed: ${res.status} ${text}`);
|
|
95
|
-
}
|
|
96
|
-
const json = (await res.json()) as T;
|
|
97
|
-
return json;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Submits FormData to the action URL. No Content-Type header — browser sets multipart/form-data with boundary.
|
|
102
|
-
* Optional headers (e.g. Accept: application/json) let the action return JSON instead of redirecting.
|
|
103
|
-
*/
|
|
104
|
-
async function submitFormDataToAction<T>(
|
|
105
|
-
actionUrl: string,
|
|
106
|
-
formData: FormData,
|
|
107
|
-
options: SubmitFormDataOptions & { fetchFn?: typeof fetch },
|
|
108
|
-
): Promise<T> {
|
|
109
|
-
const { method = "POST", headers, fetchFn = fetch } = options;
|
|
110
|
-
const res = await fetchFn(actionUrl, {
|
|
111
|
-
method,
|
|
112
|
-
...(headers && { headers }),
|
|
113
|
-
body: formData,
|
|
114
|
-
});
|
|
115
|
-
if (!res.ok) {
|
|
116
|
-
const text = await res.text();
|
|
117
|
-
throw new Error(`Action failed: ${res.status} ${text}`);
|
|
118
|
-
}
|
|
119
|
-
const json = (await res.json()) as T;
|
|
120
|
-
return json;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Hook for multiple concurrent submissions to a dynamic route action.
|
|
125
|
-
* Each submission gets an id and appears in `operations` as pending → done (or error).
|
|
126
|
-
* Strongly typed: form data from route's formSchema, result from action return type.
|
|
127
|
-
*
|
|
128
|
-
* @template TInfo - Route module type (e.g. `typeof import("./api.upload")`)
|
|
129
|
-
* @param path - Route path
|
|
130
|
-
* @param args - Route params if path has segments
|
|
131
|
-
* @returns operations map (id → Operation), submitJson, submitFormData (each returns { id, promise })
|
|
132
|
-
*/
|
|
133
|
-
export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
|
|
134
|
-
path: TInfo["route"],
|
|
135
|
-
...args: TInfo["route"] extends "undefined"
|
|
136
|
-
? HrefArgs<"/">
|
|
137
|
-
: HrefArgs<TInfo["route"]>
|
|
138
|
-
): {
|
|
139
|
-
operations: Record<
|
|
140
|
-
string,
|
|
141
|
-
Operation<
|
|
142
|
-
ActionResult<TInfo>,
|
|
143
|
-
z.infer<TInfo["formSchema"]> | FormDataSubmittedData
|
|
144
|
-
>
|
|
145
|
-
>;
|
|
146
|
-
submitJson: (
|
|
147
|
-
data: z.infer<TInfo["formSchema"]>,
|
|
148
|
-
options?: SubmitJsonOptions,
|
|
149
|
-
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
150
|
-
submitFormData: (
|
|
151
|
-
formData: FormData,
|
|
152
|
-
submittedData?: FormDataSubmittedData,
|
|
153
|
-
options?: SubmitFormDataOptions,
|
|
154
|
-
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
155
|
-
} {
|
|
156
|
-
const actionUrl = useMemo(() => {
|
|
157
|
-
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
158
|
-
return href(path, ...(args as any));
|
|
159
|
-
}, [path, args]);
|
|
160
|
-
|
|
161
|
-
type JsonFormData = z.infer<TInfo["formSchema"]>;
|
|
162
|
-
type Op = Operation<
|
|
163
|
-
ActionResult<TInfo>,
|
|
164
|
-
JsonFormData | FormDataSubmittedData
|
|
165
|
-
>;
|
|
166
|
-
|
|
167
|
-
const nextIdRef = useRef(0);
|
|
168
|
-
const [operations, setOperations] = useState<Record<string, Op>>({});
|
|
169
|
-
|
|
170
|
-
const submitJson = useCallback(
|
|
171
|
-
(
|
|
172
|
-
data: JsonFormData,
|
|
173
|
-
options: SubmitJsonOptions = {},
|
|
174
|
-
): SubmitJsonResult<ActionResult<TInfo>> => {
|
|
175
|
-
const id = `op-${++nextIdRef.current}`;
|
|
176
|
-
setOperations((prev) => ({
|
|
177
|
-
...prev,
|
|
178
|
-
[id]: { id, status: "pending", submittedData: data },
|
|
179
|
-
}));
|
|
180
|
-
|
|
181
|
-
const promise = submitJsonToAction<ActionResult<TInfo>>(
|
|
182
|
-
actionUrl,
|
|
183
|
-
data,
|
|
184
|
-
options,
|
|
185
|
-
)
|
|
186
|
-
.then((responseData) => {
|
|
187
|
-
setOperations((prev) => ({
|
|
188
|
-
...prev,
|
|
189
|
-
[id]: {
|
|
190
|
-
id,
|
|
191
|
-
status: "done",
|
|
192
|
-
submittedData: data,
|
|
193
|
-
data: responseData,
|
|
194
|
-
},
|
|
195
|
-
}));
|
|
196
|
-
return responseData;
|
|
197
|
-
})
|
|
198
|
-
.catch((error) => {
|
|
199
|
-
setOperations((prev) => ({
|
|
200
|
-
...prev,
|
|
201
|
-
[id]: {
|
|
202
|
-
id,
|
|
203
|
-
status: "error",
|
|
204
|
-
submittedData: data,
|
|
205
|
-
error,
|
|
206
|
-
},
|
|
207
|
-
}));
|
|
208
|
-
throw error;
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
return { id, promise };
|
|
212
|
-
},
|
|
213
|
-
[actionUrl],
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
const submitFormData = useCallback(
|
|
217
|
-
(
|
|
218
|
-
formData: FormData,
|
|
219
|
-
submittedData: FormDataSubmittedData = {},
|
|
220
|
-
options: SubmitFormDataOptions = {},
|
|
221
|
-
): SubmitJsonResult<ActionResult<TInfo>> => {
|
|
222
|
-
const id = `op-${++nextIdRef.current}`;
|
|
223
|
-
setOperations((prev) => ({
|
|
224
|
-
...prev,
|
|
225
|
-
[id]: { id, status: "pending", submittedData },
|
|
226
|
-
}));
|
|
227
|
-
|
|
228
|
-
const promise = submitFormDataToAction<ActionResult<TInfo>>(
|
|
229
|
-
actionUrl,
|
|
230
|
-
formData,
|
|
231
|
-
options,
|
|
232
|
-
)
|
|
233
|
-
.then((responseData) => {
|
|
234
|
-
setOperations((prev) => ({
|
|
235
|
-
...prev,
|
|
236
|
-
[id]: {
|
|
237
|
-
id,
|
|
238
|
-
status: "done",
|
|
239
|
-
submittedData,
|
|
240
|
-
data: responseData,
|
|
241
|
-
},
|
|
242
|
-
}));
|
|
243
|
-
return responseData;
|
|
244
|
-
})
|
|
245
|
-
.catch((error) => {
|
|
246
|
-
setOperations((prev) => ({
|
|
247
|
-
...prev,
|
|
248
|
-
[id]: {
|
|
249
|
-
id,
|
|
250
|
-
status: "error",
|
|
251
|
-
submittedData,
|
|
252
|
-
error,
|
|
253
|
-
},
|
|
254
|
-
}));
|
|
255
|
-
throw error;
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
return { id, promise };
|
|
259
|
-
},
|
|
260
|
-
[actionUrl],
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
return { operations, submitJson, submitFormData };
|
|
264
|
-
}
|