Skip to content

Commit 9c8d636

Browse files
DanRibbensclaude
andauthored
fix: preserve parent localization through tabs 3.x (#17591)
Backport of #17578 to `3.x`. This fixes two related localization bugs in `traverseFields`. First, entering a non-localized tab always reset `parentIsLocalized` to `false`. Fields after the tab no longer knew that an earlier parent was localized. During Copy to Locale, this could reuse nested array or block row IDs and cause a database unique-constraint error. Second, traversal checked `tab.localized` directly. A tab with `localized: true` inside an already-localized parent was incorrectly treated as another locale layer. This could make traversal interpret field names as locale names and skip nested fields. Tabs now preserve inherited localization and only create a new locale layer when `fieldShouldBeLocalized` says they should. This matches localization handling behavior we have in other parts of Payload, like our hooks. ## Notes on the backport - `3.x`'s `fieldShouldBeLocalized` keeps its `NEXT_PUBLIC_PAYLOAD_COMPATIBILITY_allowLocalizedWithinLocalized` compatibility clause; the `Boolean(...)` wrap was applied around it rather than replacing it. - Regenerated `test/localization/payload-types.ts` via `pnpm dev:generate-types localization`. - Dropped a stray `console.log('res', res)` that was present in the original test. ## Verification - `traverseFields.spec.ts` unit tests: 4/4 pass - New int test `should copy nested arrays through tabs within localized arrays`: passes - Full `localization` int suite: 124 passed, 5 skipped, 0 failed - eslint: 0 errors on changed source files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 249f8df commit 9c8d636

8 files changed

Lines changed: 237 additions & 33 deletions

File tree

packages/payload/src/fields/config/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2152,11 +2152,11 @@ export function fieldShouldBeLocalized({
21522152
field: ClientField | ClientTab | Field | Tab
21532153
parentIsLocalized: boolean
21542154
}): boolean {
2155-
return (
2155+
return Boolean(
21562156
'localized' in field &&
2157-
field.localized! &&
2158-
(!parentIsLocalized ||
2159-
process.env.NEXT_PUBLIC_PAYLOAD_COMPATIBILITY_allowLocalizedWithinLocalized === 'true')
2157+
field.localized &&
2158+
(!parentIsLocalized ||
2159+
process.env.NEXT_PUBLIC_PAYLOAD_COMPATIBILITY_allowLocalizedWithinLocalized === 'true'),
21602160
)
21612161
}
21622162

packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RequestContext } from '../../../index.js'
44
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
55
import type { Field, TabAsField } from '../../config/types.js'
66

7+
import { unflattenData } from '../../../utilities/unflattenData.js'
78
import { promise } from './promise.js'
89

910
type Args<T> = {
@@ -57,6 +58,7 @@ export const traverseFields = async <T>({
5758
siblingData,
5859
siblingDoc,
5960
}: Args<T>): Promise<void> => {
61+
unflattenData(siblingData)
6062
const promises: Promise<void>[] = []
6163

6264
fields.forEach((field, fieldIndex) => {

packages/payload/src/utilities/traverseFields.spec.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest'
22
import { traverseFields } from './traverseFields.js'
3-
import { Field } from '../fields/config/types.js'
3+
import type { Field } from '../fields/config/types.js'
44

55
describe('traverseFields', () => {
66
const tabsField: Field = {
@@ -49,4 +49,125 @@ describe('traverseFields', () => {
4949
})
5050
})
5151
})
52+
53+
describe('parent localization', () => {
54+
it('should preserve parent localization through tabs', () => {
55+
const fields: Field[] = [
56+
{
57+
name: 'group',
58+
type: 'group',
59+
localized: true,
60+
fields: [
61+
{
62+
type: 'tabs',
63+
tabs: [
64+
{
65+
label: 'Tab',
66+
fields: [
67+
{
68+
name: 'text',
69+
type: 'text',
70+
},
71+
],
72+
},
73+
],
74+
},
75+
],
76+
},
77+
]
78+
let textParentIsLocalized: boolean | undefined
79+
80+
traverseFields({
81+
fields,
82+
callback: ({ field, parentIsLocalized }) => {
83+
if (field.type === 'text') {
84+
textParentIsLocalized = parentIsLocalized
85+
}
86+
},
87+
})
88+
89+
expect(textParentIsLocalized).toBe(true)
90+
})
91+
92+
it('should traverse localized tabs within localized parents', () => {
93+
let textParentIsLocalized: boolean | undefined
94+
95+
traverseFields({
96+
callback: ({ field, parentIsLocalized }) => {
97+
if (field.type === 'text') {
98+
textParentIsLocalized = parentIsLocalized
99+
}
100+
},
101+
fields: [
102+
{
103+
name: 'group',
104+
type: 'group',
105+
localized: true,
106+
fields: [
107+
{
108+
type: 'tabs',
109+
tabs: [
110+
{
111+
name: 'tab',
112+
localized: true,
113+
fields: [
114+
{
115+
name: 'text',
116+
type: 'text',
117+
},
118+
],
119+
},
120+
],
121+
},
122+
],
123+
},
124+
],
125+
ref: {
126+
group: {
127+
en: {
128+
tab: {
129+
text: 'value',
130+
},
131+
},
132+
},
133+
},
134+
})
135+
136+
expect(textParentIsLocalized).toBe(true)
137+
})
138+
139+
it('should preserve parent localization through collapsibles', () => {
140+
const fields: Field[] = [
141+
{
142+
name: 'group',
143+
type: 'group',
144+
localized: true,
145+
fields: [
146+
{
147+
type: 'collapsible',
148+
label: 'Collapsible',
149+
fields: [
150+
{
151+
name: 'text',
152+
type: 'text',
153+
},
154+
],
155+
},
156+
],
157+
},
158+
]
159+
let textParentIsLocalized: boolean | undefined
160+
161+
traverseFields({
162+
fields,
163+
callback: ({ field, parentIsLocalized }) => {
164+
if (field.type === 'text') {
165+
textParentIsLocalized = parentIsLocalized
166+
}
167+
},
168+
})
169+
170+
expect(textParentIsLocalized).toBe(true)
171+
})
172+
})
52173
})

packages/payload/src/utilities/traverseFields.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const traverseFields = ({
159159
fillEmpty = true,
160160
isTopLevel = true,
161161
leavesFirst = false,
162-
parentIsLocalized,
162+
parentIsLocalized = false,
163163
parentPath = '',
164164
parentRef = {},
165165
ref = {},
@@ -181,15 +181,15 @@ export const traverseFields = ({
181181
if (
182182
!leavesFirst &&
183183
callback &&
184-
callback({ field, next, parentIsLocalized: parentIsLocalized!, parentPath, parentRef, ref })
184+
callback({ field, next, parentIsLocalized, parentPath, parentRef, ref })
185185
) {
186186
return true
187187
} else if (leavesFirst) {
188188
callbackStack.push(() =>
189189
callback({
190190
field,
191191
next,
192-
parentIsLocalized: parentIsLocalized!,
192+
parentIsLocalized,
193193
parentPath,
194194
parentRef,
195195
ref,
@@ -207,6 +207,7 @@ export const traverseFields = ({
207207

208208
if (field.type === 'tabs' && 'tabs' in field) {
209209
for (const tab of field.tabs) {
210+
const tabIsLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized })
210211
let tabRef = ref
211212

212213
if (skip) {
@@ -219,7 +220,7 @@ export const traverseFields = ({
219220
typeof ref[tab.name as keyof typeof ref] !== 'object'
220221
) {
221222
if (fillEmpty) {
222-
if (tab.localized) {
223+
if (tabIsLocalized) {
223224
;(ref as Record<string, any>)[tab.name] = { en: {} }
224225
} else {
225226
;(ref as Record<string, any>)[tab.name] = {}
@@ -235,7 +236,7 @@ export const traverseFields = ({
235236
callback({
236237
field: { ...tab, type: 'tab' },
237238
next,
238-
parentIsLocalized: parentIsLocalized!,
239+
parentIsLocalized,
239240
parentPath,
240241
parentRef: currentParentRef,
241242
ref: tabRef,
@@ -247,7 +248,7 @@ export const traverseFields = ({
247248
callback({
248249
field: { ...tab, type: 'tab' },
249250
next,
250-
parentIsLocalized: parentIsLocalized!,
251+
parentIsLocalized,
251252
parentPath,
252253
parentRef: currentParentRef,
253254
ref: tabRef,
@@ -257,7 +258,7 @@ export const traverseFields = ({
257258

258259
tabRef = tabRef[tab.name as keyof typeof tabRef]
259260

260-
if (tab.localized) {
261+
if (tabIsLocalized) {
261262
for (const key in tabRef as Record<string, unknown>) {
262263
if (
263264
tabRef[key as keyof typeof tabRef] &&
@@ -286,7 +287,7 @@ export const traverseFields = ({
286287
callback({
287288
field: { ...tab, type: 'tab' },
288289
next,
289-
parentIsLocalized: parentIsLocalized!,
290+
parentIsLocalized,
290291
parentPath,
291292
parentRef: currentParentRef,
292293
ref: tabRef,
@@ -298,7 +299,7 @@ export const traverseFields = ({
298299
callback({
299300
field: { ...tab, type: 'tab' },
300301
next,
301-
parentIsLocalized: parentIsLocalized!,
302+
parentIsLocalized,
302303
parentPath,
303304
parentRef: currentParentRef,
304305
ref: tabRef,
@@ -307,7 +308,7 @@ export const traverseFields = ({
307308
}
308309
}
309310

310-
if (!tab.localized) {
311+
if (!tabIsLocalized) {
311312
traverseFields({
312313
callback,
313314
callbackStack,
@@ -316,7 +317,7 @@ export const traverseFields = ({
316317
fillEmpty,
317318
isTopLevel: false,
318319
leavesFirst,
319-
parentIsLocalized: false,
320+
parentIsLocalized,
320321
parentPath: tabHasName(tab) ? `${parentPath}${tab.name}.` : parentPath,
321322
parentRef: currentParentRef,
322323
ref: tabRef,
@@ -337,13 +338,13 @@ export const traverseFields = ({
337338
if (!ref[field.name as keyof typeof ref]) {
338339
if (fillEmpty) {
339340
if (field.type === 'group' || field.type === 'tab') {
340-
if (fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! })) {
341+
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
341342
;(ref as Record<string, any>)[field.name] = { en: {} }
342343
} else {
343344
;(ref as Record<string, any>)[field.name] = {}
344345
}
345346
} else if (field.type === 'array' || field.type === 'blocks') {
346-
if (fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! })) {
347+
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
347348
;(ref as Record<string, any>)[field.name] = { en: [] }
348349
} else {
349350
;(ref as Record<string, any>)[field.name] = []
@@ -358,7 +359,7 @@ export const traverseFields = ({
358359

359360
if (
360361
(field.type === 'tab' || field.type === 'group') &&
361-
fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) &&
362+
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
362363
currentRef &&
363364
typeof currentRef === 'object'
364365
) {
@@ -403,18 +404,7 @@ export const traverseFields = ({
403404
currentRef &&
404405
typeof currentRef === 'object'
405406
) {
406-
// TODO: `?? field.localized ?? false` shouldn't be necessary, but right now it
407-
// is so that all fields are correctly traversed in copyToLocale and
408-
// therefore pass the localization integration tests.
409-
// I tried replacing the `!parentIsLocalized` condition with `parentIsLocalized === false`
410-
// in `fieldShouldBeLocalized`, but several tests failed. We must be calling it with incorrect
411-
// parameters somewhere.
412-
if (
413-
fieldShouldBeLocalized({
414-
field,
415-
parentIsLocalized: parentIsLocalized ?? false,
416-
})
417-
) {
407+
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
418408
if (Array.isArray(currentRef)) {
419409
traverseArrayOrBlocksField({
420410
callback,
@@ -458,7 +448,7 @@ export const traverseFields = ({
458448
field,
459449
fillEmpty,
460450
leavesFirst,
461-
parentIsLocalized: parentIsLocalized!,
451+
parentIsLocalized,
462452
parentPath,
463453
parentRef: currentParentRef,
464454
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { JsonObject } from '../types/index.js'
2+
3+
import { unflatten } from './unflatten.js'
4+
5+
export const unflattenData = <T extends JsonObject>(data: T): T => {
6+
if (!data || typeof data !== 'object' || !Object.keys(data).some((key) => key.includes('.'))) {
7+
return data
8+
}
9+
10+
const unflattened = unflatten(data) as JsonObject
11+
12+
for (const key of Object.keys(data)) {
13+
delete data[key]
14+
}
15+
16+
Object.assign(data, unflattened)
17+
18+
return data
19+
}

test/localization/collections/Array/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,33 @@ export const ArrayCollection: CollectionConfig = {
88
{
99
name: 'items',
1010
type: 'array',
11-
localized: true,
1211
fields: [
1312
{
1413
name: 'text',
1514
type: 'text',
1615
},
16+
{
17+
type: 'tabs',
18+
tabs: [
19+
{
20+
fields: [
21+
{
22+
name: 'nestedItems',
23+
type: 'array',
24+
fields: [
25+
{
26+
name: 'text',
27+
type: 'text',
28+
},
29+
],
30+
},
31+
],
32+
label: 'Nested',
33+
},
34+
],
35+
},
1736
],
37+
localized: true,
1838
},
1939
],
2040
}

0 commit comments

Comments
 (0)