Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/core/src/runtime/previewRasterHints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
111 changes: 89 additions & 22 deletions packages/core/src/runtime/previewRasterHints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const RUNTIME_PLAYBACK_ATTRS = new Set<string>([...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<Element>();
const markedWith = new WeakMap<Element, string>();

// Other hints (opacity, filter) make boundaries of their own, so only a pure transform hint goes.
Expand Down Expand Up @@ -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 {
Expand All @@ -75,31 +86,70 @@ 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);
}

type FollowBatch = { changed: Set<Element>; checked: Set<Element> };

// 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);
Expand All @@ -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 {
Expand Down Expand Up @@ -147,24 +200,42 @@ function ownAttributeChanges(records: MutationRecord[]): Set<Element> {
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: a marked layer that is detached and re-attached in a later batch keeps data-hf-preview-raster but is no longer in marked, so nothing restores its hint when it stops qualifying. forgetRemovedMarks drops it from the Set while the attribute stays on the node. On re-attach, fits sees isMarked = false, reads will-change: auto through our own !important rule, and returns false, and writeMark(false) is a no-op because the Set already says unmarked. From then on, followStyle skips the layer, and if this was the only mark, watch() stops observing attributes altogether. Result: a later position: fixed child, or the layer's own z-index going back to auto, paints without the containing block or stacking context the hint provides. Main and b228a7e read the attribute, so they re-check the layer.

Repro (fails on 7f8eb9c, passes on b228a7e):

const words = layer("position: absolute; will-change: transform");
const child = layer("", words);
setPreviewRasterScale(0.5);
words.remove(); await Promise.resolve();
document.body.append(words); await Promise.resolve();
child.style.position = "fixed"; await Promise.resolve();
expect(marked(words)).toBe(false); // stays true

Fix: re-adopt the attribute when an element is followed, e.g. at the top of the followTree loop: if (element.hasAttribute(PREVIEW_RASTER_ATTR)) marked.add(element);. watch() then re-arms attributes as well.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 935faf2, as you suggested: followTree adopts any element it visits that already carries data-hf-preview-raster back into marked (adoptCopiedMark), so fits reads it as marked, followStyle follows it and watch() re-arms attributes. Your repro is now a test ("gives the hint back to a marked layer re-attached after removal once a child needs it"). The same gap existed for a cloneNode(true) copy of a marked layer added after the original is removed, so that has a test too. Both fail without the adoption. Core runtime suite 2,431 green, test:browser-raster 3/3.

}

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 {
observer?.disconnect();
observer = null;
sheet?.remove();
sheet = null;
hintRuleSelector = null;
watchingAttributes = null;
clearMarks();
}

Expand All @@ -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();
}
4 changes: 2 additions & 2 deletions scripts/package-size-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"unpacked": 541601
},
"@hyperframes/producer": {
"packed": 768210,
"unpacked": 2807938
"packed": 768546,
"unpacked": 2808835
},
"@hyperframes/sdk": {
"packed": 90662,
Expand Down
Loading