@firtoz/router-toolkit 8.0.0 → 9.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.
@@ -97,28 +97,33 @@
97
97
  *
98
98
  * ```tsx
99
99
  * import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
100
+ * import { useEffect, useState } from "react";
100
101
  *
101
102
  * function PostEditor({ postId }: { postId: string }) {
102
- * // Fetch post data
103
103
  * const fetcher = useDynamicFetcher<typeof import("./api.posts.$postId")>(
104
104
  * "/api/posts/:postId",
105
105
  * { postId }
106
106
  * );
107
107
  *
108
- * // Submit updates
109
108
  * const submitter = useDynamicSubmitter<typeof import("./api.posts.$postId")>(
110
109
  * "/api/posts/:postId",
111
110
  * { postId }
112
111
  * );
113
112
  *
113
+ * const [saving, setSaving] = useState(false);
114
+ *
114
115
  * useEffect(() => {
115
116
  * fetcher.load();
116
117
  * }, [fetcher.load]);
117
118
  *
118
119
  * const handleSave = async (title: string, content: string) => {
119
- * await submitter.submitJson({ title, content }, { method: "PUT" });
120
- * // Reload after save
121
- * fetcher.load();
120
+ * setSaving(true);
121
+ * try {
122
+ * await submitter.submitJson({ title, content }, { method: "PUT" });
123
+ * fetcher.load();
124
+ * } finally {
125
+ * setSaving(false);
126
+ * }
122
127
  * };
123
128
  *
124
129
  * if (!fetcher.data) return <div>Loading...</div>;
@@ -127,17 +132,22 @@
127
132
  * <form onSubmit={(e) => {
128
133
  * e.preventDefault();
129
134
  * const form = new FormData(e.currentTarget);
130
- * handleSave(form.get("title") as string, form.get("content") as string);
135
+ * void handleSave(form.get("title") as string, form.get("content") as string);
131
136
  * }}>
132
137
  * <input name="title" defaultValue={fetcher.data.post.title} />
133
138
  * <textarea name="content" defaultValue={fetcher.data.post.content} />
134
- * <button disabled={submitter.state !== "idle"}>
135
- * {submitter.state === "submitting" ? "Saving..." : "Save"}
139
+ * <button type="submit" disabled={saving}>
140
+ * {saving ? "Saving..." : "Save"}
136
141
  * </button>
137
142
  * </form>
138
143
  * );
139
144
  * }
140
145
  * ```
146
+ *
147
+ * **Submitter UX:** Local `saving` around `await submitter.submitJson` fits a promise-first save +
148
+ * reload flow. For declarative `fetcher.state` / `fetcher.data` in JSX (e.g. with `submitter.Form`),
149
+ * use {@link useDynamicSubmitterFetcher} instead. The package README documents trade-offs under
150
+ * **useDynamicSubmitter** (heading “Local useState vs useDynamicSubmitterFetcher”).
141
151
  */
142
152
 
143
153
  import { useCallback, useMemo } from "react";
@@ -4,6 +4,30 @@
4
4
  * This module provides a hook that creates a type-safe fetcher for submitting forms
5
5
  * to dynamic routes with full TypeScript inference for the form schema and route params.
6
6
  *
7
+ * **Awaiting results:** `submit` and `submitJson` return a `Promise` that resolves with the
8
+ * action payload after the submission completes (fetcher leaves `submitting` / `loading` for
9
+ * `idle`). The hook does **not** expose `data` or `state`—use the promise result (and local
10
+ * React state) for outcomes and loading UX. For declarative UI tied to the same fetcher, use
11
+ * {@link useDynamicSubmitterFetcher} (or {@link dynamicSubmitterFetcherKey} with `useFetcher`).
12
+ * Use **one awaited submit at
13
+ * a time** per hook instance; React Router’s
14
+ * single fetcher replaces an in-flight request when you submit again. If you call `submit` or
15
+ * `submitJson` again before the previous promise settles, the previous promise is **rejected**
16
+ * with {@link SubmitterSupersededError}. That applies **per React Router fetcher key** (same
17
+ * resolved URL and {@link UseDynamicSubmitterOptions.keySuffix}): two hook instances that share
18
+ * the same key also supersede one another’s in-flight promise. Use distinct `keySuffix` values
19
+ * when you need independent overlapping submissions to the same route. For many overlapping
20
+ * operations, use `ConcurrentSubmitterProvider` / `useConcurrentSubmitter` instead.
21
+ *
22
+ * **Unmount:** If the component unmounts while a returned promise is still pending, that
23
+ * promise is **rejected** with {@link SubmitterUnmountedError}.
24
+ *
25
+ * **Local `useState` vs {@link useDynamicSubmitterFetcher}:** For programmatic
26
+ * `await submitter.submitJson`, a local pending flag and `try` / `finally` is often enough for
27
+ * disabled buttons and matches the promise-first API. Use {@link useDynamicSubmitterFetcher} when
28
+ * you want declarative `fetcher.state` / `fetcher.data` in JSX (especially with `submitter.Form`
29
+ * or inline errors). The package README documents trade-offs in more detail.
30
+ *
7
31
  * @example
8
32
  * ### Route Setup (`app/routes/admin.posts.$id.tsx`)
9
33
  *
@@ -51,17 +75,24 @@
51
75
  * { id: postId }
52
76
  * );
53
77
  *
54
- * // submitter.data is the typed response from the action
55
- * // submitter.state is "idle" | "loading" | "submitting"
78
+ * const [pending, setPending] = useState(false);
56
79
  *
57
80
  * // Option 1: Submit as JSON (recommended for programmatic submissions)
58
81
  * // Defaults to POST if no options provided
59
82
  * const handleSubmitJson = async () => {
60
- * await submitter.submitJson({
61
- * title: "My Post",
62
- * content: "Post content here",
63
- * published: true,
64
- * });
83
+ * setPending(true);
84
+ * try {
85
+ * const data = await submitter.submitJson({
86
+ * title: "My Post",
87
+ * content: "Post content here",
88
+ * published: true,
89
+ * });
90
+ * if (data?.success) {
91
+ * console.log("Saved");
92
+ * }
93
+ * } finally {
94
+ * setPending(false);
95
+ * }
65
96
  * };
66
97
  *
67
98
  * // Option 2: Submit with FormData or SubmitTarget
@@ -69,43 +100,12 @@
69
100
  * await submitter.submit(formData, { method: "POST" });
70
101
  * };
71
102
  *
72
- * // Option 3: Use the Form component (defaults to POST)
103
+ * // Option 3: Use the Form component (defaults to POST); pair with useDynamicSubmitterFetcher(submitter) if you need reactive state
73
104
  * return (
74
105
  * <submitter.Form>
75
106
  * <input name="title" />
76
107
  * <textarea name="content" />
77
- * <button type="submit">Save</button>
78
- * </submitter.Form>
79
- * );
80
- * }
81
- * ```
82
- *
83
- * @example
84
- * ### Handling responses
85
- *
86
- * ```tsx
87
- * function LoginForm() {
88
- * const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
89
- *
90
- * useEffect(() => {
91
- * if (submitter.data?.success) {
92
- * // Handle success
93
- * console.log("Logged in as:", submitter.data.value.user.email);
94
- * } else if (submitter.data && !submitter.data.success) {
95
- * // Handle error
96
- * if (submitter.data.error.type === "validation") {
97
- * console.log("Validation errors:", submitter.data.error.error);
98
- * }
99
- * }
100
- * }, [submitter.data]);
101
- *
102
- * return (
103
- * <submitter.Form>
104
- * <input name="email" type="email" />
105
- * <input name="password" type="password" />
106
- * <button disabled={submitter.state !== "idle"}>
107
- * {submitter.state === "submitting" ? "Logging in..." : "Login"}
108
- * </button>
108
+ * <button type="submit" disabled={pending}>Save</button>
109
109
  * </submitter.Form>
110
110
  * );
111
111
  * }
@@ -113,9 +113,10 @@
113
113
  */
114
114
 
115
115
  // biome-ignore lint/style/useImportType: We need to import React here.
116
- import React, { useCallback, useMemo } from "react";
116
+ import React, { useCallback, useEffect, useMemo, useRef } from "react";
117
117
  import {
118
118
  type FetcherFormProps,
119
+ type HTMLFormMethod,
119
120
  href,
120
121
  type SubmitOptions,
121
122
  type SubmitTarget,
@@ -125,6 +126,127 @@ import type { z } from "zod";
125
126
  import type { HrefArgs } from "./types/HrefArgs";
126
127
  import type { RouteWithActionModule } from "./types/RouteWithActionModule";
127
128
 
129
+ /**
130
+ * Thrown when a new `submit` or `submitJson` runs before a prior returned promise has settled.
131
+ * The new submission proceeds; catch this error if overlapping calls are expected.
132
+ */
133
+ export class SubmitterSupersededError extends Error {
134
+ override readonly name = "SubmitterSupersededError";
135
+ constructor(
136
+ message = "This submission was superseded by a newer submit before it completed.",
137
+ ) {
138
+ super(message);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Thrown when the component that owns the submitter unmounts before a `submit` /
144
+ * `submitJson` promise has settled.
145
+ */
146
+ export class SubmitterUnmountedError extends Error {
147
+ override readonly name = "SubmitterUnmountedError";
148
+ constructor(
149
+ message = "The submitter was unmounted before this submission completed.",
150
+ ) {
151
+ super(message);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Action payload type resolved by `submit` / `submitJson` (same shape React Router puts on `fetcher.data` after the action runs).
157
+ */
158
+ export type DynamicSubmitterData<TInfo extends RouteWithActionModule> =
159
+ ReturnType<typeof useFetcher<TInfo["action"]>>["data"];
160
+
161
+ /**
162
+ * Options for {@link useDynamicSubmitter}.
163
+ */
164
+ export type UseDynamicSubmitterOptions = {
165
+ /**
166
+ * Appended to the default fetcher key so multiple submitters can target the same resolved URL
167
+ * without sharing React Router fetcher state. Omit to use the default key for that URL.
168
+ */
169
+ keySuffix?: string;
170
+ };
171
+
172
+ /**
173
+ * React Router `useFetcher` key used by {@link useDynamicSubmitter} for a resolved href.
174
+ * Pass the same string as {@link UseDynamicSubmitterResult.fetcherKey} (or call with the same
175
+ * `resolvedHref` and `keySuffix` as the submitter) so a parallel `useFetcher({ key })` observes
176
+ * the same submission lifecycle.
177
+ *
178
+ * When `keySuffix` is set, it is encoded and joined with a fixed delimiter so arbitrary strings
179
+ * are safe in the key.
180
+ */
181
+ export function dynamicSubmitterFetcherKey(
182
+ resolvedHref: string,
183
+ keySuffix?: string,
184
+ ): string {
185
+ const base = `submitter-${resolvedHref}`;
186
+ if (keySuffix === undefined || keySuffix === "") {
187
+ return base;
188
+ }
189
+ return `${base}::${encodeURIComponent(keySuffix)}`;
190
+ }
191
+
192
+ function isSubmitterOptions(x: unknown): x is UseDynamicSubmitterOptions {
193
+ if (x === null || typeof x !== "object") return false;
194
+ const keys = Object.keys(x as object);
195
+ if (keys.length === 0) return false;
196
+ return keys.every((k) => k === "keySuffix");
197
+ }
198
+
199
+ function parseUseDynamicSubmitterRestArgs(args: readonly unknown[]): {
200
+ hrefArgs: unknown[];
201
+ options: UseDynamicSubmitterOptions;
202
+ } {
203
+ if (args.length === 0) {
204
+ return { hrefArgs: [], options: {} };
205
+ }
206
+ const last = args[args.length - 1];
207
+ if (args.length >= 2 && isSubmitterOptions(last)) {
208
+ return { hrefArgs: [...args.slice(0, -1)], options: last };
209
+ }
210
+ if (args.length === 1 && isSubmitterOptions(args[0])) {
211
+ return { hrefArgs: [], options: args[0] };
212
+ }
213
+ return { hrefArgs: [...args], options: {} };
214
+ }
215
+
216
+ type UseDynamicSubmitterRest<R extends RouteWithActionModule["route"]> =
217
+ HrefArgs<R> extends readonly []
218
+ ? [options?: UseDynamicSubmitterOptions]
219
+ : [...hrefArgs: HrefArgs<R>, options?: UseDynamicSubmitterOptions];
220
+
221
+ type PendingAwait = {
222
+ gen: number;
223
+ ownerId: number;
224
+ reject: (reason: unknown) => void;
225
+ /** Called when the shared fetcher reaches `idle` for this submission generation. */
226
+ finishIdle: (data: unknown, error: unknown | undefined) => void;
227
+ };
228
+
229
+ type SubmitterKeyBucket = {
230
+ submitGen: number;
231
+ pending: PendingAwait | null;
232
+ };
233
+
234
+ const submitterKeyBuckets = new Map<string, SubmitterKeyBucket>();
235
+
236
+ function getSubmitterKeyBucket(key: string): SubmitterKeyBucket {
237
+ let b = submitterKeyBuckets.get(key);
238
+ if (!b) {
239
+ b = { submitGen: 0, pending: null };
240
+ submitterKeyBuckets.set(key, b);
241
+ }
242
+ return b;
243
+ }
244
+
245
+ let nextSubmitterOwnerId = 1;
246
+ function allocateSubmitterOwnerId(): number {
247
+ return nextSubmitterOwnerId++;
248
+ }
249
+
128
250
  /**
129
251
  * Function type for submitting form data with a SubmitTarget.
130
252
  *
@@ -145,7 +267,7 @@ type SubmitFunc<TModule extends RouteWithActionModule> = (
145
267
  options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
146
268
  method: Exclude<SubmitOptions["method"], "GET">;
147
269
  },
148
- ) => Promise<void>;
270
+ ) => Promise<DynamicSubmitterData<TModule>>;
149
271
 
150
272
  /**
151
273
  * Options for submitJson function.
@@ -183,7 +305,7 @@ type SubmitJsonOptions = Omit<
183
305
  type SubmitJsonFunc<TModule extends RouteWithActionModule> = (
184
306
  data: z.infer<TModule["formSchema"]>,
185
307
  options?: SubmitJsonOptions,
186
- ) => Promise<void>;
308
+ ) => Promise<DynamicSubmitterData<TModule>>;
187
309
 
188
310
  /**
189
311
  * Form component type with pre-bound action URL.
@@ -214,6 +336,19 @@ type SubmitForm = (
214
336
  },
215
337
  ) => React.ReactElement;
216
338
 
339
+ /**
340
+ * Stable object returned by {@link useDynamicSubmitter}: `submit`, `submitJson`, `Form`, and
341
+ * `fetcherKey`. The reference is memoized and does not change when the internal fetcher’s
342
+ * `state` / `data` update.
343
+ */
344
+ export type UseDynamicSubmitterResult<TInfo extends RouteWithActionModule> = {
345
+ submit: SubmitFunc<TInfo>;
346
+ submitJson: SubmitJsonFunc<TInfo>;
347
+ Form: SubmitForm;
348
+ /** Pass to {@link useDynamicSubmitterFetcher} or `useFetcher({ key })` for reactive `state` / `data`. */
349
+ fetcherKey: string;
350
+ };
351
+
217
352
  /**
218
353
  * Creates a type-safe fetcher for submitting forms to dynamic routes.
219
354
  *
@@ -225,15 +360,13 @@ type SubmitForm = (
225
360
  * @template TInfo - The route module type (use `typeof import("./route-file")`)
226
361
  *
227
362
  * @param path - The route path (must match the route's `route` export)
228
- * @param args - Route parameters (if the route has dynamic segments like `:id`)
363
+ * @param rest - Route parameters (if any), then optional {@link UseDynamicSubmitterOptions}. For
364
+ * static routes, you may pass only options as the second argument (e.g. `{ keySuffix: "a" }`).
365
+ * Options are recognized only when the object contains exclusively the `keySuffix` key (do not use
366
+ * a route param object whose only field is named `keySuffix` unless it is meant as options).
229
367
  *
230
- * @returns An extended fetcher object with:
231
- * - `submit` - Submit with FormData/SubmitTarget (includes schema type)
232
- * - `submitJson` - Submit a plain object as JSON (schema type only)
233
- * - `Form` - Pre-bound form component
234
- * - `data` - Response data from the action (typed)
235
- * - `state` - Fetcher state ("idle" | "loading" | "submitting")
236
- * - All other useFetcher properties
368
+ * @returns Stable `{ submit, submitJson, Form, fetcherKey }`. Await the promises for action results;
369
+ * use {@link useDynamicSubmitterFetcher} or local state for reactive loading/data.
237
370
  *
238
371
  * @example
239
372
  * ### Basic usage with route parameters
@@ -254,86 +387,164 @@ type SubmitForm = (
254
387
  * { userId: "123" }
255
388
  * );
256
389
  *
257
- * // Submit using submitJson (type-safe, no FormData needed, defaults to POST)
258
- * await submitter.submitJson({
390
+ * const data = await submitter.submitJson({
259
391
  * displayName: "John Doe",
260
392
  * email: "john@example.com",
261
393
  * notifications: true,
262
394
  * });
263
395
  *
264
- * // Check the response
265
- * if (submitter.data?.success) {
396
+ * if (data?.success) {
266
397
  * console.log("Settings updated!");
267
398
  * }
268
399
  * ```
269
400
  */
270
- export const useDynamicSubmitter = <TInfo extends RouteWithActionModule>(
401
+ export function useDynamicSubmitter<TInfo extends RouteWithActionModule>(
271
402
  path: TInfo["route"],
272
- ...args: TInfo["route"] extends "undefined"
273
- ? HrefArgs<"/">
274
- : HrefArgs<TInfo["route"]>
275
- ): Omit<
276
- ReturnType<typeof useFetcher<TInfo["action"]>>,
277
- "load" | "submit" | "Form"
278
- > & {
279
- /** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
280
- submit: SubmitFunc<TInfo>;
281
- /** Submit a plain object as JSON (schema type only, defaults to POST) */
282
- submitJson: SubmitJsonFunc<TInfo>;
283
- /** Pre-bound Form component with action URL already set (defaults to POST) */
284
- Form: SubmitForm;
285
- } => {
403
+ ...rest: UseDynamicSubmitterRest<TInfo["route"]>
404
+ ): UseDynamicSubmitterResult<TInfo> {
405
+ const { hrefArgs, options } = parseUseDynamicSubmitterRestArgs(rest);
406
+ const keySuffix = options.keySuffix;
407
+
286
408
  const url = useMemo(() => {
287
409
  // biome-ignore lint/suspicious/noExplicitAny: Intentional
288
- return href(path, ...(args as any));
289
- }, [path, args]);
410
+ return href(path, ...(hrefArgs as any));
411
+ }, [path, keySuffix, ...(hrefArgs as unknown[])]);
412
+
413
+ const fetcherKey = useMemo(
414
+ () => dynamicSubmitterFetcherKey(url, keySuffix),
415
+ [url, keySuffix],
416
+ );
290
417
 
291
418
  const fetcher = useFetcher<TInfo["action"]>({
292
- key: `submitter-${url}`,
419
+ key: fetcherKey,
293
420
  });
294
421
 
422
+ const fetcherRef = useRef(fetcher);
423
+ fetcherRef.current = fetcher;
424
+
425
+ const ownerIdRef = useRef(allocateSubmitterOwnerId());
426
+ const prevStateRef = useRef(fetcher.state);
427
+
428
+ const beginSubmit = useCallback(
429
+ (runSubmit: () => void) => {
430
+ return new Promise<DynamicSubmitterData<TInfo>>((resolve, reject) => {
431
+ const bucket = getSubmitterKeyBucket(fetcherKey);
432
+ const prevPending = bucket.pending;
433
+ if (prevPending) {
434
+ prevPending.reject(new SubmitterSupersededError());
435
+ }
436
+ bucket.submitGen += 1;
437
+ const gen = bucket.submitGen;
438
+ bucket.pending = {
439
+ gen,
440
+ ownerId: ownerIdRef.current,
441
+ reject,
442
+ finishIdle: (data, error) => {
443
+ if (data !== undefined) {
444
+ resolve(data as DynamicSubmitterData<TInfo>);
445
+ } else {
446
+ reject(error ?? new Error("Submission failed"));
447
+ }
448
+ },
449
+ };
450
+ runSubmit();
451
+ });
452
+ },
453
+ [fetcherKey],
454
+ );
455
+
456
+ useEffect(() => {
457
+ return () => {
458
+ const bucket = getSubmitterKeyBucket(fetcherKey);
459
+ const pending = bucket.pending;
460
+ if (pending && pending.ownerId === ownerIdRef.current) {
461
+ bucket.pending = null;
462
+ pending.reject(new SubmitterUnmountedError());
463
+ }
464
+ };
465
+ }, [fetcherKey]);
466
+
467
+ const fetcherError = (fetcher as { error?: unknown }).error;
468
+
469
+ useEffect(() => {
470
+ const prev = prevStateRef.current;
471
+ prevStateRef.current = fetcher.state;
472
+ const wasWorking = prev === "submitting" || prev === "loading";
473
+ if (!wasWorking || fetcher.state !== "idle") {
474
+ return;
475
+ }
476
+ const bucket = getSubmitterKeyBucket(fetcherKey);
477
+ const p = bucket.pending;
478
+ if (!p || p.gen !== bucket.submitGen) {
479
+ return;
480
+ }
481
+ bucket.pending = null;
482
+ p.finishIdle(fetcher.data, fetcherError);
483
+ }, [fetcherKey, fetcher.state, fetcher.data, fetcherError]);
484
+
295
485
  const submit: SubmitFunc<TInfo> = useCallback(
296
486
  (target, options) => {
297
- return fetcher.submit(target, {
298
- ...options,
299
- method: (options?.method ??
300
- "POST") as import("react-router").HTMLFormMethod,
301
- action: url,
302
- encType: "multipart/form-data",
303
- } as Parameters<typeof fetcher.submit>[1]);
487
+ return beginSubmit(() => {
488
+ const f = fetcherRef.current;
489
+ void f.submit(target, {
490
+ ...options,
491
+ method: (options?.method ?? "POST") as HTMLFormMethod,
492
+ action: url,
493
+ encType: "multipart/form-data",
494
+ } as Parameters<typeof f.submit>[1]);
495
+ });
304
496
  },
305
- [fetcher.submit, url],
497
+ [beginSubmit, url],
306
498
  );
307
499
 
308
500
  const submitJson: SubmitJsonFunc<TInfo> = useCallback(
309
501
  (data, options = {}) => {
310
- return fetcher.submit(
311
- data as SubmitTarget,
312
- {
313
- ...options,
314
- method: (options.method ??
315
- "POST") as import("react-router").HTMLFormMethod,
316
- action: url,
317
- encType: "application/json",
318
- } as Parameters<typeof fetcher.submit>[1],
319
- );
502
+ return beginSubmit(() => {
503
+ const f = fetcherRef.current;
504
+ void f.submit(
505
+ data as SubmitTarget,
506
+ {
507
+ ...options,
508
+ method: (options.method ?? "POST") as HTMLFormMethod,
509
+ action: url,
510
+ encType: "application/json",
511
+ } as Parameters<typeof f.submit>[1],
512
+ );
513
+ });
320
514
  },
321
- [fetcher.submit, url],
515
+ [beginSubmit, url],
322
516
  );
323
517
 
324
- const OriginalForm = fetcher.Form;
518
+ const fetcherFormRef = useRef(fetcher.Form);
519
+ fetcherFormRef.current = fetcher.Form;
325
520
 
326
521
  const Form: SubmitForm = useCallback(
327
522
  ({ method = "POST", ...props }) => {
523
+ const OriginalForm = fetcherFormRef.current;
328
524
  return <OriginalForm action={url} method={method} {...props} />;
329
525
  },
330
- [url, OriginalForm],
526
+ [url],
331
527
  );
332
528
 
333
- return {
334
- ...fetcher,
335
- submit,
336
- submitJson,
337
- Form,
338
- };
339
- };
529
+ return useMemo(
530
+ () => ({
531
+ submit,
532
+ submitJson,
533
+ Form,
534
+ fetcherKey,
535
+ }),
536
+ [submit, submitJson, Form, fetcherKey],
537
+ );
538
+ }
539
+
540
+ /**
541
+ * React Router `useFetcher` bound to the same key as {@link useDynamicSubmitter}, so `state` /
542
+ * `data` reflect the same submissions as `submitter.submit` / `submitter.Form`.
543
+ *
544
+ * Call at component top level next to `useDynamicSubmitter`.
545
+ */
546
+ export function useDynamicSubmitterFetcher<TInfo extends RouteWithActionModule>(
547
+ submitter: UseDynamicSubmitterResult<TInfo>,
548
+ ) {
549
+ return useFetcher<TInfo["action"]>({ key: submitter.fetcherKey });
550
+ }
@@ -1,3 +0,0 @@
1
- export * from '@firtoz/maybe-error';
2
- //# sourceMappingURL=chunk-2RLEUOSR.js.map
3
- //# sourceMappingURL=chunk-2RLEUOSR.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/formAction.ts"],"names":[],"mappings":";;;;;AA6SO,IAAM,aAAa,CAKxB;AAAA,EACD,MAAA;AAAA,EACA;AACD,CAAA,KAA8D;AAC7D,EAAA,OAAO,OACN,IAAA,KACoE;AACpE,IAAA,IAAI;AACH,MAAA,MAAM,WAAA,GAAc,IAAA,CAAK,OAAA,CAAQ,OAAA,CAAQ,IAAI,cAAc,CAAA;AAC3D,MAAA,MAAM,MAAA,GAAS,WAAA,EAAa,QAAA,CAAS,kBAAkB,CAAA,IAAK,KAAA;AAE5D,MAAA,MAAM,WAAA,GAAc,SACjB,MAAM,MAAA,CAAO,eAAe,MAAM,IAAA,CAAK,QAAQ,IAAA,EAAM,IACrD,MAAM,GAAA,CACL,SAAS,MAAM,CAAA,CACf,eAAe,MAAM,IAAA,CAAK,OAAA,CAAQ,QAAA,EAAU,CAAA;AAEhD,MAAA,IAAI,CAAC,YAAY,OAAA,EAAS;AACzB,QAAA,OAAO,IAAA,CAAK;AAAA,UACX,IAAA,EAAM,YAAA;AAAA,UACN,OAAO,CAAA,CAAE,YAAA;AAAA,YACR,WAAA,CAAY;AAAA;AACb,SACA,CAAA;AAAA,MACF;AAEA,MAAA,MAAM,aAAA,GAAgB,MAAM,OAAA,CAAQ,IAAA,EAAM,YAAY,IAAI,CAAA;AAC1D,MAAA,IAAI,CAAC,cAAc,OAAA,EAAS;AAC3B,QAAA,OAAO,IAAA,CAAK;AAAA,UACX,IAAA,EAAM,SAAA;AAAA,UACN,OAAO,aAAA,CAAc;AAAA,SACrB,CAAA;AAAA,MACF;AAEA,MAAA,OAAO,aAAA;AAAA,IACR,SAAS,KAAA,EAAO;AAEf,MAAA,IAAI,iBAAiB,QAAA,EAAU;AAC9B,QAAA,MAAM,KAAA;AAAA,MACP;AAEA,MAAA,OAAA,CAAQ,KAAA,CAAM,mCAAmC,KAAK,CAAA;AACtD,MAAA,OAAO,IAAA,CAAK;AAAA,QACX,IAAA,EAAM;AAAA,OACN,CAAA;AAAA,IACF;AAAA,EACD,CAAA;AACD","file":"chunk-5MOCOBGV.js","sourcesContent":["/**\n * @fileoverview Type-safe form action utility for React Router 7\n *\n * This module provides a wrapper for React Router actions that handles form data and JSON\n * validation using Zod schemas and provides structured error handling with MaybeError.\n *\n * Supports both:\n * - **JSON requests** (`Content-Type: application/json`) - parsed with `request.json()` and validated directly\n * - **FormData requests** (`multipart/form-data` or `application/x-www-form-urlencoded`) - parsed with `request.formData()` and validated with zod-form-data\n *\n * ## Overview\n *\n * `formAction` is designed to work seamlessly with `useDynamicSubmitter` and `useDynamicFetcher`\n * to provide end-to-end type safety for your React Router forms.\n *\n * @example\n * ### Basic Route Setup (`app/routes/auth.login.tsx`)\n *\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction, type RoutePath } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n *\n * // 1. Export the route path for type inference\n * export const route: RoutePath<\"/auth/login\"> = \"/auth/login\";\n *\n * // 2. Define your form schema with Zod\n * export const formSchema = z.object({\n * email: z.string().email(\"Please enter a valid email\"),\n * password: z.string().min(8, \"Password must be at least 8 characters\"),\n * rememberMe: z.boolean().optional().default(false),\n * });\n *\n * // 3. Create the action with formAction\n * export const action = formAction({\n * schema: formSchema,\n * handler: async ({ request }, data) => {\n * // data is fully typed: { email: string, password: string, rememberMe: boolean }\n * try {\n * const user = await authenticateUser(data.email, data.password);\n * if (data.rememberMe) {\n * await createPersistentSession(user.id);\n * }\n * return success({ user });\n * } catch (error) {\n * return fail(\"Invalid email or password\");\n * }\n * },\n * });\n * ```\n *\n * @example\n * ### Using with useDynamicSubmitter\n *\n * The route above can be used with `useDynamicSubmitter` for type-safe form submissions:\n *\n * ```tsx\n * import { useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n *\n * function LoginForm() {\n * const submitter = useDynamicSubmitter<typeof import(\"./auth.login\")>(\"/auth/login\");\n *\n * // Option 1: Submit as JSON (defaults to POST)\n * const handleLoginJson = async () => {\n * await submitter.submitJson({\n * email: \"user@example.com\",\n * password: \"secret123\",\n * rememberMe: true,\n * });\n * };\n *\n * // Option 2: Use the Form component (defaults to POST)\n * return (\n * <submitter.Form>\n * <input name=\"email\" type=\"email\" placeholder=\"Email\" />\n * <input name=\"password\" type=\"password\" placeholder=\"Password\" />\n * <label>\n * <input name=\"rememberMe\" type=\"checkbox\" /> Remember me\n * </label>\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Logging in...\" : \"Login\"}\n * </button>\n *\n * {submitter.data && !submitter.data.success && (\n * <div className=\"error\">\n * {submitter.data.error.type === \"validation\"\n * ? \"Please check your inputs\"\n * : submitter.data.error.type === \"handler\"\n * ? submitter.data.error.error // \"Invalid email or password\"\n * : \"An unexpected error occurred\"}\n * </div>\n * )}\n * </submitter.Form>\n * );\n * }\n * ```\n *\n * @example\n * ### Combined loader + action route (`app/routes/admin.posts.$id.tsx`)\n *\n * You can combine `formAction` with a loader for full CRUD operations:\n *\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction, type RoutePath } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n * import type { LoaderFunctionArgs } from \"react-router\";\n *\n * export const route: RoutePath<\"/admin/posts/:id\"> = \"/admin/posts/:id\";\n *\n * // Loader for fetching data (used with useDynamicFetcher)\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * const post = await db.posts.findUnique({ where: { id: params.id } });\n * return { post };\n * };\n *\n * // Form schema for updates\n * export const formSchema = z.object({\n * title: z.string().min(1, \"Title is required\"),\n * content: z.string().min(10, \"Content must be at least 10 characters\"),\n * published: z.boolean().optional().default(false),\n * });\n *\n * // Action for handling form submissions (used with useDynamicSubmitter)\n * export const action = formAction({\n * schema: formSchema,\n * handler: async ({ params }, data) => {\n * const updated = await db.posts.update({\n * where: { id: params.id },\n * data,\n * });\n * return success({ post: updated });\n * },\n * });\n * ```\n *\n * @example\n * ### Full CRUD component using both hooks\n *\n * ```tsx\n * import { useDynamicFetcher, useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n * import { useEffect } from \"react\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * // Fetch post data\n * const fetcher = useDynamicFetcher<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * // Submit updates\n * const submitter = useDynamicSubmitter<typeof import(\"./admin.posts.$id\")>(\n * \"/admin/posts/:id\",\n * { id: postId }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * if (fetcher.state === \"loading\" && !fetcher.data) {\n * return <div>Loading...</div>;\n * }\n *\n * const post = fetcher.data?.post;\n *\n * return (\n * <submitter.Form method=\"PUT\">\n * <input name=\"title\" defaultValue={post?.title} />\n * <textarea name=\"content\" defaultValue={post?.content} />\n * <label>\n * <input name=\"published\" type=\"checkbox\" defaultChecked={post?.published} />\n * Published\n * </label>\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Saving...\" : \"Save\"}\n * </button>\n * </submitter.Form>\n * );\n * }\n * ```\n */\n\nimport { fail, type MaybeError } from \"@firtoz/maybe-error\";\nimport type { ActionFunctionArgs } from \"react-router\";\nimport { z } from \"zod\";\nimport { zfd } from \"zod-form-data\";\n\n/**\n * Error types that can be returned by formAction\n */\nexport type FormActionError<TError, TSchema extends z.ZodTypeAny> =\n\t| {\n\t\t\ttype: \"validation\";\n\t\t\terror: ReturnType<typeof z.treeifyError<z.infer<TSchema>>>;\n\t }\n\t| {\n\t\t\ttype: \"handler\";\n\t\t\terror: TError;\n\t }\n\t| {\n\t\t\ttype: \"unknown\";\n\t };\n\n/**\n * Configuration object for formAction\n *\n * @template TSchema - The Zod schema type for form validation\n * @template TResult - The success result type from the handler\n * @template TError - The error type that the handler can return\n * @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)\n */\nexport interface FormActionConfig<\n\tTSchema extends z.ZodTypeAny,\n\tTResult = undefined,\n\tTError = string,\n\tActionArgs extends ActionFunctionArgs = ActionFunctionArgs,\n> {\n\t/**\n\t * Zod schema to validate the form data against\n\t */\n\tschema: TSchema;\n\t/**\n\t * Handler function that processes the validated form data\n\t *\n\t * @param args - The original action function arguments\n\t * @param data - The validated form data (typed according to the schema)\n\t * @returns A promise that resolves to a MaybeError with the result or error\n\t */\n\thandler: (\n\t\targs: ActionArgs,\n\t\tdata: z.infer<TSchema>,\n\t) => Promise<MaybeError<TResult, TError>>;\n}\n\n/**\n * Creates a type-safe form action handler that validates form data or JSON and provides structured error handling.\n *\n * This function wraps a React Router action to:\n * 1. Detect content type (JSON vs FormData) from the request headers\n * 2. Parse and validate the request body using a Zod schema\n * 3. Call the provided handler with validated data\n * 4. Return structured errors for validation failures, handler errors, or unknown errors\n * 5. Preserve React Router Response objects (redirects, etc.) by re-throwing them\n *\n * **Content-Type handling:**\n * - `application/json`: Uses `request.json()` and validates directly with the schema\n * - `multipart/form-data` or `application/x-www-form-urlencoded`: Uses `request.formData()` and validates with zod-form-data\n *\n * @template TSchema - The Zod schema type for form validation\n * @template TResult - The success result type from the handler (defaults to undefined)\n * @template TError - The error type that the handler can return (defaults to string)\n * @template ActionArgs - The action function arguments type (defaults to ActionFunctionArgs)\n *\n * @param config - Configuration object containing schema and handler\n * @returns An action function that can be used with React Router\n *\n * @example\n * ```typescript\n * import { z } from \"zod\";\n * import { formAction } from \"@firtoz/router-toolkit\";\n * import { success, fail } from \"@firtoz/maybe-error\";\n *\n * const loginSchema = z.object({\n * email: z.string().email(\"Invalid email format\"),\n * password: z.string().min(8, \"Password must be at least 8 characters\"),\n * });\n *\n * export const action = formAction({\n * schema: loginSchema,\n * handler: async (args, data) => {\n * try {\n * const user = await authenticateUser(data.email, data.password);\n * return success(user);\n * } catch (error) {\n * return fail(\"Invalid credentials\");\n * }\n * },\n * });\n * ```\n *\n * @example\n * ```typescript\n * // In your component, handle the different error types:\n * const actionData = useActionData<typeof action>();\n *\n * if (actionData && !actionData.success) {\n * switch (actionData.error.type) {\n * case \"validation\":\n * // Handle validation errors - actionData.error.error contains field-specific errors\n * break;\n * case \"handler\":\n * // Handle business logic errors - actionData.error.error contains your custom error\n * break;\n * case \"unknown\":\n * // Handle unexpected errors\n * break;\n * }\n * }\n * ```\n */\nexport const formAction = <\n\tTSchema extends z.ZodTypeAny,\n\tTResult = undefined,\n\tTError = string,\n\tActionArgs extends ActionFunctionArgs = ActionFunctionArgs,\n>({\n\tschema,\n\thandler,\n}: FormActionConfig<TSchema, TResult, TError, ActionArgs>) => {\n\treturn async (\n\t\targs: ActionArgs,\n\t): Promise<MaybeError<TResult, FormActionError<TError, TSchema>>> => {\n\t\ttry {\n\t\t\tconst contentType = args.request.headers.get(\"Content-Type\");\n\t\t\tconst isJson = contentType?.includes(\"application/json\") ?? false;\n\n\t\t\tconst parseResult = isJson\n\t\t\t\t? await schema.safeParseAsync(await args.request.json())\n\t\t\t\t: await zfd\n\t\t\t\t\t\t.formData(schema)\n\t\t\t\t\t\t.safeParseAsync(await args.request.formData());\n\n\t\t\tif (!parseResult.success) {\n\t\t\t\treturn fail({\n\t\t\t\t\ttype: \"validation\" as const,\n\t\t\t\t\terror: z.treeifyError<z.infer<TSchema>>(\n\t\t\t\t\t\tparseResult.error as z.core.$ZodError<z.infer<TSchema>>,\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst handlerResult = await handler(args, parseResult.data);\n\t\t\tif (!handlerResult.success) {\n\t\t\t\treturn fail({\n\t\t\t\t\ttype: \"handler\" as const,\n\t\t\t\t\terror: handlerResult.error,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn handlerResult;\n\t\t} catch (error) {\n\t\t\t// Re-throw Response objects (redirects, etc.) to preserve React Router behavior\n\t\t\tif (error instanceof Response) {\n\t\t\t\tthrow error;\n\t\t\t}\n\n\t\t\tconsole.error(\"Unexpected error in formAction:\", error);\n\t\t\treturn fail({\n\t\t\t\ttype: \"unknown\" as const,\n\t\t\t});\n\t\t}\n\t};\n};\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/useDynamicFetcher.ts"],"names":[],"mappings":";;;;AAsMO,IAAM,iBAAA,GAAoB,CAChC,IAAA,EAAA,GACG,IAAA,KAoBC;AACJ,EAAA,MAAM,GAAA,GAAM,QAAQ,MAAM;AAEzB,IAAA,OAAO,IAAA,CAAK,IAAA,EAAM,GAAI,IAAY,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,IAAA,EAAM,IAAI,CAAC,CAAA;AAEf,EAAA,MAAM,UAAU,UAAA,CAA4B;AAAA,IAC3C,GAAA,EAAK,WAAW,GAAG,CAAA;AAAA,GACnB,CAAA;AAED,EAAA,MAAM,IAAA,GAAO,WAAA;AAAA,IACZ,CAAC,WAAA,KAAyC;AACzC,MAAA,IAAI,CAAC,WAAA,IAAe,MAAA,CAAO,KAAK,WAAW,CAAA,CAAE,WAAW,CAAA,EAAG;AAC1D,QAAA,OAAO,OAAA,CAAQ,KAAK,GAAG,CAAA;AAAA,MACxB;AAGA,MAAA,MAAM,SAAS,IAAI,GAAA,CAAI,GAAA,EAAK,MAAA,CAAO,SAAS,MAAM,CAAA;AAClD,MAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,WAAW,CAAA,EAAG;AACvD,QAAA,MAAA,CAAO,YAAA,CAAa,GAAA,CAAI,GAAA,EAAK,KAAK,CAAA;AAAA,MACnC;AAEA,MAAA,OAAO,OAAA,CAAQ,IAAA,CAAK,MAAA,CAAO,QAAA,GAAW,OAAO,MAAM,CAAA;AAAA,IACpD,CAAA;AAAA,IACA,CAAC,OAAA,CAAQ,IAAA,EAAM,GAAG;AAAA,GACnB;AAEA,EAAA,OAAO;AAAA,IACN,GAAG,OAAA;AAAA,IACH;AAAA,GACD;AACD","file":"chunk-HX57TC2S.js","sourcesContent":["/**\n * @fileoverview Type-safe dynamic data fetching hook for React Router 7\n *\n * This module provides a hook that creates a type-safe fetcher for loading data\n * from dynamic routes with full TypeScript inference for the loader response and route params.\n *\n * @example\n * ### Route Setup (`app/routes/api.users.$userId.ts`)\n *\n * First, set up your route with the required exports:\n *\n * ```typescript\n * import type { RoutePath } from \"@firtoz/router-toolkit\";\n *\n * // Export the route path for type inference\n * export const route: RoutePath<\"/api/users/:userId\"> = \"/api/users/:userId\";\n *\n * // Define the loader with a typed return value\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * const user = await db.users.findUnique({ where: { id: params.userId } });\n * return {\n * user: {\n * id: user.id,\n * email: user.email,\n * displayName: user.displayName,\n * createdAt: user.createdAt.toISOString(),\n * },\n * };\n * };\n * ```\n *\n * @example\n * ### Using the hook in a component\n *\n * ```tsx\n * import { useDynamicFetcher } from \"@firtoz/router-toolkit\";\n * import { useEffect } from \"react\";\n *\n * function UserProfile({ userId }: { userId: string }) {\n * // Type-safe fetcher with full inference\n * const fetcher = useDynamicFetcher<typeof import(\"./api.users.$userId\")>(\n * \"/api/users/:userId\",\n * { userId }\n * );\n *\n * // Load data on mount\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * // fetcher.data is fully typed: { user: { id, email, displayName, createdAt } } | undefined\n * if (fetcher.state === \"loading\") {\n * return <div>Loading...</div>;\n * }\n *\n * if (!fetcher.data) {\n * return <div>No user found</div>;\n * }\n *\n * return (\n * <div>\n * <h1>{fetcher.data.user.displayName}</h1>\n * <p>{fetcher.data.user.email}</p>\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ### Loading with query parameters\n *\n * ```tsx\n * function SearchResults() {\n * const fetcher = useDynamicFetcher<typeof import(\"./api.search\")>(\"/api/search\");\n *\n * const handleSearch = (query: string, page: number) => {\n * // Pass query params to the load function\n * fetcher.load({ q: query, page: String(page) });\n * };\n *\n * return (\n * <div>\n * <input onChange={(e) => handleSearch(e.target.value, 1)} />\n * {fetcher.data?.results.map((result) => (\n * <div key={result.id}>{result.title}</div>\n * ))}\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ### Combining with useDynamicSubmitter for full CRUD\n *\n * You can use `useDynamicFetcher` alongside `useDynamicSubmitter` to create\n * complete CRUD interfaces with type safety:\n *\n * ```tsx\n * import { useDynamicFetcher, useDynamicSubmitter } from \"@firtoz/router-toolkit\";\n *\n * function PostEditor({ postId }: { postId: string }) {\n * // Fetch post data\n * const fetcher = useDynamicFetcher<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * // Submit updates\n * const submitter = useDynamicSubmitter<typeof import(\"./api.posts.$postId\")>(\n * \"/api/posts/:postId\",\n * { postId }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * const handleSave = async (title: string, content: string) => {\n * await submitter.submitJson({ title, content }, { method: \"PUT\" });\n * // Reload after save\n * fetcher.load();\n * };\n *\n * if (!fetcher.data) return <div>Loading...</div>;\n *\n * return (\n * <form onSubmit={(e) => {\n * e.preventDefault();\n * const form = new FormData(e.currentTarget);\n * handleSave(form.get(\"title\") as string, form.get(\"content\") as string);\n * }}>\n * <input name=\"title\" defaultValue={fetcher.data.post.title} />\n * <textarea name=\"content\" defaultValue={fetcher.data.post.content} />\n * <button disabled={submitter.state !== \"idle\"}>\n * {submitter.state === \"submitting\" ? \"Saving...\" : \"Save\"}\n * </button>\n * </form>\n * );\n * }\n * ```\n */\n\nimport { useCallback, useMemo } from \"react\";\nimport { href, useFetcher } from \"react-router\";\nimport type { HrefArgs } from \"./types/HrefArgs\";\nimport type { RouteWithLoaderModule } from \"./types/RouteWithLoaderModule\";\n\n/**\n * Creates a type-safe fetcher for loading data from dynamic routes.\n *\n * This hook provides full TypeScript inference for:\n * - Route parameters (from the route path)\n * - Loader response type (from the route's loader export)\n *\n * @template TInfo - The route module type (use `typeof import(\"./route-file\")`)\n *\n * @param path - The route path (must match the route's `route` export)\n * @param args - Route parameters (if the route has dynamic segments like `:id`)\n *\n * @returns An extended fetcher object with:\n * - `load` - Function to load data, optionally with query parameters\n * - `data` - Response data from the loader (typed)\n * - `state` - Fetcher state (\"idle\" | \"loading\" | \"submitting\")\n * - All other useFetcher properties (except `submit`)\n *\n * @example\n * ### Basic usage\n *\n * ```typescript\n * // In your route file (app/routes/api.products.$productId.ts):\n * export const route: RoutePath<\"/api/products/:productId\"> = \"/api/products/:productId\";\n * export const loader = async ({ params }: LoaderFunctionArgs) => {\n * return { product: await getProduct(params.productId) };\n * };\n *\n * // In your component:\n * const fetcher = useDynamicFetcher<typeof import(\"./api.products.$productId\")>(\n * \"/api/products/:productId\",\n * { productId: \"abc123\" }\n * );\n *\n * useEffect(() => {\n * fetcher.load();\n * }, [fetcher.load]);\n *\n * // fetcher.data is typed as { product: Product } | undefined\n * ```\n *\n * @example\n * ### With query parameters\n *\n * ```typescript\n * const fetcher = useDynamicFetcher<typeof import(\"./api.search\")>(\"/api/search\");\n *\n * // Load with query params: /api/search?q=hello&limit=10\n * fetcher.load({ q: \"hello\", limit: \"10\" });\n * ```\n */\nexport const useDynamicFetcher = <TInfo extends RouteWithLoaderModule>(\n\tpath: TInfo[\"route\"],\n\t...args: TInfo[\"route\"] extends \"undefined\"\n\t\t? HrefArgs<\"/\">\n\t\t: HrefArgs<TInfo[\"route\"]>\n): Omit<ReturnType<typeof useFetcher<TInfo[\"loader\"]>>, \"load\" | \"submit\"> & {\n\t/**\n\t * Load data from the route's loader.\n\t *\n\t * @param queryParams - Optional query parameters to append to the URL\n\t * @returns A promise that resolves when the load is complete\n\t *\n\t * @example\n\t * ```typescript\n\t * // Load without query params\n\t * fetcher.load();\n\t *\n\t * // Load with query params\n\t * fetcher.load({ page: \"2\", sort: \"name\" });\n\t * ```\n\t */\n\tload: (queryParams?: Record<string, string>) => Promise<void>;\n} => {\n\tconst url = useMemo(() => {\n\t\t// biome-ignore lint/suspicious/noExplicitAny: Intentional\n\t\treturn href(path, ...(args as any));\n\t}, [path, args]);\n\n\tconst fetcher = useFetcher<TInfo[\"loader\"]>({\n\t\tkey: `fetcher-${url}`,\n\t});\n\n\tconst load = useCallback(\n\t\t(queryParams?: Record<string, string>) => {\n\t\t\tif (!queryParams || Object.keys(queryParams).length === 0) {\n\t\t\t\treturn fetcher.load(url);\n\t\t\t}\n\n\t\t\t// Build URL with query parameters\n\t\t\tconst urlObj = new URL(url, window.location.origin);\n\t\t\tfor (const [key, value] of Object.entries(queryParams)) {\n\t\t\t\turlObj.searchParams.set(key, value);\n\t\t\t}\n\n\t\t\treturn fetcher.load(urlObj.pathname + urlObj.search);\n\t\t},\n\t\t[fetcher.load, url],\n\t);\n\n\treturn {\n\t\t...fetcher,\n\t\tload,\n\t};\n};\n"]}