fix(render): avoid empty WAAPI scans and llvmpipe auto GPU (#1775)

Avoid the screenshot-path #1715 regression by skipping empty WAAPI/CSS animation scans per seek and classifying known software WebGL renderers correctly in browserGpuMode=auto.\n\nAddresses #1715.
This commit is contained in:
Miguel Ángel
2026-06-28 10:42:14 -04:00
committed by GitHub
parent 35a01d9058
commit fc0f8c3151
6 changed files with 521 additions and 147 deletions
@@ -126,6 +126,32 @@ describe("css adapter", () => {
vi.restoreAllMocks();
});
it("does not rescan element animations on every seek", () => {
const el = document.createElement("div");
el.style.animationName = "spin";
document.body.appendChild(el);
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
return { animationName: "spin" } as CSSStyleDeclaration;
});
const animation = { currentTime: 0, pause: vi.fn(), play: vi.fn() } as unknown as Animation;
const getAnimations = vi.fn(() => [animation]);
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = getAnimations;
const adapter = createCssAdapter();
adapter.discover();
adapter.seek({ time: 1 });
adapter.seek({ time: 2 });
adapter.seek({ time: 3 });
expect(getAnimations).toHaveBeenCalledTimes(1);
expect(animation.currentTime).toBe(3000);
document.body.removeChild(el);
vi.restoreAllMocks();
});
it("play resumes WAAPI animations and restores inline styles", () => {
const el = document.createElement("div");
el.style.animationName = "spin";
+5 -3
View File
@@ -8,6 +8,7 @@ export function createCssAdapter(params?: {
el: HTMLElement;
baseDelay: string;
basePlayState: string;
animations: Animation[];
}> = [];
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
@@ -84,6 +85,7 @@ export function createCssAdapter(params?: {
el: rawEl,
baseDelay: rawEl.style.animationDelay || "",
basePlayState: rawEl.style.animationPlayState || "",
animations: getAnimationsForElement(rawEl),
});
}
},
@@ -95,7 +97,7 @@ export function createCssAdapter(params?: {
? params.resolveStartSeconds(entry.el)
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
const localTimeMs = Math.max(0, time - start) * 1000;
const animations = getAnimationsForElement(entry.el);
const animations = entry.animations;
if (animations.length > 0) {
seekAnimations(animations, localTimeMs);
continue;
@@ -109,7 +111,7 @@ export function createCssAdapter(params?: {
pause: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const animations = getAnimationsForElement(entry.el);
const animations = entry.animations;
if (animations.length > 0) {
pauseAnimations(animations);
}
@@ -120,7 +122,7 @@ export function createCssAdapter(params?: {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
restoreInlineStyles(entry);
playAnimations(getAnimationsForElement(entry.el));
playAnimations(entry.animations);
}
},
revert: () => {
+176 -82
View File
@@ -4,6 +4,40 @@ import { createWaapiAdapter } from "./waapi";
describe("waapi adapter", () => {
const originalDocument = (globalThis as { document?: unknown }).document;
const makeAnimation = (currentTime = 0) => ({
addEventListener: vi.fn(),
pause: vi.fn(),
currentTime,
});
const setAnimations = (items: Array<ReturnType<typeof makeAnimation>>) => {
const getAnimations = vi.fn(() => items);
(document as any).getAnimations = getAnimations;
return getAnimations;
};
const makeDynamicDiscoveryFixture = (dynamicStartMs = 0) => {
const existing = makeAnimation();
const dynamic = makeAnimation(dynamicStartMs);
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
return {
adapter,
dynamic,
revealDynamic: () => {
includeDynamic = true;
},
};
};
beforeEach(() => {
(globalThis as { document?: unknown }).document = {
getAnimations: vi.fn(() => []),
@@ -24,38 +58,34 @@ describe("waapi adapter", () => {
});
it("seek pauses and sets currentTime on all animations", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const mockAnim = makeAnimation();
setAnimations([mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: 2.5 });
expect(mockAnim.pause).toHaveBeenCalled();
expect(mockAnim.currentTime).toBe(2500); // seconds → ms
delete (document as any).getAnimations;
});
it("seek clamps negative time to 0", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const mockAnim = makeAnimation();
setAnimations([mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: -3 });
expect(mockAnim.currentTime).toBe(0);
delete (document as any).getAnimations;
});
it("pause pauses all animations", () => {
const mockAnim = { pause: vi.fn(), currentTime: 0 };
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const mockAnim = makeAnimation();
setAnimations([mockAnim]);
const adapter = createWaapiAdapter();
adapter.pause();
expect(mockAnim.pause).toHaveBeenCalled();
delete (document as any).getAnimations;
});
it("handles missing getAnimations API", () => {
@@ -70,34 +100,27 @@ describe("waapi adapter", () => {
});
it("handles animation that throws on pause", () => {
const mockAnim = {
pause: vi.fn(() => {
throw new Error("invalid state");
}),
currentTime: 0,
};
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const mockAnim = makeAnimation();
mockAnim.pause.mockImplementation(() => {
throw new Error("invalid state");
});
setAnimations([mockAnim]);
const adapter = createWaapiAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
delete (document as any).getAnimations;
});
it("still sets currentTime when pause throws for an unresolved infinite animation", () => {
const mockAnim = {
pause: vi.fn(() => {
throw new Error("invalid state");
}),
currentTime: 0,
};
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const mockAnim = makeAnimation();
mockAnim.pause.mockImplementation(() => {
throw new Error("invalid state");
});
setAnimations([mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: 1.25 });
expect(mockAnim.currentTime).toBe(1250);
delete (document as any).getAnimations;
});
it("discover is a no-op", () => {
@@ -105,75 +128,146 @@ describe("waapi adapter", () => {
expect(() => adapter.discover()).not.toThrow();
});
it("anchors newly discovered WAAPI animations to the seek where they first appear", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 0 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
it.each([
["relative start", 0],
["inherited absolute composition time", 700],
])("anchors newly discovered WAAPI animations with %s", (_label, dynamicStartMs) => {
const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(dynamicStartMs);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
adapter.seek({ time: 0.7 });
expect(existing.currentTime).toBe(700);
expect(dynamic.currentTime).toBe(0);
adapter.seek({ time: 0.8 });
expect(dynamic.currentTime).toBe(100);
delete (document as any).getAnimations;
});
it("rebases newly discovered WAAPI animations that inherit absolute composition time", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 700 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
revealDynamic();
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(0);
adapter.seek({ time: 0.8 });
expect(dynamic.currentTime).toBe(100);
delete (document as any).getAnimations;
});
it("does not double-count inherited absolute time when discover runs again after time has advanced", () => {
const existing = { pause: vi.fn(), currentTime: 0 };
const dynamic = { pause: vi.fn(), currentTime: 700 };
let includeDynamic = false;
(document as any).getAnimations = vi.fn(() =>
includeDynamic ? [existing, dynamic] : [existing],
);
const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(700);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.6 });
expect(existing.currentTime).toBe(600);
includeDynamic = true;
revealDynamic();
adapter.discover();
adapter.seek({ time: 0.7 });
expect(dynamic.currentTime).toBe(200);
});
delete (document as any).getAnimations;
it("does not rescan document animations on every seek when discover found none", () => {
const getAnimations = setAnimations([]);
const adapter = createWaapiAdapter();
adapter.discover();
adapter.seek({ time: 0.1 });
adapter.seek({ time: 0.2 });
adapter.seek({ time: 0.3 });
expect(getAnimations).toHaveBeenCalledTimes(1);
});
it("tracks WAAPI animations created after an empty discover via Element.animate", () => {
const getAnimations = setAnimations([]);
const originalElement = (globalThis as { Element?: unknown }).Element;
const animation = makeAnimation();
class MockElement {}
(MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn(
() => animation,
);
(globalThis as { Element?: unknown }).Element = MockElement;
try {
const adapter = createWaapiAdapter();
adapter.discover();
const el = new MockElement() as InstanceType<typeof MockElement> & {
animate: () => typeof animation;
};
el.animate();
adapter.seek({ time: 0.25 });
expect(animation.currentTime).toBe(250);
expect(animation.pause).toHaveBeenCalled();
// The hook tracks the created animation; once WAAPI is active, the
// adapter may resume scanning to catch sibling animations.
expect(getAnimations).toHaveBeenCalledTimes(2);
} finally {
if (originalElement === undefined) {
delete (globalThis as { Element?: unknown }).Element;
} else {
(globalThis as { Element?: unknown }).Element = originalElement;
}
}
});
it("drops finished lazy-tracked animations so empty scans stay skipped again", () => {
const getAnimations = setAnimations([]);
const originalElement = (globalThis as { Element?: unknown }).Element;
const animation = makeAnimation();
const listeners = new Map<string, EventListener>();
animation.addEventListener.mockImplementation((type: string, listener: EventListener) => {
listeners.set(type, listener);
});
class MockElement {}
(MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn(
() => animation,
);
(globalThis as { Element?: unknown }).Element = MockElement;
const adapter = createWaapiAdapter();
try {
adapter.discover();
const el = new MockElement() as InstanceType<typeof MockElement> & {
animate: () => typeof animation;
};
el.animate();
adapter.seek({ time: 0.25 });
expect(animation.currentTime).toBe(250);
expect(getAnimations).toHaveBeenCalledTimes(2);
listeners.get("finish")?.({} as Event);
adapter.seek({ time: 0.5 });
expect(getAnimations).toHaveBeenCalledTimes(2);
expect(animation.currentTime).toBe(250);
} finally {
adapter.revert?.();
if (originalElement === undefined) {
delete (globalThis as { Element?: unknown }).Element;
} else {
(globalThis as { Element?: unknown }).Element = originalElement;
}
}
});
it("revert restores the Element.animate hook", () => {
const originalElement = (globalThis as { Element?: unknown }).Element;
const animation = makeAnimation();
const originalAnimate = vi.fn(() => animation);
class MockElement {}
(MockElement.prototype as { animate?: typeof originalAnimate }).animate = originalAnimate;
(globalThis as { Element?: unknown }).Element = MockElement;
const adapter = createWaapiAdapter();
try {
adapter.discover();
expect((MockElement.prototype as { animate?: unknown }).animate).not.toBe(originalAnimate);
adapter.revert?.();
expect((MockElement.prototype as { animate?: unknown }).animate).toBe(originalAnimate);
expect(
(MockElement.prototype as { __hfOriginalAnimate?: unknown }).__hfOriginalAnimate,
).toBeUndefined();
} finally {
if (originalElement === undefined) {
delete (globalThis as { Element?: unknown }).Element;
} else {
(globalThis as { Element?: unknown }).Element = originalElement;
}
}
});
});
+101 -7
View File
@@ -4,7 +4,17 @@ import { swallow } from "../diagnostics";
export function createWaapiAdapter(): RuntimeDeterministicAdapter {
let didDiscover = false;
let lastSeekTimeMs = 0;
const baselines = new WeakMap<
let animateHookInstalled = false;
let hookedPrototype:
| (Element & {
animate?: Element["animate"];
__hfOriginalAnimate?: Element["animate"];
})
| undefined;
let originalAnimate: Element["animate"] | undefined;
let installedAnimate: Element["animate"] | undefined;
const animations = new Set<Animation>();
let baselines = new WeakMap<
Animation,
{
compositionTimeMs: number;
@@ -54,18 +64,75 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
return baseline;
};
const trackAnimation = (animation: Animation, compositionTimeMs: number) => {
if (!animations.has(animation)) {
animations.add(animation);
const stopTracking = () => {
animations.delete(animation);
};
try {
animation.addEventListener("finish", stopTracking, { once: true });
animation.addEventListener("cancel", stopTracking, { once: true });
} catch (err) {
swallow("runtime.adapters.waapi.site4", err);
}
}
ensureBaseline(animation, compositionTimeMs);
};
const trackAnimations = (items: Animation[], compositionTimeMs: number) => {
for (const animation of items) {
trackAnimation(animation, compositionTimeMs);
}
};
const installAnimateHook = () => {
if (animateHookInstalled) return;
if (typeof Element === "undefined") return;
const proto = Element.prototype as Element & {
animate?: Element["animate"];
__hfOriginalAnimate?: Element["animate"];
};
if (typeof proto.animate !== "function" || proto.__hfOriginalAnimate) return;
const original = proto.animate;
try {
Object.defineProperty(proto, "__hfOriginalAnimate", {
value: original,
configurable: true,
});
const wrappedAnimate = function (...args: Parameters<Element["animate"]>) {
const animation = original.apply(this, args);
trackAnimation(animation, lastSeekTimeMs);
return animation;
};
proto.animate = wrappedAnimate;
hookedPrototype = proto;
originalAnimate = original;
installedAnimate = wrappedAnimate;
animateHookInstalled = true;
} catch {
// Best-effort only. Existing animations are still discovered via snapshot.
}
};
return {
name: "waapi",
discover: () => {
didDiscover = true;
for (const animation of snapshotAnimations()) {
ensureBaseline(animation, lastSeekTimeMs);
}
installAnimateHook();
trackAnimations(snapshotAnimations(), lastSeekTimeMs);
},
seek: (ctx) => {
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
lastSeekTimeMs = timeMs;
for (const animation of snapshotAnimations()) {
// document.getAnimations() is surprisingly expensive in Chromium even
// when it returns [], and renderSeek calls this adapter once per frame.
// After an empty discover, skip the per-frame global scan until authored
// code creates a WAAPI animation via Element.animate (hooked above).
if (!didDiscover || animations.size > 0) {
trackAnimations(snapshotAnimations(), didDiscover ? timeMs : 0);
}
for (const animation of animations) {
const baseline = didDiscover
? ensureBaseline(animation, timeMs)
: ensureBaseline(animation, 0);
@@ -86,8 +153,10 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
}
},
pause: () => {
if (!document.getAnimations) return;
for (const animation of document.getAnimations()) {
if (!didDiscover) {
trackAnimations(snapshotAnimations(), lastSeekTimeMs);
}
for (const animation of animations) {
try {
animation.pause();
} catch (err) {
@@ -96,5 +165,30 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
}
}
},
revert: () => {
animations.clear();
baselines = new WeakMap();
didDiscover = false;
lastSeekTimeMs = 0;
if (
hookedPrototype &&
originalAnimate &&
installedAnimate &&
hookedPrototype.animate === installedAnimate
) {
try {
hookedPrototype.animate = originalAnimate;
if (hookedPrototype.__hfOriginalAnimate === originalAnimate) {
delete hookedPrototype.__hfOriginalAnimate;
}
} catch (err) {
swallow("runtime.adapters.waapi.site5", err);
}
}
hookedPrototype = undefined;
originalAnimate = undefined;
installedAnimate = undefined;
animateHookInstalled = false;
},
};
}