症状
ある日、本番環境で「特定の画面だけ翻訳キーがそのまま表示される」という報告が上がった。
settings.dialog.confirmDelete
toast.failedToSave
こんな生のキー文字列がUIにそのまま出ている。他の画面は全部正常。翻訳ファイルを開いてみると、該当のキーはちゃんと定義されている。typoでもない。t() の呼び出しも正しい。
で、全画面を確認してみると、特定の画面だけで起きている。調べていくと、翻訳ファイルのマージ順序によって一部のキーが消えていた。
原因
うちのプロジェクトでは、i18nの翻訳JSONを機能ごとに分割管理していた。
public/locales/en/
common.json
ui.json
settings.json
adminSettings.json
dashboard.json
...(10ファイル以上)
これらをアプリ起動時に1つのオブジェクトにマージして使う、という構成。マージ処理はこう書いていた。
// こうやって順番にspreadでマージしていた
const merged = files.reduce(
(acc, { source }) => ({ ...acc, ...source }),
{}
)
一見問題なさそうだが、これがshallow mergeであることが全ての元凶だった。
何が起きていたか
例えば adminSettings.json と settings.json が、どちらもトップレベルに settings というキーを持っている。
adminSettings.json(管理画面用)
{
"settings": {
"title": "Settings",
"dialog": {
"confirmDelete": "Delete this item?"
},
"actions": {
"cancel": "CANCEL",
"delete": "DELETE"
}
}
}
settings.json(一般ユーザー用)
{
"settings": {
"title": "My Settings",
"profile": {
"displayName": "Display Name"
}
}
}
shallow mergeだと、settings.json の settings オブジェクトが adminSettings.json の settings オブジェクトを丸ごと上書きする。中身のマージではなく、オブジェクトの参照ごと差し替え。
結果、settings.dialog も settings.actions も消える。settings.json 側にあるキーだけが生き残る。
同じことが toast キー(dashboard.json vs ui.json)や buttons キー(common.json vs ui.json)でも起きていた。調べたらトップレベルキーの衝突が十数箇所あった。
修正: deep mergeに変えるだけ
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function deepMerge(
target: Record<string, unknown>,
source: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = { ...target }
for (const [key, sourceValue] of Object.entries(source)) {
const targetValue = result[key]
result[key] =
isPlainObject(targetValue) && isPlainObject(sourceValue)
? deepMerge(targetValue, sourceValue)
: sourceValue
}
return result
}
マージ処理を差し替える。
// Before
const merged = files.reduce(
(acc, { source }) => ({ ...acc, ...source }),
{}
)
// After
const merged = files.reduce(
(acc, { source }) => deepMerge(acc, source),
{} as Record<string, unknown>
)
これだけ。ロジックとしては「両方がオブジェクトなら再帰的にマージ、それ以外は後勝ち」という素朴なdeep merge。lodashの merge を使ってもいい。
なぜ気づきにくいか
この問題がたちが悪いのは、以下の条件が全部揃わないと顕在化しないところにある。
- 複数のJSONファイルが同じトップレベルキーを持っている
- そのキーの中身が異なる(片方にしかないネストキーがある)
- 消えたキーを実際に使っている画面がある
ファイルを分割した時点ではキーの衝突がなくても、後から別チームが同じトップレベルキーを使い始めた瞬間にサイレントに壊れる。TypeScriptの型チェックも通るし、t() 関数自体はエラーを出さずにキー文字列をそのまま返すだけ。テストで全キーの存在確認をしていない限り、CIも通る。
衝突検知も入れておくと安心
deep mergeで直るとはいえ、意図しない衝突は把握しておきたい。開発時だけ警告を出す仕組みも入れた。
// ネストされたオブジェクトをドットパスのMapに展開する
// { settings: { dialog: { title: "OK" } } } → Map { "settings.dialog.title" => "OK" }
function flattenLeaves(
obj: Record<string, unknown>,
prefix = ''
): Map<string, unknown> {
const result = new Map<string, unknown>()
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (isPlainObject(value)) {
for (const [k, v] of flattenLeaves(value, path)) {
result.set(k, v)
}
} else {
result.set(path, value)
}
}
return result
}
function detectKeyCollisions(
target: Record<string, unknown>,
source: Record<string, unknown>,
sourceName: string
): void {
if (process.env.NODE_ENV !== 'development') return
const targetLeaves = flattenLeaves(target)
const sourceLeaves = flattenLeaves(source)
for (const [key, sourceValue] of sourceLeaves) {
if (!targetLeaves.has(key)) continue
const targetValue = targetLeaves.get(key)
// 値が同じなら単なる重複定義なので無視
if (JSON.stringify(targetValue) === JSON.stringify(sourceValue)) continue
console.warn(`[i18n] "${key}" from ${sourceName} overrides existing key`)
}
}
flattenLeaves はネストされたオブジェクトを "settings.dialog.title" のようなドットパスのMapに展開する関数。末端の値(リーフ)だけを収集するので、中間のオブジェクトノード同士の比較にはならない。isPlainObject は前述の deepMerge で定義したものをそのまま使う。
呼び出しタイミングは、deepMerge の前に挟む。マージ済みの累積結果と、これから合体するファイルを比較する形。
const merged = files.reduce(
(acc, { source, name }) => {
detectKeyCollisions(acc, source, name)
return deepMerge(acc, source)
},
{} as Record<string, unknown>
)
ポイントは値が同じ重複は無視すること。最初は「キーが被ったら全部警告」にしていたが、開発コンソールに警告が284件出て使い物にならなかった。同じキーで同じ値なら実害がないので、値が実際に異なるケースだけに絞ったら0件になった。
next-i18next / react-i18next を使っている場合
next-i18next や react-i18next を使っていてnamespace分割している場合は、t('namespace:key') のように名前空間を明示するので、この問題は基本的に起きない。
ハマるのはnamespaceを使わずに自前で翻訳ファイルをマージしている構成。小規模プロジェクトで「namespace管理は大げさだから全部フラットに合体させよう」とやると、ファイルが増えた段階でこの罠にかかる。
所感
正直、shallow mergeだと気づくまでに結構かかった。最初は翻訳ファイルのキーのtypoを疑って、次にビルドキャッシュを疑って、JSONのパースエラーを疑って…と遠回りした。「spread構文でオブジェクトをマージしている」コードは日常的に書くし読むので、そこが壊れているとは思わなかった。
翻訳ファイルを分割管理するなら、最初からnamespace方式にするか、マージ処理はdeep mergeにしておくか、どちらかをやっておいた方がいい。後から直すと、すでに消えていた翻訳キーを全画面チェックし直す羽目になる。