Describe the bug
font-family: var(--brand-font, <fallback>) breaks distributed rendering in fail-closed mode. The font resolver splits the family stack on commas without tracking parentheses, so the var() fallback argument is torn off as a separate token and treated as a requested font family. The render aborts before the first frame:
FontFetchError: [Compiler] Unresolved fonts in fail-closed mode: inherit).
Distributed renders require all fonts to be resolvable.
inherit) is not a font — it is the tail of the var() expression, with the closing paren still attached.
This is the sibling of #1654 / #1655. That fix handled the bare var(--x) form; the var(--x, <fallback>) form was left broken.
Link to reproduction
No repo link — the reproduction is a single HTML file, inlined below, and the defect is visible statically in the source on main (details under Root cause). Happy to push a repo if that would still help triage.
<!doctype html>
<html>
<head>
<style>
/* --brand-font intentionally left undefined, as it would be when a
theme layer supplies it at runtime; see note below for the
"defined" case, which fails too. */
.title {
font-family: var(--brand-font, inherit);
}
</style>
</head>
<body>
<div id="main-comp" data-composition-id="repro" data-width="640" data-height="360" data-start="0">
<div class="title">Hello</div>
</div>
</body>
</html>
Steps to reproduce
npx hyperframes init repro --non-interactive --example blank
- Set the composition's
src/index.html to the file above (the only change needed is font-family: var(--brand-font, inherit) on a styled element).
- Run a distributed render with fail-closed font fetching enabled.
Expected behavior
var(--brand-font, inherit) is one CSS value. It should either be skipped entirely by the resolver (the #1655 behaviour for var() expressions) or resolved through the custom property, but under no circumstance should the fallback argument be extracted as a font family name.
The render should proceed, with Chrome performing the custom-property substitution at paint time as it already does for the bare var() form.
Actual behavior
The render aborts before the first frame with FontFetchError naming inherit) as an unresolved font.
Root cause
Verified against main @ 88853f1; the blob is byte-identical to v0.7.94, so the latest release is affected.
parseFontFamilyValue() — packages/producer/src/services/deterministicFonts.ts:58-63 — splits on , with no parenthesis awareness:
export function parseFontFamilyValue(value: string): string[] {
return value
.split(",")
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
.filter((piece) => piece.length > 0);
}
So the var() expression is torn in half:
| declaration |
parsed families |
var(--ui-font), sans-serif |
["var(--ui-font)", "sans-serif"] — fine today |
var(--brand-font, inherit) |
["var(--brand-font", "inherit)"] |
var(--brand-font, "Some Font"), sans-serif |
["var(--brand-font", "Some Font\")", "sans-serif"] |
var(--brand-font, sans-serif) |
["var(--brand-font", "sans-serif)"] |
Then extractRequestedFontFamilies() — same file, lines 418-433 — filters the pieces:
const normalized = originalCase.toLowerCase();
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
if (normalized.startsWith("var(")) continue; // added by #1655
if (!requested.has(normalized)) requested.set(normalized, originalCase);
Both guards miss the second fragment. "var(--brand-font" is correctly skipped by the startsWith("var(") check, but "inherit)" is not: the trailing paren means it never matches GENERIC_FAMILIES (line 34) and it does not start with var(. It falls through as a requested family, fails every resolution path, and lands in unresolved at the fail-closed throw site (lines ~1132-1139).
Two things worth flagging:
-
This is not limited to CSS-wide keywords. Any fallback is affected. A quoted fallback yields Some Font") — the leading quote is stripped but the trailing one is not, since the last character is ), not a quote. Even a generic-family fallback yields sans-serif), which misses the GENERIC_FAMILIES set for the same reason. Every one of these is equally unresolvable.
-
Defining the custom property does not save you. resolveFontFamilyDeclarationFamilies() (lines 263-274) handles the primary var() correctly — primaryCssVariableName() at line 238 is parenthesis-aware and extracts --brand-font properly — but the last line is:
return [...parseFontFamilyValue(resolved), ...families.slice(1)];
families came from the naive splitter, so families.slice(1) is ["inherit)"], not []. The poisoned fragment is carried through even on the happy path where the property resolves. The fix therefore needs to be in the splitter (or upstream of it), not only in the skip list.
Why the existing regression coverage did not catch it
Not a criticism of #1655, just an explanation of how the case survived — the fixture added there, packages/producer/tests/distributed/css-var-fonts/src/index.html, uses var(--ui-font), sans-serif and var(--display-font), sans-serif. There is no var() fallback argument anywhere in it, and the unit tests in deterministicFonts-failClosed.test.ts (the var() cases around lines 245-275) are all the same bare shape. Every var() in the suite is the one form that happens to survive a naive comma split. Adding a var(--x, <fallback>) line to that fixture would lock this down.
Suggested fix
A suggestion, not a prescription — you'll know the codebase's preferences better.
Make the stack splitter parenthesis-aware so a var() expression stays a single token: walk the string tracking paren depth and only split on commas at depth 0. primaryCssVariableName() at line 242 already has exactly this depth-tracking loop, so the logic could likely be shared. parseFontFamilyValue("var(--brand-font, inherit)") would then yield ["var(--brand-font, inherit)"], which the existing startsWith("var(") guard from #1655 already skips correctly, and families.slice(1) would correctly be empty.
An alternative would be to strip whole var(...) expressions before the comma split, though that loses the sibling families in a stack like var(--a, X), Helvetica, so the depth-aware split seems cleaner.
Environment
Version 0.7.94 (latest)
Node.js v22.14.0 (darwin arm64)
(Hand-written rather than pasted doctor output, to avoid including local filesystem paths. The root cause above is verified by reading the source at main / v0.7.94 rather than inferred from the local install.)
Additional context
Prior art: #1654 (issue) and #1655 (fix) cover the same code path and the same function. This report is the fallback-argument case that the earlier fix did not reach.
var(--x, <fallback>) is a common authoring pattern — it is the standard way to write a themeable font stack that still degrades gracefully when the theme layer has not defined the property — so this is likely to be hit by anyone using CSS custom properties for typography. It fails closed, meaning distributed renders fail outright rather than degrading.
Describe the bug
font-family: var(--brand-font, <fallback>)breaks distributed rendering in fail-closed mode. The font resolver splits the family stack on commas without tracking parentheses, so thevar()fallback argument is torn off as a separate token and treated as a requested font family. The render aborts before the first frame:inherit)is not a font — it is the tail of thevar()expression, with the closing paren still attached.This is the sibling of #1654 / #1655. That fix handled the bare
var(--x)form; thevar(--x, <fallback>)form was left broken.Link to reproduction
No repo link — the reproduction is a single HTML file, inlined below, and the defect is visible statically in the source on
main(details under Root cause). Happy to push a repo if that would still help triage.Steps to reproduce
npx hyperframes init repro --non-interactive --example blanksrc/index.htmlto the file above (the only change needed isfont-family: var(--brand-font, inherit)on a styled element).Expected behavior
var(--brand-font, inherit)is one CSS value. It should either be skipped entirely by the resolver (the #1655 behaviour forvar()expressions) or resolved through the custom property, but under no circumstance should the fallback argument be extracted as a font family name.The render should proceed, with Chrome performing the custom-property substitution at paint time as it already does for the bare
var()form.Actual behavior
The render aborts before the first frame with
FontFetchErrornaminginherit)as an unresolved font.Root cause
Verified against
main@88853f1; the blob is byte-identical tov0.7.94, so the latest release is affected.parseFontFamilyValue()—packages/producer/src/services/deterministicFonts.ts:58-63— splits on,with no parenthesis awareness:So the
var()expression is torn in half:var(--ui-font), sans-serif["var(--ui-font)", "sans-serif"]— fine todayvar(--brand-font, inherit)["var(--brand-font", "inherit)"]var(--brand-font, "Some Font"), sans-serif["var(--brand-font", "Some Font\")", "sans-serif"]var(--brand-font, sans-serif)["var(--brand-font", "sans-serif)"]Then
extractRequestedFontFamilies()— same file, lines 418-433 — filters the pieces:Both guards miss the second fragment.
"var(--brand-font"is correctly skipped by thestartsWith("var(")check, but"inherit)"is not: the trailing paren means it never matchesGENERIC_FAMILIES(line 34) and it does not start withvar(. It falls through as a requested family, fails every resolution path, and lands inunresolvedat the fail-closed throw site (lines ~1132-1139).Two things worth flagging:
This is not limited to CSS-wide keywords. Any fallback is affected. A quoted fallback yields
Some Font")— the leading quote is stripped but the trailing one is not, since the last character is), not a quote. Even a generic-family fallback yieldssans-serif), which misses theGENERIC_FAMILIESset for the same reason. Every one of these is equally unresolvable.Defining the custom property does not save you.
resolveFontFamilyDeclarationFamilies()(lines 263-274) handles the primaryvar()correctly —primaryCssVariableName()at line 238 is parenthesis-aware and extracts--brand-fontproperly — but the last line is:familiescame from the naive splitter, sofamilies.slice(1)is["inherit)"], not[]. The poisoned fragment is carried through even on the happy path where the property resolves. The fix therefore needs to be in the splitter (or upstream of it), not only in the skip list.Why the existing regression coverage did not catch it
Not a criticism of #1655, just an explanation of how the case survived — the fixture added there,
packages/producer/tests/distributed/css-var-fonts/src/index.html, usesvar(--ui-font), sans-serifandvar(--display-font), sans-serif. There is novar()fallback argument anywhere in it, and the unit tests indeterministicFonts-failClosed.test.ts(thevar()cases around lines 245-275) are all the same bare shape. Everyvar()in the suite is the one form that happens to survive a naive comma split. Adding avar(--x, <fallback>)line to that fixture would lock this down.Suggested fix
A suggestion, not a prescription — you'll know the codebase's preferences better.
Make the stack splitter parenthesis-aware so a
var()expression stays a single token: walk the string tracking paren depth and only split on commas at depth 0.primaryCssVariableName()at line 242 already has exactly this depth-tracking loop, so the logic could likely be shared.parseFontFamilyValue("var(--brand-font, inherit)")would then yield["var(--brand-font, inherit)"], which the existingstartsWith("var(")guard from #1655 already skips correctly, andfamilies.slice(1)would correctly be empty.An alternative would be to strip whole
var(...)expressions before the comma split, though that loses the sibling families in a stack likevar(--a, X), Helvetica, so the depth-aware split seems cleaner.Environment
(Hand-written rather than pasted
doctoroutput, to avoid including local filesystem paths. The root cause above is verified by reading the source atmain/v0.7.94rather than inferred from the local install.)Additional context
Prior art: #1654 (issue) and #1655 (fix) cover the same code path and the same function. This report is the fallback-argument case that the earlier fix did not reach.
var(--x, <fallback>)is a common authoring pattern — it is the standard way to write a themeable font stack that still degrades gracefully when the theme layer has not defined the property — so this is likely to be hit by anyone using CSS custom properties for typography. It fails closed, meaning distributed renders fail outright rather than degrading.