@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 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, submitJson } = useConcurrentDynamicSubmitter<
293
+ const { operations, submitFormData } = useConcurrentDynamicSubmitter<
291
294
  typeof import("./api.upload")
292
295
  >("/api/upload");
293
296
 
294
- const handleUpload = (file: { name: string; size: number }) => {
295
- submitJson({ fileName: file.name, size: file.size });
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.fileName}</Skeleton>
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 sent), and when done `data` (action response).
319
- - **`submitJson(data)`**: returns `{ id, promise }`; use `id` to look up in `operations`, `promise` to await.
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.4.0",
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
- * // Map operations: show submittedData optimistically (skeleton/list), then response when done
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<ActionResult<TInfo>, z.infer<TInfo["formSchema"]>>
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 FormData = z.infer<TInfo["formSchema"]>;
121
- type Op = Operation<ActionResult<TInfo>, FormData>;
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: FormData,
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
- return { operations, submitJson };
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
  }