Skip to content

Commit ebed48a

Browse files
yyx990803claude
andcommitted
feat: named page actions with per-mutation type safety
Export `actions` (plural) from .server.ts for pages with multiple mutations. Each named action gets its own typed validator, body type, and error keys. - Scanner detects `actions` export alongside existing `action` - Codegen emits pre-processed mapped types (no action key parsing needed) - Runtime dispatches via `?actionName` query string suffix - `useForm("/path?action", defaults)` and `action("/path?action", data)` - Template literal types parse `?name` suffix for per-action type resolution - Module-level `setActionRouter()` for `action()` helper in all adapters - Migrated pg-test playground from intent pattern to named actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 60c2a63 commit ebed48a

27 files changed

Lines changed: 565 additions & 153 deletions

File tree

‎docs/guide/type-safety.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,42 @@ const form = useForm("/users/:id", { name: "" }, { params: { id: "42" } });
162162
form.put(); // submits to /users/42
163163
```
164164

165-
Actions without validators still get URL autocomplete — the body falls back to `Record<string, unknown>`. See [Pages Routing — Forms](./pages-routing/forms) for the full API.
165+
Actions without validators still get URL autocomplete — the body falls back to `Record<string, unknown>`. See [Pages Routing — Forms](./pages-routing/actions-and-forms) for the full API.
166+
167+
### Named Actions
168+
169+
When a page exports [named actions](./pages-routing/actions-and-forms#named-actions), each action gets its own typed validator. The `?actionName` suffix on the URL selects the correct types:
170+
171+
```ts
172+
// pages/users/edit.server.ts
173+
export const actions = {
174+
update: defineHandler.withValidator({
175+
body: updateUserSchema,
176+
})(async (c, { body }) => {
177+
await db
178+
.update(users)
179+
.set(body)
180+
.where(eq(users.id, c.req.param("id")));
181+
}),
182+
183+
delete: defineHandler.withValidator({
184+
body: z.object({ id: z.string() }),
185+
})(async (c, { body }) => {
186+
await db.delete(users).where(eq(users.id, body.id));
187+
}),
188+
};
189+
```
190+
191+
```ts
192+
// Component
193+
const updateForm = useForm("/users/:id?update", { name: "", email: "" }, { params: { id } });
194+
updateForm.errors.email; // typed from updateUserSchema
195+
196+
const deleteForm = useForm("/users/:id?delete", { id: "" }, { params: { id } });
197+
deleteForm.errors.id; // typed from the delete schema
198+
```
199+
200+
The `action()` helper gets the same type checking — URLs, data payloads, and dynamic params are all validated at compile time.
166201

167202
## What Gets Checked
168203

@@ -172,6 +207,7 @@ Actions without validators still get URL autocomplete — the body falls back to
172207
| Handlers | `c.env` bindings, validator input, return type |
173208
| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
174209
| `useForm` | Page action URLs, `data` fields, `errors` keys, `reset()`/`clearErrors()` args, dynamic `params` |
210+
| `action()` | Page action URLs, `data` payload, dynamic `params` |
175211

176212
## Serialization
177213

‎packages/react/src/plugin-pages.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ function generateClientEntry(
198198
import React, { useSyncExternalStore } from "react";
199199
import { hydrateRoot } from "react-dom/client";
200200
import { flushSync } from "react-dom";
201-
import { SharedContext, ErrorsContext, RouterContext } from "@void-x/react/runtime";
201+
import { SharedContext, ErrorsContext, RouterContext, setActionRouter } from "@void-x/react/runtime";
202202
import { createPageRouter } from "void/pages-client";
203203
import { parseCacheFor } from "void/pages-prefetch";
204204
@@ -254,6 +254,8 @@ const { router, initialPageData, initialComponent, initialLayouts, start } =
254254
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(prefetch?.cacheFor ?? "30s")}),
255255
});
256256
257+
setActionRouter(router);
258+
257259
state = {
258260
component: initialComponent,
259261
props: initialPageData.props,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type {
2+
ActionUrl,
3+
ResolveActionBody,
4+
ResolveActionParams,
5+
HasActionParams,
6+
} from "void/routes";
7+
8+
type ActionOptions<U extends string> =
9+
HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };
10+
11+
let _router: any = null;
12+
13+
/** Called during app initialization to store the router instance */
14+
export function setActionRouter(router: any): void {
15+
_router = router;
16+
}
17+
18+
/** Programmatic one-shot page action call with Inertia page update */
19+
export async function action<U extends ActionUrl & string>(
20+
url: U,
21+
...args: HasActionParams<U> extends true
22+
? [data?: ResolveActionBody<U>, options?: ActionOptions<U>]
23+
: [data?: ResolveActionBody<U>]
24+
): Promise<void> {
25+
if (!_router) throw new Error("action() called before router initialized");
26+
27+
const data = args[0];
28+
const options = args[1] as ActionOptions<U> | undefined;
29+
30+
let resolvedUrl: string = url;
31+
let actionQuery = "";
32+
33+
const qIdx = resolvedUrl.indexOf("?");
34+
if (qIdx !== -1) {
35+
actionQuery = resolvedUrl.slice(qIdx);
36+
resolvedUrl = resolvedUrl.slice(0, qIdx);
37+
}
38+
39+
if ((options as any)?.params) {
40+
for (const [key, value] of Object.entries((options as any).params)) {
41+
resolvedUrl = resolvedUrl.replace(`:${key}`, encodeURIComponent(value as string));
42+
}
43+
}
44+
45+
resolvedUrl = resolvedUrl + actionQuery;
46+
await _router.visit(resolvedUrl, { method: "POST", data });
47+
}

‎packages/react/src/runtime/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { useShared } from "./use-shared";
44
export { useForm } from "./use-form";
55
export { useNavigation } from "./use-navigation";
66
export { useIslandForm } from "./use-island-form";
7+
export { action, setActionRouter } from "./action";
78
export { SharedContext, ErrorsContext, RouterContext } from "./context";

‎packages/react/src/runtime/use-form.ts‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
11
import { useState, useRef, useCallback, useContext, useMemo } from "react";
2-
import type { PageActionMap } from "void/routes";
2+
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from "void/routes";
33
import { RouterContext } from "./context";
44

5-
type HasParams<U extends keyof PageActionMap> = PageActionMap[U] extends { params: infer P }
6-
? P extends Record<string, string>
7-
? true
8-
: false
9-
: false;
10-
11-
type FormOptions<U extends keyof PageActionMap> =
12-
HasParams<U> extends true
13-
? { params: PageActionMap[U] extends { params: infer P } ? P : never }
14-
: { params?: never };
15-
165
// Typed overload — full inference from PageActionMap (requires codegen)
17-
export function useForm<U extends keyof PageActionMap & string>(
6+
export function useForm<U extends ActionUrl & string>(
187
url: U,
19-
defaults: PageActionMap[U]["body"],
20-
options?: FormOptions<U>,
8+
defaults: ResolveActionBody<U>,
9+
options?: ActionFormOptions<U>,
2110
): {
22-
data: PageActionMap[U]["body"];
23-
setData: <K extends keyof PageActionMap[U]["body"] & string>(
11+
data: ResolveActionBody<U>;
12+
setData: <K extends keyof ResolveActionBody<U> & string>(
2413
field: K,
25-
value: PageActionMap[U]["body"][K],
14+
value: ResolveActionBody<U>[K],
2615
) => void;
27-
errors: Partial<Record<keyof PageActionMap[U]["body"] & string, string>>;
16+
errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
2817
processing: boolean;
2918
isDirty: boolean;
3019
wasSuccessful: boolean;
@@ -33,8 +22,8 @@ export function useForm<U extends keyof PageActionMap & string>(
3322
put(opts?: any): void;
3423
patch(opts?: any): void;
3524
delete(opts?: any): void;
36-
reset(...fields: (keyof PageActionMap[U]["body"] & string)[]): void;
37-
clearErrors(...fields: (keyof PageActionMap[U]["body"] & string)[]): void;
25+
reset(...fields: (keyof ResolveActionBody<U> & string)[]): void;
26+
clearErrors(...fields: (keyof ResolveActionBody<U> & string)[]): void;
3827
};
3928

4029
// Untyped fallback — manual generic for use without codegen
@@ -64,12 +53,23 @@ export function useForm(
6453
options?: { params?: Record<string, string> },
6554
): any {
6655
let resolvedUrl = url;
56+
57+
// Strip ?action query before param resolution, then re-append
58+
let actionQuery = "";
59+
const qIdx = resolvedUrl.indexOf("?");
60+
if (qIdx !== -1) {
61+
actionQuery = resolvedUrl.slice(qIdx);
62+
resolvedUrl = resolvedUrl.slice(0, qIdx);
63+
}
64+
6765
if (options?.params) {
6866
for (const [key, value] of Object.entries(options.params)) {
6967
resolvedUrl = resolvedUrl.replace(`:${key}`, encodeURIComponent(value));
7068
}
7169
}
7270

71+
resolvedUrl = resolvedUrl + actionQuery;
72+
7373
const router = useContext(RouterContext);
7474
const [data, setDataState] = useState<Record<string, unknown>>({ ...defaults });
7575
const [errors, setErrors] = useState<Record<string, string>>({});

‎packages/solid/src/plugin-pages.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ function generateClientEntry(
179179
return `
180180
import { createSignal, createComponent, batch } from "solid-js";
181181
import { hydrate } from "solid-js/web";
182-
import { SharedContext, ErrorsContext, RouterContext } from "@void-x/solid/runtime";
182+
import { SharedContext, ErrorsContext, RouterContext, setActionRouter } from "@void-x/solid/runtime";
183183
import { createPageRouter } from "void/pages-client";
184184
import { parseCacheFor } from "void/pages-prefetch";
185185
@@ -217,6 +217,8 @@ const { router, initialPageData, initialComponent, initialLayouts, start } =
217217
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(prefetch?.cacheFor ?? "30s")}),
218218
});
219219
220+
setActionRouter(router);
221+
220222
batch(() => {
221223
setComponent(() => initialComponent);
222224
setPageProps(initialPageData.props);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type {
2+
ActionUrl,
3+
ResolveActionBody,
4+
ResolveActionParams,
5+
HasActionParams,
6+
} from "void/routes";
7+
8+
type ActionOptions<U extends string> =
9+
HasActionParams<U> extends true ? { params: ResolveActionParams<U> } : { params?: never };
10+
11+
let _router: any = null;
12+
13+
/** Called during app initialization to store the router instance */
14+
export function setActionRouter(router: any): void {
15+
_router = router;
16+
}
17+
18+
/** Programmatic one-shot page action call with Inertia page update */
19+
export async function action<U extends ActionUrl & string>(
20+
url: U,
21+
...args: HasActionParams<U> extends true
22+
? [data?: ResolveActionBody<U>, options?: ActionOptions<U>]
23+
: [data?: ResolveActionBody<U>]
24+
): Promise<void> {
25+
if (!_router) throw new Error("action() called before router initialized");
26+
27+
const data = args[0];
28+
const options = args[1] as ActionOptions<U> | undefined;
29+
30+
let resolvedUrl: string = url;
31+
let actionQuery = "";
32+
33+
const qIdx = resolvedUrl.indexOf("?");
34+
if (qIdx !== -1) {
35+
actionQuery = resolvedUrl.slice(qIdx);
36+
resolvedUrl = resolvedUrl.slice(0, qIdx);
37+
}
38+
39+
if ((options as any)?.params) {
40+
for (const [key, value] of Object.entries((options as any).params)) {
41+
resolvedUrl = resolvedUrl.replace(`:${key}`, encodeURIComponent(value as string));
42+
}
43+
}
44+
45+
resolvedUrl = resolvedUrl + actionQuery;
46+
await _router.visit(resolvedUrl, { method: "POST", data });
47+
}

‎packages/solid/src/runtime/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { useShared } from "./use-shared";
44
export { useForm } from "./use-form";
55
export { useNavigation } from "./use-navigation";
66
export { useIslandForm } from "./use-island-form";
7+
export { action, setActionRouter } from "./action";
78
export { SharedContext, ErrorsContext, RouterContext } from "./context";

‎packages/solid/src/runtime/use-form.ts‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
11
import { createSignal, createMemo, useContext } from "solid-js";
22
import { createStore, reconcile } from "solid-js/store";
33
import { RouterContext, ErrorsContext } from "./context";
4-
import type { PageActionMap } from "void/routes";
5-
6-
type HasParams<U extends keyof PageActionMap> = PageActionMap[U] extends { params: infer P }
7-
? P extends Record<string, string>
8-
? true
9-
: false
10-
: false;
11-
12-
type FormOptions<U extends keyof PageActionMap> =
13-
HasParams<U> extends true
14-
? { params: PageActionMap[U] extends { params: infer P } ? P : never }
15-
: { params?: never };
4+
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from "void/routes";
165

176
// Typed overload — full inference from PageActionMap (requires codegen)
18-
export function useForm<U extends keyof PageActionMap & string>(
7+
export function useForm<U extends ActionUrl & string>(
198
url: U,
20-
defaults: PageActionMap[U]["body"],
21-
options?: FormOptions<U>,
9+
defaults: ResolveActionBody<U>,
10+
options?: ActionFormOptions<U>,
2211
): {
23-
data: PageActionMap[U]["body"];
24-
setData: <K extends keyof PageActionMap[U]["body"] & string>(
12+
data: ResolveActionBody<U>;
13+
setData: <K extends keyof ResolveActionBody<U> & string>(
2514
field: K,
26-
value: PageActionMap[U]["body"][K],
15+
value: ResolveActionBody<U>[K],
2716
) => void;
28-
errors: Partial<Record<keyof PageActionMap[U]["body"] & string, string>>;
17+
errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
2918
processing: boolean;
3019
isDirty: boolean;
3120
wasSuccessful: boolean;
@@ -34,8 +23,8 @@ export function useForm<U extends keyof PageActionMap & string>(
3423
put(opts?: any): void;
3524
patch(opts?: any): void;
3625
delete(opts?: any): void;
37-
reset(...fields: (keyof PageActionMap[U]["body"] & string)[]): void;
38-
clearErrors(...fields: (keyof PageActionMap[U]["body"] & string)[]): void;
26+
reset(...fields: (keyof ResolveActionBody<U> & string)[]): void;
27+
clearErrors(...fields: (keyof ResolveActionBody<U> & string)[]): void;
3928
};
4029

4130
// Untyped fallback — manual generic for use without codegen
@@ -65,12 +54,23 @@ export function useForm(
6554
options?: { params?: Record<string, string> },
6655
): any {
6756
let resolvedUrl = url;
57+
58+
// Strip ?action query before param resolution, then re-append
59+
let actionQuery = "";
60+
const qIdx = resolvedUrl.indexOf("?");
61+
if (qIdx !== -1) {
62+
actionQuery = resolvedUrl.slice(qIdx);
63+
resolvedUrl = resolvedUrl.slice(0, qIdx);
64+
}
65+
6866
if (options?.params) {
6967
for (const [key, value] of Object.entries(options.params)) {
7068
resolvedUrl = resolvedUrl.replace(`:${key}`, encodeURIComponent(value));
7169
}
7270
}
7371

72+
resolvedUrl = resolvedUrl + actionQuery;
73+
7474
const router = useContext(RouterContext);
7575
// ErrorsContext provides the errors signal accessor — a reactive function
7676
// that returns the current global errors object. This allows useForm to

‎packages/svelte/src/plugin-pages.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ function generateClientEntry(
164164
import { hydrate } from "svelte";
165165
import { writable } from "svelte/store";
166166
import App from "@void-x/svelte/App.svelte";
167+
import { setActionRouter } from "@void-x/svelte/runtime";
167168
import { createPageRouter } from "void/pages-client";
168169
import { parseCacheFor } from "void/pages-prefetch";
169170
@@ -199,6 +200,8 @@ const { router, initialPageData, initialComponent, initialLayouts, start } =
199200
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(prefetch?.cacheFor ?? "30s")}),
200201
});
201202
203+
setActionRouter(router);
204+
202205
pageStore.set(initialComponent);
203206
propsStore.set(initialPageData.props);
204207
layoutsStore.set(initialLayouts);

0 commit comments

Comments
 (0)