diff --git a/src/index.ts b/src/index.ts index d34a8f8..a403eff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,7 +65,10 @@ export interface Instruction { optionals: string[] optionalsInArray: string[][] parentIsOptional: boolean - array: number + /** Which array context we're currently inside (for storing optionals) */ + currentArrayIndex: number + /** Mutable counter for allocating unique array indices to prevent sibling collisions */ + nextArrayIndex: { value: number } unions: TypeCheck[][] unionKeys: Record sanitize: MaybeArray<(v: string) => string> | undefined @@ -108,8 +111,8 @@ const handleRecord = ( if (!child) return property - const i = instruction.array - instruction.array++ + const i = instruction.nextArrayIndex.value + instruction.nextArrayIndex.value++ let v = `(()=>{` + @@ -117,7 +120,7 @@ const handleRecord = ( `ar${i}v={};` + `for(let i=0;i { - const i = instruction.array - instruction.array++ + const i = instruction.nextArrayIndex.value + instruction.nextArrayIndex.value++ const isRoot = property === 'v' && !instruction.unions.length @@ -147,13 +150,13 @@ const handleTuple = ( v += `const ar${i}v=[` - for (let i = 0; i < schema.length; i++) { - if (i !== 0) v += ',' + for (let idx = 0; idx < schema.length; idx++) { + if (idx !== 0) v += ',' v += mirror( - schema[i], - joinProperty(property, i, instruction.parentIsOptional), - instruction + schema[idx], + joinProperty(property, idx, instruction.parentIsOptional), + { ...instruction, currentArrayIndex: i } ) } @@ -369,7 +372,8 @@ const mirror = ( ) if (isOptional) { - const index = instruction.array + // +1 because cleanup code uses optionalsInArray[i + 1] where i is captured BEFORE increment + const index = instruction.currentArrayIndex + 1 if (property.startsWith('ar')) { const dotIndex = name.indexOf('.') @@ -451,8 +455,8 @@ const mirror = ( } } - const i = instruction.array - instruction.array++ + const i = instruction.nextArrayIndex.value + instruction.nextArrayIndex.value++ let reference = property @@ -467,7 +471,7 @@ const mirror = ( v += `for(let i=0;i<${reference}.length;i++){` + `const ar${i}p=${reference}[i];` + - `ar${i}v[i]=${mirror(schema.items, `ar${i}p`, instruction)}` + `ar${i}v[i]=${mirror(schema.items, `ar${i}p`, { ...instruction, currentArrayIndex: i })}` const optionals = instruction.optionalsInArray[i + 1] if (optionals) { @@ -562,7 +566,8 @@ export const createMirror = ( const f = mirror(schema, 'v', { optionals: [], optionalsInArray: [], - array: 0, + currentArrayIndex: 0, + nextArrayIndex: { value: 0 }, parentIsOptional: false, unions, unionKeys: {}, diff --git a/test/sibling-arrays.test.ts b/test/sibling-arrays.test.ts new file mode 100644 index 0000000..56faaed --- /dev/null +++ b/test/sibling-arrays.test.ts @@ -0,0 +1,128 @@ +import { t } from 'elysia' + +import { describe, it } from 'bun:test' +import { isEqual } from './utils' + +describe('Sibling Arrays', () => { + /** + * Regression test for sibling array index collision bug. + * Previously, sibling arrays would share the same optionalsInArray index, + * causing cleanup code for one array to reference properties from another. + */ + it('handle sibling arrays with optionals in different objects', () => { + const shape = t.Object({ + a: t.Array( + t.Object({ + obj: t.Object({ + n: t.Nullable(t.Object({ v: t.String() })) + }) + }) + ), + b: t.Object({ + arr: t.Array(t.Object({ x: t.Integer() })) + }) + }) + + const value = { + a: [{ obj: { n: null } }], + b: { arr: [{ x: 1 }] } + } + + // Should not throw "Cannot read properties of undefined (reading 'n')" + isEqual(shape, value) + }) + + it('handle sibling arrays at same depth with optionals', () => { + const shape = t.Object({ + first: t.Array( + t.Object({ + name: t.String(), + optional: t.Optional(t.String()) + }) + ), + second: t.Array( + t.Object({ + id: t.Number(), + optional: t.Optional(t.Number()) + }) + ) + }) + + const value = { + first: [ + { name: 'a', optional: 'x' }, + { name: 'b' } // no optional + ], + second: [ + { id: 1 }, + { id: 2, optional: 42 } + ] + } + + const expected = { + first: [ + { name: 'a', optional: 'x' }, + { name: 'b' } + ], + second: [ + { id: 1 }, + { id: 2, optional: 42 } + ] + } + + isEqual(shape, value, expected) + }) + + it('handle nested arrays with optionals at multiple levels', () => { + const shape = t.Array( + t.Object({ + name: t.String(), + games: t.Array( + t.Object({ + id: t.Number(), + hoursPlay: t.Optional(t.Number()) + }) + ), + // This optional should not be affected by games array processing + social: t.Optional( + t.Object({ + twitter: t.Optional(t.String()) + }) + ) + }) + ) + + const value = [ + { + name: 'user1', + games: [ + { id: 1, hoursPlay: 10 }, + { id: 2 } + ], + social: { twitter: 'user1' } + }, + { + name: 'user2', + games: [{ id: 3 }] + // no social + } + ] + + const expected = [ + { + name: 'user1', + games: [ + { id: 1, hoursPlay: 10 }, + { id: 2 } + ], + social: { twitter: 'user1' } + }, + { + name: 'user2', + games: [{ id: 3 }] + } + ] + + isEqual(shape, value, expected) + }) +})