fix(studio-server): repeated splits stop stacking id CSS rules (#6874)

* fix(studio-server): repeated splits stop stacking id CSS rules

* fix(studio-server): keep rules that exclude an id with :not when dropping its rules
This commit is contained in:
Miguel Ángel
2026-10-31 16:21:02 +00:00
committed by GitHub
parent 4d6d38519d
commit 124a00f563
2 changed files with 60 additions and 0 deletions
@@ -45,7 +45,44 @@ export function parseSourceDocument(raw: string): {
return { document, wrappedFragment: !hasDocumentShell };
}
/** Drops the rules that style `id` alone (every selector in the list names it), in each style sheet of the page. */
function dropCssRulesForId(document: Document, id: string): void {
const idToken = `#${id}`;
const namesId = (selector: string) => {
let found = false;
selectorParser((selectors) => {
selectors.walkIds((node) => {
// an id inside :not() excludes the element, so the rule styles the others
let up = node.parent;
while (up && !(up.type === "pseudo" && up.value === ":not")) up = up.parent as typeof up;
if (node.value === id && !up) found = true;
});
}).processSync(selector);
return found;
};
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent ?? "";
if (!css.includes(idToken)) continue;
let root: postcss.Root;
try {
root = postcss.parse(css);
} catch {
continue;
}
let dropped = false;
root.walkRules((rule) => {
if (rule.selectors.length > 0 && rule.selectors.every(namesId)) {
rule.remove();
dropped = true;
}
});
if (dropped) styleEl.textContent = root.toString();
}
}
// A clone's rules replace whatever an earlier piece of that id left behind, so repeated cuts keep one set per id.
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
dropCssRulesForId(document, newId);
const idToken = `#${originalId}`;
const transform = selectorParser((selectors) => {
selectors.walkIds((node) => {
@@ -161,7 +198,9 @@ export function removeElementsFromHtml(
for (const target of targets) {
const element = findTargetElement(document, target);
if (!element) continue;
const id = element.getAttribute("id");
removeElementWithGsapCascade(document, element);
if (id && !document.getElementById(id)) dropCssRulesForId(document, id);
removed = true;
}
if (!removed) return source;
@@ -3,6 +3,7 @@ import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
import {
removeElementFromHtml,
splitElementInHtml,
relinkSplitHalvesInHtml,
unwrapElementsFromHtml,
@@ -539,3 +540,23 @@ describe("relinkSplitHalvesInHtml", () => {
expect(relinkSplitHalvesInHtml(source, ["i"])).toBe(source);
});
});
describe("splitElementInHtml — id CSS rules across repeated cuts", () => {
it("keeps one rule set per live id when a clip is cut again and again", () => {
let html =
"<html><head><style>#a{opacity:.5;width:10px}#a:hover,#a.on{opacity:1}.clip:not(#a-cut){filter:blur(2px)}</style></head><body>" +
'<div id="root" data-composition-id="m"><video id="a" src="a.mp4" data-start="0" data-duration="60" ' +
'data-track-index="0"></video></div></body></html>';
for (let i = 0; i < 5; i++) {
const tail = splitElementInHtml(html, { id: "a" }, 20 + i, `a-${i + 2}`);
const middle = splitElementInHtml(tail.html, { id: "a" }, 10 + i, "a-cut");
html = removeElementFromHtml(middle.html, { id: "a-cut" });
}
const css = /<style>([\s\S]*?)<\/style>/.exec(html)?.[1] ?? "";
// two rules per live id, none left by the removed cut piece
const live = html.match(/<video\b/g)?.length ?? 0;
expect(css.match(/\{/g)).toHaveLength(live * 2 + 1);
expect(css).not.toContain("#a-cut{");
expect(css).toContain(".clip:not(#a-cut){filter:blur(2px)}");
});
});