@designcrowd/library.brand-page 6.0.6-undo-2 → 6.0.6-undo-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-BSFTp6yl.js → CreatedOnBrandCrowd-de-de-BqD7lX6T.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-rPQrOY1L.js → CreatedOnBrandCrowd-en-us-BXFZ-Vv_.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-awd8-QFa.js → CreatedOnBrandCrowd-es-es-qTAVOIsd.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-CwJ4J7Ow.js → CreatedOnBrandCrowd-fr-ca-LVZ7sAKH.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-CwJ4J7Ow.js → CreatedOnBrandCrowd-fr-fr-LVZ7sAKH.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BXYfTLlD.js → CreatedOnBrandCrowd-pt-br-TSeJN2wb.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BXYfTLlD.js → CreatedOnBrandCrowd-pt-pt-TSeJN2wb.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-kX6u1lNK.js → CreatedOnDcom-de-de-BLLcDw_4.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-Bkq5sgFs.js → CreatedOnDcom-en-us-BVdWxcIs.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-zCAC9ujj.js → CreatedOnDcom-es-es-BZRzR4GZ.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-BYz6Wzg5.js → CreatedOnDcom-fr-ca-B97f7ekB.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CN-wAaSH.js → CreatedOnDcom-fr-fr-CNWS12Rb.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-BrK4Ngg-.js → CreatedOnDcom-pt-br-CsASsHeV.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-AfI_g7xJ.js → CreatedOnDcom-pt-pt-CsYqjPls.js} +1 -1
  15. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +5 -5
  16. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +6 -6
  17. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +1 -1
  18. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +1 -1
  19. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +23 -13
  20. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +1 -1
  21. package/lib/src/index.mjs +1 -1
  22. package/lib/src/{lib-BYFWAvIq.js → lib-BNqCRgBN.js} +15895 -15862
  23. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +5 -0
  24. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.js +29 -0
  25. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +6 -6
  26. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +5 -5
  27. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +5 -5
  28. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +5 -5
  29. package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +5 -0
  30. package/lib/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.d.ts +5 -5
  31. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +0 -67
  32. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.js +4 -6
  33. package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +5 -0
  34. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +5 -0
  35. package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.d.ts +2 -0
  36. package/lib/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.js +19 -0
  37. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +5 -0
  38. package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +5 -0
  39. package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +5 -0
  40. package/lib/src/shared/content-settings/models.d.ts +11 -0
  41. package/lib/src/shared/content-settings/store/content-settings-store.d.ts +5 -5
  42. package/lib/src/shared/content-settings/store/content-settings-store.js +39 -5
  43. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +5 -5
  44. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +5 -5
  45. package/package.json +1 -1
  46. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +24 -15
  47. package/src/shared/content-settings/components/GenericTextInput.mixin.test.ts +119 -0
  48. package/src/shared/content-settings/components/GenericTextInput.mixin.ts +34 -0
  49. package/src/shared/content-settings/components/page-dropdown-field/PageDropdownField.vue +10 -5
  50. package/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.ts +4 -7
  51. package/src/shared/content-settings/components/text-field/TextField.vue +2 -1
  52. package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.test.ts +149 -0
  53. package/src/shared/content-settings/components/text-items-field/TextItemsField.mixin.ts +26 -0
  54. package/src/shared/content-settings/components/textarea-field/TextareaField.vue +2 -1
  55. package/src/shared/content-settings/models.ts +11 -0
  56. package/src/shared/content-settings/store/content-settings-store.test.ts +137 -0
  57. package/src/shared/content-settings/store/content-settings-store.ts +45 -0
@@ -0,0 +1,149 @@
1
+ import { defineComponent } from 'vue';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import type { Content, LogoTheme, TextItem } from '@brand-page/models';
5
+ import TextItemsFieldMixin from './TextItemsField.mixin';
6
+
7
+ // BP-5810: TextItemsField re-wraps per-item TextareaField payloads into one
8
+ // textItems array payload behind its own 250ms debounce. The hydration flag
9
+ // must survive this second hop (AND rule: any user action in the burst
10
+ // downgrades it).
11
+
12
+ const DEBOUNCE_IN_MS = 250;
13
+
14
+ interface EmittedPayload {
15
+ id: string;
16
+ name: string;
17
+ value: TextItem[];
18
+ isHydrationWrite?: boolean;
19
+ }
20
+
21
+ const TestHost = defineComponent({
22
+ mixins: [TextItemsFieldMixin],
23
+ template: '<div />',
24
+ });
25
+
26
+ const makeTextItem = (text: string): TextItem =>
27
+ ({
28
+ text,
29
+ textStyle: {
30
+ fontSize: '16px',
31
+ textAlign: 'left',
32
+ textStyleType: 'paragraphs',
33
+ applyAutoScale: true,
34
+ },
35
+ }) as TextItem;
36
+
37
+ const mountHost = () =>
38
+ mount(TestHost, {
39
+ props: {
40
+ id: 'item-1',
41
+ name: 'textItems',
42
+ content: { id: 'item-1', type: 'text' } as Content,
43
+ value: [makeTextItem('<p>one</p>'), makeTextItem('<p>two</p>')],
44
+ isAdmin: false,
45
+ logoTheme: {
46
+ logoTextColor: '#000000',
47
+ logoBackgroundColor: '#ffffff',
48
+ } as unknown as LogoTheme,
49
+ showRichTextEditor: true,
50
+ },
51
+ });
52
+
53
+ const emittedPayload = (
54
+ wrapper: ReturnType<typeof mountHost>,
55
+ call: number,
56
+ ): EmittedPayload =>
57
+ wrapper.emitted('update:model-value')![call][0] as EmittedPayload;
58
+
59
+ describe('TextItemsField hydration burst flag', () => {
60
+ beforeEach(() => {
61
+ setActivePinia(createPinia());
62
+ vi.useFakeTimers();
63
+ });
64
+
65
+ afterEach(() => {
66
+ vi.useRealTimers();
67
+ });
68
+
69
+ test('hydration-only item writes emit a flagged textItems payload', async () => {
70
+ const wrapper = mountHost();
71
+
72
+ wrapper.vm.onTextAreaTextChange(
73
+ {
74
+ id: 'item-1',
75
+ name: 'textItems',
76
+ value: '<p style="text-align: center;">one</p>',
77
+ isHydrationWrite: true,
78
+ },
79
+ 0,
80
+ );
81
+ wrapper.vm.onTextAreaTextChange(
82
+ {
83
+ id: 'item-1',
84
+ name: 'textItems',
85
+ value: '<p style="text-align: center;">two</p>',
86
+ isHydrationWrite: true,
87
+ },
88
+ 1,
89
+ );
90
+
91
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_IN_MS + 10);
92
+
93
+ expect(wrapper.emitted('update:model-value')).toHaveLength(1);
94
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBe(true);
95
+ expect(emittedPayload(wrapper, 0).value[0].text).toBe(
96
+ '<p style="text-align: center;">one</p>',
97
+ );
98
+ });
99
+
100
+ test('an unflagged item write keeps the payload unflagged', async () => {
101
+ const wrapper = mountHost();
102
+
103
+ wrapper.vm.onTextAreaTextChange(
104
+ { id: 'item-1', name: 'textItems', value: '<p>typed</p>' },
105
+ 0,
106
+ );
107
+
108
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_IN_MS + 10);
109
+
110
+ expect(wrapper.emitted('update:model-value')).toHaveLength(1);
111
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBeUndefined();
112
+ });
113
+
114
+ test('a user action in the burst downgrades a pending hydration flag', async () => {
115
+ const wrapper = mountHost();
116
+
117
+ wrapper.vm.onTextAreaTextChange(
118
+ {
119
+ id: 'item-1',
120
+ name: 'textItems',
121
+ value: '<p>canonical</p>',
122
+ isHydrationWrite: true,
123
+ },
124
+ 0,
125
+ );
126
+
127
+ // Style change is a user action and emits immediately.
128
+ wrapper.vm.setTextStyleOption(
129
+ {
130
+ id: 'item-1',
131
+ name: 'textItems',
132
+ value: { fontSize: '20px', textAlign: 'left' },
133
+ },
134
+ 1,
135
+ );
136
+
137
+ expect(wrapper.emitted('update:model-value')).toHaveLength(1);
138
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBeUndefined();
139
+
140
+ // The trailing debounced emit must not resurrect the flag either.
141
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_IN_MS + 10);
142
+
143
+ const calls = wrapper.emitted('update:model-value')!;
144
+
145
+ for (let i = 0; i < calls.length; i++) {
146
+ expect(emittedPayload(wrapper, i).isHydrationWrite).toBeUndefined();
147
+ }
148
+ });
149
+ });
@@ -84,6 +84,11 @@ export default defineComponent({
84
84
  textEmptyErrorMessage: TEXT_EMPTY(),
85
85
  textMaxLengthErrorMessage: MAX_LENGTH_ERROR_MESSAGE(),
86
86
  isInternalWatchEnabled: true,
87
+ // Second burst layer on top of GenericTextInput's (this field re-wraps
88
+ // per-item TextareaField payloads into one textItems array payload
89
+ // behind its own debounce). Hydration only if EVERY write in the burst
90
+ // was hydration; any user action downgrades it. undefined = no burst.
91
+ burstIsHydration: undefined as boolean | undefined,
87
92
  }),
88
93
  async created() {
89
94
  this.options = cloneDeep(this.value);
@@ -97,6 +102,9 @@ export default defineComponent({
97
102
  onDelete(index: number) {
98
103
  this.options = this.options.filter((_o, i) => i !== index);
99
104
 
105
+ // Deleting is a user action — downgrade any pending hydration burst.
106
+ this.burstIsHydration = false;
107
+
100
108
  if (this.changeDebounce) {
101
109
  this.changeDebounce();
102
110
  }
@@ -107,6 +115,8 @@ export default defineComponent({
107
115
  this.addOption();
108
116
  },
109
117
  addOption(): void {
118
+ this.burstIsHydration = false;
119
+
110
120
  this.options.push({
111
121
  text: '',
112
122
  textStyle: {
@@ -131,16 +141,24 @@ export default defineComponent({
131
141
  return decodeHtmlEntities(text);
132
142
  },
133
143
  emitChange(): void {
144
+ const isHydrationWrite = this.burstIsHydration === true;
145
+
146
+ this.burstIsHydration = undefined;
147
+
134
148
  const payload: UpdateItemValuePayload<TextItem[]> = {
135
149
  id: this.id,
136
150
  name: this.name,
137
151
  value: this.options,
152
+ ...(isHydrationWrite ? { isHydrationWrite: true } : {}),
138
153
  };
139
154
 
140
155
  this.$emit('update:model-value', payload);
141
156
  },
142
157
 
143
158
  onItemsChange(items: TextItem[]) {
159
+ // Reordering is a user action — downgrade any pending hydration burst.
160
+ this.burstIsHydration = false;
161
+
144
162
  const payload: UpdateItemValuePayload<TextItem[]> = {
145
163
  id: this.id,
146
164
  name: this.name,
@@ -161,9 +179,15 @@ export default defineComponent({
161
179
  id: string;
162
180
  name: string;
163
181
  value: string;
182
+ isHydrationWrite?: boolean;
164
183
  },
165
184
  index: number,
166
185
  ) {
186
+ // Carry the hydration flag from the per-item TextareaField burst into
187
+ // this field's own burst (AND rule — one user write downgrades it).
188
+ this.burstIsHydration =
189
+ this.burstIsHydration !== false && payload.isHydrationWrite === true;
190
+
167
191
  this.options[index].text = payload.value;
168
192
  this.isModified[index] = true;
169
193
  },
@@ -177,6 +201,8 @@ export default defineComponent({
177
201
  index: number,
178
202
  ) {
179
203
  this.options[index].textStyle = payload.value;
204
+ // Style edits are user actions — downgrade any pending hydration burst.
205
+ this.burstIsHydration = false;
180
206
  this.emitChange();
181
207
  },
182
208
 
@@ -5,7 +5,7 @@
5
5
  v-if="showRichTextEditor"
6
6
  :id="`${id}-${name}`"
7
7
  ref="richTextEditorRef"
8
- v-model="text"
8
+ :model-value="text"
9
9
  class="tw-bg-white"
10
10
  :attrs="attrs"
11
11
  :label="label"
@@ -21,6 +21,7 @@
21
21
  :content-type="contentType"
22
22
  @focus="onFocus"
23
23
  @blur="onBlur"
24
+ @update:model-value="onRteModelValue"
24
25
  />
25
26
  <Textarea
26
27
  v-else
@@ -123,6 +123,15 @@ export interface UpdateItemValuePayload<T> {
123
123
  name: string;
124
124
  value: T;
125
125
  multipleValues?: boolean;
126
+ /**
127
+ * Marks this write as a hydration write-back (e.g. the RTE rewriting the
128
+ * model into canonical HTML on mount) rather than a user edit. The
129
+ * content-settings store arms the undo history's merge flag synchronously
130
+ * before applying a flagged write, so it folds into the current history
131
+ * head instead of pushing a phantom entry (BP-5810). The flag travels
132
+ * with the value so it survives the debounced field → store hops.
133
+ */
134
+ isHydrationWrite?: boolean;
126
135
  }
127
136
 
128
137
  // eslint-disable-next-line @typescript-eslint/ban-types
@@ -131,6 +140,8 @@ export interface UpdateMultipleItemValuesPayload<T extends object> {
131
140
  name: string;
132
141
  value: T;
133
142
  index?: number;
143
+ /** See UpdateItemValuePayload.isHydrationWrite. */
144
+ isHydrationWrite?: boolean;
134
145
  }
135
146
 
136
147
  export interface ValidateItemPayload {
@@ -5,6 +5,16 @@ import {
5
5
  useUiContentSettingsStore,
6
6
  } from './index';
7
7
 
8
+ const { mergeNextMutationMock } = vi.hoisted(() => ({
9
+ mergeNextMutationMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@brand-page-maker/stores/history/history-store', () => ({
13
+ useHistoryStore: vi.fn(() => ({
14
+ mergeNextMutation: mergeNextMutationMock,
15
+ })),
16
+ }));
17
+
8
18
  describe('content settings store', () => {
9
19
  beforeAll(() => {
10
20
  const pinia = createPinia();
@@ -69,3 +79,130 @@ describe('content settings store', () => {
69
79
  expect(store.modeStack[0].mode).toBe(DEFAULT_CONSTANT_SETTINGS_MODE);
70
80
  });
71
81
  });
82
+
83
+ // BP-5810: hydration write-backs (RTE canonical HTML on mount, lazy config
84
+ // hydration, stale-id clears) arrive with `isHydrationWrite` on the payload.
85
+ // The store must arm the undo history's merge flag in the same tick as the
86
+ // write so it folds into the current head instead of pushing a phantom entry.
87
+ describe('content settings store — hydration writes arm the history merge', () => {
88
+ const makeItem = () => ({
89
+ id: 'item-1',
90
+ type: 'social-links',
91
+ settings: {
92
+ content: { title: '<p>old</p>', titleStyle: { fontSize: '2em' } },
93
+ },
94
+ });
95
+
96
+ const setupStore = () => {
97
+ setActivePinia(createPinia());
98
+
99
+ const store = useUiContentSettingsStore();
100
+
101
+ store.items.push(makeItem() as never);
102
+
103
+ // Isolate updateItemValue: validation and the content-changed fan-out
104
+ // reach into maker stores that are out of scope here.
105
+ store.validateItem = vi.fn();
106
+ store.onContentChanged = vi.fn();
107
+
108
+ return store;
109
+ };
110
+
111
+ beforeEach(() => {
112
+ mergeNextMutationMock.mockClear();
113
+ });
114
+
115
+ test('updateItemValue with isHydrationWrite arms the merge and applies the write', () => {
116
+ const store = setupStore();
117
+
118
+ store.updateItemValue({
119
+ id: 'item-1',
120
+ name: 'title',
121
+ value: '<p style="text-align: center;"></p>',
122
+ isHydrationWrite: true,
123
+ });
124
+
125
+ expect(mergeNextMutationMock).toHaveBeenCalledTimes(1);
126
+ expect((store.items[0].settings.content as { title?: string }).title).toBe(
127
+ '<p style="text-align: center;"></p>',
128
+ );
129
+ });
130
+
131
+ test('updateItemValue without the flag does not touch the history merge', () => {
132
+ const store = setupStore();
133
+
134
+ store.updateItemValue({
135
+ id: 'item-1',
136
+ name: 'title',
137
+ value: '<p>user edit</p>',
138
+ });
139
+
140
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
141
+ expect((store.items[0].settings.content as { title?: string }).title).toBe(
142
+ '<p>user edit</p>',
143
+ );
144
+ });
145
+
146
+ test('updateItemValue for an unknown item does not arm the merge', () => {
147
+ const store = setupStore();
148
+
149
+ store.updateItemValue({
150
+ id: 'missing-item',
151
+ name: 'title',
152
+ value: '<p>x</p>',
153
+ isHydrationWrite: true,
154
+ });
155
+
156
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
157
+ });
158
+
159
+ // The RTE's mount-time style sync (setInitialStyleSettings) writes through
160
+ // updateTextStyles ~100ms BEFORE the canonical text write-back. If it isn't
161
+ // armed, it opens a plain capture burst that swallows the flagged text
162
+ // write — the phantom's second act (BP-5810).
163
+ test('updateTextStyles honours the hydration-write contract', () => {
164
+ const store = setupStore();
165
+
166
+ store.updateTextStyles({
167
+ id: 'item-1',
168
+ name: 'title',
169
+ value: { fontSize: '2.25em' },
170
+ isHydrationWrite: true,
171
+ });
172
+
173
+ expect(mergeNextMutationMock).toHaveBeenCalledTimes(1);
174
+
175
+ mergeNextMutationMock.mockClear();
176
+
177
+ store.updateTextStyles({
178
+ id: 'item-1',
179
+ name: 'title',
180
+ value: { fontSize: '3em' },
181
+ });
182
+
183
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
184
+ });
185
+
186
+ test('updateMultipleItemValues honours the same contract', () => {
187
+ const store = setupStore();
188
+
189
+ store.updateMultipleItemValues({
190
+ id: 'item-1',
191
+ name: 'title',
192
+ value: { title: '<p>merged</p>' },
193
+ isHydrationWrite: true,
194
+ });
195
+
196
+ expect(mergeNextMutationMock).toHaveBeenCalledTimes(1);
197
+
198
+ mergeNextMutationMock.mockClear();
199
+
200
+ store.updateMultipleItemValues({
201
+ id: 'item-1',
202
+ name: 'title',
203
+ value: { title: '<p>user</p>' },
204
+ });
205
+
206
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
207
+ });
208
+ });
@@ -36,6 +36,7 @@ import {
36
36
  useBrandPageMakerStore,
37
37
  useVideoStore,
38
38
  } from '@brand-page-maker/stores';
39
+ import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
39
40
  import { useUiSideBarStore } from '../../side-bar/store';
40
41
  import {
41
42
  createDefaultContentItemGroupState,
@@ -389,6 +390,16 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
389
390
  return;
390
391
  }
391
392
 
393
+ // Hydration write-backs (RTE canonical HTML on mount, lazy config
394
+ // hydration, stale-id clears) fold into the current undo head instead
395
+ // of pushing an entry. Arming here — same tick as the write — is what
396
+ // makes the merge reliable: the flag travelled with the payload
397
+ // through the debounced field chain, so emit-time arming would lapse
398
+ // before the write lands (BP-5810).
399
+ if (payload.isHydrationWrite) {
400
+ useHistoryStore().mergeNextMutation();
401
+ }
402
+
392
403
  item.settings.content[payload.name] = payload.value;
393
404
 
394
405
  this.validateItem({
@@ -402,6 +413,7 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
402
413
  id,
403
414
  name,
404
415
  value,
416
+ isHydrationWrite,
405
417
  }: UpdateMultipleItemValuesPayload<any>): void {
406
418
  const item = this.items.find((i) => i.id === id);
407
419
 
@@ -409,6 +421,11 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
409
421
  return;
410
422
  }
411
423
 
424
+ // Same hydration-write contract as updateItemValue.
425
+ if (isHydrationWrite) {
426
+ useHistoryStore().mergeNextMutation();
427
+ }
428
+
412
429
  item.settings.content = vueDeepMerge(item.settings.content, value);
413
430
  this.validateItem({ id, name });
414
431
  this.onContentChanged();
@@ -417,6 +434,7 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
417
434
  id,
418
435
  name,
419
436
  value,
437
+ isHydrationWrite,
420
438
  }: UpdateMultipleItemValuesPayload<any>): void {
421
439
  const item = this.items.find((i) => i.id === id);
422
440
 
@@ -424,6 +442,11 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
424
442
  return;
425
443
  }
426
444
 
445
+ // Same hydration-write contract as updateItemValue.
446
+ if (isHydrationWrite) {
447
+ useHistoryStore().mergeNextMutation();
448
+ }
449
+
427
450
  item.settings.content[`${name}Style`].textStyleType = vueDeepMerge(
428
451
  item.settings.content,
429
452
  value,
@@ -435,6 +458,7 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
435
458
  id,
436
459
  name,
437
460
  value,
461
+ isHydrationWrite,
438
462
  }: UpdateMultipleItemValuesPayload<any>): void {
439
463
  const item = this.items.find((i) => i.id === id);
440
464
 
@@ -442,6 +466,15 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
442
466
  return;
443
467
  }
444
468
 
469
+ // Same hydration-write contract as updateItemValue. The RTE's
470
+ // mount-time style sync (setInitialStyleSettings) lands here ~100ms
471
+ // BEFORE its canonical text write-back — if this write opens a plain
472
+ // capture burst, the flagged text write joins it and both commit as a
473
+ // phantom entry (BP-5810's second act).
474
+ if (isHydrationWrite) {
475
+ useHistoryStore().mergeNextMutation();
476
+ }
477
+
445
478
  item.settings.content[`${name}Style`] = vueDeepMerge(
446
479
  item.settings.content[`${name}Style`],
447
480
  value,
@@ -463,6 +496,7 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
463
496
  name,
464
497
  value,
465
498
  index,
499
+ isHydrationWrite,
466
500
  }: UpdateMultipleItemValuesPayload<any>): void {
467
501
  const item = this.items.find((i) => i.id === id);
468
502
 
@@ -470,6 +504,11 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
470
504
  return;
471
505
  }
472
506
 
507
+ // Same hydration-write contract as updateItemValue.
508
+ if (isHydrationWrite) {
509
+ useHistoryStore().mergeNextMutation();
510
+ }
511
+
473
512
  item.settings.content.textItems[index!].textStyle.textStyleType =
474
513
  vueDeepMerge(
475
514
  item.settings.content.textItems[index!].textStyle.textStyleType,
@@ -483,6 +522,7 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
483
522
  name,
484
523
  value,
485
524
  index,
525
+ isHydrationWrite,
486
526
  }: UpdateMultipleItemValuesPayload<any>): void {
487
527
  const item = this.items.find((i) => i.id === id);
488
528
 
@@ -490,6 +530,11 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
490
530
  return;
491
531
  }
492
532
 
533
+ // Same hydration-write contract as updateItemValue.
534
+ if (isHydrationWrite) {
535
+ useHistoryStore().mergeNextMutation();
536
+ }
537
+
493
538
  item.settings.content.textItems[index!].textStyle = vueDeepMerge(
494
539
  item.settings.content.textItems[index!].textStyle,
495
540
  value,