@firtoz/router-toolkit 5.4.0 → 5.5.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 +12 -6
- package/package.json +1 -1
- package/src/useConcurrentDynamicSubmitter.tsx +102 -11
package/README.md
CHANGED
|
@@ -283,16 +283,21 @@ export default function ContactForm() {
|
|
|
283
283
|
|
|
284
284
|
Run multiple submissions to the same action in parallel, each tracked independently. No single fetcher; each call returns `{ id, promise }` and adds an entry to `operations` with `submittedData` (for optimistic UI) and `data` when done.
|
|
285
285
|
|
|
286
|
+
- **`submitJson(data)`** — POST JSON; `submittedData` is the payload you send.
|
|
287
|
+
- **`submitFormData(formData, submittedData?)`** — POST multipart/form-data (e.g. file uploads). 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
|
+
|
|
286
289
|
```tsx
|
|
287
290
|
import { useConcurrentDynamicSubmitter } from '@firtoz/router-toolkit';
|
|
288
291
|
|
|
289
292
|
function UploadList() {
|
|
290
|
-
const { operations,
|
|
293
|
+
const { operations, submitFormData } = useConcurrentDynamicSubmitter<
|
|
291
294
|
typeof import("./api.upload")
|
|
292
295
|
>("/api/upload");
|
|
293
296
|
|
|
294
|
-
const handleUpload = (file:
|
|
295
|
-
|
|
297
|
+
const handleUpload = (file: File) => {
|
|
298
|
+
const fd = new FormData();
|
|
299
|
+
fd.set("file", file);
|
|
300
|
+
submitFormData(fd, { type: "upload", label: file.name });
|
|
296
301
|
};
|
|
297
302
|
|
|
298
303
|
return (
|
|
@@ -300,7 +305,7 @@ function UploadList() {
|
|
|
300
305
|
{Object.values(operations).map((op) => (
|
|
301
306
|
<li key={op.id}>
|
|
302
307
|
{op.status === "pending" && (
|
|
303
|
-
<Skeleton>{op.submittedData.
|
|
308
|
+
<Skeleton>{(op.submittedData as { label?: string }).label}</Skeleton>
|
|
304
309
|
)}
|
|
305
310
|
{op.status === "done" && (
|
|
306
311
|
<span>Saved: {op.data?.id}</span>
|
|
@@ -315,8 +320,9 @@ function UploadList() {
|
|
|
315
320
|
}
|
|
316
321
|
```
|
|
317
322
|
|
|
318
|
-
- **`operations`**: `Record<string, Operation<T>>` — each operation has `id`, `status` (`"pending"` | `"done"` | `"error"`), `submittedData` (payload
|
|
319
|
-
- **`submitJson(data)`**: returns `{ id, promise }
|
|
323
|
+
- **`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.
|
|
320
326
|
|
|
321
327
|
### `useFetcherStateChanged`
|
|
322
328
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.1",
|
|
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",
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* const a = submitJson({ fileId: "1", name: "a.pdf" });
|
|
13
13
|
* const b = submitJson({ fileId: "2", name: "b.pdf" });
|
|
14
14
|
*
|
|
15
|
-
* //
|
|
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
|
|
16
19
|
* {Object.values(operations).map((op) => (
|
|
17
20
|
* <div key={op.id}>
|
|
18
|
-
* {op.status === "pending" && <Skeleton
|
|
21
|
+
* {op.status === "pending" && <Skeleton>{op.submittedData.label}</Skeleton>}
|
|
19
22
|
* {op.status === "done" && <Item data={op.data} />}
|
|
20
|
-
* <span>{op.submittedData.name}</span>
|
|
21
23
|
* </div>
|
|
22
24
|
* ))}
|
|
23
25
|
* ```
|
|
@@ -54,7 +56,7 @@ export type Operation<TResponse, TFormData = unknown> = {
|
|
|
54
56
|
error?: unknown;
|
|
55
57
|
};
|
|
56
58
|
|
|
57
|
-
/** Result of submitJson: id to look up in operations, promise to await */
|
|
59
|
+
/** Result of submitJson / submitFormData: id to look up in operations, promise to await */
|
|
58
60
|
export type SubmitJsonResult<T> = {
|
|
59
61
|
id: string;
|
|
60
62
|
promise: Promise<T>;
|
|
@@ -64,9 +66,17 @@ type SubmitJsonOptions = {
|
|
|
64
66
|
method?: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
65
67
|
};
|
|
66
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
|
+
|
|
67
78
|
/**
|
|
68
79
|
* Submits JSON to the action URL via fetch and returns the parsed JSON.
|
|
69
|
-
* Used so we can run N requests in parallel without N fetchers.
|
|
70
80
|
*/
|
|
71
81
|
async function submitJsonToAction<T>(
|
|
72
82
|
actionUrl: string,
|
|
@@ -87,6 +97,29 @@ async function submitJsonToAction<T>(
|
|
|
87
97
|
return json;
|
|
88
98
|
}
|
|
89
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
|
+
|
|
90
123
|
/**
|
|
91
124
|
* Hook for multiple concurrent submissions to a dynamic route action.
|
|
92
125
|
* Each submission gets an id and appears in `operations` as pending → done (or error).
|
|
@@ -95,7 +128,7 @@ async function submitJsonToAction<T>(
|
|
|
95
128
|
* @template TInfo - Route module type (e.g. `typeof import("./api.upload")`)
|
|
96
129
|
* @param path - Route path
|
|
97
130
|
* @param args - Route params if path has segments
|
|
98
|
-
* @returns operations map (id → Operation), submitJson (returns { id, promise })
|
|
131
|
+
* @returns operations map (id → Operation), submitJson, submitFormData (each returns { id, promise })
|
|
99
132
|
*/
|
|
100
133
|
export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
|
|
101
134
|
path: TInfo["route"],
|
|
@@ -105,27 +138,38 @@ export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
|
|
|
105
138
|
): {
|
|
106
139
|
operations: Record<
|
|
107
140
|
string,
|
|
108
|
-
Operation<
|
|
141
|
+
Operation<
|
|
142
|
+
ActionResult<TInfo>,
|
|
143
|
+
z.infer<TInfo["formSchema"]> | FormDataSubmittedData
|
|
144
|
+
>
|
|
109
145
|
>;
|
|
110
146
|
submitJson: (
|
|
111
147
|
data: z.infer<TInfo["formSchema"]>,
|
|
112
148
|
options?: SubmitJsonOptions,
|
|
113
149
|
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
150
|
+
submitFormData: (
|
|
151
|
+
formData: FormData,
|
|
152
|
+
submittedData?: FormDataSubmittedData,
|
|
153
|
+
options?: SubmitFormDataOptions,
|
|
154
|
+
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
114
155
|
} {
|
|
115
156
|
const actionUrl = useMemo(() => {
|
|
116
157
|
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
117
158
|
return href(path, ...(args as any));
|
|
118
159
|
}, [path, args]);
|
|
119
160
|
|
|
120
|
-
type
|
|
121
|
-
type Op = Operation<
|
|
161
|
+
type JsonFormData = z.infer<TInfo["formSchema"]>;
|
|
162
|
+
type Op = Operation<
|
|
163
|
+
ActionResult<TInfo>,
|
|
164
|
+
JsonFormData | FormDataSubmittedData
|
|
165
|
+
>;
|
|
122
166
|
|
|
123
167
|
const nextIdRef = useRef(0);
|
|
124
168
|
const [operations, setOperations] = useState<Record<string, Op>>({});
|
|
125
169
|
|
|
126
170
|
const submitJson = useCallback(
|
|
127
171
|
(
|
|
128
|
-
data:
|
|
172
|
+
data: JsonFormData,
|
|
129
173
|
options: SubmitJsonOptions = {},
|
|
130
174
|
): SubmitJsonResult<ActionResult<TInfo>> => {
|
|
131
175
|
const id = `op-${++nextIdRef.current}`;
|
|
@@ -169,5 +213,52 @@ export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
|
|
|
169
213
|
[actionUrl],
|
|
170
214
|
);
|
|
171
215
|
|
|
172
|
-
|
|
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 };
|
|
173
264
|
}
|