mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"unpacked": 541601
|
||||
},
|
||||
"@hyperframes/producer": {
|
||||
"packed": 768210,
|
||||
"unpacked": 2807938
|
||||
"packed": 768546,
|
||||
"unpacked": 2808835
|
||||
},
|
||||
"@hyperframes/sdk": {
|
||||
"packed": 90662,
|
||||
|
||||
Reference in New Issue
Block a user