perf(core): mounting a scene no longer restyles it to look for raster hints (#6300)

* perf(core): mounting a scene no longer restyles it to look for raster hints

* perf(core): raster hints read inline hints directly instead of matching every style

* perf(core): raster hints read the stylesheets once per page check, not per batch

* perf(core): raster hints watch no attributes while nothing is marked or hinted

* fix(core): a copied or re-attached marked layer keeps being followed

* chore(producer): raise the packed size budget by the 336 bytes the raster-hint fix adds

The producer bundles the runtime. On main (aae32e43ed) it packs at 773560 bytes
(2821768 unpacked) and this branch at 773896 (2822665): packed rises by 336, unpacked by 897.
This commit is contained in:
Miguel Ángel
2026-10-25 12:52:45 +00:00
committed by GitHub
parent d7f323d319
commit 89af290808
3 changed files with 204 additions and 24 deletions
@@ -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);
+89 -22
View File
@@ -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.
@@ -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<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);
@@ -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<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);
}
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();
}
+2 -2
View File
@@ -36,8 +36,8 @@
"unpacked": 541601
},
"@hyperframes/producer": {
"packed": 768210,
"unpacked": 2807938
"packed": 768546,
"unpacked": 2808835
},
"@hyperframes/sdk": {
"packed": 90662,