Skip to content

fix(compiler): split font-family only on top-level commas - #3067

Open
akzarma wants to merge 1 commit into
heygen-com:mainfrom
akzarma:fix/font-family-var-fallback-parsing
Open

fix(compiler): split font-family only on top-level commas#3067
akzarma wants to merge 1 commit into
heygen-com:mainfrom
akzarma:fix/font-family-var-fallback-parsing

Conversation

@akzarma

@akzarma akzarma commented Aug 6, 2026

Copy link
Copy Markdown

What

  • Split font-family values on top-level commas only, so a var() expression stays a single token
  • Regression coverage: parser unit cases + a fail-closed case using a var() fallback, and the distributed css-var-fonts fixture now exercises the fallback form

Closes #3066

Why

parseFontFamilyValue() in packages/producer/src/services/deterministicFonts.ts split the family stack on every comma with no parenthesis awareness, so:

font-family: var(--brand-font, inherit);

parsed to two tokens — var(--brand-font and inherit).

The guard added in #1655 (for #1654) skips only tokens that start with var(, so the orphan fragment inherit) survived as a "requested font family". It matches no bundled alias, no Google Fonts family and no system font, so fail-closed distributed renders abort before the first frame:

FontFetchError: [Compiler] Unresolved fonts in fail-closed mode: inherit).
Distributed renders require all fonts to be resolvable.

Any fallback argument is affected, not just CSS-wide keywords — var(--brand-font, "Inter") leaks Inter") the same way. #1655 fixed the bare var(--x) form and its regression fixture uses no fallback argument, which is why this shape survived.

How

The splitter now walks the value and only cuts at commas seen at paren depth 0, which keeps var(--x, fallback) — and nested var(--x, var(--y, "Inter")) — as one token, so the existing var( guard and resolveFontFamilyDeclarationFamilies() both see the whole expression. No special-casing of inherit or any other keyword.

Quotes are tracked in the same pass for two reasons: a parenthesis inside a quoted family name would otherwise skew the depth counter, and a legal quoted comma ("Display, Condensed") no longer splits into two junk names. Trimming, surrounding-quote stripping and empty-entry dropping are unchanged.

Adversarial notes, in case they matter to a reviewer:

  • resolveFontFamilyDeclarationFamilies() is unaffected in shape — families[0] is now the complete var() expression it always meant to be, and families.slice(1) correctly holds only the external fallbacks, not the ones inside the parens.
  • A stray unmatched ) is harmless: depth is clamped at 0.
  • Malformed input (unbalanced (, unterminated quote) consumes the rest of the value as one token. That is invalid CSS which the browser would also discard, and it fails closed toward "skip", not toward "request a bogus family". Left as-is rather than growing the change.
  • Not addressed here (pre-existing, and out of scope): when a var() primary is undefined, a concrete font in its fallback argument still isn't pre-embedded — it is skipped like any other var(). That is fix(compiler): skip CSS var() in font resolver #1655's behaviour, unchanged, and strictly better than today's hard failure.

The distributed fixture change (var(--display-font)var(--display-font, "Montserrat")) is deliberately render-neutral: --display-font is defined as "Montserrat" in :root, so the computed value and the embedded faces are identical and the existing baseline mp4 stays valid. Reverting the source fix makes that fixture fail resolution again.

Test plan

  • Unit tests added/updated — 3 parser cases in planValidation.test.ts (var() fallback, nested var() fallback, quoted comma) and does NOT throw when font-family uses a CSS var() reference with a fallback in deterministicFonts-failClosed.test.ts
  • All 4 new tests verified failing with the source change reverted and passing with it
  • bun test over the touched files: 49 pass / 0 fail
  • bun test over every deterministicFonts* + planValidation test file: 84 pass / 0 fail
  • oxfmt --check and oxlint clean on the touched files
  • Distributed regression lane (css-var-fonts baseline) — not run locally, no render infrastructure; relying on CI

parseFontFamilyValue() split the family stack on every comma, so
`font-family: var(--brand-font, inherit)` became two tokens:
`var(--brand-font` and `inherit)`. The var() guard from heygen-com#1655 only
skips tokens starting with `var(`, so the orphan fragment was treated
as a requested family, failed every resolution path, and aborted
fail-closed distributed renders with:

  FontFetchError: [Compiler] Unresolved fonts in fail-closed mode:
  inherit). Distributed renders require all fonts to be resolvable.

Split on top-level commas only, so a var() expression (including a
nested one) stays a single token. Quotes are tracked as well, both so
parentheses inside a quoted family name cannot skew the depth counter
and so a legal quoted comma no longer splits.

Closes heygen-com#3066
@akzarma
akzarma force-pushed the fix/font-family-var-fallback-parsing branch from 9d0b305 to c72870b Compare August 6, 2026 16:35

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: font-family top-level comma splitting

Is this the best solution?

Yes. The hand-written scanner is the right call for this specific problem. I considered three alternatives:

  1. Regex (e.g. ,(?=(?:[^()]*\([^()]*\))*[^()]*$)) — doesn't handle nesting or quotes reliably. The nested var(--x, var(--y, "Inter")) case would need a context-free grammar, not a regular expression.

  2. postcss-value-parser — a real CSS value parser that handles the full grammar. But it's a new dependency for a single utility, and parseFontFamilyValue is a hot path (font extraction across all stylesheets in every compile). The scanner is ~30 lines, self-contained, and does exactly what's needed.

  3. Patch the downstream var( guard to also match fragments like inherit) — wrong level of abstraction. The bug is in the parser, not the filter. Fixing the parser is SSOT-correct: every consumer gets the right tokens instead of each one learning to work around broken ones.

The scanner is minimal, correct, and fails closed on malformed input (unterminated quote / unbalanced paren → consumes the rest as one token, which is invalid CSS the browser would also discard, so it skips rather than requesting a bogus family). That's the right failure mode.

What I verified

Scanner correctness. Walked all five state transitions:

Character Behavior
\ Skip next char (handles \" in quotes, \, in values)
' or " outside quotes Enter quote mode
Matching quote Exit quote mode
( outside quotes depth += 1
) outside quotes depth = Math.max(0, depth - 1) (clamp prevents negative on stray ))
, at depth 0, outside quotes Split
Everything else Continue

Priority is correct: escape check first (so \" doesn't exit quotes), then quote check (so ( inside quotes doesn't bump depth).

Trailing backslash edge case. If \ is the last character: index += 1value.length, continue → loop increment → value.length + 1, condition fails, loop exits. value.slice(start) captures the trailing backslash in the final piece. Correct — malformed CSS, fails closed.

Post-processing chain. The .trim().replace(/^['"]/, "").replace(/['"]$/, "").trim().filter(nonEmpty) is unchanged. Verified it doesn't corrupt var() tokens — outer quotes aren't present on a var(--x, "Inter") token, so nothing is stripped. The var( guard at line 462 correctly skips the whole expression.

Downstream consumer: resolveFontFamilyDeclarationFamilies. With the fix, families[0] for var(--brand-font, inherit), sans-serif is the complete var(--brand-font, inherit) token. primaryCssVariableName correctly extracts --brand-font via its own paren-aware walk. families.slice(1) holds only the external fallbacks (["sans-serif"]), not the var's internal fallback. Shape is correct.

Downstream consumer: extractRequestedFontFamilies. The normalized.startsWith("var(") guard at line 462 correctly skips the complete var token. No fragments like inherit) survive to the font-request map. Fail-closed mode no longer aborts.

Test coverage

  • Parser unit tests (3 new): var() fallback, nested var() fallback, quoted comma — all verify the split produces the expected token list.
  • Fail-closed integration test (1 new): var(--brand-font, inherit) in a full HTML document through injectDeterministicFontFaces with failClosedFontFetch: true — confirms no FontFetchError.
  • Distributed fixture (css-var-fonts): changed from var(--display-font) to var(--display-font, "Montserrat"). Render-neutral because --display-font is defined as "Montserrat" in :root — computed value and embedded faces are identical, existing baseline stays valid. Good coverage addition.

CI note

Only the WIP check has run so far — full CI hasn't triggered yet. The distributed regression lane (css-var-fonts baseline) is the key check to watch.

No blocking concerns. Clean, correct fix at the right level of abstraction.

— Miga

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.

fix(compiler): font resolver mis-parses var() fallback, yielding "inherit)" as a font name

3 participants