Skip to content

Commit 34d22d5

Browse files
fix: skip react-grab shadow DOM animations during global unfreeze (#163)
* fix: skip react-grab shadow DOM animations during global unfreeze `unfreezeGlobalAnimations` was calling `animation.finish()` on all document animations including those inside react-grab's own shadow DOM. The freeze CSS only affects main-document elements (shadow DOM style encapsulation), but the unfreeze path was not scoped, causing react-grab UI elements (toolbar, labels) to have their animations force-finished. Filter out animations whose target lives in the react-grab shadow root before calling finish(). Add e2e tests and animated playground elements. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: correct vacuous test assertion for shadow DOM animations Replace toBeGreaterThanOrEqual(0) with toBe(shadowAnimationCountBefore) to properly verify that shadow DOM animations are preserved after deactivation * fix: skip all shadow DOM animations during unfreeze, not just react-grab's own --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent db65be1 commit 34d22d5

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed

packages/e2e-playground/src/App.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,28 @@ const VariousElements = () => {
357357
);
358358
};
359359

360+
const AnimatedElements = () => {
361+
return (
362+
<section className="border rounded-lg p-4" data-testid="animated-section">
363+
<h2 className="text-lg font-bold mb-4">Animated Elements</h2>
364+
<div className="space-y-4">
365+
<div
366+
className="w-8 h-8 bg-blue-500 rounded-full animate-pulse"
367+
data-testid="animated-pulse"
368+
/>
369+
<div
370+
className="w-8 h-8 bg-green-500 rounded animate-spin"
371+
data-testid="animated-spin"
372+
/>
373+
<div
374+
className="w-8 h-8 bg-red-500 rounded animate-bounce"
375+
data-testid="animated-bounce"
376+
/>
377+
</div>
378+
</section>
379+
);
380+
};
381+
360382
const ZeroDimensionElements = () => {
361383
return (
362384
<section
@@ -504,6 +526,8 @@ export default function App() {
504526

505527
<VariousElements />
506528

529+
<AnimatedElements />
530+
507531
<ZeroDimensionElements />
508532

509533
<DropdownSection />
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { test, expect } from "./fixtures.js";
2+
3+
const ATTRIBUTE_NAME = "data-react-grab";
4+
5+
test.describe("Freeze Animations", () => {
6+
test.describe("Page Animation Freezing", () => {
7+
test("should pause page animations when activated", async ({
8+
reactGrab,
9+
}) => {
10+
const getPageAnimationStates = async () => {
11+
return reactGrab.page.evaluate((attrName) => {
12+
return document.getAnimations().reduce<string[]>(
13+
(states, animation) => {
14+
if (animation.effect instanceof KeyframeEffect) {
15+
const target = animation.effect.target;
16+
if (target instanceof Element) {
17+
const rootNode = target.getRootNode();
18+
if (
19+
rootNode instanceof ShadowRoot &&
20+
rootNode.host.hasAttribute(attrName)
21+
) {
22+
return states;
23+
}
24+
}
25+
}
26+
states.push(animation.playState);
27+
return states;
28+
},
29+
[],
30+
);
31+
}, ATTRIBUTE_NAME);
32+
};
33+
34+
const statesBefore = await getPageAnimationStates();
35+
expect(statesBefore.length).toBeGreaterThan(0);
36+
expect(statesBefore.every((state) => state === "running")).toBe(true);
37+
38+
await reactGrab.activate();
39+
await reactGrab.page.waitForTimeout(100);
40+
41+
const statesDuring = await getPageAnimationStates();
42+
expect(statesDuring.every((state) => state === "paused")).toBe(true);
43+
});
44+
45+
test("should not leave page animations in paused state after deactivation", async ({
46+
reactGrab,
47+
}) => {
48+
await reactGrab.activate();
49+
await reactGrab.page.waitForTimeout(100);
50+
51+
await reactGrab.deactivate();
52+
await reactGrab.page.waitForTimeout(100);
53+
54+
const pausedPageAnimationCount = await reactGrab.page.evaluate(
55+
(attrName) => {
56+
return document.getAnimations().filter((animation) => {
57+
if (animation.effect instanceof KeyframeEffect) {
58+
const target = animation.effect.target;
59+
if (target instanceof Element) {
60+
const rootNode = target.getRootNode();
61+
if (
62+
rootNode instanceof ShadowRoot &&
63+
rootNode.host.hasAttribute(attrName)
64+
) {
65+
return false;
66+
}
67+
}
68+
}
69+
return animation.playState === "paused";
70+
}).length;
71+
},
72+
ATTRIBUTE_NAME,
73+
);
74+
75+
expect(pausedPageAnimationCount).toBe(0);
76+
});
77+
78+
test("should not leave global freeze style element in document after deactivation", async ({
79+
reactGrab,
80+
}) => {
81+
await reactGrab.activate();
82+
await reactGrab.page.waitForTimeout(100);
83+
84+
const hasFreezeStyleDuring = await reactGrab.page.evaluate(() => {
85+
return (
86+
document.querySelector("[data-react-grab-global-freeze]") !== null
87+
);
88+
});
89+
expect(hasFreezeStyleDuring).toBe(true);
90+
91+
await reactGrab.deactivate();
92+
await reactGrab.page.waitForTimeout(100);
93+
94+
const hasFreezeStyleAfter = await reactGrab.page.evaluate(() => {
95+
return (
96+
document.querySelector("[data-react-grab-global-freeze]") !== null
97+
);
98+
});
99+
expect(hasFreezeStyleAfter).toBe(false);
100+
});
101+
});
102+
103+
test.describe("React Grab UI Preservation", () => {
104+
test("should not finish react-grab shadow DOM animations on deactivation", async ({
105+
reactGrab,
106+
}) => {
107+
await reactGrab.activate();
108+
await reactGrab.hoverElement("li:first-child");
109+
await reactGrab.waitForSelectionBox();
110+
await reactGrab.page.waitForTimeout(200);
111+
112+
const shadowAnimationCountBefore = await reactGrab.page.evaluate(
113+
(attrName) => {
114+
return document.getAnimations().filter((animation) => {
115+
if (animation.effect instanceof KeyframeEffect) {
116+
const target = animation.effect.target;
117+
if (target instanceof Element) {
118+
const rootNode = target.getRootNode();
119+
return (
120+
rootNode instanceof ShadowRoot &&
121+
rootNode.host.hasAttribute(attrName)
122+
);
123+
}
124+
}
125+
return false;
126+
}).length;
127+
},
128+
ATTRIBUTE_NAME,
129+
);
130+
131+
await reactGrab.deactivate();
132+
await reactGrab.page.waitForTimeout(100);
133+
134+
const shadowAnimationCountAfter = await reactGrab.page.evaluate(
135+
(attrName) => {
136+
return document.getAnimations().filter((animation) => {
137+
if (animation.effect instanceof KeyframeEffect) {
138+
const target = animation.effect.target;
139+
if (target instanceof Element) {
140+
const rootNode = target.getRootNode();
141+
return (
142+
rootNode instanceof ShadowRoot &&
143+
rootNode.host.hasAttribute(attrName)
144+
);
145+
}
146+
}
147+
return false;
148+
}).length;
149+
},
150+
ATTRIBUTE_NAME,
151+
);
152+
153+
if (shadowAnimationCountBefore > 0) {
154+
expect(shadowAnimationCountAfter).toBe(shadowAnimationCountBefore);
155+
}
156+
});
157+
158+
test("toolbar should remain visible after activation cycle", async ({
159+
reactGrab,
160+
}) => {
161+
await expect
162+
.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 })
163+
.toBe(true);
164+
165+
await reactGrab.activate();
166+
await reactGrab.deactivate();
167+
await reactGrab.page.waitForTimeout(200);
168+
169+
await expect
170+
.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 })
171+
.toBe(true);
172+
});
173+
174+
test("toolbar should remain functional after activation cycle", async ({
175+
reactGrab,
176+
}) => {
177+
await reactGrab.activate();
178+
await reactGrab.deactivate();
179+
await reactGrab.page.waitForTimeout(200);
180+
181+
await reactGrab.clickToolbarToggle();
182+
expect(await reactGrab.isOverlayVisible()).toBe(true);
183+
184+
await reactGrab.clickToolbarToggle();
185+
expect(await reactGrab.isOverlayVisible()).toBe(false);
186+
});
187+
188+
test("selection label should be visible during hover after prior activation cycle", async ({
189+
reactGrab,
190+
}) => {
191+
await reactGrab.activate();
192+
await reactGrab.deactivate();
193+
await reactGrab.page.waitForTimeout(200);
194+
195+
await reactGrab.activate();
196+
await reactGrab.hoverElement("li:first-child");
197+
await reactGrab.waitForSelectionBox();
198+
await reactGrab.waitForSelectionLabel();
199+
200+
const labelInfo = await reactGrab.getSelectionLabelInfo();
201+
expect(labelInfo.isVisible).toBe(true);
202+
});
203+
});
204+
205+
test.describe("Freeze/Unfreeze Cycles", () => {
206+
test("should handle rapid activation cycles without breaking animations", async ({
207+
reactGrab,
208+
}) => {
209+
for (let iteration = 0; iteration < 5; iteration++) {
210+
await reactGrab.activate();
211+
await reactGrab.page.waitForTimeout(50);
212+
await reactGrab.deactivate();
213+
await reactGrab.page.waitForTimeout(50);
214+
}
215+
216+
const hasFreezeStyle = await reactGrab.page.evaluate(() => {
217+
return (
218+
document.querySelector("[data-react-grab-global-freeze]") !== null
219+
);
220+
});
221+
expect(hasFreezeStyle).toBe(false);
222+
223+
const toolbarVisible = await reactGrab.isToolbarVisible();
224+
expect(toolbarVisible).toBe(true);
225+
});
226+
227+
test("should correctly freeze animations after reactivation", async ({
228+
reactGrab,
229+
}) => {
230+
await reactGrab.activate();
231+
await reactGrab.deactivate();
232+
await reactGrab.page.waitForTimeout(200);
233+
234+
await reactGrab.page.evaluate(() => {
235+
const element = document.querySelector(
236+
"[data-testid='animated-section']",
237+
);
238+
if (element) {
239+
const child = document.createElement("div");
240+
child.className = "animate-ping w-4 h-4 bg-yellow-500 rounded-full";
241+
child.setAttribute("data-testid", "injected-animation");
242+
element.appendChild(child);
243+
}
244+
});
245+
await reactGrab.page.waitForTimeout(100);
246+
247+
await reactGrab.activate();
248+
await reactGrab.page.waitForTimeout(100);
249+
250+
const pausedAnimationCount = await reactGrab.page.evaluate((attrName) => {
251+
return document.getAnimations().filter((animation) => {
252+
if (animation.effect instanceof KeyframeEffect) {
253+
const target = animation.effect.target;
254+
if (target instanceof Element) {
255+
const rootNode = target.getRootNode();
256+
if (
257+
rootNode instanceof ShadowRoot &&
258+
rootNode.host.hasAttribute(attrName)
259+
) {
260+
return false;
261+
}
262+
}
263+
}
264+
return animation.playState === "paused";
265+
}).length;
266+
}, ATTRIBUTE_NAME);
267+
268+
expect(pausedAnimationCount).toBeGreaterThan(0);
269+
270+
await reactGrab.deactivate();
271+
});
272+
273+
test("should not leave stale freeze styles after toolbar hover cycle", async ({
274+
reactGrab,
275+
}) => {
276+
await reactGrab.activate();
277+
await reactGrab.hoverElement("li:first-child");
278+
await reactGrab.waitForSelectionBox();
279+
await reactGrab.page.waitForTimeout(200);
280+
281+
await reactGrab.deactivate();
282+
await reactGrab.page.waitForTimeout(200);
283+
284+
const hasFreezeStyle = await reactGrab.page.evaluate(() => {
285+
return (
286+
document.querySelector("[data-react-grab-global-freeze]") !== null
287+
);
288+
});
289+
expect(hasFreezeStyle).toBe(false);
290+
});
291+
});
292+
293+
test.describe("Toolbar Hover Freeze", () => {
294+
test("should clean up freeze styles after toolbar hover cycle", async ({
295+
reactGrab,
296+
}) => {
297+
await expect
298+
.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 })
299+
.toBe(true);
300+
301+
const toolbarInfo = await reactGrab.getToolbarInfo();
302+
303+
if (toolbarInfo.position) {
304+
await reactGrab.page.mouse.move(
305+
toolbarInfo.position.x + 10,
306+
toolbarInfo.position.y + 10,
307+
);
308+
await reactGrab.page.waitForTimeout(200);
309+
}
310+
311+
await reactGrab.page.mouse.move(0, 0);
312+
await reactGrab.page.waitForTimeout(200);
313+
314+
const hasFreezeStyle = await reactGrab.page.evaluate(() => {
315+
return (
316+
document.querySelector("[data-react-grab-global-freeze]") !== null
317+
);
318+
});
319+
expect(hasFreezeStyle).toBe(false);
320+
});
321+
});
322+
});

packages/react-grab/src/utils/freeze-animations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ export const unfreezeGlobalAnimations = (): void => {
9191
`;
9292

9393
for (const animation of document.getAnimations()) {
94+
if (animation.effect instanceof KeyframeEffect) {
95+
const target = animation.effect.target;
96+
if (target instanceof Element) {
97+
const rootNode = target.getRootNode();
98+
if (rootNode instanceof ShadowRoot) {
99+
continue;
100+
}
101+
}
102+
}
103+
94104
try {
95105
animation.finish();
96106
} catch {

0 commit comments

Comments
 (0)