Files
open-design/apps/web/tests/components/touchpointVisibility.test.ts
T
3d76f3c94c fix(cms): keep watching a hover entry until it is really visible (#8263)
The hover entry sampled its visibility once, one animation frame after the
state update that removes `hidden`. When that frame won the race with React's
commit the host was still `display: none` and reported no box, and because
nothing ever looked again the Test acceptance receipt was suppressed for the
whole session — the entry then displayed normally, so the campaign sat in the
CMS waiting on a receipt that could no longer arrive.

Visibility now resolves through one shared watch that re-checks on layout,
on page visibility and after re-mount, reports at most once, and keeps
watching a slow host instead of giving up on it. `mountTouchpoint` moves onto
the same watch so the modal and badge hosts stop carrying a second, weaker
copy of this rule.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 04:08:17 +00:00

173 lines
4.8 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { watchTouchpointVisibility } from "../../src/components/touchpoint-lifecycle";
/**
* These cover the frame the acceptance receipt used to be lost in. The old
* callers sampled visibility once, right after the state update that removes
* `hidden`; when that sample lost the race with React's commit the host was
* still `display: none`, reported no box, and nothing ever looked again.
*/
describe("watchTouchpointVisibility", () => {
let rectCount = 1;
let hidden = false;
const observerCallbacks: Array<() => void> = [];
const notifyResize = () => {
for (const trigger of [...observerCallbacks]) trigger();
};
const settleFrames = async () => {
for (let i = 0; i < 3; i++)
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
};
const host = () => {
const element = document.createElement("div");
document.body.append(element);
return element;
};
beforeEach(() => {
rectCount = 1;
hidden = false;
observerCallbacks.length = 0;
// Registering on `observe`, not on construction, is what makes these
// tests able to tell an observed host from a merely constructed observer.
vi.stubGlobal(
"ResizeObserver",
class {
private trigger?: () => void;
constructor(private readonly callback: ResizeObserverCallback) {}
observe() {
this.trigger = () =>
this.callback([], this as unknown as ResizeObserver);
observerCallbacks.push(this.trigger);
}
disconnect() {
const index = this.trigger
? observerCallbacks.indexOf(this.trigger)
: -1;
if (index >= 0) observerCallbacks.splice(index, 1);
}
},
);
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(
() => ({ length: rectCount, item: () => null }) as unknown as DOMRectList,
);
});
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("waits for a host that only gains its box after the first frame", async () => {
rectCount = 0;
const onVisible = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => true,
onVisible,
});
await settleFrames();
expect(onVisible).not.toHaveBeenCalled();
rectCount = 1;
notifyResize();
await settleFrames();
expect(onVisible).toHaveBeenCalledTimes(1);
stop();
});
it("reports a host at most once however often its layout changes", async () => {
const onVisible = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => true,
onVisible,
});
await settleFrames();
notifyResize();
notifyResize();
await settleFrames();
expect(onVisible).toHaveBeenCalledTimes(1);
stop();
});
it("does not report a laid-out host while the page is hidden, and recovers when it returns", async () => {
hidden = true;
const onVisible = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => true,
onVisible,
});
await settleFrames();
expect(onVisible).not.toHaveBeenCalled();
hidden = false;
document.dispatchEvent(new Event("visibilitychange"));
await settleFrames();
expect(onVisible).toHaveBeenCalledTimes(1);
stop();
});
it("never reports a host whose authority lapsed while it was waiting", async () => {
rectCount = 0;
let authorized = true;
const onVisible = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => authorized,
onVisible,
});
await settleFrames();
authorized = false;
rectCount = 1;
notifyResize();
await settleFrames();
expect(onVisible).not.toHaveBeenCalled();
stop();
});
it("stops watching once released, so a later layout cannot report a released host", async () => {
rectCount = 0;
const onVisible = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => true,
onVisible,
});
stop();
rectCount = 1;
notifyResize();
document.dispatchEvent(new Event("visibilitychange"));
await settleFrames();
expect(onVisible).not.toHaveBeenCalled();
});
it("reports a slow host without giving up on it", async () => {
vi.useFakeTimers();
try {
rectCount = 0;
const onVisible = vi.fn();
const onSlow = vi.fn();
const stop = watchTouchpointVisibility({
element: host(),
isCurrent: () => true,
onVisible,
onSlow,
slowAfterMs: 1_000,
});
vi.advanceTimersByTime(1_500);
expect(onSlow).toHaveBeenCalledWith("touchpoint_visibility_slow");
expect(onVisible).not.toHaveBeenCalled();
rectCount = 1;
notifyResize();
await vi.advanceTimersByTimeAsync(100);
expect(onVisible).toHaveBeenCalledTimes(1);
stop();
} finally {
vi.useRealTimers();
}
});
});