Skip to content

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

Merged
miga-heygen merged 2 commits into
mainfrom
fix/invalid-css-drops-composition-3585
Sep 3, 2026
Merged

fix(core): catch PostCSS parse errors instead of dropping compositions#3589
miga-heygen merged 2 commits into
mainfrom
fix/invalid-css-drops-composition-3585

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

What

Invalid CSS in a sub-composition <style> block drops the entire composition
silently. Lint reports 0 warnings.

Fixes #3585.

Why

scopeCssToComposition calls postcss.parse with no try/catch. A malformed
declaration like transform: xPercent: -10; throws Missed semicolon. The
throw propagates to the composition loader's catch block, which calls
resetCompositionHost — emptying the slot. The scene never registers on the
timeline. Meanwhile, lint's own postcss.parse calls swallow the same error
via catch { continue }, so hyperframes check reports success.

How

Two fixes:

  1. Runtime (compositionScoping.ts): wrap postcss.parse in try/catch.
    On failure, return the original (unscoped) CSS. The composition still mounts
    with its stylesheet applied browser-like (the browser drops the bad
    declaration and keeps the rest).

  2. Lint (core.ts): emit a css_parse_error finding instead of silently
    continuing. hyperframes check now surfaces the offending rule and line.

Test plan

  • compositionScoping.test.ts: new test verifies malformed CSS returns
    the original string instead of throwing (51 tests pass)
  • core.test.ts: new test verifies css_parse_error finding is emitted
    with severity error and message containing Missed semicolon (58 tests pass)

— Miga

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>

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full files at d0daa443, not just the diff. The lint half is a clean win; the core half ships the exact tradeoff #3585 said needed a product call, and it ships the leaky side of it.

Strengths

  • packages/lint/src/rules/core.ts:315-321 is a straight improvement: the old catch { continue } discarded a CssSyntaxError with no output at all, and the author now gets a css_parse_error at severity error. The continue is correctly retained, so one bad stylesheet still doesn't abort the remaining rules.
  • The finding's message pins postcss's real reason string. I confirmed against the repo's own postcss (8.5.14) that Missed semicolon is what it actually emits for the fixture, so the assertion isn't guessing at wording.
  • HyperframeLintFinding.code is a free-form string (packages/lint/src/types.ts), so there's no code registry or union that a new finding has to be added to. Nothing was missed on that axis.
  • The problem being fixed is real, and worse than the title suggests. On the inline-template path, loadInlineTemplateCompositions (packages/core/src/runtime/compositionLoader.ts:606-630) calls mountCompositionContent with no try/catch around it, so a single malformed stylesheet rejected that promise and took down every composition on the page, not just its own.

Blocker — the catch path returns UNSCOPED CSS, and every sub-composition call site injects it into the parent

packages/core/src/compiler/compositionScoping.ts:233-238 returns css verbatim on a parse error. All four sub-composition call sites pass scopeRootSelectors: true specifically to prevent unscoped sub-comp CSS from reaching the host, and the catch bypasses every one of them:

  • packages/core/src/runtime/compositionLoader.ts:463-473, then :475 appends the returned text to the parent document's <head>
  • packages/core/src/compiler/inlineSubCompositions.ts:298-300
  • packages/core/src/compiler/htmlBundler.ts:1014-1016 and :1045-1047, which push it into compStyleChunks — so on the bundler path the leak is baked into the rendered output, not just the live preview

The hazard is documented in the file the change routes into. compositionLoader.ts:468-472: "Sub-comp styles are injected into the PARENT preview document, so remap html/body/:root to the composition box — otherwise a sub-comp body { width/height/overflow } clobbers the host body and clips the preview to the last-mounted sub-comp's size." And :417-420: "mounting the content whole left its CSS unscoped and leaking into the host document."

The reason this is not a narrow escape: postcss aborts the whole stylesheet, so the catch hands back every rule, not just the bad one. Measured with the repo's postcss 8.5.14 on a stylesheet whose only defect is on the last line:

body { margin: 0; overflow: hidden; background: #000; }
:root { --accent: #5ef17c; }
.stage { position: absolute; inset: 0; width: 1920px; height: 1080px; }
.title { font-size: 72px; color: var(--accent); }
.broken { transform: xPercent: -10; }

CssSyntaxError: Missed semicolon at line 6, nothing partial returned.

So all five rules are returned unscoped and appended to the parent <head>, where the browser happily applies the four valid ones. That body { overflow: hidden } and that :root custom property now belong to the host preview — precisely the failure the scopeRootSelectors option exists to prevent, and on the bundler path it reaches the final render.

The strict-vs-tolerant product question raised on #3585 is Miguel's call, not mine. But returning unscoped CSS is the wrong implementation of tolerance under either answer, because a one-line change gets the stated goal without the leak: return "" instead of css. The composition still mounts (no more dropped compositions, which is the PR's actual objective), and the stylesheet that could not be parsed is dropped instead of being promoted to global scope.

Important — the runtime half degrades silently, and this is a step back from the previous behaviour

The catch is bare: no diagnostic, no warning. That leaves the runtime with strictly less signal than before the change. loadExternalCompositions previously caught the throw at compositionLoader.ts:733-744 and emitted external_composition_load_failed carrying errorMessage, then reset the host — the composition was dropped, but loudly. Now it mounts with leaked styles and says nothing.

mountCompositionContent already receives an onDiagnostic hook (compositionLoader.ts:409-412) and the codebase already has the code convention for this. A css_parse_error-shaped diagnostic on the runtime path would keep the lint improvement's benefit where the rendering actually happens. As written, the only surface that reports the problem is the linter, which is not in the path for a live preview or a render.

Nit — the test fixture is the one member of its class that cannot show the leak

packages/core/src/compiler/compositionScoping.test.ts uses .stage { transform: xPercent: -10; }, whose only declaration is the malformed one. Leaked or not, the browser drops that declaration and the surviving empty rule paints nothing, so the fixture is inert by construction and the test passes without ever exercising the risk. expect(result).toBe(malformedCss) then pins "return unscoped" as intended behaviour.

A fixture shaped like the block above — valid body/:root rules plus one malformed tail — would make the leak visible, and would fail against the current implementation. That is the test worth having whichever way the product call lands.

Verdict

REQUEST CHANGES. The lint half is ready as-is. The core half needs the catch to stop returning unscoped CSS ("" is sufficient and meets the stated goal) and should emit a diagnostic rather than degrading in silence. The strict-vs-tolerant decision from #3585 is Miguel's to make, and I have not tried to settle it here — but the leak is a defect under either choice.

Footnote: CI was fully concluded at this head before I submitted (single wave, all 8 required contexts green), so nothing was in flight to cancel. Submitting this review may briefly stamp skipped over the green required contexts and flash BLOCKED for a minute; that is the review event, not a regression, and does not need a rerun.

— Rames Jusso

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>

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the review:

Blocker fixed: scopeCssToComposition now returns "" on parse failure instead of the original unscoped CSS. This prevents sub-composition stylesheets from leaking body/:root rules into the parent document on all four call sites (compositionLoader, inlineSubCompositions, htmlBundler ×2).

Test updated: Fixture now uses valid body/:root/.stage rules with a malformed tail (.broken { transform: xPercent: -10; }), and asserts "" return — directly exercises the leak scenario rather than using an inert single-declaration fixture.

Lint half: Unchanged — css_parse_error finding emission is the right call and stays as-is.

On the diagnostic/onDiagnostic point: You're right that the runtime path currently degrades silently. The lint path catches it for hyperframes check users, but live preview and render get no signal. I'll add that as a follow-up since the diagnostic plumbing needs to thread through scopeCssToComposition's callers — the immediate leak fix shouldn't wait on that.

— Miga

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 989110c6c. The blocker is resolved, and the fix is the minimal one — +1/-1 in source.

Blocker resolved

packages/core/src/compiler/compositionScoping.ts:237 now returns "" instead of the original CSS, so an unparseable stylesheet is dropped rather than promoted to global scope. The four sub-composition call sites that pass scopeRootSelectors: true no longer have their guarantee bypassed on the error path, and the bundler no longer bakes unscoped rules into the render. Nothing else in the file moved.

The new test actually earns its name

I checked this rather than assuming it, because the change implements my own suggestion and that is exactly where a reviewer stops looking. It holds up:

  • The fixture carries three valid rulesbody { margin: 0; overflow: hidden; }, :root { --accent }, .stage { position: absolute; inset: 0; } — plus the malformed .broken tail.
  • Run through the repo's postcss 8.5.14 it throws CssSyntaxError: Missed semicolon at line 4, so the catch is genuinely exercised, not bypassed by a fixture that happens to parse.
  • expect(result).toBe("") fails against the previous return css, so it discriminates between the two implementations instead of merely passing.
  • The name changed from "returns original CSS" to "drops the entire stylesheet", which is now what it checks. That was the nit; it is gone.

Those three valid rules are precisely what used to leak into the parent document, so the fixture reproduces the reported scenario rather than gesturing at it.

Residual, and why it is not gating

The catch is still silent — no diagnostic on the runtime or bundler path. My previous verdict line bundled that with the blocker, so to be explicit rather than quietly dropping it: I am downgrading it to a non-gating follow-up. Nothing renders incorrectly, the composition simply loses styles that were never parseable, and hyperframes check does report the problem through the css_parse_error finding this PR added.

What would flip it back to worth-its-own-PR: the first time someone spends real time on "why is my sub-composition unstyled" with nothing in the console pointing at their CSS. mountCompositionContent already takes an onDiagnostic hook (packages/core/src/runtime/compositionLoader.ts:409-412) and the loader already emits external_composition_load_failed, so the wiring exists whenever that becomes worth doing.

The strict-vs-tolerant question from #3585 is still Miguel's to settle, and this change does not foreclose either answer — it just implements tolerance without the leak.

Verdict

APPROVE. Blocker fixed at the root, test now discriminates, lint half unchanged and still a clean improvement. One non-gating follow-up noted above. Not merging — that call is not mine.

Held this submit until the pull_request matrix at this head concluded, for the same reason as last time: a review event can cancel an in-flight matrix in the same concurrency group and nothing re-fires it.

— Rames Jusso

@miga-heygen
miga-heygen merged commit 1a788d6 into main Sep 3, 2026
60 checks passed
@miga-heygen
miga-heygen deleted the fix/invalid-css-drops-composition-3585 branch September 3, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid CSS in a sub-composition drops the whole composition

2 participants