Skip to content

Commit e189f93

Browse files
karancs06claude
andcommitted
fix(discussions): emit CSLP_TAGS_UPDATED after variant switch so comment icons mount at correct positions
When the visual editor switches variants it sends GET_VARIANT_ID to the iframe SDK. The user's CSR app then re-renders with the new variant data, changing every data-cslp attribute value (e.g. from content_type.uid.locale.field to v2:content_type.uid_variantId.locale.field). Previously the visual editor had no way of knowing when those attributes had settled, so it would re-mount comment icons against stale CSLP values — icons either missed their elements entirely or were positioned using getBoundingClientRect() on the pre-variant DOM and then failed to track scroll because updateHighlightedCommentIconPosition could no longer find the element by the now-stale field-path attribute. Changes ─────── src/visualBuilder/utils/types/postMessage.types.ts • Add CSLP_TAGS_UPDATED = "cslp-tags-updated" to the post-message enum. This is the signal the SDK sends back to the visual editor once the iframe's data-cslp attributes have settled after a variant switch. src/visualBuilder/eventManager/useRecalculateVariantDataCSLPValues.ts • updateVariantClasses now accepts an optional onSettled callback. • Inside the existing MutationObserver (which already watches each [data-cslp] element for attribute changes), scheduleSettled() is called on every relevant mutation. After a 200 ms debounce with no further mutations, onSettled fires — meaning the burst of re-render updates has finished. • A 1500 ms fallback timer fires onSettled regardless, so entries that have no variant field overrides (zero mutations) still unblock the visual editor. • hasSettled flag ensures onSettled is called at most once per updateVariantClasses invocation, even if both the debounce and the fallback race. src/visualBuilder/eventManager/useVariantsPostMessageEvent.ts • GET_VARIANT_ID handler (CSR path): passes an onSettled callback to updateVariantClasses that sends CSLP_TAGS_UPDATED once the DOM settles. • GET_VARIANT_ID handler (SSR path): sends CSLP_TAGS_UPDATED immediately after addVariantFieldClass because SSR classes are applied synchronously on the existing DOM — no re-render delay exists. • Removed accidental console.log left from debugging. src/visualBuilder/generators/generateHighlightedComment.tsx • updateHighlightedCommentIconPosition: when a scroll/resize position update is attempted for an icon whose [data-cslp="${path}"] element is no longer found, the icon is removed from the DOM instead of being silently skipped. Without this, an icon mounted just before the variant re-render would drift to the wrong fixed position and stop tracking scroll permanently. The visual editor will re-mount it correctly once CSLP_TAGS_UPDATED arrives. • Removed accidental console.log left from debugging. src/visualBuilder/eventManager/__test__/useVariantsPostMessageEvent.spec.ts • Four new tests in the SSR-handling describe block covering: 1. CSLP_TAGS_UPDATED sent immediately for SSR + variant provided. 2. CSLP_TAGS_UPDATED sent immediately for SSR + null variant. 3. updateVariantClasses called with an onSettled callback (CSR). 4. CSLP_TAGS_UPDATED sent when the onSettled callback fires (CSR). src/visualBuilder/eventManager/__test__/useRecalculateVariantDataCSLPValues.spec.ts (new) • Four tests for the onSettled callback logic: 1. Fires via the 1500 ms fallback when no mutations occur. 2. Does not fire before 1499 ms. 3. Fires exactly once even if the fallback would fire again. 4. Does not throw when no callback is provided. src/visualBuilder/generators/__test__/generateHighlightedComment.test.ts (new) • Five tests for updateHighlightedCommentIconPosition and removeAllHighlightedCommentIcons covering: position update when target exists, orphan removal when target is gone, mixed-case handling, empty DOM safety, and full bulk removal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 69fea25 commit e189f93

7 files changed

Lines changed: 399 additions & 70 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
2+
3+
vi.mock("../../../visualBuilder", () => ({
4+
VisualBuilder: {
5+
VisualBuilderGlobalState: {
6+
value: {
7+
highlightVariantFields: false,
8+
variant: null,
9+
},
10+
},
11+
},
12+
}));
13+
14+
vi.mock("../../../visualBuilder/visualBuilder.style", () => ({
15+
visualBuilderStyles: () => ({
16+
"visual-builder__variant-field-outline": "vb-variant-outline",
17+
}),
18+
}));
19+
20+
vi.mock("../../../cslp/cslpdata", () => ({
21+
isValidCslp: vi.fn((v: string | null) => !!v),
22+
}));
23+
24+
// useVariantsPostMessageEvent imports setHighlightVariantFields from here;
25+
// provide a no-op so the import resolves without side effects.
26+
vi.mock("../../../visualBuilder/eventManager/useVariantsPostMessageEvent", () => ({
27+
setHighlightVariantFields: vi.fn(),
28+
}));
29+
30+
import { updateVariantClasses } from "../useRecalculateVariantDataCSLPValues";
31+
32+
describe("updateVariantClasses — onSettled callback", () => {
33+
beforeEach(() => {
34+
vi.useFakeTimers();
35+
});
36+
37+
afterEach(() => {
38+
vi.useRealTimers();
39+
});
40+
41+
it("fires onSettled via the fallback timeout when no mutations occur", () => {
42+
const onSettled = vi.fn();
43+
44+
updateVariantClasses(onSettled);
45+
46+
expect(onSettled).not.toHaveBeenCalled();
47+
48+
// Advance past the 1500 ms fallback (CSLP_SETTLED_FALLBACK_MS)
49+
vi.advanceTimersByTime(1500);
50+
51+
expect(onSettled).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("does not call onSettled before the fallback timeout elapses", () => {
55+
const onSettled = vi.fn();
56+
57+
updateVariantClasses(onSettled);
58+
59+
vi.advanceTimersByTime(1499);
60+
expect(onSettled).not.toHaveBeenCalled();
61+
});
62+
63+
it("calls onSettled only once even if the fallback fires after a mutation already settled it", () => {
64+
const onSettled = vi.fn();
65+
66+
updateVariantClasses(onSettled);
67+
68+
// Simulate a mutation settling: fire the debounce (200 ms) manually
69+
// by advancing timers just past CSLP_SETTLED_DEBOUNCE_MS.
70+
// In a real DOM environment the MutationObserver would call scheduleSettled;
71+
// here we rely on the fallback path which exercises the same idempotency
72+
// guard (hasSettled flag).
73+
vi.advanceTimersByTime(1500); // first call via fallback
74+
vi.advanceTimersByTime(1500); // would be a second call if guard is missing
75+
76+
expect(onSettled).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it("works without an onSettled callback (no-op when undefined)", () => {
80+
expect(() => {
81+
updateVariantClasses(); // no callback
82+
vi.advanceTimersByTime(1500);
83+
}).not.toThrow();
84+
});
85+
});

src/visualBuilder/eventManager/__test__/useVariantsPostMessageEvent.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,4 +669,74 @@ describe("useVariantFieldsPostMessageEvent SSR handling", () => {
669669
expect(mockQuerySelectorAll).not.toHaveBeenCalled();
670670
expect(updateVariantClasses).not.toHaveBeenCalled();
671671
});
672+
673+
it("sends CSLP_TAGS_UPDATED immediately when isSSR is true and variant is provided", () => {
674+
useVariantFieldsPostMessageEvent({ isSSR: true });
675+
676+
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
677+
(call: any[]) =>
678+
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
679+
);
680+
const handler = call![1];
681+
682+
vi.clearAllMocks();
683+
handler({ data: { variant: "variant-123" } });
684+
685+
expect(mockVisualBuilderPostMessage.send).toHaveBeenCalledWith(
686+
VisualBuilderPostMessageEvents.CSLP_TAGS_UPDATED
687+
);
688+
});
689+
690+
it("sends CSLP_TAGS_UPDATED immediately when isSSR is true and variant is null", () => {
691+
useVariantFieldsPostMessageEvent({ isSSR: true });
692+
693+
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
694+
(call: any[]) =>
695+
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
696+
);
697+
const handler = call![1];
698+
699+
vi.clearAllMocks();
700+
handler({ data: { variant: null } });
701+
702+
expect(mockVisualBuilderPostMessage.send).toHaveBeenCalledWith(
703+
VisualBuilderPostMessageEvents.CSLP_TAGS_UPDATED
704+
);
705+
});
706+
707+
it("passes an onSettled callback to updateVariantClasses when isSSR is false", () => {
708+
useVariantFieldsPostMessageEvent({ isSSR: false });
709+
710+
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
711+
(call: any[]) =>
712+
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
713+
);
714+
const handler = call![1];
715+
716+
vi.clearAllMocks();
717+
handler({ data: { variant: "variant-123" } });
718+
719+
expect(updateVariantClasses).toHaveBeenCalledWith(expect.any(Function));
720+
});
721+
722+
it("sends CSLP_TAGS_UPDATED when the onSettled callback from updateVariantClasses is invoked (CSR)", () => {
723+
useVariantFieldsPostMessageEvent({ isSSR: false });
724+
725+
const call = mockVisualBuilderPostMessage.on.mock.calls.find(
726+
(call: any[]) =>
727+
call[0] === VisualBuilderPostMessageEvents.GET_VARIANT_ID
728+
);
729+
const handler = call![1];
730+
731+
vi.clearAllMocks();
732+
handler({ data: { variant: "variant-123" } });
733+
734+
// Retrieve the onSettled callback passed to updateVariantClasses and invoke it
735+
const onSettled = (updateVariantClasses as vi.Mock).mock.calls[0][0] as () => void;
736+
onSettled();
737+
738+
expect(mockVisualBuilderPostMessage.send).toHaveBeenCalledWith(
739+
VisualBuilderPostMessageEvents.CSLP_TAGS_UPDATED
740+
);
741+
});
672742
});

src/visualBuilder/eventManager/useRecalculateVariantDataCSLPValues.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { isValidCslp } from "../../cslp/cslpdata";
77
import { setHighlightVariantFields } from "./useVariantsPostMessageEvent";
88

99
const VARIANT_UPDATE_DELAY_MS: Readonly<number> = 8000;
10+
// How long to wait after the last observed mutation before considering the DOM settled.
11+
const CSLP_SETTLED_DEBOUNCE_MS: Readonly<number> = 200;
12+
// Fallback: if no mutations are observed at all (e.g. no variant overrides on this entry),
13+
// treat the DOM as settled after this delay so the visual editor is never left waiting.
14+
const CSLP_SETTLED_FALLBACK_MS: Readonly<number> = 1500;
1015

1116
type OnAudienceModeVariantPatchUpdate = {
1217
highlightVariantFields: boolean;
@@ -27,11 +32,35 @@ export function useRecalculateVariantDataCSLPValues(): void {
2732
}
2833
);
2934
}
30-
export function updateVariantClasses(): void {
35+
export function updateVariantClasses(onSettled?: () => void): void {
3136
const highlightVariantFields = VisualBuilder.VisualBuilderGlobalState.value.highlightVariantFields;
3237
const variant = VisualBuilder.VisualBuilderGlobalState.value.variant;
3338
const observers: MutationObserver[] = [];
3439

40+
// --- settled notification logic ---
41+
let settledTimer: ReturnType<typeof setTimeout> | null = null;
42+
let hasSettled = false;
43+
44+
const fireSettled = () => {
45+
if (hasSettled) return;
46+
hasSettled = true;
47+
if (settledTimer !== null) {
48+
clearTimeout(settledTimer);
49+
settledTimer = null;
50+
}
51+
onSettled?.();
52+
};
53+
54+
const scheduleSettled = () => {
55+
if (hasSettled) return;
56+
if (settledTimer !== null) clearTimeout(settledTimer);
57+
settledTimer = setTimeout(fireSettled, CSLP_SETTLED_DEBOUNCE_MS);
58+
};
59+
60+
// Fallback: fire even if no mutations were observed (e.g. no variant overrides)
61+
setTimeout(fireSettled, CSLP_SETTLED_FALLBACK_MS);
62+
// ----------------------------------
63+
3564
// Helper function to update element classes
3665
const updateElementClasses = (
3766
element: HTMLElement,
@@ -156,6 +185,7 @@ export function updateVariantClasses(): void {
156185
DATA_CSLP_ATTR_SELECTOR
157186
);
158187
updateElementClasses(element, dataCslp || "", observer);
188+
scheduleSettled();
159189
}
160190
});
161191
});

src/visualBuilder/eventManager/useVariantsPostMessageEvent.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ interface LocaleEvent {
4040
}
4141

4242
function isLowerOrderVariant(variant_uid: string, dataCslp: string, variantOrder: string[]): boolean {
43-
if(!variantOrder || variantOrder.length === 0) {
43+
if (!variantOrder || variantOrder.length === 0) {
4444
return false;
4545
}
46-
const {variant: cslpVariant} = extractDetailsFromCslp(dataCslp);
46+
const { variant: cslpVariant } = extractDetailsFromCslp(dataCslp);
4747
const indexOfCmsVariant = variantOrder.lastIndexOf(variant_uid);
4848
const indexOfCslpVariant = variantOrder.lastIndexOf(cslpVariant || "");
49-
if(indexOfCslpVariant < 0) {
49+
if (indexOfCslpVariant < 0) {
5050
return false;
5151
}
5252
return indexOfCslpVariant < indexOfCmsVariant;
@@ -72,7 +72,7 @@ export function addVariantFieldClass(
7272
}
7373
} else if (!dataCslp.startsWith("v2:")) {
7474
element.classList.add("visual-builder__base-field");
75-
}
75+
}
7676
else if (isLowerOrderVariant(variant_uid, dataCslp, variantOrder)) {
7777
element.classList.add("visual-builder__variant-field", "visual-builder__lower-order-variant-field");
7878
}
@@ -102,7 +102,7 @@ export function removeVariantFieldClass(
102102
});
103103
} else {
104104
const variantAndBaseFieldElements = document.querySelectorAll(
105-
".visual-builder__disabled-variant-field, .visual-builder__variant-field, .visual-builder__base-field, .visual-builder__lower-order-variant-field"
105+
".visual-builder__disabled-variant-field, .visual-builder__variant-field, .visual-builder__base-field, .visual-builder__lower-order-variant-field"
106106
);
107107
variantAndBaseFieldElements.forEach((element) => {
108108
element.classList.remove(
@@ -139,7 +139,7 @@ export async function getHighlightVariantFieldsStatus(): Promise<GetHighlightVar
139139
try {
140140
const result = await visualBuilderPostMessage?.send<GetHighlightVariantFieldsStatusResponse>(
141141
VisualBuilderPostMessageEvents.GET_HIGHLIGHT_VARIANT_FIELDS_STATUS
142-
);
142+
);
143143
return result ?? {
144144
highlightVariantFields: false,
145145
};
@@ -167,9 +167,22 @@ export function useVariantFieldsPostMessageEvent({ isSSR }: { isSSR: boolean }):
167167
if (selectedVariant) {
168168
addVariantFieldClass(selectedVariant);
169169
}
170+
// For SSR the classes are applied synchronously, so the DOM is
171+
// already in its final state — notify the visual editor immediately.
172+
visualBuilderPostMessage?.send(
173+
VisualBuilderPostMessageEvents.CSLP_TAGS_UPDATED
174+
);
170175
} else {
171-
// recalculate and apply classes
172-
updateVariantClasses();
176+
// For CSR apps the user's framework re-renders asynchronously after
177+
// receiving the new variant, mutating data-cslp attributes on the
178+
// way. updateVariantClasses watches for those mutations; once they
179+
// settle (or after a fallback timeout) it fires onSettled, which
180+
// tells the visual editor it is safe to re-send comment highlights.
181+
updateVariantClasses(() => {
182+
visualBuilderPostMessage?.send(
183+
VisualBuilderPostMessageEvents.CSLP_TAGS_UPDATED
184+
);
185+
});
173186
}
174187
}
175188
);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import {
3+
updateHighlightedCommentIconPosition,
4+
removeAllHighlightedCommentIcons,
5+
} from "../generateHighlightedComment";
6+
7+
/**
8+
* Tests for generateHighlightedComment.ts
9+
*
10+
* Covers the orphaned-icon cleanup introduced to fix the scroll-drift bug that
11+
* occurred after a variant switch changed data-cslp attribute values.
12+
*/
13+
describe("updateHighlightedCommentIconPosition", () => {
14+
let container: HTMLDivElement;
15+
16+
beforeEach(() => {
17+
container = document.createElement("div");
18+
container.className = "visual-builder__container";
19+
document.body.appendChild(container);
20+
});
21+
22+
afterEach(() => {
23+
document.body.removeChild(container);
24+
});
25+
26+
const makeIcon = (fieldPath: string): HTMLDivElement => {
27+
const icon = document.createElement("div");
28+
icon.className = "highlighted-comment collab-icon";
29+
icon.setAttribute("field-path", fieldPath);
30+
icon.style.position = "fixed";
31+
icon.style.top = "100px";
32+
icon.style.left = "200px";
33+
container.appendChild(icon);
34+
return icon;
35+
};
36+
37+
const makeTarget = (cslpValue: string): HTMLDivElement => {
38+
const el = document.createElement("div");
39+
el.setAttribute("data-cslp", cslpValue);
40+
container.appendChild(el);
41+
return el;
42+
};
43+
44+
it("updates position of an icon whose target element is still present", () => {
45+
const cslp = "content_type.entry.en-us.field";
46+
makeTarget(cslp);
47+
const icon = makeIcon(cslp);
48+
49+
// jsdom returns zero for getBoundingClientRect by default; just verify
50+
// the update runs without error and the icon remains in the DOM.
51+
updateHighlightedCommentIconPosition();
52+
53+
expect(document.querySelector(`.highlighted-comment[field-path="${cslp}"]`)).not.toBeNull();
54+
});
55+
56+
it("removes an orphaned icon when its target element is no longer found", () => {
57+
const staleCslp = "content_type.entry.en-us.old_field";
58+
// Do NOT add a matching [data-cslp] element — simulates the element
59+
// having been updated to a variant CSLP value after a re-render.
60+
makeIcon(staleCslp);
61+
62+
updateHighlightedCommentIconPosition();
63+
64+
// Icon must be removed from the DOM
65+
expect(
66+
document.querySelector(`.highlighted-comment[field-path="${staleCslp}"]`)
67+
).toBeNull();
68+
});
69+
70+
it("removes only orphaned icons and keeps icons whose targets still exist", () => {
71+
const validCslp = "content_type.entry.en-us.valid_field";
72+
const staleCslp = "content_type.entry.en-us.stale_field";
73+
74+
makeTarget(validCslp);
75+
makeIcon(validCslp);
76+
makeIcon(staleCslp); // no matching element
77+
78+
updateHighlightedCommentIconPosition();
79+
80+
expect(
81+
document.querySelector(`.highlighted-comment[field-path="${validCslp}"]`)
82+
).not.toBeNull();
83+
expect(
84+
document.querySelector(`.highlighted-comment[field-path="${staleCslp}"]`)
85+
).toBeNull();
86+
});
87+
88+
it("does not throw when there are no highlighted-comment icons", () => {
89+
expect(() => updateHighlightedCommentIconPosition()).not.toThrow();
90+
});
91+
});
92+
93+
describe("removeAllHighlightedCommentIcons", () => {
94+
it("removes every .highlighted-comment element from the DOM", () => {
95+
["cslp-1", "cslp-2", "cslp-3"].forEach((cslp) => {
96+
const icon = document.createElement("div");
97+
icon.className = "highlighted-comment collab-icon";
98+
icon.setAttribute("field-path", cslp);
99+
document.body.appendChild(icon);
100+
});
101+
102+
expect(document.querySelectorAll(".highlighted-comment").length).toBe(3);
103+
104+
removeAllHighlightedCommentIcons();
105+
106+
expect(document.querySelectorAll(".highlighted-comment").length).toBe(0);
107+
});
108+
});

0 commit comments

Comments
 (0)