@designcrowd/library.brand-page 6.0.6-undo-5 → 6.0.6-undo-7

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 (30) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-BRaWY6nF.js → CreatedOnBrandCrowd-de-de-BDxea4Se.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-D0DuxW2v.js → CreatedOnBrandCrowd-en-us-BvCtPc-H.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-Du0Wjx0E.js → CreatedOnBrandCrowd-es-es-BFQR8krQ.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Cz1OGFZT.js → CreatedOnBrandCrowd-fr-ca-Dy0Xb5GE.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Cz1OGFZT.js → CreatedOnBrandCrowd-fr-fr-Dy0Xb5GE.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-TfMgQ231.js → CreatedOnBrandCrowd-pt-br-BSGwZq7a.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-TfMgQ231.js → CreatedOnBrandCrowd-pt-pt-BSGwZq7a.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CIxWfXsX.js → CreatedOnDcom-de-de-DU42Z-z_.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-BQeFEYIF.js → CreatedOnDcom-en-us-BR_2u6_i.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-Bu9Nr8t-.js → CreatedOnDcom-es-es-Dts49b6I.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-Dpsu-wxQ.js → CreatedOnDcom-fr-ca-C7Lo2AbX.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CWGZAKa0.js → CreatedOnDcom-fr-fr-ZpEbqlkx.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-Ds8H3Kiq.js → CreatedOnDcom-pt-br-CrUIv-Ye.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-kDx8VWIo.js → CreatedOnDcom-pt-pt-JlbQbaR1.js} +1 -1
  15. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +65 -54
  16. package/lib/src/css/library.brand-page-brandCrowd.min.css +2 -2
  17. package/lib/src/css/library.brand-page-brandPage.min.css +2 -2
  18. package/lib/src/css/library.brand-page-designCom.min.css +2 -2
  19. package/lib/src/css/library.brand-page-preview.min.css +2 -2
  20. package/lib/src/index.mjs +1 -1
  21. package/lib/src/{lib-BJdIOaxe.js → lib-6j91ExXu.js} +10679 -10663
  22. package/lib/src/shared/content-settings/store/content-settings-store.js +17 -0
  23. package/package.json +1 -1
  24. package/src/brand-page-maker/components/quick-link-bar/RedoButton.vue +9 -1
  25. package/src/brand-page-maker/components/quick-link-bar/UndoButton.vue +9 -1
  26. package/src/brand-page-maker/components/quick-link-bar/undo-redo-buttons.test.ts +29 -2
  27. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +130 -0
  28. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +71 -62
  29. package/src/shared/content-settings/store/content-settings-store.test.ts +103 -0
  30. package/src/shared/content-settings/store/content-settings-store.ts +28 -0
@@ -14,6 +14,17 @@ const DEFAULT_TEXT_COLOR_DEPENDENT_TYPES = [
14
14
  'profile',
15
15
  'text',
16
16
  ];
17
+ /**
18
+ * True when the incoming value is byte-identical (as JSON, matching how
19
+ * history snapshots the model) to what the model already holds. Such
20
+ * re-writes must be dropped whole: the assignment would be a reactive
21
+ * no-op, but onContentChanged() still flips isModifiedAfterTemplateApplied
22
+ * — and when that flag was false, its flip is itself a model mutation, so
23
+ * undo history commits an entry in which nothing user-visible changed
24
+ * (phantom undo state, BP-5772: re-clicking the logo panel's Upload/Logo
25
+ * option, re-clicking an already-active display style).
26
+ */
27
+ const isSameValue = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
17
28
  export const CONTENT_SETTINGS_MODES = {
18
29
  edit: 'edit',
19
30
  add: 'add',
@@ -211,6 +222,9 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
211
222
  if (!item) {
212
223
  return;
213
224
  }
225
+ if (isSameValue(item.settings.content[payload.name], payload.value)) {
226
+ return; // no-op write: don't validate, don't flip the modified flag
227
+ }
214
228
  // Hydration write-backs (RTE canonical HTML on mount, lazy config
215
229
  // hydration, stale-id clears) fold into the current undo head instead
216
230
  // of pushing an entry. Arming here — same tick as the write — is what
@@ -232,6 +246,9 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
232
246
  if (!item) {
233
247
  return;
234
248
  }
249
+ if (Object.keys(value ?? {}).every((k) => isSameValue(item.settings.content[k], value[k]))) {
250
+ return; // every field a no-op — see isSameValue
251
+ }
235
252
  // Same hydration-write contract as updateItemValue.
236
253
  if (isHydrationWrite) {
237
254
  useHistoryStore().mergeNextMutation();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.0.6-undo-5",
4
+ "version": "6.0.6-undo-7",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -39,17 +39,25 @@ import { Icon, Tooltip } from '@designcrowd/fe-shared-lib';
39
39
  import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
40
40
  import { useAiWebsiteBuilderThreadStore } from '@brand-page-maker/stores/ai-thread/ai-website-builder-thread-store';
41
41
  import { useBrandPageContainerStore } from '@brand-page-maker/stores/brandpage-container';
42
+ import { useUiTemplateSettingsStore } from '@shared/template-settings/store';
42
43
  import { quickLinkBarTr } from '@src/composables/useTranslate';
43
44
 
44
45
  const historyStore = useHistoryStore();
45
46
  const aiThreadStore = useAiWebsiteBuilderThreadStore();
46
47
  const containerStore = useBrandPageContainerStore();
48
+ const templateSettingsStore = useUiTemplateSettingsStore();
47
49
 
48
50
  const isDisabled = computed(
49
51
  () =>
50
52
  !historyStore.canRedo ||
51
53
  aiThreadStore.isProcessing ||
52
- containerStore.isSaving,
54
+ containerStore.isSaving ||
55
+ // Locked while a restore is applying (the apply awaits the designed-logo
56
+ // re-render) or the template previews are refetching: re-entering
57
+ // mid-regeneration overlapped two reloads and left the sidebar keyed to
58
+ // the wrong logo (fast undo→redo desync).
59
+ historyStore.isApplyingSnapshot ||
60
+ templateSettingsStore.isBusy,
53
61
  );
54
62
 
55
63
  const onClick = () => {
@@ -39,17 +39,25 @@ import { Icon, Tooltip } from '@designcrowd/fe-shared-lib';
39
39
  import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
40
40
  import { useAiWebsiteBuilderThreadStore } from '@brand-page-maker/stores/ai-thread/ai-website-builder-thread-store';
41
41
  import { useBrandPageContainerStore } from '@brand-page-maker/stores/brandpage-container';
42
+ import { useUiTemplateSettingsStore } from '@shared/template-settings/store';
42
43
  import { quickLinkBarTr } from '@src/composables/useTranslate';
43
44
 
44
45
  const historyStore = useHistoryStore();
45
46
  const aiThreadStore = useAiWebsiteBuilderThreadStore();
46
47
  const containerStore = useBrandPageContainerStore();
48
+ const templateSettingsStore = useUiTemplateSettingsStore();
47
49
 
48
50
  const isDisabled = computed(
49
51
  () =>
50
52
  !historyStore.canUndo ||
51
53
  aiThreadStore.isProcessing ||
52
- containerStore.isSaving,
54
+ containerStore.isSaving ||
55
+ // Locked while a restore is applying (the apply awaits the designed-logo
56
+ // re-render) or the template previews are refetching: re-entering
57
+ // mid-regeneration overlapped two reloads and left the sidebar keyed to
58
+ // the wrong logo (fast undo→redo desync).
59
+ historyStore.isApplyingSnapshot ||
60
+ templateSettingsStore.isBusy,
53
61
  );
54
62
 
55
63
  const onClick = () => {
@@ -6,8 +6,10 @@ import RedoButton from './RedoButton.vue';
6
6
 
7
7
  // These tests cover the click-dispatch contract for the undo/redo buttons:
8
8
  // - Click → historyStore.undo()/redo() ONLY when enabled.
9
- // - Disabled when any of three guards is true: canUndo/canRedo,
10
- // aiThread.isProcessing, container.isSaving.
9
+ // - Disabled when any of five guards is true: canUndo/canRedo,
10
+ // aiThread.isProcessing, container.isSaving, history.isApplyingSnapshot
11
+ // (a restore is applying — spans the awaited logo re-render), or
12
+ // templateSettings.isBusy (template previews are refetching).
11
13
  //
12
14
  // Markup, class names, and i18n strings are intentionally not asserted —
13
15
  // they're covered by e2e tests and the data-component / data-test-*
@@ -34,6 +36,7 @@ vi.mock('@designcrowd/fe-shared-lib', () => ({
34
36
  const historyStoreMock = {
35
37
  canUndo: true,
36
38
  canRedo: true,
39
+ isApplyingSnapshot: false,
37
40
  undo: vi.fn(),
38
41
  redo: vi.fn(),
39
42
  };
@@ -57,14 +60,22 @@ vi.mock('@brand-page-maker/stores/brandpage-container', () => ({
57
60
  useBrandPageContainerStore: vi.fn(() => containerStoreMock),
58
61
  }));
59
62
 
63
+ const templateSettingsStoreMock = { isBusy: false };
64
+
65
+ vi.mock('@shared/template-settings/store', () => ({
66
+ useUiTemplateSettingsStore: vi.fn(() => templateSettingsStoreMock),
67
+ }));
68
+
60
69
  beforeEach(() => {
61
70
  setActivePinia(createPinia());
62
71
  historyStoreMock.canUndo = true;
63
72
  historyStoreMock.canRedo = true;
73
+ historyStoreMock.isApplyingSnapshot = false;
64
74
  historyStoreMock.undo = vi.fn();
65
75
  historyStoreMock.redo = vi.fn();
66
76
  aiStoreMock.isProcessing = false;
67
77
  containerStoreMock.isSaving = false;
78
+ templateSettingsStoreMock.isBusy = false;
68
79
  });
69
80
 
70
81
  describe('UndoButton', () => {
@@ -80,6 +91,14 @@ describe('UndoButton', () => {
80
91
  ['canUndo is false', () => (historyStoreMock.canUndo = false)],
81
92
  ['AI is processing', () => (aiStoreMock.isProcessing = true)],
82
93
  ['save is in flight', () => (containerStoreMock.isSaving = true)],
94
+ [
95
+ 'a restore is applying',
96
+ () => (historyStoreMock.isApplyingSnapshot = true),
97
+ ],
98
+ [
99
+ 'template previews are refetching',
100
+ () => (templateSettingsStoreMock.isBusy = true),
101
+ ],
83
102
  ])('does not dispatch when %s', async (_label, applyGuard) => {
84
103
  applyGuard();
85
104
 
@@ -104,6 +123,14 @@ describe('RedoButton', () => {
104
123
  ['canRedo is false', () => (historyStoreMock.canRedo = false)],
105
124
  ['AI is processing', () => (aiStoreMock.isProcessing = true)],
106
125
  ['save is in flight', () => (containerStoreMock.isSaving = true)],
126
+ [
127
+ 'a restore is applying',
128
+ () => (historyStoreMock.isApplyingSnapshot = true),
129
+ ],
130
+ [
131
+ 'template previews are refetching',
132
+ () => (templateSettingsStoreMock.isBusy = true),
133
+ ],
107
134
  ])('does not dispatch when %s', async (_label, applyGuard) => {
108
135
  applyGuard();
109
136
 
@@ -50,6 +50,9 @@ import {
50
50
  } from '@shared/content-settings/store';
51
51
  import { useUiSideBarStore } from '@shared/side-bar/store';
52
52
  import { useUiEditableLogoStore } from '@shared/logo-settings/store/editable-logo-store';
53
+ import { useUiLogoSettingsStore } from '@shared/logo-settings/store';
54
+ import { useLogoStore } from '@brand-page-maker/stores';
55
+ import { getLogoImageFromModel } from '@brand-page/utils/logo.util';
53
56
  import { SIDE_BAR_TAB_NAMES } from '@brand-page-maker/constants';
54
57
  import { PAGE_SECTIONS } from '@shared/page-settings/models';
55
58
  import { FabricRenderClient } from '../../../clients/fabric-render-client';
@@ -988,4 +991,131 @@ describe('brand page maker store module', () => {
988
991
  // Still 'Acme' — no restore attempted.
989
992
  expect(editableLogoStore.logoEdits.textObjects[0].text).toBe('Acme');
990
993
  });
994
+
995
+ // The template sidebar regenerates off the selectedLogoImage watcher, so
996
+ // the restore must hand it a READY preview key. For a designed logo
997
+ // (pre-purchase) that key is the s3 render token minted by the awaited
998
+ // fabric re-render — exposing selectedLogoImage BEFORE the render made the
999
+ // sidebar fetch a list keyed to the previous render, which repainted
1000
+ // seconds later (the undo "flash"). Same precondition that makes the BYO
1001
+ // upload flow work: the key exists before the watcher fires.
1002
+ test('_syncLogoSettingsFromSnapshot re-renders the designed logo BEFORE exposing selectedLogoImage', async () => {
1003
+ const makerStore = useBrandPageMakerStore();
1004
+ const containerStore = useBrandPageContainerStore();
1005
+ const editableLogoStore = useUiEditableLogoStore();
1006
+ const logoSettingsStore = useUiLogoSettingsStore();
1007
+ const logoStore = useLogoStore();
1008
+
1009
+ await makerStore.initialise(INITIALISE_PAYLOAD);
1010
+
1011
+ const originalStamp = editableLogoStore.stampLogoBase64IntoBrandPageData;
1012
+ const originalRender = editableLogoStore.updateEditableLogoImageUrlAsync;
1013
+ const originalSetSelected = logoSettingsStore.setSelectedLogoImage;
1014
+
1015
+ try {
1016
+ containerStore.isPrePurchase = true;
1017
+ logoStore.setLogoTemplateAsset({
1018
+ token: 'designed-template',
1019
+ isByo: false,
1020
+ } as never);
1021
+ logoStore.addLogoDraftAsset({
1022
+ logoDraftAsset: { token: 'designed-2', tick: 1, isByo: false },
1023
+ } as never);
1024
+ // Previous token differs from the snapshot's → not the
1025
+ // logoUnchanged short-circuit.
1026
+ editableLogoStore.setLogoDraftToken('designed-1');
1027
+
1028
+ const snapshot = JSON.parse(
1029
+ JSON.stringify(makerStore.brandPageData),
1030
+ ) as BrandPageModel;
1031
+ const snapshotLogoImage = getLogoImageFromModel(snapshot);
1032
+
1033
+ expect(snapshotLogoImage).toBeTruthy();
1034
+ snapshotLogoImage!.logoDraftToken = 'designed-2';
1035
+
1036
+ const callOrder: string[] = [];
1037
+
1038
+ editableLogoStore.stampLogoBase64IntoBrandPageData = vi.fn();
1039
+ editableLogoStore.updateEditableLogoImageUrlAsync = vi.fn(async () => {
1040
+ // Async like the real fabric render — the order assertion below
1041
+ // fails if the sync doesn't await it before exposing the logo.
1042
+ await new Promise((resolve) => setTimeout(resolve, 0));
1043
+ callOrder.push('render');
1044
+ });
1045
+ logoSettingsStore.setSelectedLogoImage = vi.fn(() => {
1046
+ callOrder.push('selectedLogoImage');
1047
+ });
1048
+
1049
+ await makerStore._syncLogoSettingsFromSnapshot(
1050
+ snapshot,
1051
+ '{"colors":["#123456"]}',
1052
+ );
1053
+
1054
+ expect(callOrder).toEqual(['render', 'selectedLogoImage']);
1055
+ expect(logoSettingsStore.setSelectedLogoImage).toHaveBeenCalledWith(
1056
+ expect.objectContaining({ logoDraftToken: 'designed-2' }),
1057
+ );
1058
+ } finally {
1059
+ containerStore.isPrePurchase = false;
1060
+ editableLogoStore.stampLogoBase64IntoBrandPageData = originalStamp;
1061
+ editableLogoStore.updateEditableLogoImageUrlAsync = originalRender;
1062
+ logoSettingsStore.setSelectedLogoImage = originalSetSelected;
1063
+ }
1064
+ });
1065
+
1066
+ test('_syncLogoSettingsFromSnapshot still exposes selectedLogoImage on the BYO and token-less paths', async () => {
1067
+ const makerStore = useBrandPageMakerStore();
1068
+ const editableLogoStore = useUiEditableLogoStore();
1069
+ const logoSettingsStore = useUiLogoSettingsStore();
1070
+ const logoStore = useLogoStore();
1071
+
1072
+ await makerStore.initialise(INITIALISE_PAYLOAD);
1073
+
1074
+ const originalStamp = editableLogoStore.stampLogoBase64IntoBrandPageData;
1075
+ const originalSetSelected = logoSettingsStore.setSelectedLogoImage;
1076
+
1077
+ try {
1078
+ editableLogoStore.stampLogoBase64IntoBrandPageData = vi.fn();
1079
+
1080
+ const setSelectedSpy = vi.fn();
1081
+
1082
+ logoSettingsStore.setSelectedLogoImage = setSelectedSpy;
1083
+
1084
+ // BYO path: registered byo draft, differing previous token.
1085
+ logoStore.addLogoDraftAsset({
1086
+ logoDraftAsset: { token: 'byo-1', tick: 2, isByo: true },
1087
+ } as never);
1088
+ editableLogoStore.setLogoDraftToken('designed-1');
1089
+
1090
+ const byoSnapshot = JSON.parse(
1091
+ JSON.stringify(makerStore.brandPageData),
1092
+ ) as BrandPageModel;
1093
+ const byoLogoImage = getLogoImageFromModel(byoSnapshot);
1094
+
1095
+ byoLogoImage!.logoDraftToken = 'byo-1';
1096
+
1097
+ await makerStore._syncLogoSettingsFromSnapshot(byoSnapshot);
1098
+
1099
+ expect(setSelectedSpy).toHaveBeenCalledWith(
1100
+ expect.objectContaining({ logoDraftToken: 'byo-1' }),
1101
+ );
1102
+
1103
+ // Token-less path: the logoImage exists but has no draft token.
1104
+ setSelectedSpy.mockClear();
1105
+
1106
+ const tokenlessSnapshot = JSON.parse(
1107
+ JSON.stringify(makerStore.brandPageData),
1108
+ ) as BrandPageModel;
1109
+ const tokenlessLogoImage = getLogoImageFromModel(tokenlessSnapshot);
1110
+
1111
+ tokenlessLogoImage!.logoDraftToken = '';
1112
+
1113
+ await makerStore._syncLogoSettingsFromSnapshot(tokenlessSnapshot);
1114
+
1115
+ expect(setSelectedSpy).toHaveBeenCalledTimes(1);
1116
+ } finally {
1117
+ editableLogoStore.stampLogoBase64IntoBrandPageData = originalStamp;
1118
+ logoSettingsStore.setSelectedLogoImage = originalSetSelected;
1119
+ }
1120
+ });
991
1121
  });
@@ -960,83 +960,92 @@ export const useBrandPageMakerStore = defineStore('brandPageMaker', {
960
960
 
961
961
  if (!logoImage) return;
962
962
 
963
- // Assign a FRESH object — the same way the upload/selection flows
964
- // write selectedLogoImage. A setState/$patch here deep-merges into the
965
- // existing object, so the templates store's deep watcher receives
966
- // old === new (same reference), sees no logoDraftToken change, and
967
- // never refetches the template previews. Post-purchase that watcher
968
- // is the ONLY refetch path (previews are keyed by logoDraftToken and
969
- // the designed-logo re-render below is pre-purchase-only), which left
970
- // the sidebar stuck on the undone logo (BP-5814).
971
- logoSettingsStore.setSelectedLogoImage({ ...logoImage });
972
-
973
- const draftToken: string | undefined = logoImage.logoDraftToken;
963
+ try {
964
+ const draftToken: string | undefined = logoImage.logoDraftToken;
974
965
 
975
- if (!draftToken) return;
966
+ if (!draftToken) return;
976
967
 
977
- // Re-derive enableEditMode + templateAsset from the draft registry
978
- // (append-only for the session) instead of stashing on every entry.
979
- const draft = logoStore.getLogoDraftAssetByToken(draftToken);
968
+ // Re-derive enableEditMode + templateAsset from the draft registry
969
+ // (append-only for the session) instead of stashing on every entry.
970
+ const draft = logoStore.getLogoDraftAssetByToken(draftToken);
980
971
 
981
- if (draft) {
982
- editableLogoStore.setEnableEditMode(!draft.isByo);
972
+ if (draft) {
973
+ editableLogoStore.setEnableEditMode(!draft.isByo);
983
974
 
984
- if (draft.templateAsset) {
985
- logoStore.setLogoTemplateAsset(draft.templateAsset);
975
+ if (draft.templateAsset) {
976
+ logoStore.setLogoTemplateAsset(draft.templateAsset);
977
+ }
986
978
  }
987
- }
988
979
 
989
- editableLogoStore.setLogoDraftToken(draftToken);
980
+ editableLogoStore.setLogoDraftToken(draftToken);
990
981
 
991
- // No logo change → cache is still correct; just stamp base64 back
992
- // (snapshot doesn't carry logoImageBase64Url).
993
- const logoUnchanged =
994
- prevDraftToken === draftToken &&
995
- (logoEditsStr === undefined || logoEditsStr === prevLogoEditsStr);
982
+ // No logo change → cache is still correct; just stamp base64 back
983
+ // (snapshot doesn't carry logoImageBase64Url).
984
+ const logoUnchanged =
985
+ prevDraftToken === draftToken &&
986
+ (logoEditsStr === undefined || logoEditsStr === prevLogoEditsStr);
996
987
 
997
- if (logoUnchanged) {
998
- editableLogoStore.stampLogoBase64IntoBrandPageData();
988
+ if (logoUnchanged) {
989
+ editableLogoStore.stampLogoBase64IntoBrandPageData();
999
990
 
1000
- return;
1001
- }
991
+ return;
992
+ }
1002
993
 
1003
- const isByo = !!draft?.isByo;
994
+ const isByo = !!draft?.isByo;
1004
995
 
1005
- if (isByo) {
1006
- // BYO mints a fresh draftToken per upload so the per-token
1007
- // cache always matches; no re-render needed.
1008
- if (editableLogoStore.restoreVariationsForDraftToken(draftToken)) {
1009
- editableLogoStore.stampLogoBase64IntoBrandPageData();
1010
- }
996
+ if (isByo) {
997
+ // BYO mints a fresh draftToken per upload so the per-token
998
+ // cache always matches; no re-render needed.
999
+ if (editableLogoStore.restoreVariationsForDraftToken(draftToken)) {
1000
+ editableLogoStore.stampLogoBase64IntoBrandPageData();
1001
+ }
1011
1002
 
1012
- return;
1013
- }
1003
+ return;
1004
+ }
1014
1005
 
1015
- // Designed: draft token is reused across edits, so the cache
1016
- // only holds the latest render. Re-issue fabric-render — same
1017
- // call as the live edit path. Awaited so concurrent undos
1018
- // serialize and the call sits inside the isApplyingSnapshot
1019
- // window (watcher guard suppresses the writeback).
1020
- if (
1021
- containerStore.isPrePurchase &&
1022
- logoEditsStr &&
1023
- logoStore.logoTemplateAsset
1024
- ) {
1025
- // Stamp the cached (pre-undo) base64 onto brandPageData first
1026
- // so the canvas keeps showing the previous logo while the
1027
- // re-render is in flight. Without this the logoImageBase64Url
1028
- // fields are undefined (snapshot is stripped pre-purchase) and
1029
- // the canvas flickers blank for ~500-1500ms.
1030
- editableLogoStore.stampLogoBase64IntoBrandPageData();
1006
+ // Designed: draft token is reused across edits, so the cache
1007
+ // only holds the latest render. Re-issue fabric-render — same
1008
+ // call as the live edit path. Awaited so concurrent undos
1009
+ // serialize and the call sits inside the isApplyingSnapshot
1010
+ // window (watcher guard suppresses the writeback).
1011
+ if (
1012
+ containerStore.isPrePurchase &&
1013
+ logoEditsStr &&
1014
+ logoStore.logoTemplateAsset
1015
+ ) {
1016
+ // Stamp the cached (pre-undo) base64 onto brandPageData first
1017
+ // so the canvas keeps showing the previous logo while the
1018
+ // re-render is in flight. Without this the logoImageBase64Url
1019
+ // fields are undefined (snapshot is stripped pre-purchase) and
1020
+ // the canvas flickers blank for ~500-1500ms.
1021
+ editableLogoStore.stampLogoBase64IntoBrandPageData();
1031
1022
 
1032
- try {
1033
- await editableLogoStore.updateEditableLogoImageUrlAsync();
1034
- } catch (error) {
1035
- console.error(
1036
- 'brandpage-maker-store: failed to re-render logo on history apply',
1037
- error,
1038
- );
1023
+ try {
1024
+ await editableLogoStore.updateEditableLogoImageUrlAsync();
1025
+ } catch (error) {
1026
+ console.error(
1027
+ 'brandpage-maker-store: failed to re-render logo on history apply',
1028
+ error,
1029
+ );
1030
+ }
1039
1031
  }
1032
+ } finally {
1033
+ // Assign a FRESH object — the same way the upload/selection flows
1034
+ // write selectedLogoImage. A setState/$patch here deep-merges into
1035
+ // the existing object, so the templates store's deep watcher would
1036
+ // receive old === new (same reference), see no logoDraftToken
1037
+ // change, and never refetch the template previews (BP-5814).
1038
+ //
1039
+ // Set LAST — after the branch above has prepared the preview key
1040
+ // (BYO cache restore, or the awaited designed re-render that mints
1041
+ // the s3 token) — so the watcher always fires with a fetchable key,
1042
+ // the same precondition that makes the upload flow work. Firing it
1043
+ // before the re-render fetched the template list with the previous
1044
+ // render's s3 token: an obsolete list that repainted seconds later
1045
+ // (the undo "flash"). The sidebar instead holds the previous
1046
+ // previews while the render is in flight, matching the canvas
1047
+ // policy above.
1048
+ logoSettingsStore.setSelectedLogoImage({ ...logoImage });
1040
1049
  }
1041
1050
  },
1042
1051
 
@@ -206,3 +206,106 @@ describe('content settings store — hydration writes arm the history merge', ()
206
206
  expect(mergeNextMutationMock).not.toHaveBeenCalled();
207
207
  });
208
208
  });
209
+
210
+ // BP-5772: a value-identical re-write must be dropped whole. The assignment
211
+ // would be a reactive no-op, but onContentChanged() still flips
212
+ // isModifiedAfterTemplateApplied — and when that flag was false, its flip is
213
+ // itself a model mutation, so history commits an entry in which nothing
214
+ // user-visible changed. Live repro: with the logo visible, clicking the logo
215
+ // panel's "Upload" option re-emits { logoImage, logoTemplateImage,
216
+ // logoImageBase64Url, isLogoVisible } deep-equal to the model under fresh
217
+ // references (LogoSelectionField.onLogoVisibleChanged). Same class:
218
+ // re-clicking the already-active display style.
219
+ describe('content settings store — value-identical writes are dropped', () => {
220
+ const logoImage = () => ({
221
+ logoDraftToken: 'dae5cda0-602d-4c69-a26e-000000000001',
222
+ logoVariation: 0,
223
+ logoLastUpdate: '638000000000000000',
224
+ });
225
+
226
+ const makeItem = () => ({
227
+ id: 'item-1',
228
+ type: 'logo',
229
+ settings: {
230
+ content: {
231
+ title: '<p>old</p>',
232
+ isLogoVisible: true,
233
+ logoImage: logoImage(),
234
+ },
235
+ },
236
+ });
237
+
238
+ const setupStore = () => {
239
+ setActivePinia(createPinia());
240
+
241
+ const store = useUiContentSettingsStore();
242
+
243
+ store.items.push(makeItem() as never);
244
+
245
+ store.validateItem = vi.fn();
246
+ store.onContentChanged = vi.fn();
247
+
248
+ return store;
249
+ };
250
+
251
+ beforeEach(() => {
252
+ mergeNextMutationMock.mockClear();
253
+ });
254
+
255
+ test('updateMultipleItemValues drops the logo-panel Upload re-click payload', () => {
256
+ const store = setupStore();
257
+
258
+ // Deep-equal to the model but every reference is fresh — exactly what
259
+ // the Upload option click emits when visibility didn't change.
260
+ store.updateMultipleItemValues({
261
+ id: 'item-1',
262
+ name: 'logoImage',
263
+ value: { logoImage: logoImage(), isLogoVisible: true },
264
+ });
265
+
266
+ expect(store.onContentChanged).not.toHaveBeenCalled();
267
+ expect(store.validateItem).not.toHaveBeenCalled();
268
+ });
269
+
270
+ test('updateItemValue drops an identical primitive', () => {
271
+ const store = setupStore();
272
+
273
+ store.updateItemValue({
274
+ id: 'item-1',
275
+ name: 'title',
276
+ value: '<p>old</p>',
277
+ });
278
+
279
+ expect(store.onContentChanged).not.toHaveBeenCalled();
280
+ expect(store.validateItem).not.toHaveBeenCalled();
281
+ });
282
+
283
+ test('updateMultipleItemValues writes when any one field differs', () => {
284
+ const store = setupStore();
285
+
286
+ store.updateMultipleItemValues({
287
+ id: 'item-1',
288
+ name: 'logoImage',
289
+ value: { logoImage: logoImage(), isLogoVisible: false },
290
+ });
291
+
292
+ expect(store.onContentChanged).toHaveBeenCalledTimes(1);
293
+ expect(
294
+ (store.items[0].settings.content as { isLogoVisible?: boolean })
295
+ .isLogoVisible,
296
+ ).toBe(false);
297
+ });
298
+
299
+ test('a dropped write never arms the history merge, even when hydration-flagged', () => {
300
+ const store = setupStore();
301
+
302
+ store.updateItemValue({
303
+ id: 'item-1',
304
+ name: 'title',
305
+ value: '<p>old</p>',
306
+ isHydrationWrite: true,
307
+ });
308
+
309
+ expect(mergeNextMutationMock).not.toHaveBeenCalled();
310
+ });
311
+ });
@@ -60,6 +60,19 @@ const DEFAULT_TEXT_COLOR_DEPENDENT_TYPES: readonly ContentType[] = [
60
60
  'text',
61
61
  ] as const;
62
62
 
63
+ /**
64
+ * True when the incoming value is byte-identical (as JSON, matching how
65
+ * history snapshots the model) to what the model already holds. Such
66
+ * re-writes must be dropped whole: the assignment would be a reactive
67
+ * no-op, but onContentChanged() still flips isModifiedAfterTemplateApplied
68
+ * — and when that flag was false, its flip is itself a model mutation, so
69
+ * undo history commits an entry in which nothing user-visible changed
70
+ * (phantom undo state, BP-5772: re-clicking the logo panel's Upload/Logo
71
+ * option, re-clicking an already-active display style).
72
+ */
73
+ const isSameValue = (a: unknown, b: unknown): boolean =>
74
+ a === b || JSON.stringify(a) === JSON.stringify(b);
75
+
63
76
  export const CONTENT_SETTINGS_MODES = {
64
77
  edit: 'edit',
65
78
  add: 'add',
@@ -390,6 +403,10 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
390
403
  return;
391
404
  }
392
405
 
406
+ if (isSameValue(item.settings.content[payload.name], payload.value)) {
407
+ return; // no-op write: don't validate, don't flip the modified flag
408
+ }
409
+
393
410
  // Hydration write-backs (RTE canonical HTML on mount, lazy config
394
411
  // hydration, stale-id clears) fold into the current undo head instead
395
412
  // of pushing an entry. Arming here — same tick as the write — is what
@@ -421,6 +438,17 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
421
438
  return;
422
439
  }
423
440
 
441
+ if (
442
+ Object.keys(value ?? {}).every((k) =>
443
+ isSameValue(
444
+ item.settings.content[k],
445
+ (value as Record<string, unknown>)[k],
446
+ ),
447
+ )
448
+ ) {
449
+ return; // every field a no-op — see isSameValue
450
+ }
451
+
424
452
  // Same hydration-write contract as updateItemValue.
425
453
  if (isHydrationWrite) {
426
454
  useHistoryStore().mergeNextMutation();