Skip to content

Commit 6557292

Browse files
axe312gerclaudeGermanJablo
authored
fix: preserve block metadata in mergeLocalizedData and filterDataToSelectedLocales (#15715)
## Summary - Fixes `mergeLocalizedData` dropping `blockType`, `id`, and `blockName` from non-localized blocks when the existing document has no blocks yet (e.g. after an initial autosave before blocks were added) - Fixes the same class of bug in `filterDataToSelectedLocales` - Not specific to `localizeStatus: true` — affects any collection using `publishSpecificLocale` with versioned blocks + autosave Supersedes #15659 — the bug actually exists in **two spots**: both `filterDataToSelectedLocales` (which #15659 addresses) **and** `mergeLocalizedData` (which #15659 misses). This PR fixes both, using direct property assignment instead of object spread for less overhead. ## Root cause In both `mergeLocalizedData` and `filterDataToSelectedLocales`, the recursive calls for non-localized blocks only iterate `block.fields`. But `blockType`, `id`, and `blockName` are internal metadata not present in `block.fields`. When `existingValue` is `undefined` (main doc has no blocks because autosave only wrote to the versions table), `blockData` falls back to `{}`, and the recursive call starts with an empty object — metadata is lost. When `existingValue` already has blocks, `blockData` contains `blockType` from the spread and the bug doesn't trigger. ## Fix After each recursive `mergeLocalizedData`/`filterDataToSelectedLocales` call for blocks, explicitly reattach `blockType`, `id`, and `blockName` from the incoming data. Direct property assignment (no extra object spread) for minimal overhead. ## Related - #15655 / #15659 — same bug class in `filterDataToSelectedLocales` (this PR includes that fix with a more performant approach) - #15642 / #15658 — sibling bug in `mergeLocalizedData` (unnamed groups losing data) ## Test plan - [x] Unit tests for `mergeLocalizedData` — 3 new tests covering empty existing doc, undefined block field, and happy path - [x] Unit tests for `filterDataToSelectedLocales` — new spec file with 5 tests covering block metadata preservation - [x] Integration test — creates doc without blocks, adds blocks, publishes with `publishSpecificLocale`, verifies `blockType` and `id` survive - [x] E2E test — same scenario through the admin UI + API 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
1 parent 0a0afb0 commit 6557292

7 files changed

Lines changed: 508 additions & 2 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import type { Field } from '../fields/config/types.js'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { filterDataToSelectedLocales } from './filterDataToSelectedLocales.js'
6+
7+
describe('filterDataToSelectedLocales', () => {
8+
const selectedLocales = ['en']
9+
const configBlockReferences = []
10+
11+
describe('block metadata preservation', () => {
12+
it('should preserve blockType, id, and blockName on non-localized blocks', () => {
13+
const fields: Field[] = [
14+
{
15+
name: 'layout',
16+
type: 'blocks',
17+
blocks: [
18+
{
19+
slug: 'content',
20+
fields: [
21+
{
22+
name: 'richText',
23+
type: 'richText',
24+
localized: true,
25+
},
26+
],
27+
},
28+
],
29+
},
30+
]
31+
32+
const docWithLocales = {
33+
layout: [
34+
{
35+
blockType: 'content',
36+
id: 'abc123',
37+
blockName: 'My Block',
38+
richText: { en: 'English', es: 'Spanish' },
39+
},
40+
],
41+
}
42+
43+
const result = filterDataToSelectedLocales({
44+
configBlockReferences: [],
45+
docWithLocales,
46+
fields,
47+
selectedLocales,
48+
})
49+
50+
expect(result.layout).toHaveLength(1)
51+
expect(result.layout[0].blockType).toBe('content')
52+
expect(result.layout[0].id).toBe('abc123')
53+
expect(result.layout[0].blockName).toBe('My Block')
54+
expect(result.layout[0].richText).toEqual({ en: 'English' })
55+
})
56+
57+
it('should preserve blockType and id when block has no blockName', () => {
58+
const fields: Field[] = [
59+
{
60+
name: 'layout',
61+
type: 'blocks',
62+
blocks: [
63+
{
64+
slug: 'text',
65+
fields: [
66+
{
67+
name: 'text',
68+
type: 'text',
69+
localized: true,
70+
},
71+
],
72+
},
73+
],
74+
},
75+
]
76+
77+
const docWithLocales = {
78+
layout: [
79+
{
80+
blockType: 'text',
81+
id: 'def456',
82+
text: { en: 'English', es: 'Spanish' },
83+
},
84+
],
85+
}
86+
87+
const result = filterDataToSelectedLocales({
88+
configBlockReferences: [],
89+
docWithLocales,
90+
fields,
91+
selectedLocales,
92+
})
93+
94+
expect(result.layout).toHaveLength(1)
95+
expect(result.layout[0].blockType).toBe('text')
96+
expect(result.layout[0].id).toBe('def456')
97+
expect(result.layout[0].text).toEqual({ en: 'English' })
98+
})
99+
100+
it('should preserve blockType and id with configBlockReferences', () => {
101+
const fields: Field[] = [
102+
{
103+
name: 'layout',
104+
type: 'blocks',
105+
blocks: [],
106+
blockReferences: ['content'],
107+
},
108+
]
109+
110+
const result = filterDataToSelectedLocales({
111+
configBlockReferences: [
112+
{
113+
slug: 'content',
114+
fields: [
115+
{
116+
name: 'body',
117+
type: 'text',
118+
localized: true,
119+
},
120+
],
121+
},
122+
],
123+
docWithLocales: {
124+
layout: [
125+
{
126+
blockType: 'content',
127+
id: 'ref123',
128+
body: { en: 'English', es: 'Spanish' },
129+
},
130+
],
131+
},
132+
fields,
133+
selectedLocales,
134+
})
135+
136+
expect(result.layout).toHaveLength(1)
137+
expect(result.layout[0].blockType).toBe('content')
138+
expect(result.layout[0].id).toBe('ref123')
139+
expect(result.layout[0].body).toEqual({ en: 'English' })
140+
})
141+
})
142+
143+
describe('simple fields', () => {
144+
it('should filter localized field values to selected locales', () => {
145+
const fields: Field[] = [
146+
{
147+
name: 'title',
148+
type: 'text',
149+
localized: true,
150+
},
151+
]
152+
153+
const docWithLocales = {
154+
title: {
155+
en: 'English Title',
156+
es: 'Spanish Title',
157+
de: 'German Title',
158+
},
159+
}
160+
161+
const result = filterDataToSelectedLocales({
162+
configBlockReferences: [],
163+
docWithLocales,
164+
fields,
165+
selectedLocales,
166+
})
167+
168+
expect(result.title).toEqual({ en: 'English Title' })
169+
})
170+
171+
it('should pass through non-localized fields as-is', () => {
172+
const fields: Field[] = [
173+
{
174+
name: 'slug',
175+
type: 'text',
176+
},
177+
]
178+
179+
const docWithLocales = {
180+
slug: 'my-slug',
181+
}
182+
183+
const result = filterDataToSelectedLocales({
184+
configBlockReferences: [],
185+
docWithLocales,
186+
fields,
187+
selectedLocales,
188+
})
189+
190+
expect(result.slug).toBe('my-slug')
191+
})
192+
})
193+
})

packages/payload/src/utilities/filterDataToSelectedLocales.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,21 @@ export function filterDataToSelectedLocales({
6868
}
6969

7070
if (block) {
71-
return filterDataToSelectedLocales({
71+
const filtered = filterDataToSelectedLocales({
7272
configBlockReferences,
7373
docWithLocales: blockData,
7474
fields: block?.fields || [],
7575
parentIsLocalized: fieldIsLocalized,
7676
selectedLocales,
7777
})
78+
79+
// blockType, id, blockName are set by Payload internally
80+
// and not part of block.fields, so they must be preserved explicitly
81+
filtered.blockType = blockData.blockType
82+
filtered.id = blockData.id
83+
filtered.blockName = blockData.blockName
84+
85+
return filtered
7886
}
7987

8088
return blockData

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

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,4 +923,160 @@ describe('mergeLocalizedData', () => {
923923
})
924924
})
925925
})
926+
927+
describe('block metadata preservation', () => {
928+
it('should preserve blockType, id, and blockName when existing doc has no blocks', () => {
929+
// Reproduces the bug where autosave creates a doc without blocks,
930+
// then blocks are added and publishSpecificLocale drops metadata
931+
const fields: Field[] = [
932+
{
933+
name: 'layout',
934+
type: 'blocks',
935+
blocks: [
936+
{
937+
slug: 'content',
938+
fields: [
939+
{
940+
name: 'richText',
941+
type: 'richText',
942+
localized: true,
943+
},
944+
],
945+
},
946+
],
947+
},
948+
]
949+
950+
// Main doc has no blocks (autosave wrote before blocks were added)
951+
const docWithLocales = {}
952+
953+
const dataWithLocales = {
954+
layout: [
955+
{
956+
blockType: 'content',
957+
id: 'abc123',
958+
blockName: 'My Content Block',
959+
richText: { en: 'Hello' },
960+
},
961+
],
962+
}
963+
964+
const result = mergeLocalizedData({
965+
configBlockReferences: [],
966+
dataWithLocales,
967+
docWithLocales,
968+
fields,
969+
selectedLocales: ['en'],
970+
})
971+
972+
expect(result.layout).toHaveLength(1)
973+
expect(result.layout[0].blockType).toBe('content')
974+
expect(result.layout[0].id).toBe('abc123')
975+
expect(result.layout[0].blockName).toBe('My Content Block')
976+
})
977+
978+
it('should preserve blockType and id when existing doc has undefined for block field', () => {
979+
const fields: Field[] = [
980+
{
981+
name: 'layout',
982+
type: 'blocks',
983+
blocks: [
984+
{
985+
slug: 'text',
986+
fields: [
987+
{
988+
name: 'text',
989+
type: 'text',
990+
localized: true,
991+
},
992+
],
993+
},
994+
],
995+
},
996+
]
997+
998+
// existingValue is undefined (field not present in existing doc)
999+
const docWithLocales = {
1000+
layout: undefined,
1001+
}
1002+
1003+
const dataWithLocales = {
1004+
layout: [
1005+
{
1006+
blockType: 'text',
1007+
id: 'def456',
1008+
text: { en: 'English text', es: 'Spanish text' },
1009+
},
1010+
],
1011+
}
1012+
1013+
const result = mergeLocalizedData({
1014+
configBlockReferences: [],
1015+
dataWithLocales,
1016+
docWithLocales,
1017+
fields,
1018+
selectedLocales: ['en'],
1019+
})
1020+
1021+
expect(result.layout).toHaveLength(1)
1022+
expect(result.layout[0].blockType).toBe('text')
1023+
expect(result.layout[0].id).toBe('def456')
1024+
expect(result.layout[0].text).toEqual({ en: 'English text' })
1025+
})
1026+
1027+
it('should preserve blockType when existing doc already has blocks', () => {
1028+
// Ensures the fix doesn't break the happy path
1029+
const fields: Field[] = [
1030+
{
1031+
name: 'layout',
1032+
type: 'blocks',
1033+
blocks: [
1034+
{
1035+
slug: 'content',
1036+
fields: [
1037+
{
1038+
name: 'body',
1039+
type: 'text',
1040+
localized: true,
1041+
},
1042+
],
1043+
},
1044+
],
1045+
},
1046+
]
1047+
1048+
const docWithLocales = {
1049+
layout: [
1050+
{
1051+
blockType: 'content',
1052+
id: 'existing-id',
1053+
body: { en: 'Old English', es: 'Old Spanish' },
1054+
},
1055+
],
1056+
}
1057+
1058+
const dataWithLocales = {
1059+
layout: [
1060+
{
1061+
blockType: 'content',
1062+
id: 'existing-id',
1063+
body: { en: 'New English', es: 'New Spanish' },
1064+
},
1065+
],
1066+
}
1067+
1068+
const result = mergeLocalizedData({
1069+
configBlockReferences: [],
1070+
dataWithLocales,
1071+
docWithLocales,
1072+
fields,
1073+
selectedLocales: ['en'],
1074+
})
1075+
1076+
expect(result.layout).toHaveLength(1)
1077+
expect(result.layout[0].blockType).toBe('content')
1078+
expect(result.layout[0].id).toBe('existing-id')
1079+
expect(result.layout[0].body).toEqual({ en: 'New English', es: 'Old Spanish' })
1080+
})
1081+
})
9261082
})

0 commit comments

Comments
 (0)