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

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-x7BcD7Sj.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-rPQrOY1L.js → CreatedOnBrandCrowd-en-us-CG-ThD_q.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-awd8-QFa.js → CreatedOnBrandCrowd-es-es-aab4_Re2.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-CwJ4J7Ow.js → CreatedOnBrandCrowd-fr-ca-Drl8X5uh.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-CwJ4J7Ow.js → CreatedOnBrandCrowd-fr-fr-Drl8X5uh.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BXYfTLlD.js → CreatedOnBrandCrowd-pt-br-8Ib7T70i.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BXYfTLlD.js → CreatedOnBrandCrowd-pt-pt-8Ib7T70i.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-kX6u1lNK.js → CreatedOnDcom-de-de-FJtydzc5.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-Bkq5sgFs.js → CreatedOnDcom-en-us-BMiXru6P.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-zCAC9ujj.js → CreatedOnDcom-es-es-B8Srdnku.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-BYz6Wzg5.js → CreatedOnDcom-fr-ca-tcXGiBNt.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CN-wAaSH.js → CreatedOnDcom-fr-fr-nE1cuUEU.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-BrK4Ngg-.js → CreatedOnDcom-pt-br-DxCCXCg8.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-AfI_g7xJ.js → CreatedOnDcom-pt-pt-Csabip9G.js} +1 -1
  15. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +1 -1
  16. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +2 -2
  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 +17 -12
  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-C_UlYMJy.js} +15640 -15612
  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 +2 -2
  26. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +1 -1
  27. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +1 -1
  28. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +1 -1
  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 +1 -1
  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 +1 -1
  42. package/lib/src/shared/content-settings/store/content-settings-store.js +15 -1
  43. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +1 -1
  44. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +1 -1
  45. package/package.json +1 -1
  46. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +18 -14
  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 +110 -0
  57. package/src/shared/content-settings/store/content-settings-store.ts +17 -0
@@ -0,0 +1,119 @@
1
+ import { defineComponent } from 'vue';
2
+ import { mount } from '@vue/test-utils';
3
+ import GenericTextInputMixin from './GenericTextInput.mixin';
4
+
5
+ // BP-5810: the RTE attaches `{ isHydration: true }` meta to its canonical
6
+ // write-back emits. GenericTextInput must carry that through its 100ms
7
+ // debounce onto the outgoing payload as `isHydrationWrite` — and downgrade
8
+ // the burst as soon as a single non-hydration write lands in it.
9
+
10
+ const DEBOUNCE_WAIT_MS = 100;
11
+
12
+ interface EmittedPayload {
13
+ id: string;
14
+ name: string;
15
+ value: string | false;
16
+ isHydrationWrite?: boolean;
17
+ }
18
+
19
+ const TestHost = defineComponent({
20
+ mixins: [GenericTextInputMixin],
21
+ template: '<div />',
22
+ });
23
+
24
+ const mountHost = () =>
25
+ mount(TestHost, {
26
+ props: {
27
+ id: 'item-1',
28
+ name: 'title',
29
+ value: '<p>seed</p>',
30
+ showRichTextEditor: true,
31
+ },
32
+ });
33
+
34
+ const emittedPayload = (
35
+ wrapper: ReturnType<typeof mountHost>,
36
+ call: number,
37
+ ): EmittedPayload =>
38
+ wrapper.emitted('update:modelValue')![call][0] as EmittedPayload;
39
+
40
+ describe('GenericTextInput hydration burst flag', () => {
41
+ beforeEach(() => {
42
+ vi.useFakeTimers();
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ });
48
+
49
+ test('a hydration-only burst emits a payload flagged isHydrationWrite', async () => {
50
+ const wrapper = mountHost();
51
+
52
+ wrapper.vm.onRteModelValue('<p style="text-align: center;"></p>', {
53
+ isHydration: true,
54
+ });
55
+
56
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
57
+
58
+ const emitted = wrapper.emitted('update:modelValue');
59
+
60
+ expect(emitted).toHaveLength(1);
61
+ expect(emitted![0][0]).toEqual({
62
+ id: 'item-1',
63
+ name: 'title',
64
+ value: '<p style="text-align: center;"></p>',
65
+ isHydrationWrite: true,
66
+ });
67
+ });
68
+
69
+ test('coalesced hydration writes in one burst stay flagged', async () => {
70
+ const wrapper = mountHost();
71
+
72
+ wrapper.vm.onRteModelValue('<p>a</p>', { isHydration: true });
73
+ wrapper.vm.onRteModelValue('<p>b</p>', { isHydration: true });
74
+
75
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
76
+
77
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
78
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBe(true);
79
+ expect(emittedPayload(wrapper, 0).value).toBe('<p>b</p>');
80
+ });
81
+
82
+ test('a user write in the same burst downgrades it to a normal edit', async () => {
83
+ const wrapper = mountHost();
84
+
85
+ wrapper.vm.onRteModelValue('<p>canonical</p>', { isHydration: true });
86
+ // Focused typing emits without meta.
87
+ wrapper.vm.onRteModelValue('<p>typed</p>');
88
+
89
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
90
+
91
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
92
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBeUndefined();
93
+ });
94
+
95
+ test('a plain write (no meta) is never flagged', async () => {
96
+ const wrapper = mountHost();
97
+
98
+ wrapper.vm.text = '<p>typed</p>';
99
+
100
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
101
+
102
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
103
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBeUndefined();
104
+ });
105
+
106
+ test('the flag resets between bursts', async () => {
107
+ const wrapper = mountHost();
108
+
109
+ wrapper.vm.onRteModelValue('<p>canonical</p>', { isHydration: true });
110
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
111
+
112
+ wrapper.vm.text = '<p>typed later</p>';
113
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS + 10);
114
+
115
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(2);
116
+ expect(emittedPayload(wrapper, 0).isHydrationWrite).toBe(true);
117
+ expect(emittedPayload(wrapper, 1).isHydrationWrite).toBeUndefined();
118
+ });
119
+ });
@@ -108,6 +108,13 @@ export default defineComponent({
108
108
  isDirty: false,
109
109
  emitChangeDebounce: undefined as DebounceHandle | undefined,
110
110
  lengthError: false,
111
+ // Whether every write in the current debounce burst was an RTE
112
+ // hydration write-back (see UpdateItemValuePayload.isHydrationWrite).
113
+ // undefined = no burst pending; any user write downgrades it to false.
114
+ burstIsHydration: undefined as boolean | undefined,
115
+ // One-shot channel from onRteModelValue into the `text` setter for
116
+ // the write currently being applied.
117
+ pendingWriteIsHydration: false,
111
118
  };
112
119
  },
113
120
  computed: {
@@ -144,6 +151,15 @@ export default defineComponent({
144
151
  return unescapeContentText(this.value);
145
152
  },
146
153
  set(newVal: string | false): void {
154
+ // A burst is hydration only if EVERY write in it is hydration —
155
+ // one real keystroke downgrades the whole burst to a user edit
156
+ // (the canonicalisation then rides inside that legitimate entry).
157
+ const writeIsHydration = this.pendingWriteIsHydration;
158
+
159
+ this.pendingWriteIsHydration = false;
160
+ this.burstIsHydration =
161
+ this.burstIsHydration !== false && writeIsHydration;
162
+
147
163
  if (newVal === false) {
148
164
  this._value = false;
149
165
  } else {
@@ -179,12 +195,16 @@ export default defineComponent({
179
195
  this._value = false;
180
196
  }
181
197
 
198
+ // Toggling is a user action — downgrade any pending hydration burst
199
+ // so this emit commits as a normal entry.
200
+ this.burstIsHydration = false;
182
201
  this.emitChange();
183
202
  },
184
203
 
185
204
  showRichTextEditor(newVal: boolean) {
186
205
  if (!newVal && this._value && typeof this._value === 'string') {
187
206
  this._value = encodeContentText(this._value);
207
+ this.burstIsHydration = false;
188
208
  this.emitChange();
189
209
  }
190
210
  },
@@ -208,11 +228,25 @@ export default defineComponent({
208
228
  this.isDirty = false;
209
229
  },
210
230
 
231
+ // RTE branch handler: receives the hydration meta the RichTextEditor
232
+ // attaches to its update:modelValue emits (a plain v-model binding
233
+ // would drop the second argument), records it for the `text` setter,
234
+ // then applies the write.
235
+ onRteModelValue(newVal: string, meta?: { isHydration?: boolean }): void {
236
+ this.pendingWriteIsHydration = meta?.isHydration === true;
237
+ this.text = newVal;
238
+ },
239
+
211
240
  emitChange() {
241
+ const isHydrationWrite = this.burstIsHydration === true;
242
+
243
+ this.burstIsHydration = undefined;
244
+
212
245
  this.$emit('update:modelValue', {
213
246
  id: this.id,
214
247
  name: this.name,
215
248
  value: this._value,
249
+ ...(isHydrationWrite ? { isHydrationWrite: true } : {}),
216
250
  });
217
251
  },
218
252
  },
@@ -24,7 +24,6 @@ import { Button } from '@designcrowd/fe-shared-lib';
24
24
  import { ref, computed, onMounted } from 'vue';
25
25
  import { useUiPageSettingsStore } from '../../../page-settings/store';
26
26
  import { useBrandPageMakerUserStore } from '../../../../brand-page-maker/stores';
27
- import { useHistoryStore } from '../../../../brand-page-maker/stores/history/history-store';
28
27
  import { pageSettingTr } from '../../../../composables/useTranslate';
29
28
  import PageSelectorDropdown from '../../../page-navigation/PageSelectorDropdown.vue';
30
29
  import { usePageNavigationStore } from '../../../page-navigation/store/page-navigation-store';
@@ -44,14 +43,18 @@ const props = withDefaults(defineProps<Props>(), {
44
43
  const emit = defineEmits<{
45
44
  (
46
45
  e: 'update:modelValue',
47
- payload: { id: string; name: string; value: string },
46
+ payload: {
47
+ id: string;
48
+ name: string;
49
+ value: string;
50
+ isHydrationWrite?: boolean;
51
+ },
48
52
  ): void;
49
53
  }>();
50
54
 
51
55
  const pageNavigationStore = usePageNavigationStore();
52
56
  const pageSettingsStore = useUiPageSettingsStore();
53
57
  const userStore = useBrandPageMakerUserStore();
54
- const historyStore = useHistoryStore();
55
58
 
56
59
  const selectedPage = ref('');
57
60
 
@@ -111,12 +114,14 @@ onMounted(() => {
111
114
  if (routeByPageId) {
112
115
  selectedPage.value = routeByPageId.displayName;
113
116
  } else {
114
- // Stale page-id clear at mount — fold into current head.
115
- historyStore.mergeNextMutation();
117
+ // Stale page-id clear at mount — a hydration write, not a user edit.
118
+ // The flag makes the content-settings store fold it into the current
119
+ // undo head at write time (see UpdateItemValuePayload.isHydrationWrite).
116
120
  emit('update:modelValue', {
117
121
  id: props.id,
118
122
  name: props.name,
119
123
  value: '',
124
+ isHydrationWrite: true,
120
125
  });
121
126
  }
122
127
  }
@@ -15,7 +15,6 @@ import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settin
15
15
  import { paymentConfigTr } from '@src/composables/useTranslate';
16
16
  import brandPageClient from '../../../../clients/brand-page-client';
17
17
  import { useUiPageSettingsStore } from '@shared/page-settings/store';
18
- import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
19
18
 
20
19
  export default defineComponent({
21
20
  props: {
@@ -42,7 +41,6 @@ export default defineComponent({
42
41
  const uiPageSettingsStore = useUiPageSettingsStore();
43
42
  const eventManagerStore = useEventManagerStore();
44
43
  const shopConfigStore = useShopConfigStore();
45
- const historyStore = useHistoryStore();
46
44
 
47
45
  return {
48
46
  brandPageToken: computed(() => brandPageContainerStore.brandPageToken),
@@ -53,7 +51,6 @@ export default defineComponent({
53
51
  eventManagerStore,
54
52
  shopConfigStore,
55
53
  uiPageSettingsStore,
56
- historyStore,
57
54
  };
58
55
  },
59
56
  emits: ['update:modelValue'],
@@ -113,16 +110,16 @@ export default defineComponent({
113
110
  paymentConfig: this.shopConfigStore.paymentConfig,
114
111
  };
115
112
 
113
+ // Lazy paymentConfig hydration — a hydration write, not a user edit.
114
+ // The flag makes the content-settings store fold it into the current
115
+ // undo head at write time (see UpdateItemValuePayload.isHydrationWrite).
116
116
  const payload: UpdateItemValuePayload<PaymentConfig> = {
117
117
  id: this.id,
118
118
  name: this.name,
119
119
  value,
120
+ isHydrationWrite: true,
120
121
  };
121
122
 
122
- // Lazy paymentConfig hydration — fold into current head instead
123
- // of pushing a phantom entry.
124
- this.historyStore.mergeNextMutation();
125
-
126
123
  this.$emit('update:modelValue', payload);
127
124
  }
128
125
  },
@@ -4,7 +4,7 @@
4
4
  v-if="showRichTextEditor"
5
5
  :id="`${id}-${name}`"
6
6
  ref="richTextEditorRef"
7
- v-model="text"
7
+ :model-value="text"
8
8
  :attrs="attrs"
9
9
  :label="label"
10
10
  :placeholder="placeholder"
@@ -17,6 +17,7 @@
17
17
  :content-type="contentType"
18
18
  @focus="onFocus"
19
19
  @blur="onBlur"
20
+ @update:model-value="onRteModelValue"
20
21
  />
21
22
  <TextInput
22
23
  v-else
@@ -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,103 @@ 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>' },
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
+ test('updateMultipleItemValues honours the same contract', () => {
160
+ const store = setupStore();
161
+
162
+ store.updateMultipleItemValues({
163
+ id: 'item-1',
164
+ name: 'title',
165
+ value: { title: '<p>merged</p>' },
166
+ isHydrationWrite: true,
167
+ });
168
+
169
+ expect(mergeNextMutationMock).toHaveBeenCalledTimes(1);
170
+
171
+ mergeNextMutationMock.mockClear();
172
+
173
+ store.updateMultipleItemValues({
174
+ id: 'item-1',
175
+ name: 'title',
176
+ value: { title: '<p>user</p>' },
177
+ });
178
+
179
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
180
+ });
181
+ });