@firtoz/router-toolkit 5.3.1 → 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 +46 -0
- package/package.json +4 -4
- package/src/index.ts +1 -0
- package/src/useConcurrentDynamicSubmitter.tsx +257 -0
package/README.md
CHANGED
|
@@ -13,6 +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 (`useConcurrentDynamicSubmitter`)
|
|
16
17
|
- 🔄 **State tracking** - Monitor fetcher state changes with ease
|
|
17
18
|
- 🎯 **Zero configuration** - Works out of the box with React Router 7
|
|
18
19
|
- 📦 **Tree-shakeable** - Import only what you need
|
|
@@ -278,6 +279,51 @@ export default function ContactForm() {
|
|
|
278
279
|
}
|
|
279
280
|
```
|
|
280
281
|
|
|
282
|
+
### `useConcurrentDynamicSubmitter`
|
|
283
|
+
|
|
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
|
+
|
|
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
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
import { useConcurrentDynamicSubmitter } from '@firtoz/router-toolkit';
|
|
291
|
+
|
|
292
|
+
function UploadList() {
|
|
293
|
+
const { operations, submitFormData } = useConcurrentDynamicSubmitter<
|
|
294
|
+
typeof import("./api.upload")
|
|
295
|
+
>("/api/upload");
|
|
296
|
+
|
|
297
|
+
const handleUpload = (file: File) => {
|
|
298
|
+
const fd = new FormData();
|
|
299
|
+
fd.set("file", file);
|
|
300
|
+
submitFormData(fd, { type: "upload", label: file.name });
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<ul>
|
|
305
|
+
{Object.values(operations).map((op) => (
|
|
306
|
+
<li key={op.id}>
|
|
307
|
+
{op.status === "pending" && (
|
|
308
|
+
<Skeleton>{(op.submittedData as { label?: string }).label}</Skeleton>
|
|
309
|
+
)}
|
|
310
|
+
{op.status === "done" && (
|
|
311
|
+
<span>Saved: {op.data?.id}</span>
|
|
312
|
+
)}
|
|
313
|
+
{op.status === "error" && (
|
|
314
|
+
<span>Failed: {String(op.error)}</span>
|
|
315
|
+
)}
|
|
316
|
+
</li>
|
|
317
|
+
))}
|
|
318
|
+
</ul>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
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.
|
|
326
|
+
|
|
281
327
|
### `useFetcherStateChanged`
|
|
282
328
|
|
|
283
329
|
Track changes in fetcher state and react to them. Perfect for triggering side effects, showing notifications, or handling state transitions in your application.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "5.
|
|
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",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@testing-library/react": "^16.3.1",
|
|
74
74
|
"@types/jsdom": "^27.0.0",
|
|
75
|
-
"@types/react": "^19.2.
|
|
76
|
-
"bun-types": "^1.3.
|
|
77
|
-
"jsdom": "^28.
|
|
75
|
+
"@types/react": "^19.2.14",
|
|
76
|
+
"bun-types": "^1.3.9",
|
|
77
|
+
"jsdom": "^28.1.0"
|
|
78
78
|
}
|
|
79
79
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,5 +3,6 @@ export * from "./types/index";
|
|
|
3
3
|
export * from "./useCachedFetch";
|
|
4
4
|
export * from "./useDynamicFetcher";
|
|
5
5
|
export * from "./useDynamicSubmitter";
|
|
6
|
+
export * from "./useConcurrentDynamicSubmitter";
|
|
6
7
|
export * from "./useFetcherStateChanged";
|
|
7
8
|
// Test comment to trigger release
|
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
/**
|
|
73
|
+
* Submits JSON to the action URL via fetch and returns the parsed JSON.
|
|
74
|
+
*/
|
|
75
|
+
async function submitJsonToAction<T>(
|
|
76
|
+
actionUrl: string,
|
|
77
|
+
data: unknown,
|
|
78
|
+
options: SubmitJsonOptions & { fetchFn?: typeof fetch },
|
|
79
|
+
): Promise<T> {
|
|
80
|
+
const { method = "POST", fetchFn = fetch } = options;
|
|
81
|
+
const res = await fetchFn(actionUrl, {
|
|
82
|
+
method,
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify(data),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
throw new Error(`Action failed: ${res.status} ${text}`);
|
|
89
|
+
}
|
|
90
|
+
const json = (await res.json()) as T;
|
|
91
|
+
return json;
|
|
92
|
+
}
|
|
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
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hook for multiple concurrent submissions to a dynamic route action.
|
|
120
|
+
* Each submission gets an id and appears in `operations` as pending → done (or error).
|
|
121
|
+
* Strongly typed: form data from route's formSchema, result from action return type.
|
|
122
|
+
*
|
|
123
|
+
* @template TInfo - Route module type (e.g. `typeof import("./api.upload")`)
|
|
124
|
+
* @param path - Route path
|
|
125
|
+
* @param args - Route params if path has segments
|
|
126
|
+
* @returns operations map (id → Operation), submitJson, submitFormData (each returns { id, promise })
|
|
127
|
+
*/
|
|
128
|
+
export function useConcurrentDynamicSubmitter<TInfo extends RouteModule>(
|
|
129
|
+
path: TInfo["route"],
|
|
130
|
+
...args: TInfo["route"] extends "undefined"
|
|
131
|
+
? HrefArgs<"/">
|
|
132
|
+
: HrefArgs<TInfo["route"]>
|
|
133
|
+
): {
|
|
134
|
+
operations: Record<
|
|
135
|
+
string,
|
|
136
|
+
Operation<
|
|
137
|
+
ActionResult<TInfo>,
|
|
138
|
+
z.infer<TInfo["formSchema"]> | FormDataSubmittedData
|
|
139
|
+
>
|
|
140
|
+
>;
|
|
141
|
+
submitJson: (
|
|
142
|
+
data: z.infer<TInfo["formSchema"]>,
|
|
143
|
+
options?: SubmitJsonOptions,
|
|
144
|
+
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
145
|
+
submitFormData: (
|
|
146
|
+
formData: FormData,
|
|
147
|
+
submittedData?: FormDataSubmittedData,
|
|
148
|
+
) => SubmitJsonResult<ActionResult<TInfo>>;
|
|
149
|
+
} {
|
|
150
|
+
const actionUrl = useMemo(() => {
|
|
151
|
+
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
152
|
+
return href(path, ...(args as any));
|
|
153
|
+
}, [path, args]);
|
|
154
|
+
|
|
155
|
+
type JsonFormData = z.infer<TInfo["formSchema"]>;
|
|
156
|
+
type Op = Operation<
|
|
157
|
+
ActionResult<TInfo>,
|
|
158
|
+
JsonFormData | FormDataSubmittedData
|
|
159
|
+
>;
|
|
160
|
+
|
|
161
|
+
const nextIdRef = useRef(0);
|
|
162
|
+
const [operations, setOperations] = useState<Record<string, Op>>({});
|
|
163
|
+
|
|
164
|
+
const submitJson = useCallback(
|
|
165
|
+
(
|
|
166
|
+
data: JsonFormData,
|
|
167
|
+
options: SubmitJsonOptions = {},
|
|
168
|
+
): SubmitJsonResult<ActionResult<TInfo>> => {
|
|
169
|
+
const id = `op-${++nextIdRef.current}`;
|
|
170
|
+
setOperations((prev) => ({
|
|
171
|
+
...prev,
|
|
172
|
+
[id]: { id, status: "pending", submittedData: data },
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const promise = submitJsonToAction<ActionResult<TInfo>>(
|
|
176
|
+
actionUrl,
|
|
177
|
+
data,
|
|
178
|
+
options,
|
|
179
|
+
)
|
|
180
|
+
.then((responseData) => {
|
|
181
|
+
setOperations((prev) => ({
|
|
182
|
+
...prev,
|
|
183
|
+
[id]: {
|
|
184
|
+
id,
|
|
185
|
+
status: "done",
|
|
186
|
+
submittedData: data,
|
|
187
|
+
data: responseData,
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
return responseData;
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
setOperations((prev) => ({
|
|
194
|
+
...prev,
|
|
195
|
+
[id]: {
|
|
196
|
+
id,
|
|
197
|
+
status: "error",
|
|
198
|
+
submittedData: data,
|
|
199
|
+
error,
|
|
200
|
+
},
|
|
201
|
+
}));
|
|
202
|
+
throw error;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return { id, promise };
|
|
206
|
+
},
|
|
207
|
+
[actionUrl],
|
|
208
|
+
);
|
|
209
|
+
|
|
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 };
|
|
257
|
+
}
|