@firtoz/router-toolkit 5.4.0 → 5.5.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 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.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",
@@ -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,11 @@ 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
+
67
72
  /**
68
73
  * 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
74
  */
71
75
  async function submitJsonToAction<T>(
72
76
  actionUrl: string,
@@ -87,6 +91,30 @@ async function submitJsonToAction<T>(
87
91
  return json;
88
92
  }
89
93
 
94
+ /**
95
+ * Submits FormData to the action URL. No Content-Type header — browser sets multipart/form-data with boundary.
96
+ */
97
+ async function submitFormDataToAction<T>(
98
+ actionUrl: string,
99
+ formData: FormData,
100
+ options: {
101
+ method?: "POST" | "PUT" | "PATCH" | "DELETE";
102
+ fetchFn?: typeof fetch;
103
+ },
104
+ ): Promise<T> {
105
+ const { method = "POST", fetchFn = fetch } = options;
106
+ const res = await fetchFn(actionUrl, {
107
+ method,
108
+ body: formData,
109
+ });
110
+ if (!res.ok) {
111
+ const text = await res.text();
112
+ throw new Error(`Action failed: ${res.status} ${text}`);
113
+ }
114
+ const json = (await res.json()) as T;
115
+ return json;
116
+ }
117
+
90
118
  /**
91
119
  * Hook for multiple concurrent submissions to a dynamic route action.
92
120
  * Each submission gets an id and appears in `operations` as pending → done (or error).
@@ -95,7 +123,7 @@ async function submitJsonToAction<T>(
95
123
  * @template TInfo - Route module type (e.g. `typeof import("./api.upload")`)
96
124
  * @param path - Route path
97
125
  * @param args - Route params if path has segments
98
- * @returns operations map (id → Operation), submitJson (returns { id, promise })
126
+ * @returns operations map (id → Operation), submitJson, submitFormData (each returns { id, promise })
99
127
  */
100
128
  export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
101
129
  path: TInfo["route"],
@@ -105,27 +133,37 @@ export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
105
133
  ): {
106
134
  operations: Record<
107
135
  string,
108
- Operation<ActionResult<TInfo>, z.infer<TInfo["formSchema"]>>
136
+ Operation<
137
+ ActionResult<TInfo>,
138
+ z.infer<TInfo["formSchema"]> | FormDataSubmittedData
139
+ >
109
140
  >;
110
141
  submitJson: (
111
142
  data: z.infer<TInfo["formSchema"]>,
112
143
  options?: SubmitJsonOptions,
113
144
  ) => SubmitJsonResult<ActionResult<TInfo>>;
145
+ submitFormData: (
146
+ formData: FormData,
147
+ submittedData?: FormDataSubmittedData,
148
+ ) => SubmitJsonResult<ActionResult<TInfo>>;
114
149
  } {
115
150
  const actionUrl = useMemo(() => {
116
151
  // biome-ignore lint/suspicious/noExplicitAny: Intentional
117
152
  return href(path, ...(args as any));
118
153
  }, [path, args]);
119
154
 
120
- type FormData = z.infer<TInfo["formSchema"]>;
121
- type Op = Operation<ActionResult<TInfo>, FormData>;
155
+ type JsonFormData = z.infer<TInfo["formSchema"]>;
156
+ type Op = Operation<
157
+ ActionResult<TInfo>,
158
+ JsonFormData | FormDataSubmittedData
159
+ >;
122
160
 
123
161
  const nextIdRef = useRef(0);
124
162
  const [operations, setOperations] = useState<Record<string, Op>>({});
125
163
 
126
164
  const submitJson = useCallback(
127
165
  (
128
- data: FormData,
166
+ data: JsonFormData,
129
167
  options: SubmitJsonOptions = {},
130
168
  ): SubmitJsonResult<ActionResult<TInfo>> => {
131
169
  const id = `op-${++nextIdRef.current}`;
@@ -169,5 +207,51 @@ export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
169
207
  [actionUrl],
170
208
  );
171
209
 
172
- return { operations, submitJson };
210
+ const submitFormData = useCallback(
211
+ (
212
+ formData: FormData,
213
+ submittedData: FormDataSubmittedData = {},
214
+ ): SubmitJsonResult<ActionResult<TInfo>> => {
215
+ const id = `op-${++nextIdRef.current}`;
216
+ setOperations((prev) => ({
217
+ ...prev,
218
+ [id]: { id, status: "pending", submittedData },
219
+ }));
220
+
221
+ const promise = submitFormDataToAction<ActionResult<TInfo>>(
222
+ actionUrl,
223
+ formData,
224
+ { method: "POST" },
225
+ )
226
+ .then((responseData) => {
227
+ setOperations((prev) => ({
228
+ ...prev,
229
+ [id]: {
230
+ id,
231
+ status: "done",
232
+ submittedData,
233
+ data: responseData,
234
+ },
235
+ }));
236
+ return responseData;
237
+ })
238
+ .catch((error) => {
239
+ setOperations((prev) => ({
240
+ ...prev,
241
+ [id]: {
242
+ id,
243
+ status: "error",
244
+ submittedData,
245
+ error,
246
+ },
247
+ }));
248
+ throw error;
249
+ });
250
+
251
+ return { id, promise };
252
+ },
253
+ [actionUrl],
254
+ );
255
+
256
+ return { operations, submitJson, submitFormData };
173
257
  }