Skip to content
Open
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
107 changes: 90 additions & 17 deletions packages/core/components/AutoFrame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,24 @@ import {
useEffect,
useState,
} from "react";
import hash from "object-hash";
import { createPortal } from "react-dom";

const styleSelector = 'style, link[rel="stylesheet"]';

/**
* Fast, non-cryptographic djb2 hash over a string.
* Replaces `object-hash` which serialises the full outerHTML via JSON
* and is O(n) on the CSS string length — catastrophically slow when
* many large style nodes are present.
*/
const fastHash = (str: string): string => {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
}
return h.toString(36);
};

const collectStyles = (doc: Document) => {
const collected: HTMLElement[] = [];

Expand Down Expand Up @@ -66,6 +79,25 @@ const syncAttributes = (sourceElement: Element, targetElement: Element) => {

const defer = (fn: () => void) => setTimeout(fn, 0);

/**
* Compute a cheap hash key for a style element.
* - For <link> elements we key on the href (a short string).
* - For <style> elements we key on the CSS text content plus any attributes
* (e.g. nonce, data-*) so that two tags with identical CSS but different
* attributes are not incorrectly treated as duplicates.
* We deliberately avoid hashing outerHTML because it includes the full
* serialised CSS blob and was the primary source of the 30-45 s freeze.
*/
const styleElHash = (el: HTMLElement): string => {
if (el.nodeName === "LINK") {
return `link:${(el as HTMLLinkElement).href}`;
}
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${a.value}`)
.join(",");
return `style:${fastHash(el.innerHTML)}:${attrs}`;
};

const CopyHostStyles = ({
children,
debug = false,
Expand Down Expand Up @@ -144,13 +176,7 @@ const CopyHostStyles = ({
return;
}

const mirror = await mirrorEl(el);

if (!mirror) {
return;
}

const elHash = hash(mirror.outerHTML);
const elHash = styleElHash(el);

if (hashes[elHash]) {
if (debug)
Expand All @@ -161,6 +187,11 @@ const CopyHostStyles = ({
return;
}

const mirror = await mirrorEl(el);
if (!mirror) {
return;
}

hashes[elHash] = true;

doc.head.append(mirror as HTMLElement);
Expand All @@ -180,14 +211,45 @@ const CopyHostStyles = ({
return;
}

const elHash = hash(el.outerHTML);
const elHash = styleElHash(el);

elements[index]?.mirror?.remove();
delete hashes[elHash];

// Must splice so lookupEl doesn't return stale entries for nodes that
// were removed and later re-added (e.g. on remount), and to avoid
// unbounded growth of the array over the lifetime of the observer.
elements.splice(index, 1);

if (debug) console.log(`Removed style node ${el.outerHTML}`);
};

// Batch pending mutations so that a burst of style injections only
// triggers a single round of addEl/removeEl work instead of one
// synchronous call per node.
let pendingAdded: HTMLElement[] = [];
let pendingRemoved: HTMLElement[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

const flushPending = () => {
flushTimer = null;
const toAdd = pendingAdded;
const toRemove = pendingRemoved;
pendingAdded = [];
pendingRemoved = [];

toRemove.forEach((el) => removeEl(el));
// addEl is async but we intentionally don't await here to keep the
// flush non-blocking; each addEl checks hashes before touching the DOM.
toAdd.forEach((el) => addEl(el));
};

const scheduledFlush = () => {
if (flushTimer === null) {
flushTimer = defer(flushPending);
}
};

const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
Expand All @@ -202,7 +264,8 @@ const CopyHostStyles = ({
: (node as HTMLElement);

if (el && el.matches(styleSelector)) {
defer(() => addEl(el));
pendingAdded.push(el);
scheduledFlush();
}
}
});
Expand All @@ -218,7 +281,8 @@ const CopyHostStyles = ({
: (node as HTMLElement);

if (el && el.matches(styleSelector)) {
defer(() => removeEl(el));
pendingRemoved.push(el);
scheduledFlush();
}
}
});
Expand Down Expand Up @@ -253,10 +317,22 @@ const CopyHostStyles = ({
hrefs.push(linkHref);
}

// Deduplicate style nodes before mirroring: if two <style> tags have
// identical content, only mirror the first occurrence.
const elHash = styleElHash(styleNode);
if (hashes[elHash]) {
if (debug)
console.log(
`Skipping duplicate style node during initial collection...`
);
return;
}

const mirror = await mirrorEl(styleNode);

if (!mirror) return;

hashes[elHash] = true;
elements.push({ original: styleNode, mirror });

return mirror;
Expand Down Expand Up @@ -302,16 +378,13 @@ const CopyHostStyles = ({
}

observer.observe(parentDocument.head, { childList: true, subtree: true });

filtered.forEach((el) => {
const elHash = hash(el.outerHTML);

hashes[elHash] = true;
});
});

return () => {
observer.disconnect();
if (flushTimer !== null) {
clearTimeout(flushTimer);
}
};
}, []);

Expand Down
2 changes: 0 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@
"@types/deep-diff": "^1.0.3",
"@types/flat": "^5.0.5",
"@types/jest": "^29.5.14",
"@types/object-hash": "^3.0.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
Expand Down Expand Up @@ -126,7 +125,6 @@
"fast-equals": "5.2.2",
"flat": "^5.0.2",
"happy-dom": "^20.0.10",
"object-hash": "^3.0.0",
"react-hotkeys-hook": "^4.6.1",
"use-debounce": "^9.0.4",
"uuid": "^9.0.1",
Expand Down
41 changes: 3 additions & 38 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4663,11 +4663,6 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==

"@types/object-hash@^3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-3.0.6.tgz#25c052428199d374ef723b7b0ed44b5bfe1b3029"
integrity sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==

"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
Expand Down Expand Up @@ -13638,11 +13633,6 @@ object-assign@^4.0.1, object-assign@^4.1.1:
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==

object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==

object-inspect@^1.13.3, object-inspect@^1.13.4:
version "1.13.4"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
Expand Down Expand Up @@ -16352,16 +16342,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"

"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -16469,14 +16450,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand Down Expand Up @@ -18125,7 +18099,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -18143,15 +18117,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down