fix(core): catch PostCSS parse errors instead of dropping compositions (#3589)

* fix(core): catch PostCSS parse errors instead of dropping compositions

Invalid CSS in a sub-composition style block made postcss.parse throw
inside scopeCssToComposition. The throw propagated to the composition
loader's catch block, which emptied the host — silently dropping the
entire scene. Lint swallowed the same error via catch { continue },
reporting 0 warnings.

Two fixes:
- Runtime: wrap postcss.parse in try/catch and return the original
  (unscoped) CSS on failure, so the composition still mounts
- Lint: emit a css_parse_error finding instead of silently continuing

Fixes #3585.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: drop unparseable CSS instead of leaking it unscoped

Return "" on PostCSS parse failure so sub-composition stylesheets
that cannot be scoped are dropped rather than injected unscoped into
the parent document. Updates test fixture to use valid+malformed CSS
that demonstrates the leak risk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
miga-heygen
2026-09-03 04:22:21 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7dc31bd2cc
commit 1a788d62e7
4 changed files with 37 additions and 2 deletions
@@ -1054,4 +1054,13 @@ describe("wrapInlineScriptWithErrorBoundary — <script> breakout", () => {
expect(body).not.toContain("</script");
expect(scriptsAfterRoundTrip(body)).toHaveLength(1);
});
it("drops the entire stylesheet when PostCSS cannot parse it", () => {
const malformedCss = `body { margin: 0; overflow: hidden; }
:root { --accent: #5ef17c; }
.stage { position: absolute; inset: 0; }
.broken { transform: xPercent: -10; }`;
const result = scopeCssToComposition(malformedCss, "scene-bad");
expect(result).toBe("");
});
});
@@ -230,7 +230,12 @@ export function scopeCssToComposition(
const scope =
scopeSelectorOverride ||
`[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
const root = postcss.parse(css);
let root: postcss.Root;
try {
root = postcss.parse(css);
} catch {
return "";
}
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
+16
View File
@@ -777,4 +777,20 @@ describe("core rules", () => {
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeDefined();
});
});
describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => {
it("reports a css_parse_error finding for unparseable CSS", async () => {
const html = `<html><body>
<style>.stage { transform: xPercent: -10; }</style>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
<script src="gsap.min.js"></script>
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "css_parse_error");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Missed semicolon");
});
});
});
+6 -1
View File
@@ -312,7 +312,12 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
} catch (error) {
findings.push({
code: "css_parse_error",
severity: "error",
message: `CSS parse error: ${error instanceof Error ? error.message : "unknown"}`,
});
continue;
}
root.walkRules((rule) => {