Skip to content

Commit 9645ed8

Browse files
snomiaoclaude
andauthored
🐛 fix: Preserve numeric string keys as objects instead of arrays (#164)
Fixed issue where objects with numeric string keys (e.g., {"0": {...}, "1": {...}}) were being converted to arrays during i18n translation processing. The root cause was lodash set() treating numeric string keys as array indices. Implemented custom setByPath() function that always treats keys as object properties. Related: Comfy-Org/ComfyUI_frontend#8718 Co-authored-by: Claude Sonnet 4.5 <[email protected]>
1 parent 7b4f7f4 commit 9645ed8

File tree

3 files changed

+184
-2
lines changed

3 files changed

+184
-2
lines changed

packages/lobe-i18n/src/utils/diffJson.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { diff as justDiff } from 'just-diff';
2-
import { cloneDeep, set, unset } from 'lodash-es';
2+
import { cloneDeep, unset } from 'lodash-es';
33

44
import { LocaleObj } from '@/types';
55
import { I18nConfig, KeyStyle } from '@/types/config';
66

7+
import { setByPath } from './setByPath';
8+
79
type DiffPath = string | Array<number | string>;
810

911
const hasOwnKey = (obj: LocaleObj, key: string) => Object.prototype.hasOwnProperty.call(obj, key);
@@ -45,7 +47,9 @@ export const diff = (
4547

4648
for (const item of add) {
4749
const path = resolveDiffPath(entry, item.path as DiffPath, keyStyle);
48-
set(extra, path, item.value);
50+
// Use custom setByPath to preserve numeric string keys as object keys
51+
const pathArray = Array.isArray(path) ? path : [path];
52+
setByPath(extra, pathArray, item.value);
4953
}
5054

5155
return {
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { setByPath } from './setByPath';
4+
5+
describe('setByPath', () => {
6+
it('should set a simple nested value', () => {
7+
const obj = {};
8+
setByPath(obj, ['a', 'b', 'c'], 'value');
9+
expect(obj).toEqual({ a: { b: { c: 'value' } } });
10+
});
11+
12+
it('should preserve numeric string keys as object keys, not array indices', () => {
13+
const obj = {};
14+
setByPath(obj, ['nodeDefs', '0', 'name'], 'First Node');
15+
setByPath(obj, ['nodeDefs', '1', 'name'], 'Second Node');
16+
setByPath(obj, ['nodeDefs', '2', 'name'], 'Third Node');
17+
18+
expect(obj).toEqual({
19+
nodeDefs: {
20+
'0': { name: 'First Node' },
21+
'1': { name: 'Second Node' },
22+
'2': { name: 'Third Node' },
23+
},
24+
});
25+
26+
// Verify it's an object, not an array
27+
expect(Array.isArray((obj as any).nodeDefs)).toBe(false);
28+
});
29+
30+
it('should handle numeric keys at the root level', () => {
31+
const obj = {};
32+
setByPath(obj, ['0'], 'value0');
33+
setByPath(obj, ['1'], 'value1');
34+
35+
expect(obj).toEqual({
36+
'0': 'value0',
37+
'1': 'value1',
38+
});
39+
40+
expect(Array.isArray(obj)).toBe(false);
41+
});
42+
43+
it('should handle mixed numeric and string keys', () => {
44+
const obj = {};
45+
setByPath(obj, ['items', '0', 'id'], 'first');
46+
setByPath(obj, ['items', 'abc', 'id'], 'second');
47+
setByPath(obj, ['items', '1', 'id'], 'third');
48+
49+
expect(obj).toEqual({
50+
items: {
51+
'0': { id: 'first' },
52+
'1': { id: 'third' },
53+
'abc': { id: 'second' },
54+
},
55+
});
56+
57+
expect(Array.isArray((obj as any).items)).toBe(false);
58+
});
59+
60+
it('should overwrite existing values', () => {
61+
const obj = { a: { b: 'old' } };
62+
setByPath(obj, ['a', 'b'], 'new');
63+
expect(obj).toEqual({ a: { b: 'new' } });
64+
});
65+
66+
it('should handle single-key paths', () => {
67+
const obj = {};
68+
setByPath(obj, ['key'], 'value');
69+
expect(obj).toEqual({ key: 'value' });
70+
});
71+
72+
it('should handle empty path gracefully', () => {
73+
const obj = { existing: 'data' };
74+
setByPath(obj, [], 'value');
75+
// Should not modify the object
76+
expect(obj).toEqual({ existing: 'data' });
77+
});
78+
79+
it('should create intermediate objects when path does not exist', () => {
80+
const obj = {};
81+
setByPath(obj, ['a', 'b', 'c', 'd'], 'value');
82+
expect(obj).toEqual({
83+
a: {
84+
b: {
85+
c: {
86+
d: 'value',
87+
},
88+
},
89+
},
90+
});
91+
});
92+
93+
it('should replace non-object intermediate values with objects', () => {
94+
const obj: any = { a: 'string' };
95+
setByPath(obj, ['a', 'b'], 'value');
96+
expect(obj).toEqual({
97+
a: {
98+
b: 'value',
99+
},
100+
});
101+
});
102+
103+
it('should handle complex nested structures with numeric keys', () => {
104+
const obj = {};
105+
setByPath(obj, ['nodeDefs', '0', 'inputs', '0', 'name'], 'input1');
106+
setByPath(obj, ['nodeDefs', '0', 'inputs', '1', 'name'], 'input2');
107+
setByPath(obj, ['nodeDefs', '1', 'outputs', '0', 'name'], 'output1');
108+
109+
expect(obj).toEqual({
110+
nodeDefs: {
111+
'0': {
112+
inputs: {
113+
'0': { name: 'input1' },
114+
'1': { name: 'input2' },
115+
},
116+
},
117+
'1': {
118+
outputs: {
119+
'0': { name: 'output1' },
120+
},
121+
},
122+
},
123+
});
124+
125+
// Verify all levels are objects, not arrays
126+
const typed = obj as any;
127+
expect(Array.isArray(typed.nodeDefs)).toBe(false);
128+
expect(Array.isArray(typed.nodeDefs['0'].inputs)).toBe(false);
129+
expect(Array.isArray(typed.nodeDefs['1'].outputs)).toBe(false);
130+
});
131+
132+
it('should handle number type in path (not just numeric strings)', () => {
133+
const obj = {};
134+
setByPath(obj, ['items', 0, 'name'], 'First');
135+
setByPath(obj, ['items', 1, 'name'], 'Second');
136+
137+
expect(obj).toEqual({
138+
items: {
139+
'0': { name: 'First' },
140+
'1': { name: 'Second' },
141+
},
142+
});
143+
144+
expect(Array.isArray((obj as any).items)).toBe(false);
145+
});
146+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Custom implementation of lodash set() that preserves numeric string keys as object keys
3+
* instead of converting them to array indices.
4+
*
5+
* This fixes the issue where objects like {"0": {...}, "1": {...}} were being converted
6+
* to arrays during the i18n translation process.
7+
*
8+
* @param obj - The object to set the value in
9+
* @param path - Array of keys representing the path
10+
* @param value - The value to set
11+
*/
12+
export function setByPath(obj: any, path: Array<string | number>, value: any): void {
13+
if (path.length === 0) return;
14+
15+
let current = obj;
16+
17+
// Navigate to the parent of the target key
18+
for (let i = 0; i < path.length - 1; i++) {
19+
const key = String(path[i]); // Always treat keys as strings
20+
21+
// Create intermediate object if it doesn't exist or is not an object
22+
if (!current[key] || typeof current[key] !== 'object') {
23+
current[key] = {};
24+
}
25+
26+
current = current[key];
27+
}
28+
29+
// Set the final value
30+
const finalKey = String(path.at(-1));
31+
current[finalKey] = value;
32+
}

0 commit comments

Comments
 (0)