Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/lobe-i18n/src/utils/diffJson.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { diff as justDiff } from 'just-diff';
import { cloneDeep, set, unset } from 'lodash-es';
import { cloneDeep, unset } from 'lodash-es';

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

import { setByPath } from './setByPath';

type DiffPath = string | Array<number | string>;

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

for (const item of add) {
const path = resolveDiffPath(entry, item.path as DiffPath, keyStyle);
set(extra, path, item.value);
// Use custom setByPath to preserve numeric string keys as object keys
const pathArray = Array.isArray(path) ? path : [path];
setByPath(extra, pathArray, item.value);
Comment on lines 48 to +52
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diffJson.diff now relies on setByPath to build extra for added paths, but there’s no integration coverage here for (1) numeric-string object keys staying objects (the reported bug) and (2) array index paths (e.g. ['array', 3]) producing the expected structure in result.entry. Adding/adjusting tests in diffJson.test.ts to assert result.entry for these cases would help prevent regressions in the translation chunk building pipeline.

Copilot uses AI. Check for mistakes.
}

return {
Expand Down
146 changes: 146 additions & 0 deletions packages/lobe-i18n/src/utils/setByPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';

import { setByPath } from './setByPath';

describe('setByPath', () => {
it('should set a simple nested value', () => {
const obj = {};
setByPath(obj, ['a', 'b', 'c'], 'value');
expect(obj).toEqual({ a: { b: { c: 'value' } } });
});

it('should preserve numeric string keys as object keys, not array indices', () => {
const obj = {};
setByPath(obj, ['nodeDefs', '0', 'name'], 'First Node');
setByPath(obj, ['nodeDefs', '1', 'name'], 'Second Node');
setByPath(obj, ['nodeDefs', '2', 'name'], 'Third Node');

expect(obj).toEqual({
nodeDefs: {
'0': { name: 'First Node' },
'1': { name: 'Second Node' },
'2': { name: 'Third Node' },
},
});

// Verify it's an object, not an array
expect(Array.isArray((obj as any).nodeDefs)).toBe(false);
});

it('should handle numeric keys at the root level', () => {
const obj = {};
setByPath(obj, ['0'], 'value0');
setByPath(obj, ['1'], 'value1');

expect(obj).toEqual({
'0': 'value0',
'1': 'value1',
});

expect(Array.isArray(obj)).toBe(false);
});

it('should handle mixed numeric and string keys', () => {
const obj = {};
setByPath(obj, ['items', '0', 'id'], 'first');
setByPath(obj, ['items', 'abc', 'id'], 'second');
setByPath(obj, ['items', '1', 'id'], 'third');

expect(obj).toEqual({
items: {
'0': { id: 'first' },
'1': { id: 'third' },
'abc': { id: 'second' },
},
});

expect(Array.isArray((obj as any).items)).toBe(false);
});

it('should overwrite existing values', () => {
const obj = { a: { b: 'old' } };
setByPath(obj, ['a', 'b'], 'new');
expect(obj).toEqual({ a: { b: 'new' } });
});

it('should handle single-key paths', () => {
const obj = {};
setByPath(obj, ['key'], 'value');
expect(obj).toEqual({ key: 'value' });
});

it('should handle empty path gracefully', () => {
const obj = { existing: 'data' };
setByPath(obj, [], 'value');
// Should not modify the object
expect(obj).toEqual({ existing: 'data' });
});

it('should create intermediate objects when path does not exist', () => {
const obj = {};
setByPath(obj, ['a', 'b', 'c', 'd'], 'value');
expect(obj).toEqual({
a: {
b: {
c: {
d: 'value',
},
},
},
});
});

it('should replace non-object intermediate values with objects', () => {
const obj: any = { a: 'string' };
setByPath(obj, ['a', 'b'], 'value');
expect(obj).toEqual({
a: {
b: 'value',
},
});
});

it('should handle complex nested structures with numeric keys', () => {
const obj = {};
setByPath(obj, ['nodeDefs', '0', 'inputs', '0', 'name'], 'input1');
setByPath(obj, ['nodeDefs', '0', 'inputs', '1', 'name'], 'input2');
setByPath(obj, ['nodeDefs', '1', 'outputs', '0', 'name'], 'output1');

expect(obj).toEqual({
nodeDefs: {
'0': {
inputs: {
'0': { name: 'input1' },
'1': { name: 'input2' },
},
},
'1': {
outputs: {
'0': { name: 'output1' },
},
},
},
});

// Verify all levels are objects, not arrays
const typed = obj as any;
expect(Array.isArray(typed.nodeDefs)).toBe(false);
expect(Array.isArray(typed.nodeDefs['0'].inputs)).toBe(false);
expect(Array.isArray(typed.nodeDefs['1'].outputs)).toBe(false);
});

it('should handle number type in path (not just numeric strings)', () => {
const obj = {};
setByPath(obj, ['items', 0, 'name'], 'First');
setByPath(obj, ['items', 1, 'name'], 'Second');

expect(obj).toEqual({
items: {
'0': { name: 'First' },
'1': { name: 'Second' },
},
});

expect(Array.isArray((obj as any).items)).toBe(false);
});
Comment on lines +132 to +145
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test should handle number type in path asserts that numeric number segments (0/1) are treated as object keys. In production, array diffs from just-diff use number indices (see existing diffJson tests), so encoding number segments as object properties makes it impossible for setByPath to preserve real arrays. Recommend adjusting this test (and adding a dedicated array case) so number path segments can represent array indices, while numeric string segments remain object keys.

Copilot uses AI. Check for mistakes.
});
32 changes: 32 additions & 0 deletions packages/lobe-i18n/src/utils/setByPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Custom implementation of lodash set() that preserves numeric string keys as object keys
* instead of converting them to array indices.
*
* This fixes the issue where objects like {"0": {...}, "1": {...}} were being converted
* to arrays during the i18n translation process.
*
* @param obj - The object to set the value in
* @param path - Array of keys representing the path
* @param value - The value to set
*/
export function setByPath(obj: any, path: Array<string | number>, value: any): void {
if (path.length === 0) return;

let current = obj;

// Navigate to the parent of the target key
for (let i = 0; i < path.length - 1; i++) {
const key = String(path[i]); // Always treat keys as strings

// Create intermediate object if it doesn't exist or is not an object
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
Comment on lines +21 to +23
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setByPath currently stringifies every path segment and always creates {} for missing intermediates. This prevents creating real arrays when the path contains a numeric index (e.g. ['array', 3] from just-diff), so added array elements will be represented as objects with '3' keys instead of sparse arrays (behavior change vs previous lodash.set). Consider preserving arrays by creating [] when the next path segment is a number (array index), while still treating numeric strings (e.g. '0') as object keys to fix the reported issue.

Suggested change
// Create intermediate object if it doesn't exist or is not an object
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
// Create intermediate container if it doesn't exist or is not an object.
// If the next path segment is a number, we create an array to preserve
// array semantics (e.g. ['items', 3]); otherwise we create an object.
if (
current[key] === undefined ||
current[key] === null ||
typeof current[key] !== 'object'
) {
const nextSegment = path[i + 1];
current[key] = typeof nextSegment === 'number' ? [] : {};

Copilot uses AI. Check for mistakes.
}

current = current[key];
}

// Set the final value
const finalKey = String(path.at(-1));
current[finalKey] = value;
}
Loading