diff --git a/packages/core/src/runtime/previewRasterHints.test.ts b/packages/core/src/runtime/previewRasterHints.test.ts index be593886714..a656e66740e 100644 --- a/packages/core/src/runtime/previewRasterHints.test.ts +++ b/packages/core/src/runtime/previewRasterHints.test.ts @@ -172,6 +172,119 @@ describe("setPreviewRasterScale", () => { reads.mockRestore(); }); + it("reads no computed style for a mounted scene that nothing gives a hint", async () => { + setPreviewRasterScale(0.5); + const reads = vi.spyOn(window, "getComputedStyle"); + const pageQueries = vi.spyOn(document, "querySelector"); + const scene = document.createElement("div"); + const sheet = document.createElement("style"); + sheet.textContent = ".mc-layer { position: absolute }"; + scene.append(sheet); + for (let i = 0; i < 50; i += 1) + layer("position: absolute; left: 4px", scene).className = "mc-layer"; + document.body.append(scene); + await Promise.resolve(); + document.body.append(layer("position: absolute", document.createElement("div"))); + await Promise.resolve(); + const counts = [reads.mock.calls.length, pageQueries.mock.calls.length]; + reads.mockRestore(); + pageQueries.mockRestore(); + expect(counts).toEqual([0, 0]); + }); + + it("marks a layer added later that only a grouped stylesheet rule hints", async () => { + const sheet = document.createElement("style"); + sheet.textContent = "@media all { .late-hinted { will-change: transform } }"; + document.head.append(sheet); + setPreviewRasterScale(0.5); + const late = layer("position: absolute"); + late.className = "late-hinted"; + const plain = layer("position: absolute"); + await Promise.resolve(); + expect([late, plain].map(marked)).toEqual([true, false]); + sheet.remove(); + }); + + it("reads the stylesheets once, not for every batch of added layers", async () => { + setPreviewRasterScale(0.5); + const sheetReads = vi.spyOn(Document.prototype, "styleSheets", "get"); + for (let frame = 0; frame < 3; frame += 1) { + layer("position: absolute"); + await Promise.resolve(); + } + const reads = sheetReads.mock.calls.length; + sheetReads.mockRestore(); + expect(reads).toBeLessThanOrEqual(1); + }); + + it("sees a hint rule from a style that arrives while the preview is small", async () => { + setPreviewRasterScale(0.5); + layer("position: absolute"); + await Promise.resolve(); + const sheet = document.createElement("style"); + sheet.textContent = ".arrived-hint { will-change: transform }"; + document.head.append(sheet); + await Promise.resolve(); + const late = layer("position: absolute"); + late.className = "arrived-hint"; + await Promise.resolve(); + expect(marked(late)).toBe(true); + sheet.remove(); + }); + + it("walks no holders for a mounted scene with nothing marked in or around it", async () => { + setPreviewRasterScale(0.5); + const walks = vi.spyOn(Element.prototype, "closest"); + const scene = document.createElement("div"); + for (let i = 0; i < 50; i += 1) layer("position: absolute", scene); + document.body.append(scene); + await Promise.resolve(); + const count = walks.mock.calls.length; + walks.mockRestore(); + expect(count).toBeLessThanOrEqual(1); + }); + + it("does no work for attribute and style writes on a page that nothing hints", async () => { + const paths = Array.from({ length: 20 }, () => layer("position: absolute")); + setPreviewRasterScale(0.5); + const walks = vi.spyOn(Element.prototype, "querySelectorAll"); + for (const path of paths) { + path.setAttribute("fill", "#fff"); + path.style.left = "4px"; + } + await Promise.resolve(); + const count = walks.mock.calls.length; + walks.mockRestore(); + expect(count).toBe(0); + }); + + it("gives the hint back to a marked layer re-attached after removal once a child needs it", async () => { + const words = layer(POSITIONED); + const child = layer("", words); + setPreviewRasterScale(0.5); + expect(marked(words)).toBe(true); + words.remove(); + await Promise.resolve(); + document.body.append(words); + await Promise.resolve(); + child.style.position = "fixed"; + await Promise.resolve(); + expect(marked(words)).toBe(false); + }); + + it("follows a copy of a marked layer that arrives with the mark", async () => { + const words = layer(POSITIONED); + layer("", words); + setPreviewRasterScale(0.5); + const copy = words.cloneNode(true) as HTMLElement; + words.remove(); + document.body.append(copy); + await Promise.resolve(); + (copy.firstElementChild as HTMLElement).style.position = "fixed"; + await Promise.resolve(); + expect(marked(copy)).toBe(false); + }); + it("re-checks the page when a linked stylesheet loads", async () => { const sheet = document.createElement("style"); document.head.append(sheet); diff --git a/packages/core/src/runtime/previewRasterHints.ts b/packages/core/src/runtime/previewRasterHints.ts index 0277b6ab181..43a5d051676 100644 --- a/packages/core/src/runtime/previewRasterHints.ts +++ b/packages/core/src/runtime/previewRasterHints.ts @@ -13,6 +13,9 @@ const RUNTIME_PLAYBACK_ATTRS = new Set([...PREVIEW_ONLY_ATTRS, CLIP_HIDD let sheet: HTMLStyleElement | null = null; let observer: MutationObserver | null = null; +let hintRuleSelector: string | null = null; +let watchingAttributes: boolean | null = null; +const marked = new Set(); const markedWith = new WeakMap(); // Other hints (opacity, filter) make boundaries of their own, so only a pure transform hint goes. @@ -52,19 +55,27 @@ function inlineFit(element: Element, style: CSSStyleDeclaration): string { // the layer, so that only happens when the element itself changed. function fits(element: Element, readOwnHints: boolean): boolean { if (element === sheet) return false; - const marked = element.hasAttribute(PREVIEW_RASTER_ATTR); - if (marked && readOwnHints) element.removeAttribute(PREVIEW_RASTER_ATTR); + const isMarked = marked.has(element); + if (isMarked && readOwnHints) setMark(element, false); const style = getComputedStyle(element); - const hintsFit = (marked && !readOwnHints) || onlyTransformHints(style); + const hintsFit = (isMarked && !readOwnHints) || onlyTransformHints(style); const drop = hintsFit && canDropHint(element, style); if (drop) markedWith.set(element, inlineFit(element, style)); return drop; } +function setMark(element: Element, on: boolean): void { + element.toggleAttribute(PREVIEW_RASTER_ATTR, on); + if (on) marked.add(element); + else marked.delete(element); +} + +function adoptCopiedMark(element: Element): void { + if (element.hasAttribute(PREVIEW_RASTER_ATTR)) marked.add(element); +} + function writeMark(element: Element, drop: boolean): void { - if (drop !== element.hasAttribute(PREVIEW_RASTER_ATTR)) { - element.toggleAttribute(PREVIEW_RASTER_ATTR, drop); - } + if (drop !== marked.has(element)) setMark(element, drop); } function check(element: Element, readOwnHints: boolean): void { @@ -75,23 +86,62 @@ function unmarkHolders(element: Element): void { let holder = element.parentElement?.closest(MARKED); while (holder) { if (escapes(element, getComputedStyle(holder).zIndex !== "auto")) { - holder.removeAttribute(PREVIEW_RASTER_ATTR); + setMark(holder, false); } holder = holder.parentElement?.closest(MARKED); } } +// Only an element hinted inline or by a rule can carry will-change; neither check reads style. +function readHintRules(): string { + const selectors: string[] = []; + const collect = (rules: CSSRuleList, relative: boolean): void => { + for (const rule of rules) { + const isStyleRule = rule.type === CSSRule.STYLE_RULE; + if (isStyleRule && (rule as CSSStyleRule).style.getPropertyValue("will-change")) { + selectors.push(relative ? "*" : (rule as CSSStyleRule).selectorText); + } + const imported = (rule as CSSImportRule).styleSheet; + if (imported) collect(imported.cssRules, relative); + if ("cssRules" in rule) collect((rule as CSSGroupingRule).cssRules, relative || isStyleRule); + } + }; + try { + for (const rules of [...document.styleSheets, ...(document.adoptedStyleSheets ?? [])]) { + if (rules !== sheet?.sheet) collect(rules.cssRules, false); + } + document.createDocumentFragment().querySelector(selectors.join(",") || "*"); + } catch { + // Cross-origin rules are unreadable, and a namespaced selector does not parse here. + return "*"; + } + return selectors.join(","); +} + +// Re-read where the page is re-checked, when a style or link arrives or loads. +function hintRules(): string { + return (hintRuleSelector ??= readHintRules()); +} + +function mayHint(element: Element): boolean { + if (marked.has(element) || (element as HTMLElement).style?.getPropertyValue("will-change")) { + return true; + } + const rules = hintRules(); + return rules !== "" && element.matches(rules); +} + function followTree(root: Element, batch: FollowBatch): void { const decidedBeforeAnyWrite: Array<[Element, boolean]> = []; for (const element of [root, ...root.querySelectorAll("*")]) { if (batch.checked.has(element)) continue; batch.checked.add(element); - decidedBeforeAnyWrite.push([ - element, - fits(element, element === root || batch.changed.has(element)), - ]); + adoptCopiedMark(element); + const readOwnHints = element === root || batch.changed.has(element); + decidedBeforeAnyWrite.push([element, mayHint(element) && fits(element, readOwnHints)]); } for (const [element, drop] of decidedBeforeAnyWrite) writeMark(element, drop); + if (!marked.size) return; for (const [element] of decidedBeforeAnyWrite) unmarkHolders(element); } @@ -99,7 +149,7 @@ type FollowBatch = { changed: Set; checked: Set }; // Scripts restyle inline every frame, so only what can break a mark is looked at. function followStyle(element: Element): void { - if (element.hasAttribute(PREVIEW_RASTER_ATTR)) { + if (marked.has(element)) { const style = getComputedStyle(element); if (style.position === "static" || inlineFit(element, style) !== markedWith.get(element)) { check(element, true); @@ -109,16 +159,19 @@ function followStyle(element: Element): void { } function clearMarks(): void { - for (const element of document.querySelectorAll(MARKED)) { - element.removeAttribute(PREVIEW_RASTER_ATTR); - } + for (const element of document.querySelectorAll(MARKED)) setMark(element, false); + marked.clear(); } // Every mark is decided before any is written, so the page restyles twice, not once per layer. function markLayers(): void { clearMarks(); - const layers = [...document.querySelectorAll("*")].filter((element) => fits(element, true)); - for (const layer of layers) layer.setAttribute(PREVIEW_RASTER_ATTR, ""); + hintRuleSelector = null; + const layers = [...document.querySelectorAll("*")].filter( + (element) => mayHint(element) && fits(element, true), + ); + for (const layer of layers) setMark(layer, true); + watch(); } function followAttribute(record: MutationRecord, batch: FollowBatch): void { @@ -147,17 +200,33 @@ function ownAttributeChanges(records: MutationRecord[]): Set { return changed; } +// With nothing marked and no rule giving a hint, no attribute or inline style change can make or +// break a mark: only added elements are followed, and a hint missed that way stays as authored. +function watch(): void { + const attributes = marked.size > 0 || hintRules() !== ""; + if (!observer || attributes === watchingAttributes) return; + watchingAttributes = attributes; + observer.observe(document.documentElement, { subtree: true, childList: true, attributes }); +} + +function forgetRemovedMarks(records: MutationRecord[]): void { + if (!records.some((record) => record.removedNodes.length)) return; + for (const element of marked) if (!element.isConnected) marked.delete(element); +} + function follow(records: MutationRecord[]): void { const rules = records.flatMap(addedRules); for (const rule of rules) { rule.addEventListener("load", () => observer && markLayers(), { once: true }); } if (rules.length) return markLayers(); + forgetRemovedMarks(records); const batch: FollowBatch = { changed: ownAttributeChanges(records), checked: new Set() }; for (const record of records) { if (record.type === "attributes") followAttribute(record, batch); else for (const node of record.addedNodes) if (isElementNode(node)) followTree(node, batch); } + watch(); } function restore(): void { @@ -165,6 +234,8 @@ function restore(): void { observer = null; sheet?.remove(); sheet = null; + hintRuleSelector = null; + watchingAttributes = null; clearMarks(); } @@ -178,9 +249,5 @@ export function setPreviewRasterScale(scale: number): void { (document.head ?? document.documentElement).append(sheet); markLayers(); observer = new MutationObserver(follow); - observer.observe(document.documentElement, { - subtree: true, - childList: true, - attributes: true, - }); + watch(); } diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index c078389849e..ceb05c18314 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -36,8 +36,8 @@ "unpacked": 541601 }, "@hyperframes/producer": { - "packed": 768210, - "unpacked": 2807938 + "packed": 768546, + "unpacked": 2808835 }, "@hyperframes/sdk": { "packed": 90662,