@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.
@@ -1,5 +1,6 @@
1
- import React from 'react';
1
+ import * as react_router from 'react-router';
2
2
  import { useFetcher, SubmitTarget, SubmitOptions, FetcherFormProps } from 'react-router';
3
+ import React from 'react';
3
4
  import { z } from 'zod';
4
5
  import { HrefArgs } from './types/HrefArgs.js';
5
6
  import { RouteWithActionModule } from './types/RouteWithActionModule.js';
@@ -7,119 +8,268 @@ import './types/RegisterPages.js';
7
8
  import './types/Func.js';
8
9
 
9
10
  /**
10
- * @fileoverview Type-safe dynamic form submission hook for React Router 7
11
- *
12
- * This module provides a hook that creates a type-safe fetcher for submitting forms
13
- * to dynamic routes with full TypeScript inference for the form schema and route params.
14
- *
15
- * @example
16
- * ### Route Setup (`app/routes/admin.posts.$id.tsx`)
17
- *
18
- * First, set up your route with the required exports:
19
- *
20
- * ```typescript
21
- * import { z } from "zod";
22
- * import { formAction, type RoutePath } from "@firtoz/router-toolkit";
23
- * import { success, fail } from "@firtoz/maybe-error";
24
- *
25
- * // Export the route path for type inference
26
- * export const route: RoutePath<"/admin/posts/:id"> = "/admin/posts/:id";
27
- *
28
- * // Define the form schema
29
- * export const formSchema = z.object({
30
- * title: z.string().min(1, "Title is required"),
31
- * content: z.string().min(10, "Content must be at least 10 characters"),
32
- * published: z.boolean().optional().default(false),
33
- * });
34
- *
35
- * // Create the action using formAction
36
- * export const action = formAction({
37
- * schema: formSchema,
38
- * handler: async ({ request, params }, formData) => {
39
- * const postId = params.id;
40
- * const updated = await db.posts.update({
41
- * where: { id: postId },
42
- * data: formData,
43
- * });
44
- * return success(updated);
45
- * },
46
- * });
47
- * ```
48
- *
49
- * @example
50
- * ### Using the hook in a component
51
- *
52
- * ```tsx
53
- * import { useDynamicSubmitter } from "@firtoz/router-toolkit";
54
- *
55
- * function EditPostForm({ postId }: { postId: string }) {
56
- * // Type-safe submitter with full inference
57
- * const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
58
- * "/admin/posts/:id",
59
- * { id: postId }
60
- * );
61
- *
62
- * // submitter.data is the typed response from the action
63
- * // submitter.state is "idle" | "loading" | "submitting"
64
- *
65
- * // Option 1: Submit as JSON (recommended for programmatic submissions)
66
- * // Defaults to POST if no options provided
67
- * const handleSubmitJson = async () => {
68
- * await submitter.submitJson({
69
- * title: "My Post",
70
- * content: "Post content here",
71
- * published: true,
72
- * });
73
- * };
74
- *
75
- * // Option 2: Submit with FormData or SubmitTarget
76
- * const handleSubmit = async (formData: FormData) => {
77
- * await submitter.submit(formData, { method: "POST" });
78
- * };
79
- *
80
- * // Option 3: Use the Form component (defaults to POST)
81
- * return (
82
- * <submitter.Form>
83
- * <input name="title" />
84
- * <textarea name="content" />
85
- * <button type="submit">Save</button>
86
- * </submitter.Form>
87
- * );
88
- * }
89
- * ```
11
+ * An augmentable interface users can modify in their app-code to opt into
12
+ * future-flag-specific types
13
+ */
14
+ interface Future {
15
+ }
16
+ type MiddlewareEnabled = Future extends {
17
+ v8_middleware: infer T extends boolean;
18
+ } ? T : false;
19
+ /**
20
+ * A context instance used as the key for the `get`/`set` methods of a
21
+ * {@link RouterContextProvider}. Accepts an optional default
22
+ * value to be returned if no value has been set.
23
+ */
24
+ interface RouterContext<T = unknown> {
25
+ defaultValue?: T;
26
+ }
27
+ /**
28
+ * Provides methods for writing/reading values in application context in a
29
+ * type-safe way. Primarily for usage with [middleware](../../how-to/middleware).
90
30
  *
91
31
  * @example
92
- * ### Handling responses
93
- *
94
- * ```tsx
95
- * function LoginForm() {
96
- * const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
97
- *
98
- * useEffect(() => {
99
- * if (submitter.data?.success) {
100
- * // Handle success
101
- * console.log("Logged in as:", submitter.data.value.user.email);
102
- * } else if (submitter.data && !submitter.data.success) {
103
- * // Handle error
104
- * if (submitter.data.error.type === "validation") {
105
- * console.log("Validation errors:", submitter.data.error.error);
106
- * }
107
- * }
108
- * }, [submitter.data]);
109
- *
110
- * return (
111
- * <submitter.Form>
112
- * <input name="email" type="email" />
113
- * <input name="password" type="password" />
114
- * <button disabled={submitter.state !== "idle"}>
115
- * {submitter.state === "submitting" ? "Logging in..." : "Login"}
116
- * </button>
117
- * </submitter.Form>
118
- * );
119
- * }
120
- * ```
32
+ * import {
33
+ * createContext,
34
+ * RouterContextProvider
35
+ * } from "react-router";
36
+ *
37
+ * const userContext = createContext<User | null>(null);
38
+ * const contextProvider = new RouterContextProvider();
39
+ * contextProvider.set(userContext, getUser());
40
+ * // ^ Type-safe
41
+ * const user = contextProvider.get(userContext);
42
+ * // ^ User
43
+ *
44
+ * @public
45
+ * @category Utils
46
+ * @mode framework
47
+ * @mode data
48
+ */
49
+ declare class RouterContextProvider {
50
+ #private;
51
+ /**
52
+ * Create a new `RouterContextProvider` instance
53
+ * @param init An optional initial context map to populate the provider with
54
+ */
55
+ constructor(init?: Map<RouterContext, unknown>);
56
+ /**
57
+ * Access a value from the context. If no value has been set for the context,
58
+ * it will return the context's `defaultValue` if provided, or throw an error
59
+ * if no `defaultValue` was set.
60
+ * @param context The context to get the value for
61
+ * @returns The value for the context, or the context's `defaultValue` if no
62
+ * value was set
63
+ */
64
+ get<T>(context: RouterContext<T>): T;
65
+ /**
66
+ * Set a value for the context. If the context already has a value set, this
67
+ * will overwrite it.
68
+ *
69
+ * @param context The context to set the value for
70
+ * @param value The value to set for the context
71
+ * @returns {void}
72
+ */
73
+ set<C extends RouterContext>(context: C, value: C extends RouterContext<infer T> ? T : never): void;
74
+ }
75
+ type DefaultContext = MiddlewareEnabled extends true ? Readonly<RouterContextProvider> : any;
76
+ /**
77
+ * @private
78
+ * Arguments passed to route loader/action functions. Same for now but we keep
79
+ * this as a private implementation detail in case they diverge in the future.
80
+ */
81
+ interface DataFunctionArgs<Context> {
82
+ /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read headers (like cookies, and {@link https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams URLSearchParams} from the request. */
83
+ request: Request;
84
+ /**
85
+ * A URL instance representing the application location being navigated to or fetched.
86
+ * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`.
87
+ * With `future.unstable_passThroughRequests` enabled, this is a normalized
88
+ * URL with React-Router-specific implementation details removed (`.data`
89
+ * suffixes, `index`/`_routes` search params).
90
+ * The URL includes the origin from the request for convenience.
91
+ */
92
+ unstable_url: URL;
93
+ /**
94
+ * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug).
95
+ * Mostly useful as a identifier to aggregate on for logging/tracing/etc.
96
+ */
97
+ unstable_pattern: string;
98
+ /**
99
+ * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route.
100
+ * @example
101
+ * // app/routes.ts
102
+ * route("teams/:teamId", "./team.tsx"),
103
+ *
104
+ * // app/team.tsx
105
+ * export function loader({
106
+ * params,
107
+ * }: Route.LoaderArgs) {
108
+ * params.teamId;
109
+ * // ^ string
110
+ * }
111
+ */
112
+ params: Params;
113
+ /**
114
+ * This is the context passed in to your server adapter's getLoadContext() function.
115
+ * It's a way to bridge the gap between the adapter's request/response API with your React Router app.
116
+ * It is only applicable if you are using a custom server adapter.
117
+ */
118
+ context: Context;
119
+ }
120
+ /**
121
+ * Arguments passed to loader functions
122
+ */
123
+ interface LoaderFunctionArgs<Context = DefaultContext> extends DataFunctionArgs<Context> {
124
+ }
125
+ /**
126
+ * Arguments passed to action functions
127
+ */
128
+ interface ActionFunctionArgs<Context = DefaultContext> extends DataFunctionArgs<Context> {
129
+ }
130
+ /**
131
+ * The parameters that were parsed from the URL path.
121
132
  */
133
+ type Params<Key extends string = string> = {
134
+ readonly [key in Key]: string | undefined;
135
+ };
136
+ declare class DataWithResponseInit<D> {
137
+ type: string;
138
+ data: D;
139
+ init: ResponseInit | null;
140
+ constructor(data: D, init?: ResponseInit);
141
+ }
142
+
143
+ type Serializable = undefined | null | boolean | string | symbol | number | Array<Serializable> | {
144
+ [key: PropertyKey]: Serializable;
145
+ } | bigint | Date | URL | RegExp | Error | Map<Serializable, Serializable> | Set<Serializable> | Promise<Serializable>;
146
+
147
+ type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
148
+ type IsAny<T> = 0 extends 1 & T ? true : false;
149
+ type Func = (...args: any[]) => unknown;
122
150
 
151
+ /**
152
+ * A brand that can be applied to a type to indicate that it will serialize
153
+ * to a specific type when transported to the client from a loader.
154
+ * Only use this if you have additional serialization/deserialization logic
155
+ * in your application.
156
+ */
157
+ type unstable_SerializesTo<T> = {
158
+ unstable__ReactRouter_SerializesTo: [T];
159
+ };
160
+
161
+ type Serialize<T> = T extends unstable_SerializesTo<infer To> ? To : T extends Serializable ? T : T extends (...args: any[]) => unknown ? undefined : T extends Promise<infer U> ? Promise<Serialize<U>> : T extends Map<infer K, infer V> ? Map<Serialize<K>, Serialize<V>> : T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<Serialize<K>, Serialize<V>> : T extends Set<infer U> ? Set<Serialize<U>> : T extends ReadonlySet<infer U> ? ReadonlySet<Serialize<U>> : T extends [] ? [] : T extends readonly [infer F, ...infer R] ? [Serialize<F>, ...Serialize<R>] : T extends Array<infer U> ? Array<Serialize<U>> : T extends readonly unknown[] ? readonly Serialize<T[number]>[] : T extends Record<any, any> ? {
162
+ [K in keyof T]: Serialize<T[K]>;
163
+ } : undefined;
164
+ type VoidToUndefined<T> = Equal<T, void> extends true ? undefined : T;
165
+ type DataFrom<T> = IsAny<T> extends true ? undefined : T extends Func ? VoidToUndefined<Awaited<ReturnType<T>>> : undefined;
166
+ type ClientData<T> = T extends Response ? never : T extends DataWithResponseInit<infer U> ? U : T;
167
+ type ServerData<T> = T extends Response ? never : T extends DataWithResponseInit<infer U> ? Serialize<U> : Serialize<T>;
168
+ type ServerDataFrom<T> = ServerData<DataFrom<T>>;
169
+ type ClientDataFrom<T> = ClientData<DataFrom<T>>;
170
+ type ClientDataFunctionArgs<Params> = {
171
+ /**
172
+ * A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the URL, the method, the "content-type" header, and the request body from the request.
173
+ *
174
+ * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission.
175
+ **/
176
+ request: Request;
177
+ /**
178
+ * A URL instance representing the application location being navigated to or fetched.
179
+ * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`.
180
+ * With `future.unstable_passThroughRequests` enabled, this is a normalized
181
+ * URL with React-Router-specific implementation details removed (`.data`
182
+ * pathnames, `index`/`_routes` search params).
183
+ * The URL includes the origin from the request for convenience.
184
+ */
185
+ unstable_url: URL;
186
+ /**
187
+ * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route.
188
+ * @example
189
+ * // app/routes.ts
190
+ * route("teams/:teamId", "./team.tsx"),
191
+ *
192
+ * // app/team.tsx
193
+ * export function clientLoader({
194
+ * params,
195
+ * }: Route.ClientLoaderArgs) {
196
+ * params.teamId;
197
+ * // ^ string
198
+ * }
199
+ **/
200
+ params: Params;
201
+ /**
202
+ * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug).
203
+ * Mostly useful as a identifier to aggregate on for logging/tracing/etc.
204
+ */
205
+ unstable_pattern: string;
206
+ /**
207
+ * When `future.v8_middleware` is not enabled, this is undefined.
208
+ *
209
+ * When `future.v8_middleware` is enabled, this is an instance of
210
+ * `RouterContextProvider` and can be used to access context values
211
+ * from your route middlewares. You may pass in initial context values in your
212
+ * `<HydratedRouter getContext>` prop
213
+ */
214
+ context: Readonly<RouterContextProvider>;
215
+ };
216
+ type SerializeFrom<T> = T extends (...args: infer Args) => unknown ? Args extends [
217
+ ClientLoaderFunctionArgs | ClientActionFunctionArgs | ClientDataFunctionArgs<unknown>
218
+ ] ? ClientDataFrom<T> : ServerDataFrom<T> : T;
219
+ /**
220
+ * Arguments passed to a route `clientAction` function
221
+ */
222
+ type ClientActionFunctionArgs = ActionFunctionArgs & {
223
+ serverAction: <T = unknown>() => Promise<SerializeFrom<T>>;
224
+ };
225
+ /**
226
+ * Arguments passed to a route `clientLoader` function
227
+ */
228
+ type ClientLoaderFunctionArgs = LoaderFunctionArgs & {
229
+ serverLoader: <T = unknown>() => Promise<SerializeFrom<T>>;
230
+ };
231
+
232
+ /**
233
+ * Thrown when a new `submit` or `submitJson` runs before a prior returned promise has settled.
234
+ * The new submission proceeds; catch this error if overlapping calls are expected.
235
+ */
236
+ declare class SubmitterSupersededError extends Error {
237
+ readonly name = "SubmitterSupersededError";
238
+ constructor(message?: string);
239
+ }
240
+ /**
241
+ * Thrown when the component that owns the submitter unmounts before a `submit` /
242
+ * `submitJson` promise has settled.
243
+ */
244
+ declare class SubmitterUnmountedError extends Error {
245
+ readonly name = "SubmitterUnmountedError";
246
+ constructor(message?: string);
247
+ }
248
+ /**
249
+ * Action payload type resolved by `submit` / `submitJson` (same shape React Router puts on `fetcher.data` after the action runs).
250
+ */
251
+ type DynamicSubmitterData<TInfo extends RouteWithActionModule> = ReturnType<typeof useFetcher<TInfo["action"]>>["data"];
252
+ /**
253
+ * Options for {@link useDynamicSubmitter}.
254
+ */
255
+ type UseDynamicSubmitterOptions = {
256
+ /**
257
+ * Appended to the default fetcher key so multiple submitters can target the same resolved URL
258
+ * without sharing React Router fetcher state. Omit to use the default key for that URL.
259
+ */
260
+ keySuffix?: string;
261
+ };
262
+ /**
263
+ * React Router `useFetcher` key used by {@link useDynamicSubmitter} for a resolved href.
264
+ * Pass the same string as {@link UseDynamicSubmitterResult.fetcherKey} (or call with the same
265
+ * `resolvedHref` and `keySuffix` as the submitter) so a parallel `useFetcher({ key })` observes
266
+ * the same submission lifecycle.
267
+ *
268
+ * When `keySuffix` is set, it is encoded and joined with a fixed delimiter so arbitrary strings
269
+ * are safe in the key.
270
+ */
271
+ declare function dynamicSubmitterFetcherKey(resolvedHref: string, keySuffix?: string): string;
272
+ type UseDynamicSubmitterRest<R extends RouteWithActionModule["route"]> = HrefArgs<R> extends readonly [] ? [options?: UseDynamicSubmitterOptions] : [...hrefArgs: HrefArgs<R>, options?: UseDynamicSubmitterOptions];
123
273
  /**
124
274
  * Function type for submitting form data with a SubmitTarget.
125
275
  *
@@ -137,7 +287,7 @@ import './types/Func.js';
137
287
  */
138
288
  type SubmitFunc<TModule extends RouteWithActionModule> = (target: z.infer<TModule["formSchema"]> & SubmitTarget, options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
139
289
  method: Exclude<SubmitOptions["method"], "GET">;
140
- }) => Promise<void>;
290
+ }) => Promise<DynamicSubmitterData<TModule>>;
141
291
  /**
142
292
  * Options for submitJson function.
143
293
  * Method defaults to "POST" if not specified.
@@ -167,7 +317,7 @@ type SubmitJsonOptions = Omit<SubmitOptions, "action" | "method" | "encType"> &
167
317
  * await submitter.submitJson(data, { method: "PUT" });
168
318
  * ```
169
319
  */
170
- type SubmitJsonFunc<TModule extends RouteWithActionModule> = (data: z.infer<TModule["formSchema"]>, options?: SubmitJsonOptions) => Promise<void>;
320
+ type SubmitJsonFunc<TModule extends RouteWithActionModule> = (data: z.infer<TModule["formSchema"]>, options?: SubmitJsonOptions) => Promise<DynamicSubmitterData<TModule>>;
171
321
  /**
172
322
  * Form component type with pre-bound action URL.
173
323
  *
@@ -191,6 +341,18 @@ type SubmitJsonFunc<TModule extends RouteWithActionModule> = (data: z.infer<TMod
191
341
  type SubmitForm = (props: Omit<FetcherFormProps & React.RefAttributes<HTMLFormElement>, "action" | "method"> & {
192
342
  method?: Exclude<SubmitOptions["method"], "GET">;
193
343
  }) => React.ReactElement;
344
+ /**
345
+ * Stable object returned by {@link useDynamicSubmitter}: `submit`, `submitJson`, `Form`, and
346
+ * `fetcherKey`. The reference is memoized and does not change when the internal fetcher’s
347
+ * `state` / `data` update.
348
+ */
349
+ type UseDynamicSubmitterResult<TInfo extends RouteWithActionModule> = {
350
+ submit: SubmitFunc<TInfo>;
351
+ submitJson: SubmitJsonFunc<TInfo>;
352
+ Form: SubmitForm;
353
+ /** Pass to {@link useDynamicSubmitterFetcher} or `useFetcher({ key })` for reactive `state` / `data`. */
354
+ fetcherKey: string;
355
+ };
194
356
  /**
195
357
  * Creates a type-safe fetcher for submitting forms to dynamic routes.
196
358
  *
@@ -202,15 +364,13 @@ type SubmitForm = (props: Omit<FetcherFormProps & React.RefAttributes<HTMLFormEl
202
364
  * @template TInfo - The route module type (use `typeof import("./route-file")`)
203
365
  *
204
366
  * @param path - The route path (must match the route's `route` export)
205
- * @param args - Route parameters (if the route has dynamic segments like `:id`)
367
+ * @param rest - Route parameters (if any), then optional {@link UseDynamicSubmitterOptions}. For
368
+ * static routes, you may pass only options as the second argument (e.g. `{ keySuffix: "a" }`).
369
+ * Options are recognized only when the object contains exclusively the `keySuffix` key (do not use
370
+ * a route param object whose only field is named `keySuffix` unless it is meant as options).
206
371
  *
207
- * @returns An extended fetcher object with:
208
- * - `submit` - Submit with FormData/SubmitTarget (includes schema type)
209
- * - `submitJson` - Submit a plain object as JSON (schema type only)
210
- * - `Form` - Pre-bound form component
211
- * - `data` - Response data from the action (typed)
212
- * - `state` - Fetcher state ("idle" | "loading" | "submitting")
213
- * - All other useFetcher properties
372
+ * @returns Stable `{ submit, submitJson, Form, fetcherKey }`. Await the promises for action results;
373
+ * use {@link useDynamicSubmitterFetcher} or local state for reactive loading/data.
214
374
  *
215
375
  * @example
216
376
  * ### Basic usage with route parameters
@@ -231,26 +391,24 @@ type SubmitForm = (props: Omit<FetcherFormProps & React.RefAttributes<HTMLFormEl
231
391
  * { userId: "123" }
232
392
  * );
233
393
  *
234
- * // Submit using submitJson (type-safe, no FormData needed, defaults to POST)
235
- * await submitter.submitJson({
394
+ * const data = await submitter.submitJson({
236
395
  * displayName: "John Doe",
237
396
  * email: "john@example.com",
238
397
  * notifications: true,
239
398
  * });
240
399
  *
241
- * // Check the response
242
- * if (submitter.data?.success) {
400
+ * if (data?.success) {
243
401
  * console.log("Settings updated!");
244
402
  * }
245
403
  * ```
246
404
  */
247
- declare const useDynamicSubmitter: <TInfo extends RouteWithActionModule>(path: TInfo["route"], ...args: TInfo["route"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["route"]>) => Omit<ReturnType<typeof useFetcher<TInfo["action"]>>, "load" | "submit" | "Form"> & {
248
- /** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
249
- submit: SubmitFunc<TInfo>;
250
- /** Submit a plain object as JSON (schema type only, defaults to POST) */
251
- submitJson: SubmitJsonFunc<TInfo>;
252
- /** Pre-bound Form component with action URL already set (defaults to POST) */
253
- Form: SubmitForm;
254
- };
405
+ declare function useDynamicSubmitter<TInfo extends RouteWithActionModule>(path: TInfo["route"], ...rest: UseDynamicSubmitterRest<TInfo["route"]>): UseDynamicSubmitterResult<TInfo>;
406
+ /**
407
+ * React Router `useFetcher` bound to the same key as {@link useDynamicSubmitter}, so `state` /
408
+ * `data` reflect the same submissions as `submitter.submit` / `submitter.Form`.
409
+ *
410
+ * Call at component top level next to `useDynamicSubmitter`.
411
+ */
412
+ declare function useDynamicSubmitterFetcher<TInfo extends RouteWithActionModule>(submitter: UseDynamicSubmitterResult<TInfo>): react_router.FetcherWithComponents<SerializeFrom<TInfo["action"]>>;
255
413
 
256
- export { useDynamicSubmitter };
414
+ export { type DynamicSubmitterData, SubmitterSupersededError, SubmitterUnmountedError, type UseDynamicSubmitterOptions, type UseDynamicSubmitterResult, dynamicSubmitterFetcherKey, useDynamicSubmitter, useDynamicSubmitterFetcher };
@@ -1,3 +1,3 @@
1
- export { useDynamicSubmitter } from './chunk-JJN6GBJL.js';
1
+ export { SubmitterSupersededError, SubmitterUnmountedError, dynamicSubmitterFetcherKey, useDynamicSubmitter, useDynamicSubmitterFetcher } from './chunk-2WSA75KM.js';
2
2
  //# sourceMappingURL=useDynamicSubmitter.js.map
3
3
  //# sourceMappingURL=useDynamicSubmitter.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/router-toolkit",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -64,9 +64,8 @@
64
64
  "url": "https://github.com/firtoz/fullstack-toolkit/issues"
65
65
  },
66
66
  "peerDependencies": {
67
- "@firtoz/maybe-error": "^1.6.0",
68
- "react": "^19.2.4",
69
- "react-router": "^7.13.2",
67
+ "react": "^19.2.5",
68
+ "react-router": "^7.14.2",
70
69
  "zod": "^4.3.6"
71
70
  },
72
71
  "engines": {
@@ -76,13 +75,14 @@
76
75
  "access": "public"
77
76
  },
78
77
  "dependencies": {
78
+ "@firtoz/maybe-error": "^1.6.1",
79
79
  "zod-form-data": "^3.0.1"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@testing-library/react": "^16.3.1",
83
83
  "@types/jsdom": "^28.0.1",
84
84
  "@types/react": "^19.2.14",
85
- "bun-types": "^1.3.11",
86
- "jsdom": "^29.0.1"
85
+ "bun-types": "^1.3.13",
86
+ "jsdom": "^29.0.2"
87
87
  }
88
88
  }
package/src/formAction.ts CHANGED
@@ -52,13 +52,26 @@
52
52
  * @example
53
53
  * ### Using with useDynamicSubmitter
54
54
  *
55
- * The route above can be used with `useDynamicSubmitter` for type-safe form submissions:
55
+ * The route above can be used with `useDynamicSubmitter` for type-safe form submissions.
56
+ * The hook exposes {@link UseDynamicSubmitterResult.fetcherKey} (built with
57
+ * {@link dynamicSubmitterFetcherKey}) so a parallel `useFetcher` stays aligned; prefer
58
+ * {@link useDynamicSubmitterFetcher} instead of hand-rolling the key.
59
+ *
60
+ * The **optional** {@link useDynamicSubmitterFetcher} below is only for declarative UI that reads
61
+ * `fetcher.state` / `fetcher.data` in render (same submission as `submitter`). For promise-first
62
+ * flows, omit it and use `await submitter.submitJson(...)` plus local `useState` for pending.
63
+ * Use {@link UseDynamicSubmitterOptions.keySuffix} when two submitters target the same URL and
64
+ * must not share fetcher state.
56
65
  *
57
66
  * ```tsx
58
- * import { useDynamicSubmitter } from "@firtoz/router-toolkit";
67
+ * import {
68
+ * useDynamicSubmitter,
69
+ * useDynamicSubmitterFetcher,
70
+ * } from "@firtoz/router-toolkit";
59
71
  *
60
72
  * function LoginForm() {
61
73
  * const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
74
+ * const fetcher = useDynamicSubmitterFetcher(submitter);
62
75
  *
63
76
  * // Option 1: Submit as JSON (defaults to POST)
64
77
  * const handleLoginJson = async () => {
@@ -69,7 +82,7 @@
69
82
  * });
70
83
  * };
71
84
  *
72
- * // Option 2: Use the Form component (defaults to POST)
85
+ * // Option 2: Form + useDynamicSubmitterFetcher for reactive state/data
73
86
  * return (
74
87
  * <submitter.Form>
75
88
  * <input name="email" type="email" placeholder="Email" />
@@ -77,16 +90,16 @@
77
90
  * <label>
78
91
  * <input name="rememberMe" type="checkbox" /> Remember me
79
92
  * </label>
80
- * <button disabled={submitter.state !== "idle"}>
81
- * {submitter.state === "submitting" ? "Logging in..." : "Login"}
93
+ * <button type="submit" disabled={fetcher.state === "submitting"}>
94
+ * {fetcher.state === "submitting" ? "Logging in..." : "Login"}
82
95
  * </button>
83
96
  *
84
- * {submitter.data && !submitter.data.success && (
97
+ * {fetcher.data && !fetcher.data.success && (
85
98
  * <div className="error">
86
- * {submitter.data.error.type === "validation"
99
+ * {fetcher.data.error.type === "validation"
87
100
  * ? "Please check your inputs"
88
- * : submitter.data.error.type === "handler"
89
- * ? submitter.data.error.error // "Invalid email or password"
101
+ * : fetcher.data.error.type === "handler"
102
+ * ? fetcher.data.error.error
90
103
  * : "An unexpected error occurred"}
91
104
  * </div>
92
105
  * )}
@@ -139,21 +152,21 @@
139
152
  *
140
153
  * ```tsx
141
154
  * import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
142
- * import { useEffect } from "react";
155
+ * import { useEffect, useState } from "react";
143
156
  *
144
157
  * function PostEditor({ postId }: { postId: string }) {
145
- * // Fetch post data
146
158
  * const fetcher = useDynamicFetcher<typeof import("./admin.posts.$id")>(
147
159
  * "/admin/posts/:id",
148
160
  * { id: postId }
149
161
  * );
150
162
  *
151
- * // Submit updates
152
163
  * const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
153
164
  * "/admin/posts/:id",
154
165
  * { id: postId }
155
166
  * );
156
167
  *
168
+ * const [saving, setSaving] = useState(false);
169
+ *
157
170
  * useEffect(() => {
158
171
  * fetcher.load();
159
172
  * }, [fetcher.load]);
@@ -165,15 +178,28 @@
165
178
  * const post = fetcher.data?.post;
166
179
  *
167
180
  * return (
168
- * <submitter.Form method="PUT">
181
+ * <submitter.Form
182
+ * method="PUT"
183
+ * onSubmit={async (e) => {
184
+ * e.preventDefault();
185
+ * setSaving(true);
186
+ * try {
187
+ * const fd = new FormData(e.currentTarget);
188
+ * await submitter.submit(fd, { method: "PUT" });
189
+ * fetcher.load();
190
+ * } finally {
191
+ * setSaving(false);
192
+ * }
193
+ * }}
194
+ * >
169
195
  * <input name="title" defaultValue={post?.title} />
170
196
  * <textarea name="content" defaultValue={post?.content} />
171
197
  * <label>
172
198
  * <input name="published" type="checkbox" defaultChecked={post?.published} />
173
199
  * Published
174
200
  * </label>
175
- * <button disabled={submitter.state !== "idle"}>
176
- * {submitter.state === "submitting" ? "Saving..." : "Save"}
201
+ * <button type="submit" disabled={saving}>
202
+ * {saving ? "Saving..." : "Save"}
177
203
  * </button>
178
204
  * </submitter.Form>
179
205
  * );
@@ -1,4 +1,3 @@
1
- export * from "@firtoz/maybe-error";
2
1
  export * from "./Func";
3
2
  export * from "./HrefArgs";
4
3
  export * from "./RegisterPages";