fix(core): never merge head styles across a stylesheet link, in preview or render

This commit is contained in:
Miguel Ángel
2026-09-28 06:03:35 -04:00
parent 0d9f1f0846
commit 719587d171
5 changed files with 43 additions and 6 deletions
@@ -1481,6 +1481,18 @@ describe("bundleToSingleHtml", () => {
}
});
it("does not merge styles across a stylesheet link", async () => {
expect(
await bundledHeadStyles(
`<style>p{color:red}</style><link rel="stylesheet" href="https://example.com/x.css">` +
`<link rel="preconnect" href="https://example.com"><style>p{color:blue}</style>`,
),
).toEqual([
{ media: null, type: null, css: "p{color:red}" },
{ media: null, type: null, css: "p{color:blue}" },
]);
});
it("keeps rule order across a conditional style", async () => {
expect(
await bundledHeadStyles(
+2 -1
View File
@@ -720,7 +720,8 @@ function pushRun<T>(runs: PartRun<T>[], scene: string | undefined, chunk: T): vo
function coalesceHeadStylesAndBodyScripts(document: Document): void {
const allHeadStyles = [...document.querySelectorAll("head style")];
const isScenePart = (el: Element) => el.hasAttribute(SCENE_PART_ATTR);
for (const run of allHeadStyles.length > 1 ? headStyleRuns(allHeadStyles, isScenePart) : []) {
const headSheets = [...document.querySelectorAll("head style, head link")];
for (const run of allHeadStyles.length > 1 ? headStyleRuns(headSheets, isScenePart) : []) {
const merged = joinCssHoistingImports(run.map((el) => el.textContent || ""));
if (!merged) continue;
run[0]!.textContent = merged;
+6 -4
View File
@@ -46,15 +46,17 @@ export function cssStyleMergeKey(el: Element): string | undefined {
return JSON.stringify([media === "all" ? "" : media, el.getAttribute("title") ?? ""]);
}
/** Groups head styles into runs of adjacent styles with one merge key, so merging a run never reorders rules. */
/** Groups head styles into runs of adjacent same-key styles; a stylesheet link or pinned style ends a run. */
export function headStyleRuns(
styles: readonly Element[],
elements: readonly Element[],
isPinned: (el: Element) => boolean = () => false,
): Element[][] {
const runs: Element[][] = [];
let previousKey: string | undefined;
for (const el of styles) {
const key = isPinned(el) ? undefined : cssStyleMergeKey(el);
for (const el of elements) {
if (el.tagName === "LINK" && !/(^|\s)stylesheet(\s|$)/i.test(el.getAttribute("rel") ?? ""))
continue;
const key = el.tagName === "STYLE" && !isPinned(el) ? cssStyleMergeKey(el) : undefined;
if (key !== undefined && key === previousKey) runs.at(-1)!.push(el);
else if (key !== undefined) runs.push([el]);
previousKey = key;
@@ -234,6 +234,27 @@ describe("preview/render semantic compilation parity", () => {
expect(render).toEqual(preview);
});
it("keeps a stylesheet link between the head styles it separates in render", async () => {
const dir = project({
"index.html": shell(
`<main data-composition-id="main" data-start="0" data-width="320" data-height="180" data-duration="1"></main>`,
`<style>p{color:red}</style><link rel="stylesheet" href="x.css"><style>p{color:blue}</style>`,
),
"x.css": "p{color:green}",
});
const sheets = (html: string) =>
[...new DOMParser().parseFromString(html, "text/html").head.querySelectorAll("style, link")]
.filter((el) => !el.hasAttribute("data-hyperframes-text-rendering"))
.map((el) => el.getAttribute("href") ?? (el.textContent ?? "").trim());
expect(sheets(await bundleToSingleHtml(dir))).toEqual([
"p{color:red}\n\np{color:green}\n\np{color:blue}",
]);
const render = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
allowSystemFontCapture: false,
});
expect(sheets(render.html)).toEqual(["p{color:red}", "x.css", "p{color:blue}"]);
});
it("keeps legacy end/layer timing semantically identical", async () => {
const result = await contracts({
"index.html":
@@ -873,7 +873,8 @@ function coalesceHeadStylesAndBodyScripts(html: string): string {
const styleEls = Array.from(head.querySelectorAll("style"));
const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
for (const run of styleEls.length > 1 ? headStyleRuns(styleEls) : []) {
const headSheets = Array.from(head.querySelectorAll("style, link"));
for (const run of styleEls.length > 1 ? headStyleRuns(headSheets) : []) {
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set<string>();