diff --git a/packages/core/src/htmlInCanvasDefects.test.ts b/packages/core/src/htmlInCanvasDefects.test.ts index b7965abfdf7..5a6cca81d28 100644 --- a/packages/core/src/htmlInCanvasDefects.test.ts +++ b/packages/core/src/htmlInCanvasDefects.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { chromeMajor, + createDefectScan, findCaptureDefect, scanBackfaceDefect, type DefectEnv, @@ -30,6 +31,21 @@ const env = (over: Partial = {}): DefectEnv => ({ const code = (root: HTMLElement, e: DefectEnv) => findCaptureDefect(root, e)?.code ?? null; +/** Every Chrome the render and preview browsers can be, and an unknown one. */ +const VERSIONS = [152, 155, 156, 157, 158, null] as const; + +/** The defect found with the page's user agent set to `major`. */ +function codeOn(major: number | null, root: HTMLElement, e: DefectEnv): string | null { + const spy = vi + .spyOn(window.navigator, "userAgent", "get") + .mockReturnValue(ua(major).navigator.userAgent); + try { + return code(root, e); + } finally { + spy.mockRestore(); + } +} + afterEach(() => { document.body.innerHTML = document.head.innerHTML = ""; vi.unstubAllGlobals(); @@ -57,9 +73,8 @@ describe("nested-capture-canvas", () => { it("is found on every version, in either API form", () => { for (const attr of ["layoutsubtree", 'content="drawable"']) { const root = mount(``); - for (const major of [152, 158, null]) { - expect(code(root, env({ win: ua(major) }))).toBe("nested-capture-canvas"); - } + for (const major of VERSIONS) + expect(codeOn(major, root, env())).toBe("nested-capture-canvas"); } }); }); @@ -67,57 +82,174 @@ describe("nested-capture-canvas", () => { describe("accelerated-canvas", () => { const gl = (root: HTMLElement) => env({ isAccelerated: (c) => root.contains(c) }); - it("applies below 157 and when the version is unknown, not from 157", () => { + it("applies on every version, 157 and 158 included", () => { const root = mount(""); - for (const major of [152, 155, null]) { - expect(code(root, { ...gl(root), win: ua(major) })).toBe("accelerated-canvas"); + for (const major of VERSIONS) { + expect(codeOn(major, root, gl(root))).toBe("accelerated-canvas"); + } + }); + + it("finds a WebGL canvas outside the root, where a render's shader compositor puts it", () => { + const root = mount(""); + const outside = document.createElement("canvas"); + document.body.append(outside); + for (const major of VERSIONS) { + expect(codeOn(major, root, env({ isAccelerated: (c) => c === outside }))).toBe( + "accelerated-canvas", + ); } - for (const major of [157, 158]) expect(code(root, { ...gl(root), win: ua(major) })).toBeNull(); }); it("ignores a canvas the page did not give a WebGL context", () => { - expect(code(mount(""), env({ win: ua(152) }))).toBeNull(); + expect(code(mount(""), env())).toBeNull(); }); }); describe("iframe-in-root", () => { it("applies on every version: a reload cannot be undone", () => { const root = mount('
'); - for (const major of [152, 157, 158]) - expect(code(root, env({ win: ua(major) }))).toBe("iframe-in-root"); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("iframe-in-root"); }); it("is not reported for a composition without an iframe", () => { expect(code(mount("
"), env())).toBeNull(); }); + + it("also covers , and defined custom elements, which are re-initialised too", () => { + customElements.define("x-defect-card", class extends HTMLElement {}); + for (const html of ["", "", ""]) + expect(findCaptureDefect(mount(html), env())).toMatchObject({ + code: "iframe-in-root", + reason: expect.stringContaining("re-initialised when blur wraps the root"), + }); + expect(code(mount(""), env())).toBeNull(); + }); + + it("is found by the timeline scan at any step, and says where it first appeared", () => { + const root = mount(""); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + root.append(document.createElement("iframe")); + scan.sample(3, 10, 1.5); + expect(scan.result()).toMatchObject({ code: "iframe-in-root" }); + expect(scan.result()?.reason).toContain("t=1.5s"); + }); +}); + +describe("facesNow: the scan's candidates, read at the current time", () => { + it("sees a hidden face flip into 3D after the scan, without rescanning", () => { + const root = mount('
'); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + expect(scan.facesNow()).toBe(false); + const card = document.getElementById("card"); + if (card) card.style.transform = "rotateY(100deg)"; + expect(scan.facesNow()).toBe(true); + if (card) card.style.transform = "none"; + expect(scan.facesNow()).toBe(false); + }); + + it("takes in a face an animation keys, and keeps it once the animation is gone", () => { + const root = mount('
'); + const face = root.querySelector("#f") ?? root; + const scan = createDefectScan(root, () => false); + animating(face, [{ backfaceVisibility: "hidden" }]); + scan.sample(0, 10, 0); + expect(scan.result()).toBeNull(); + expect(scan.facesNow()).toBe(false); + // The rule comes after the scan read the sheets: only the animation made the face a candidate. + document.head.innerHTML = ""; + face.className = "hide"; + expect(scan.facesNow()).toBe(true); + Object.assign(document, { getAnimations: () => [] }); + scan.sample(10, 10, 1); + expect(scan.result()?.code).toBe("backface-visibility"); + }); + + it("knows only the elements the scan's last face check found", () => { + const root = mount(""); + const scan = createDefectScan(root, () => false); + scan.sample(0, 10, 0); + const late = document.createElement("div"); + late.setAttribute("style", "backface-visibility: hidden; transform: rotateY(100deg)"); + root.append(late); + expect(scan.facesNow()).toBe(false); + }); }); describe("root-transform", () => { - it("applies below 157 and when the version is unknown, not from 157", () => { + it("applies on every version, 157 and 158 included", () => { const root = mount("", "transform: scale(0.5)"); - expect(code(root, env({ win: ua(152) }))).toBe("root-transform"); - expect(code(root, env({ win: ua(156) }))).toBe("root-transform"); - expect(code(root, env({ win: ua(null) }))).toBe("root-transform"); - expect(code(root, env({ win: ua(157) }))).toBeNull(); - expect(code(root, env({ win: ua(158) }))).toBeNull(); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-transform"); }); it("ignores a root without a transform", () => { - expect(code(mount(""), env({ win: ua(152) }))).toBeNull(); + expect(code(mount(""), env())).toBeNull(); + }); + + it("ignores an identity transform, 2D or 3D, which changes nothing on screen", () => { + for (const t of [ + "matrix(1, 0, 0, 1, 0, 0)", + "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)", + ]) { + for (const major of VERSIONS) + expect(codeOn(major, mount("", `transform: ${t}`), env())).toBeNull(); + } + }); + + it("reports a real translate, scale or rotate, 2D or 3D", () => { + for (const t of [ + "translateX(3px)", + "scale(1.01)", + "rotate(1deg)", + "matrix(1, 0, 0, 1, 0, 2)", + "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1)", + ]) { + expect(code(mount("", `transform: ${t}`), env())).toBe("root-transform"); + } + }); +}); + +describe("root individual transform properties and blend mode", () => { + it("reports a root moved by scale, rotate or translate, not an identity one", () => { + for (const t of ["scale: 0.5", "rotate: 3deg", "translate: 5px 0"]) + expect(code(mount("", t), env())).toBe("root-transform"); + for (const t of ["scale: 1", "rotate: 0deg", "translate: 0px"]) + expect(code(mount("", t), env())).toBeNull(); + }); + + it("reports a root mix-blend-mode other than normal", () => { + for (const major of VERSIONS) + expect(codeOn(major, mount("", "mix-blend-mode: multiply"), env())).toBe("root-blend-mode"); + expect(code(mount("", "mix-blend-mode: normal"), env())).toBeNull(); }); }); describe("root-opacity", () => { - it("applies from 155 and when the version is unknown, not on 152", () => { + it("applies on every version, 152 included", () => { const root = mount("", "opacity: 0.5"); - expect(code(root, env({ win: ua(152) }))).toBeNull(); - expect(code(root, env({ win: ua(155) }))).toBe("root-opacity"); - expect(code(root, env({ win: ua(158) }))).toBe("root-opacity"); - expect(code(root, env({ win: ua(null) }))).toBe("root-opacity"); + for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-opacity"); }); it("ignores a fully opaque root", () => { - expect(code(mount("", "opacity: 1"), env({ win: ua(158) }))).toBeNull(); + expect(code(mount("", "opacity: 1"), env())).toBeNull(); + }); +}); + +describe("no version gating", () => { + // Mutation guard: a defect that once applied only below 157 (or from 155) must still block on + // 158 (or 152). Reintroducing any from/until test on the Chrome major fails this. + it("blocks a WebGL canvas, a root transform and a root fade on every version alike", () => { + const fixtures: Array<[string, () => HTMLElement, DefectEnv]> = [ + ["accelerated-canvas", () => mount(""), env({ isAccelerated: () => true })], + ["root-transform", () => mount("", "transform: translateX(5px)"), env()], + ["root-opacity", () => mount("", "opacity: 0.2"), env()], + ]; + for (const [expected, build, e] of fixtures) { + const root = build(); + const seen = new Set(VERSIONS.map((major) => codeOn(major, root, e))); + expect([...seen]).toEqual([expected]); + } }); }); @@ -126,8 +258,8 @@ describe("backface-visibility", () => { it("is found on every version once the scan saw one", () => { const root = mount(""); - for (const major of [152, 155, 157, 158, null]) { - expect(code(root, env({ backface: true, win: ua(major) }))).toBe("backface-visibility"); + for (const major of VERSIONS) { + expect(codeOn(major, root, env({ backface: true }))).toBe("backface-visibility"); } }); @@ -138,6 +270,12 @@ describe("backface-visibility", () => { expect(scanBackfaceDefect(root)).toBe(true); }); + it("scan: a face turned in 3D by the individual rotate or translate properties", () => { + for (const own of ["rotate: y 180deg", "rotate: 0 1 0 180deg", "translate: 0 0 10px"]) + expect(scanBackfaceDefect(mount(`
`))).toBe(true); + expect(scanBackfaceDefect(mount(`
`))).toBe(false); + }); + it("scan: a face under a perspective ancestor", () => { const root = mount(`
`); expect(scanBackfaceDefect(root)).toBe(true); @@ -318,6 +456,46 @@ describe("backface-visibility rules the page reaches indirectly", () => { describe("order", () => { it("reports the first applicable defect", () => { const root = mount("", "opacity: 0.5"); - expect(code(root, env({ win: ua(158), backface: true }))).toBe("nested-capture-canvas"); + expect(code(root, env({ backface: true }))).toBe("nested-capture-canvas"); + }); +}); + +describe("createDefectScan", () => { + const face = "backface-visibility: hidden"; + const run = (root: HTMLElement, steps: number, each?: (i: number) => void) => { + const scan = createDefectScan(root, () => false); + for (let i = 0; i <= steps; i++) { + each?.(i); + scan.sample(i, steps, i / 10); + } + return scan.result(); + }; + + it("is clean for a quiet timeline", () => { + expect(run(mount(""), 20)).toBeNull(); + }); + + it("names the defect and when it was first seen", () => { + const root = mount(""); + const found = run(root, 20, (i) => { + if (i === 7) root.style.opacity = "0.5"; + }); + expect(found?.code).toBe("root-opacity"); + expect(found?.reason).toContain("first seen at t=0.7s"); + }); + + it("sees a rotation that a later step applies to a face with no 3D context at the start", () => { + const root = mount(`
`); + const el = root.querySelector("#f"); + const found = run(root, 20, (i) => { + if (i === 12 && el) el.style.transform = "rotateY(90deg)"; + }); + expect(found?.code).toBe("backface-visibility"); + expect(found?.reason).toContain("first seen at t=2.0s"); + }); + + it("leaves a flat face alone however long the timeline", () => { + const root = mount(`
`); + expect(run(root, 40)).toBeNull(); }); }); diff --git a/packages/core/src/htmlInCanvasDefects.ts b/packages/core/src/htmlInCanvasDefects.ts index fa30b9d2697..a667231eb12 100644 --- a/packages/core/src/htmlInCanvasDefects.ts +++ b/packages/core/src/htmlInCanvasDefects.ts @@ -17,96 +17,94 @@ export interface DefectEnv { isAccelerated: (canvas: HTMLCanvasElement) => boolean; /** Result of `scanBackfaceDefect`, refreshed whenever the scene is rescanned. */ backface: boolean; - win?: UaWindow; } +// Every defect blocks on every Chrome version: render and preview browsers can differ. interface Defect { code: string; reason: string; - /** Chrome majors the defect applies to; null bounds are open. */ - from: number | null; - until: number | null; detect: (root: HTMLElement, env: DefectEnv) => boolean; } const hasHidden3dFace = (_root: HTMLElement, env: DefectEnv): boolean => env.backface; +// The whole document: a render's shader-transition canvas sits in , preview's in the root. const hasAcceleratedCanvas = (root: HTMLElement, env: DefectEnv): boolean => - Array.from(root.querySelectorAll("canvas")).some(env.isAccelerated); + Array.from(root.ownerDocument.querySelectorAll("canvas")).some(env.isAccelerated); +const IDENTITY_TRANSFORM = + /^matrix\(\s*1,\s*0,\s*0,\s*1,\s*0,\s*0\s*\)$|^matrix3d\(\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1,\s*0,\s*0,\s*0,\s*0,\s*1\s*\)$/; + +const moves = (value: string | undefined, identity: number): boolean => + !!value && value !== "none" && !value.split(/\s+/).every((p) => parseFloat(p) === identity); + +// `scale`, `rotate` and `translate` transform the root just as `transform` does. const hasRootTransform = (root: HTMLElement): boolean => { - const t = getComputedStyle(root).transform; - return t !== "none" && t !== ""; + const cs = getComputedStyle(root); + const t = cs.transform.trim(); + if (moves(cs.scale, 1) || moves(cs.rotate, 0) || moves(cs.translate, 0)) return true; + return t !== "none" && t !== "" && !IDENTITY_TRANSFORM.test(t); +}; + +const hasRootBlend = (root: HTMLElement): boolean => { + const mode = getComputedStyle(root).mixBlendMode; + return !!mode && mode !== "normal"; }; const hasRootOpacity = (root: HTMLElement): boolean => Number.parseFloat(getComputedStyle(root).opacity) < 1; -const hasIframe = (root: HTMLElement): boolean => root.querySelector("iframe") !== null; +// Moving the root tears these down and starts them again: a frame reloads, a custom element reconnects. +const hasReinitialised = (root: HTMLElement): boolean => + root.querySelector("iframe, object, embed") !== null || + Array.from(root.querySelectorAll("*")).some( + (el) => el.localName.includes("-") && customElements.get(el.localName) !== undefined, + ); -// Chrome <=156 captures a WebGL canvas frozen at its pre-wrap frame; 157 fixed it. const DEFECTS: readonly Defect[] = [ { code: "nested-capture-canvas", reason: "the composition already uses html-in-canvas", - from: null, - until: null, detect: (root) => root.querySelector(CAPTURE_CANVAS_SELECTOR) !== null, }, { + // Chrome <=156 captures the canvas frozen at its pre-wrap frame; 157 fixed it. code: "accelerated-canvas", reason: "the composition draws a WebGL/WebGPU canvas, which html-in-canvas captures frozen", - from: null, - until: 157, detect: hasAcceleratedCanvas, }, { code: "backface-visibility", reason: "backface-visibility: hidden inside a 3D transform is drawn mirrored by html-in-canvas", - from: null, - until: null, detect: hasHidden3dFace, }, { code: "iframe-in-root", - reason: "the composition contains an