Skip to content

Commit 6d182fe

Browse files
fix(actions): support for nested objects in form (#15978)
* fix(actions): support for nested objects in form Closes #14387 * Apply suggestion from @ematipico --------- Co-authored-by: Emanuele Stoppa <[email protected]>
1 parent 3cb5319 commit 6d182fe

File tree

5 files changed

+356
-7
lines changed

5 files changed

+356
-7
lines changed

.changeset/fair-balloons-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a bug where Astro Actions didn't properly support nested object properties, causing problems when users used zod functions such as `superRefine` or `discriminatedUnion`.

packages/astro/src/actions/runtime/server.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,19 @@ function isActionAPIContext(ctx: ActionAPIContext): boolean {
323323
export function formDataToObject<T extends z.$ZodObject>(
324324
formData: FormData,
325325
schema: T,
326+
/** @internal */
327+
prefix = '',
326328
): Record<string, unknown> {
329+
const formKeys = [...formData.keys()];
327330
const obj: Record<string, unknown> = schema._zod.def.catchall
328-
? Object.fromEntries(formData.entries())
331+
? Object.fromEntries(
332+
[...formData.entries()]
333+
.filter(([k]) => k.startsWith(prefix))
334+
.map(([k, v]) => [k.slice(prefix.length), v]),
335+
)
329336
: {};
330337
for (const [key, baseValidator] of Object.entries(schema._zod.def.shape)) {
338+
const prefixedKey = prefix + key;
331339
let validator = baseValidator;
332340

333341
while (
@@ -336,7 +344,7 @@ export function formDataToObject<T extends z.$ZodObject>(
336344
validator instanceof z.$ZodDefault
337345
) {
338346
// use default value when key is undefined
339-
if (validator instanceof z.$ZodDefault && !formData.has(key)) {
347+
if (validator instanceof z.$ZodDefault && !formDataHasKeyOrPrefix(formKeys, prefixedKey)) {
340348
obj[key] =
341349
validator._zod.def.defaultValue instanceof Function
342350
? validator._zod.def.defaultValue()
@@ -345,21 +353,55 @@ export function formDataToObject<T extends z.$ZodObject>(
345353
validator = validator._zod.def.innerType;
346354
}
347355

348-
if (!formData.has(key) && key in obj) {
356+
// Unwrap pipe (from .transform() / .pipe()) to find nested objects
357+
while (validator instanceof z.$ZodPipe) {
358+
validator = validator._zod.def.in;
359+
}
360+
361+
// Resolve nested discriminatedUnion to the matching variant
362+
if (validator instanceof z.$ZodDiscriminatedUnion) {
363+
const typeKey = validator._zod.def.discriminator;
364+
const typeValue = formData.get(prefixedKey + '.' + typeKey);
365+
if (typeof typeValue === 'string') {
366+
const match = validator._zod.def.options.find((option: any) =>
367+
option.def.shape[typeKey].values.has(typeValue),
368+
);
369+
if (match) {
370+
validator = match;
371+
}
372+
}
373+
}
374+
375+
if (validator instanceof z.$ZodObject) {
376+
const nestedPrefix = prefixedKey + '.';
377+
const hasNestedKeys = formKeys.some((k) => k.startsWith(nestedPrefix));
378+
if (hasNestedKeys) {
379+
obj[key] = formDataToObject(formData, validator, nestedPrefix);
380+
} else if (!(key in obj)) {
381+
// No nested keys and no default was set — respect optional/nullable
382+
obj[key] = baseValidator instanceof z.$ZodNullable ? null : undefined;
383+
}
384+
} else if (!formData.has(prefixedKey) && key in obj) {
349385
// continue loop if form input is not found and default value is set
350386
continue;
351387
} else if (validator instanceof z.$ZodBoolean) {
352-
const val = formData.get(key);
353-
obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key);
388+
const val = formData.get(prefixedKey);
389+
obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(prefixedKey);
354390
} else if (validator instanceof z.$ZodArray) {
355-
obj[key] = handleFormDataGetAll(key, formData, validator);
391+
obj[key] = handleFormDataGetAll(prefixedKey, formData, validator);
356392
} else {
357-
obj[key] = handleFormDataGet(key, formData, validator, baseValidator);
393+
obj[key] = handleFormDataGet(prefixedKey, formData, validator, baseValidator);
358394
}
359395
}
360396
return obj;
361397
}
362398

399+
/** Check if formKeys contains an exact key or any keys with the given prefix (for nested objects). */
400+
function formDataHasKeyOrPrefix(formKeys: string[], key: string): boolean {
401+
const prefix = key + '.';
402+
return formKeys.some((k) => k === key || k.startsWith(prefix));
403+
}
404+
363405
function handleFormDataGetAll(key: string, formData: FormData, validator: z.$ZodArray) {
364406
const entries = Array.from(formData.getAll(key));
365407
const elementValidator = validator._zod.def.element;

packages/astro/test/actions.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,66 @@ describe('Astro Actions', () => {
139139
assert.equal(data.type, 'AstroActionInputError');
140140
});
141141

142+
it('Handles nested form objects with dot notation', async () => {
143+
const formData = new FormData();
144+
formData.append('a', 'hello');
145+
formData.append('bc.b', 'hoge');
146+
formData.append('bc.c', 'world');
147+
const req = new Request('http://example.com/_actions/nestedFormObject', {
148+
method: 'POST',
149+
body: formData,
150+
});
151+
const res = await app.render(req);
152+
153+
assert.equal(res.ok, true);
154+
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
155+
156+
const data = devalue.parse(await res.text());
157+
assert.equal(data.a, 'hello');
158+
assert.deepEqual(data.bc, { b: 'hoge', c: 'world' });
159+
});
160+
161+
it('Validates nested form objects with superRefine', async () => {
162+
const formData = new FormData();
163+
formData.append('a', 'hello');
164+
formData.append('bc.b', 'huga');
165+
// Omit bc.c to trigger superRefine validation
166+
const req = new Request('http://example.com/_actions/nestedFormObject', {
167+
method: 'POST',
168+
body: formData,
169+
});
170+
const res = await app.render(req);
171+
172+
assert.equal(res.ok, false);
173+
assert.equal(res.status, 400);
174+
175+
const data = await res.json();
176+
assert.equal(data.type, 'AstroActionInputError');
177+
assert.ok(
178+
data.issues.some((issue) => issue.path.includes('c')),
179+
'Should have a validation issue for field c',
180+
);
181+
});
182+
183+
it('Handles nested discriminatedUnion in form data', async () => {
184+
const formData = new FormData();
185+
formData.append('name', 'Ben');
186+
formData.append('contact.type', 'email');
187+
formData.append('contact.email', '[email protected]');
188+
const req = new Request('http://example.com/_actions/nestedDiscriminatedUnion', {
189+
method: 'POST',
190+
body: formData,
191+
});
192+
const res = await app.render(req);
193+
194+
assert.equal(res.ok, true);
195+
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
196+
197+
const data = devalue.parse(await res.text());
198+
assert.equal(data.name, 'Ben');
199+
assert.deepEqual(data.contact, { type: 'email', email: '[email protected]' });
200+
});
201+
142202
it('Exposes plain formData action', async () => {
143203
const formData = new FormData();
144204
formData.append('channel', 'bholmesdev');

packages/astro/test/fixtures/actions/src/actions/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,42 @@ export const server = {
117117
return data;
118118
},
119119
}),
120+
nestedDiscriminatedUnion: defineAction({
121+
accept: 'form',
122+
input: z.object({
123+
name: z.string(),
124+
contact: z.discriminatedUnion('type', [
125+
z.object({ type: z.literal('email'), email: z.string() }),
126+
z.object({ type: z.literal('phone'), phone: z.string() }),
127+
]),
128+
}),
129+
handler: async (data) => {
130+
return data;
131+
},
132+
}),
133+
nestedFormObject: defineAction({
134+
accept: 'form',
135+
input: z.object({
136+
a: z.string(),
137+
bc: z
138+
.object({
139+
b: z.enum(['hoge', 'huga']),
140+
c: z.string().optional(),
141+
})
142+
.superRefine((data, ctx) => {
143+
if (data.b === 'huga' && (!data.c || data.c.trim() === '')) {
144+
ctx.addIssue({
145+
code: z.ZodIssueCode.custom,
146+
path: ['bc', 'c'],
147+
message: 'C is required when B is "huga"',
148+
});
149+
}
150+
}),
151+
}),
152+
handler: async (data) => {
153+
return data;
154+
},
155+
}),
120156
transformFormInput: defineAction({
121157
accept: 'form',
122158
input: z.instanceof(FormData).transform((formData) => Object.fromEntries(formData.entries())),

0 commit comments

Comments
 (0)