Skip to content

Commit 420d804

Browse files
authored
Improve serialization in formDataToObject() when mixing numeric and non-numeric object keys (#2692)
* wip * react/svelte * wip * improve edge cases
1 parent b9ba41a commit 420d804

File tree

7 files changed

+367
-6
lines changed

7 files changed

+367
-6
lines changed

packages/core/src/files.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { FormDataConvertible, RequestPayload } from './types'
22

3+
export const isFile = (value: unknown): boolean =>
4+
(typeof File !== 'undefined' && value instanceof File) ||
5+
value instanceof Blob ||
6+
(typeof FileList !== 'undefined' && value instanceof FileList && value.length > 0)
7+
38
export function hasFiles(data: RequestPayload | FormDataConvertible): boolean {
49
return (
5-
data instanceof File ||
6-
data instanceof Blob ||
7-
(data instanceof FileList && data.length > 0) ||
10+
isFile(data) ||
811
(data instanceof FormData && Array.from(data.values()).some((value) => hasFiles(value))) ||
912
(typeof data === 'object' && data !== null && Object.values(data).some((value) => hasFiles(value)))
1013
)

packages/core/src/formObject.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { get, set } from 'lodash-es'
2+
import { isFile } from './files'
23
import { FormDataConvertible } from './types'
34

45
/**
@@ -58,6 +59,74 @@ function parseKey(key: string): (string | number | '')[] {
5859
return path
5960
}
6061

62+
/**
63+
* Set value in nested object, always creating objects (never arrays).
64+
* This ensures we can analyze the final structure before deciding what should be arrays.
65+
*/
66+
function setNestedObject(obj: Record<string, any>, path: string[], value: any): void {
67+
let current = obj
68+
69+
for (let i = 0; i < path.length - 1; i++) {
70+
if (!(path[i] in current)) {
71+
current[path[i]] = {}
72+
}
73+
74+
current = current[path[i]]
75+
}
76+
77+
current[path[path.length - 1]] = value
78+
}
79+
80+
/**
81+
* Check if an object has sequential numeric keys (0, 1, 2, ...).
82+
*/
83+
function objectHasSequentialNumericKeys(value: any): boolean {
84+
const keys = Object.keys(value)
85+
const numericKeys = keys
86+
.filter((k) => /^\d+$/.test(k))
87+
.map(Number)
88+
.sort((a, b) => a - b)
89+
90+
return (
91+
keys.length === numericKeys.length &&
92+
numericKeys.length > 0 &&
93+
numericKeys[0] === 0 &&
94+
numericKeys.every((n, i) => n === i)
95+
)
96+
}
97+
98+
/**
99+
* Convert objects with sequential numeric keys (0, 1, 2, ...) to arrays.
100+
*/
101+
function convertSequentialObjectsToArrays(value: any): any {
102+
if (Array.isArray(value)) {
103+
return value.map(convertSequentialObjectsToArrays)
104+
}
105+
106+
if (typeof value !== 'object' || value === null || isFile(value)) {
107+
return value
108+
}
109+
110+
if (objectHasSequentialNumericKeys(value)) {
111+
const result = []
112+
113+
for (let i = 0; i < Object.keys(value).length; i++) {
114+
result[i] = convertSequentialObjectsToArrays(value[i])
115+
}
116+
117+
return result
118+
}
119+
120+
// Keep as object, recursively process values
121+
const result: Record<string, any> = {}
122+
123+
for (const key in value) {
124+
result[key] = convertSequentialObjectsToArrays(value[key])
125+
}
126+
127+
return result
128+
}
129+
61130
/**
62131
* Convert a FormData instance into an object structure.
63132
*/
@@ -84,16 +153,26 @@ export function formDataToObject(source: FormData): Record<string, FormDataConve
84153

85154
if (Array.isArray(existing)) {
86155
existing.push(value)
156+
} else if (existing && typeof existing === 'object' && !isFile(existing)) {
157+
// If existing is an object with numeric keys, convert to array (treating indices as relative)
158+
const numericKeys = Object.keys(existing)
159+
.filter((k) => /^\d+$/.test(k))
160+
.map(Number)
161+
.sort((a, b) => a - b)
162+
163+
set(form, arrayPath, numericKeys.length > 0 ? [...numericKeys.map((k) => existing[k]), value] : [value])
87164
} else {
88165
set(form, arrayPath, [value])
89166
}
90167

91168
continue
92169
}
93170

94-
// No brackets: last value wins when multiple fields have the same key
95-
set(form, path, value)
171+
// Always build nested objects first, then convert sequential numeric keys to arrays.
172+
// This prevents the creation of sparse arrays when mixing numeric and string keys.
173+
setNestedObject(form, path.map(String), value)
96174
}
97175

98-
return form
176+
// Convert objects with sequential numeric keys (0, 1, 2, ...) to arrays
177+
return convertSequentialObjectsToArrays(form)
99178
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Form } from '@inertiajs/react'
2+
3+
export default () => {
4+
return (
5+
<div>
6+
<h1>Mixed Key Serialization</h1>
7+
8+
<Form action="/dump/post" method="post">
9+
<div>
10+
<input type="text" name="fields[entries][100][name]" placeholder="Name for ID 100" defaultValue="John Doe" />
11+
</div>
12+
13+
<div>
14+
<input
15+
type="email"
16+
name="fields[entries][100][email]"
17+
placeholder="Email for ID 100"
18+
defaultValue="[email protected]"
19+
/>
20+
</div>
21+
22+
<div>
23+
<input
24+
type="text"
25+
name="fields[entries][new:1][name]"
26+
placeholder="Name for new entry"
27+
defaultValue="Jane Smith"
28+
/>
29+
</div>
30+
31+
<div>
32+
<input
33+
type="email"
34+
name="fields[entries][new:1][email]"
35+
placeholder="Email for new entry"
36+
defaultValue="[email protected]"
37+
/>
38+
</div>
39+
40+
<button type="submit">Submit</button>
41+
</Form>
42+
</div>
43+
)
44+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script>
2+
import { Form } from '@inertiajs/svelte'
3+
</script>
4+
5+
<div>
6+
<h1>Mixed Key Serialization</h1>
7+
8+
<Form action="/dump/post" method="post">
9+
<div>
10+
<input type="text" name="fields[entries][100][name]" placeholder="Name for ID 100" value="John Doe" />
11+
</div>
12+
13+
<div>
14+
<input type="email" name="fields[entries][100][email]" placeholder="Email for ID 100" value="[email protected]" />
15+
</div>
16+
17+
<div>
18+
<input type="text" name="fields[entries][new:1][name]" placeholder="Name for new entry" value="Jane Smith" />
19+
</div>
20+
21+
<div>
22+
<input
23+
type="email"
24+
name="fields[entries][new:1][email]"
25+
placeholder="Email for new entry"
26+
27+
/>
28+
</div>
29+
30+
<button type="submit">Submit</button>
31+
</Form>
32+
</div>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import { FormComponentRef } from '@inertiajs/core'
3+
import { Form } from '@inertiajs/vue3'
4+
import { ref } from 'vue'
5+
6+
const formRef = ref<FormComponentRef | null>(null)
7+
</script>
8+
9+
<template>
10+
<div>
11+
<h1>Mixed Key Serialization</h1>
12+
13+
<Form ref="formRef" action="/dump/post" method="post">
14+
<div>
15+
<input type="text" name="fields[entries][100][name]" placeholder="Name for ID 100" value="John Doe" />
16+
</div>
17+
18+
<div>
19+
<input
20+
type="email"
21+
name="fields[entries][100][email]"
22+
placeholder="Email for ID 100"
23+
24+
/>
25+
</div>
26+
27+
<div>
28+
<input type="text" name="fields[entries][new:1][name]" placeholder="Name for new entry" value="Jane Smith" />
29+
</div>
30+
31+
<div>
32+
<input
33+
type="email"
34+
name="fields[entries][new:1][email]"
35+
placeholder="Email for new entry"
36+
37+
/>
38+
</div>
39+
40+
<button type="submit">Submit</button>
41+
</Form>
42+
</div>
43+
</template>

tests/core/formObject.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,5 +249,136 @@ test.describe('formObject.ts', () => {
249249
},
250250
})
251251
})
252+
253+
test('handles mixed numeric and string keys as objects', () => {
254+
const formData = makeFormData([
255+
['fields[entries][100][name]', 'John Doe'],
256+
['fields[entries][100][email]', '[email protected]'],
257+
['fields[entries][new:1][name]', 'Jane Smith'],
258+
['fields[entries][new:1][email]', '[email protected]'],
259+
])
260+
261+
const result = formDataToObject(formData)
262+
263+
expect(Array.isArray(result.fields.entries)).toBe(false)
264+
expect(typeof result.fields.entries).toBe('object')
265+
266+
expect(Object.keys(result.fields.entries)).toHaveLength(2)
267+
268+
expect(result.fields.entries['100']).toEqual({
269+
name: 'John Doe',
270+
271+
})
272+
273+
expect(result.fields.entries['new:1']).toEqual({
274+
name: 'Jane Smith',
275+
276+
})
277+
})
278+
279+
test('still creates arrays for sequential numeric indices', () => {
280+
const formData = makeFormData([
281+
['items[0][name]', 'First'],
282+
['items[1][name]', 'Second'],
283+
['items[2][name]', 'Third'],
284+
])
285+
286+
const result = formDataToObject(formData)
287+
288+
// Should create an array for sequential indices
289+
expect(Array.isArray(result.items)).toBe(true)
290+
expect(result.items).toEqual([{ name: 'First' }, { name: 'Second' }, { name: 'Third' }])
291+
})
292+
293+
test('creates objects for non-sequential numeric keys', () => {
294+
const formData = makeFormData([
295+
['items[0][name]', 'First'],
296+
['items[5][name]', 'Sixth'],
297+
['items[10][name]', 'Eleventh'],
298+
])
299+
300+
const result = formDataToObject(formData)
301+
302+
expect(Array.isArray(result.items)).toBe(false)
303+
expect(typeof result.items).toBe('object')
304+
expect(Object.keys(result.items)).toHaveLength(3)
305+
expect(result.items['0']).toEqual({ name: 'First' })
306+
expect(result.items['5']).toEqual({ name: 'Sixth' })
307+
expect(result.items['10']).toEqual({ name: 'Eleventh' })
308+
})
309+
310+
test('creates objects when mixing numeric and string keys', () => {
311+
const formData = makeFormData([
312+
['users[123][name]', 'User 123'],
313+
['users[admin][name]', 'Admin User'],
314+
['users[0][name]', 'User 0'],
315+
])
316+
317+
const result = formDataToObject(formData)
318+
319+
expect(Array.isArray(result.users)).toBe(false)
320+
expect(typeof result.users).toBe('object')
321+
expect(Object.keys(result.users)).toHaveLength(3)
322+
expect(result.users['123']).toEqual({ name: 'User 123' })
323+
expect(result.users['admin']).toEqual({ name: 'Admin User' })
324+
expect(result.users['0']).toEqual({ name: 'User 0' })
325+
})
326+
327+
test('handles explicit indexed arrays correctly', () => {
328+
const formData = makeFormData([
329+
['emails[1]', '[email protected]'],
330+
['emails[0]', '[email protected]'],
331+
['emails[2]', '[email protected]'],
332+
])
333+
334+
const result = formDataToObject(formData)
335+
336+
expect(Array.isArray(result.emails)).toBe(true)
337+
expect(result.emails).toEqual(['[email protected]', '[email protected]', '[email protected]'])
338+
})
339+
340+
test('handles mixed empty bracket and explicit index notation - empty brackets first', () => {
341+
const formData = makeFormData([
342+
['tags[]', 'tag1'],
343+
['tags[]', 'tag2'],
344+
['tags[2]', 'tag3'],
345+
['tags[3]', 'tag4'],
346+
])
347+
348+
const result = formDataToObject(formData)
349+
350+
expect(Array.isArray(result.tags)).toBe(true)
351+
expect(result.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4'])
352+
})
353+
354+
test('handles mixed empty bracket and explicit index notation - explicit indices first', () => {
355+
const formData = makeFormData([
356+
['tags[2]', 'tag1'],
357+
['tags[3]', 'tag2'],
358+
['tags[]', 'tag3'],
359+
['tags[]', 'tag4'],
360+
])
361+
362+
const result = formDataToObject(formData)
363+
364+
expect(Array.isArray(result.tags)).toBe(true)
365+
expect(result.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4'])
366+
})
367+
368+
test('handles mixed empty bracket and explicit index notation - mixed', () => {
369+
const formData = makeFormData([
370+
['tags[2]', 'tag1'],
371+
['tags[3]', 'tag2'],
372+
['tags[]', 'tag3'],
373+
['tags[]', 'tag4'],
374+
['tags[4]', 'tag5'],
375+
['tags[5]', 'tag6'],
376+
])
377+
378+
const result = formDataToObject(formData)
379+
380+
expect(Array.isArray(result.tags)).toBe(true)
381+
expect(result.tags).toEqual(['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6'])
382+
})
252383
})
253384
})

0 commit comments

Comments
 (0)