Skip to content

fix(native): narrow a var()-valued font-family to its first family - #417

Open
YevheniiKotyrlo wants to merge 10 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/font-family-var-stack
Open

fix(native): narrow a var()-valued font-family to its first family#417
YevheniiKotyrlo wants to merge 10 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/font-family-var-stack

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Problem

React Native's fontFamily takes one family, not a stack. Three compiler paths produce it and only two narrowed:

  • parseFontFamily — the typed path, took value[0].
  • parseFont — the font shorthand, took value.family[0].
  • parseUnparsed — everything LightningCSS cannot type, returned the token list as it found it.

That third path is not exotic. Every var()-valued font-family goes down it, which is how Tailwind emits font-sans / font-serif / font-mono, and so does plain CSS the parser rejects:

font-family: Inter, Helvetica,;      ->  fontFamily: ["Inter","Helvetica"]
font-family: ,Inter, Helvetica;      ->  fontFamily: ["Inter","Helvetica"]
font-family: Inter,,Helvetica;       ->  fontFamily: ["Inter","Helvetica"]
font-family: "Inter", "Helvetica",;  ->  fontFamily: ["Inter","Helvetica"]
font-family: 12, Inter;              ->  fontFamily: [12,"Inter"]
font-family: ,;                      ->  fontFamily: []

@keyframes takes the same path, so an animation carried the array too. TextStyle.fontFamily is a string, so Fabric refuses every one of them: no font change, no warning, the element renders in the platform default.

None of it can be caught downstream — applyDeclarations copies a static style onto the props with Object.assign, never through applyValue.

Those three are the producers of a font-family-keyed value. There is a fourth route that produces a font-keyed one, which neither plane narrows and this PR does not change; it is the last entry under Known limits.

Fix

The reduction exists once, in src/utilities/font-family.ts, and the three producers read it. It is flatten-then-first-usable rather than take-the-first: a nested group is read in place, and an entry that cannot name a family — a number, a null, an empty group — is skipped, the way a browser skips a family it cannot use. A stack with nothing usable emits no declaration at all, so a family set by a lower-specificity rule survives the cascade instead of being overwritten with [].

The reduction has no opinion about generic keywords, and that is deliberate. font-family: sans-serif, Inter narrows to "sans-serif", because the first entry is usable as a family name and a browser resolves it the same way — a generic is always available, so nothing behind it is ever reached. Measured identical on main and this branch; it is stated here only because it was nowhere stated before.

The one answer the compiler cannot give is deferred: the first usable entry is a var(), whose value only exists at render. That descriptor is emitted whole and reduced again in applyValue, the first place on that path where the property name and the resolved value are both in hand. A var() standing behind a literal is narrowed away at compile time, because React Native can never reach it — which also drops the declaration's reactivity, since its value can no longer change.

One nuance on that runtime half, measured rather than assumed. When a resolved value yields nothing usable, applyValue declines to set the key — but it cannot preserve a family an earlier rule put there, because applyDeclarations runs delete target[prop] before it resolves. Under .b { font-family: Georgia } followed by .a { font-family: var(--n) } over --n: 12, this branch produces {} and main produces { fontFamily: 12 }: better either way, since 12 is a value React Native refuses, but Georgia is gone in both. The cascade genuinely does survive on the compile-time path, where no descriptor is emitted at all — the same pair written .a { font-family: ,; } keeps Georgia on this branch and yields [] on main. The source comment at that branch says which of the two it is claiming.

Two supporting fixes fall out:

  • isStyleFunction asked only whether index 0 was typeof "object" with no own keys, so [[], "Arial"] read as a function call (Object.keys([]) is empty too) and [null, "Arial"] threw Cannot convert undefined or null to object. Both shapes occur in a resolved stack.
  • applyValue's previous first-then-descend loop set a raw null on fontFamily ten lines below the comment explaining that null means "set to undefined", and set fontFamily: 12 for --n: 12; font-family: var(--n).

Values, before and after

Measured by rendering each stylesheet through registerCSS + render on this branch and on main:

CSS main this branch
--f: Inter, Helvetica; font-family: var(--f) ["Inter","Helvetica"] "Inter"
font-family: var(--missing, Inter, Helvetica) ["Inter","Helvetica"] "Inter"
--f: "Helvetica Neue", Arial; font-family: var(--f) ["Helvetica Neue","Arial"] "Helvetica Neue"
--f: "Foo, Bar", Arial; font-family: var(--f) ["Foo, Bar","Arial"] "Foo, Bar"
font-family: Inter, Helvetica,; ["Inter","Helvetica"] "Inter"
font-family: var(--missing), Helvetica ["Helvetica"] "Helvetica"
vars({ "--stack": ["Inter","Helvetica"] }) ["Inter","Helvetica"] "Inter"
<VariableContextProvider value={{ "--stack": [[], "Arial"] }} /> [[],"Arial"] "Arial"

Every main cell is a value React Native refuses.

Tests

62 cases across four planes — the reduction on its own, the compiler, applyValue, and a real render. 41 of them fail on upstream/main with this branch's source reverted; 21 pass, and each one says in a comment why it is a deliberate control rather than a guard. I measured that per test rather than assuming it: the seven tests this PR opened with turned out to be five controls and two guards, which is what prompted the rest of this work.

The measurement is reproducible: put all five source files back to f70c402 byte-exactly, keep every test, and run the six files this PR touches. That reports Tests: 41 failed, 117 passed, 158 total with numRuntimeErrorTestSuites 0 — no suite fails to load. The 158 is the six files in full; 96 of them are typography.test.tsx's pre-existing utilities, which leaves the 62 this PR adds.

Plane File Cases Red on main
the reduction on its own __tests__/utilities/font-family.test.ts 9 9
__tests__/utilities/style-descriptor.test.ts 4 2
the compiler __tests__/compiler/font-family.test.ts 17 9
applyValue __tests__/native/font-family-stack.test.ts 8 4
a real render __tests__/native/font-family.test.tsx 18 15
__tests__/vendor/tailwind/typography.test.tsx (new block) 6 2
62 41

Nine of the 41 are the new unit's own file, which fails with narrowFontFamily is not a function once src/utilities/font-family.ts is gone. That is what a missing module looks like rather than a value difference, so the honest figure is 32 of the 62 red for a real difference in what React Native is handed, and 9 more that exist only because the unit does.

The native plane carries the cases no compiler assertion can reach. Every var() spelling below compiles to the same deferred descriptor whatever the variable holds, so only a render says which family lands:

  • a var() holding a multi-family stack
  • an undefined var() with a literal fallback in its own parentheses, and one whose fallback is itself a stack
  • a nested var(--a, var(--b, serif)), both with and without --b defined
  • a fallback in the head of a stack, with a family behind it
  • quoted family names containing spaces, and containing commas
  • an unusable head inside the resolved stack (12, unset, an empty group)
  • a stack supplied at render through <VariableContextProvider />, and its update on rerender

Every var() fixture declares its variable twice on purpose. A single-definition variable is inlined at compile time and narrowed there, which makes the runtime path unreachable from a naively written fixture.

src/__tests__/vendor/tailwind/typography.test.tsx gains the Font Family block the file was missing. The four default-theme cases are controls — ui-sans-serif is a CSS generic no typeface is registered under, so narrowing makes the value type-correct without changing what is drawn. Both overridden---font-sans cases bind, and only one of them reaches a real family: the :root override resolves Georgia, while the .dark override is not active and resolves the theme's own ui-sans-serif — the same generic as the four controls, with no face behind it either. It binds anyway, because a second definition defeats the inliner and on main that generic arrives as the whole seven-entry stack rather than as a string.

Mutation-proved, one broken thing at a time, counting reddened cases across the six test files this PR touches:

Mutation Red
isStyleFunction stops excluding an array head and a null head 8
the reduction descends into [0] instead of flattening 8
the reduction stops recognising a deferred head 22
the unparsed compiler path stops narrowing 11
the runtime reduction is removed 19
the delayed-style marker is no longer excluded 15
nothing-usable clears the key instead of leaving it 2
the runtime reduction is applied to every property 13
reduceParseUnparsed joins a space group (lifts the first known limit below) 2
parseFontFamily goes back to return stack[0] 0
parseFont goes back to value.family[0] 0

The in-scope column understates one row. Unscoping the runtime reduction from fontFamily reddens 13 cases in these six files but 134 across 19 files repo-wide, because every array-valued property then goes through the reduction — box-shadow, filter, transform, safe-area and the rest. That whole-repo figure is the one worth reading; the prop === "fontFamily" guard is load-bearing far outside this feature.

The last two rows are reported rather than hidden, and they are the same finding twice. Both typed producers hand the reduction a string[]: parseFontFamily a value and parseFont a value.family. For any array of strings narrowFontFamily returns the first element when there is one and nothing when there is not, which is exactly what [0] returns — so the two spellings are equivalent on every input the type permits and no test can distinguish them. I checked the second one repo-wide as well as in scope, and it reddens nothing there either. Both blocks guard the paths staying attached to the shared reduction, not a behaviour change, and their comments say exactly that.

Known limits

An unquoted multi-word family name behind a var() loses its tail. reduceParseUnparsed groups an unparsed value by comma and nests a multi-token group, so for font-family a one-entry stack of two idents and a two-entry stack of one ident each collapse onto the identical array:

--f: Helvetica Neue    ->  ["f", ["Helvetica", "Neue"]]
--f: Inter, Helvetica  ->  ["f", ["Inter", "Helvetica"]]

Nothing downstream can separate them, so the reduction reads both as a stack and --f: Helvetica Neue renders as Helvetica. Quoting the name keeps it a single string (["f", "Helvetica Neue"]) and it renders whole, which is CSS's own answer for a family name that is not one ident. The typed path is unaffected — LightningCSS joins the idents there, and this PR pins that both paths agree on the quoted spelling.

I left it rather than picking a reading: joining a nested group instead would fix this case and break var(--x, A, B), C, whose fallback nests the same way and does mean a comma list. A real fix separates the two groupings in reduceParseUnparsed, which is a shared parser change touching every property. Both halves of the limit are pinned — the identical compiled value on the compiler plane, the resulting family on the native plane — and the mutation table's space-group row shows that lifting it reddens exactly those two and nothing else.

vars() cannot express a font stack under tsc, though it now works at runtime. The behaviour half is closed: vars({ "--stack": ["Inter","Helvetica"] }) renders fontFamily: "Inter" on this branch and hands React Native the array on main. The types are the part that is still shut, on the deprecated API, in two independent places:

  • react-native-css/runtime type-resolves to src/runtime.tssrc/web/api.tsx, whose vars is Record<string, string | number>. A stack is TS2322: Type 'string[]' is not assignable to type 'string | number'. Jest resolves the same specifier to src/runtime.native.ts and runs the native function, so the mismatch is invisible at runtime.
  • the native vars() return type is not assignable to a style prop at all: TS2769 … Type '{ [VAR_SYMBOL]: string; } & { [k: string]: StyleDescriptor; }' is not assignable to type 'StyleProp<ViewStyle>'.

Both are types-only defects on vars() itself rather than anything this change touches, so the equivalent runtime coverage is written through <VariableContextProvider /> — the API vars() is deprecated in favour of — which typechecks and exercises the identical path.

A null head cannot reach the reduction through a render. applyValue handles [null, "Arial"] and a unit test pins it, but StyleDescriptor has no null member so the value does not compile, and resolveValue's own isDescriptorArray reads a null head as a style-function call (typeof null === "object") and resolves the stack away before applyValue sees it. That predicate is the same defect isStyleFunction had, on a shared path used by every property; fixing it there is a separate change. The native-plane test states the measurement and asserts the head the type system does allow.

font: 12px var(--stack) is narrowed by neither plane. The font shorthand with a var() inside it cannot be typed by LightningCSS either, so it takes the unparsed path — but with property === "font", which the compiler's narrowing misses because that is keyed on font-family, and which applyValue misses because its guard is prop === "fontFamily". Measured identical on main and on this branch:

:root { --stack: Inter, Helvetica; }
.other { --stack: Georgia, serif; }
.a { font: 12px var(--stack); }   ->  { "font": [12, ["Inter","Helvetica"]] }

React Native has no font style prop, so nothing consumes that value on either side and this is pre-existing rather than a regression. I am naming it because it is a fourth route to the same shape, and the framing at the top — three producers, two of which narrowed — does not account for it. Closing it properly means the unparsed path understanding that font decomposes into font-family and friends, rather than a second property name added to two guards; that is a larger change than this one and I would rather not smuggle it in here.

One thing to flag

src/utilities/index.ts gains export * from "./font-family", and ./utilities is an already-published subpath, so narrowFontFamily and the FontFamilyNarrowing union become supported API on merge. Nothing marks them internal. That matches how isStyleFunction and Specificity are already exported from the same subpath, so I have assumed it is intended — say the word and I will move the module somewhere unexported instead. Relatedly, isStyleFunction's parameter widens from StyleDescriptor to unknown: source-compatible for every caller, but it does change the exported .d.ts and removes some compile-time pressure on them.

Second commit — the sibling predicate had the same hole

isStyleFunction is not alone in that file. isStyleDescriptorArray sits six lines above it, asks the same question from the other side — is this a list of VALUES rather than a function to evaluate — and carried the identical untreated typeof value[0] === "object".

Its failure is quieter than the throw, which is why it outlived it. typeof null is "object", so a null head sends it into the branch that demands an array, and it answers false for a value that IS a descriptor array:

isStyleDescriptorArray([null, "Arial"])   before: false      after: true
isStyleDescriptorArray(["Inter", "Arial"]) before: true      after: true    (control)
isStyleDescriptorArray([{}, "var"])        before: false     after: false   (control)
isStyleDescriptorArray([["Inter"], "Arial"]) before: true    after: true    (control)

Null is not a function head. It is a value — a hole left where an operand could not be parsed — and it reaches a native runtime as null rather than undefined because the sheet goes through JSON.stringify on the way. The predicate is exported and read at ten call sites across dimension, filters, transform-functions, box-shadow, _expand and variables, so the misclassification is not local to one caller.

Both functions are now the same shape for the same stated reason, and src/__tests__/utilities/style-descriptor.test.ts gains a describe for the sibling mirroring the one it already had: three controls that pass before and after, and the null case that does not.

Fixing one copy and leaving its twin would have made this pull request a partial fix of one defect.

Verification

Windows, warm cache, run twice with identical results:

Test Suites: 2 failed, 4 skipped, 58 passed, 60 of 64 total
Tests:       3 failed, 21 skipped, 1114 passed, 1138 total

Zero suite-load failures, verified from --json rather than from the summary line: numRuntimeErrorTestSuites 0, numTotalTests 1138, and the two failing suites account for all 3 failures inside their own assertions. Those three are src/__tests__/babel/{react-native,react-native-web}.test.ts, a pre-existing Windows-only babel-plugin-tester output mismatch over an unrewritten relative require("../View"); they fail identically on pristine main on the same machine. yarn typecheck and yarn lint both exit 0.

React Native's `fontFamily` takes ONE family, not a stack. Written out, the
compiler already narrows a family list to its first entry. Through a `var()`
it did not: the whole stack was delivered as an array, and the text rendered
in the platform default instead of the requested face.

    .a { font-family: "Helvetica Neue", Arial, sans-serif; }   /* narrows */
    .b { --f: "Helvetica Neue", Arial, sans-serif;
         font-family: var(--f); }                              /* whole stack */

The narrowing now happens where the value is applied, so both spellings
deliver the same single family. The walk is recursive because a resolved
variable nests its comma groups.

Tests: 5, covering a literal stack, a var()-valued stack, a nested resolution,
a single family (unchanged), and a non-array value (unchanged).
The runtime reduction in objects.ts rests on a compile-time claim nothing tested:
that a literal stack is already narrowed to its first family, and that a
var()-valued one is not, because it never reaches parseFontFamily.

Both are now pinned. The var fixture declares its property twice on purpose — a
single-definition variable is inlined at compile time and would be narrowed
after all, which is what makes the runtime path unreachable from a naive test.
…ed array

A style function's head is `Record<never, never>` - a plain object with no
keys - but the test only asked whether index 0 was `typeof "object"` with no
own keys. Two shapes that occur in a resolved style descriptor slip through:

- `[[], "Arial"]` reports true, because `Object.keys([])` is empty too. A
  nested font stack whose first group is empty is read as a function call.
- `[null, "Arial"]` throws `Cannot convert undefined or null to object`,
  because `typeof null` is `"object"`.

Excluding arrays and null first is what `isStyleDescriptorArray` already does
one function up.

The parameter widens to `unknown`: the body was always a total runtime check,
and the callers that need it most are holding a value off the wire.
React Native's `fontFamily` is one family name, never a stack, and three
compiler paths produce it. Two narrowed - `parseFontFamily` and the `font`
shorthand, both by taking `[0]`. The third did not.

A `font-family` LightningCSS cannot type falls to `parseUnparsed`, which
returns the token list as it found it, and `addDescriptor` stores it as a
static style. Six spellings of plain CSS reach it, none of them exotic:

    font-family: Inter, Helvetica,;      ->  fontFamily: ["Inter","Helvetica"]
    font-family: ,Inter, Helvetica;      ->  fontFamily: ["Inter","Helvetica"]
    font-family: Inter,,Helvetica;       ->  fontFamily: ["Inter","Helvetica"]
    font-family: "Inter", "Helvetica",;  ->  fontFamily: ["Inter","Helvetica"]
    font-family: 12, Inter;              ->  fontFamily: [12,"Inter"]
    font-family: ,;                      ->  fontFamily: []

A keyframe takes the same path, so `@keyframes` carried the array too. None of
it can be caught downstream: `applyDeclarations` copies a static style onto the
props with `Object.assign`, never through `applyValue`.

The reduction now exists once, in `src/utilities/font-family.ts`, and the three
producers read it. It is flatten-then-first-usable rather than take-the-first:
a nested group is read in place, and an entry that cannot name a family - a
number, a null, an empty group - is skipped, the way a browser skips a family
it cannot use. A stack with nothing usable emits no declaration at all, so a
family set by a lower-specificity rule survives the cascade instead of being
overwritten with `[]`.

The one answer the compiler cannot give is `deferred`: the first usable entry
is a `var()`, whose value only exists at render. That descriptor is emitted
whole and reduced again at render. A `var()` standing BEHIND a literal is
narrowed away here, because React Native can never reach it - which also drops
the declaration's reactivity, since its value can no longer change.
…does

The compiler narrows every stack it can read. The one it cannot is the value
behind a `var()`, and `applyValue` is the first place on that path where the
property name and the resolved value are both in hand.

It now runs the same reduction, so the two planes cannot disagree, and three
shapes the previous first-then-descend loop got wrong are covered:

- `[[], "Arial"]` returned `undefined`. The descent walked into the empty
  first group and never came back for the sibling.
- `[null, "Arial"]` set a raw `null` on `fontFamily`, ten lines below the
  comment explaining that null means "set to undefined" in React Native.
- `--n: 12; font-family: var(--n)` set `fontFamily: 12`, a number on a
  property React Native types `string`.

Nothing usable now leaves the key absent rather than clearing it, matching
what `applyValue` already means by `undefined`: the declaration failed, so
whatever an earlier rule set stands.

The guard excludes plain objects because `applyDeclarations` parks
`{ [prop]: true }` on the target while a delayed value resolves and reclaims
it by identity. Reducing that marker away would strand every `var()`-valued
font-family unresolved.
typography.test.tsx had blocks for Font Size, Smoothing, Style, Weight,
Variant Numeric and Letter Spacing, and none for Font Family.

The two override cases are the end-to-end proof of the runtime reduction: a
theme variable with a single definition is inlined and narrowed by the
compiler, so only a SECOND definition puts a stack in front of the runtime.

They also record what the default theme actually produces. `font-sans` is
`ui-sans-serif` - a CSS generic no typeface is registered under on either
platform - so narrowing makes the value type-correct without changing what is
drawn. It is the overridden `--font-sans` that reaches a real face.
`calc()` in the head of a font stack is a style function too, so the compiler
defers it and the runtime reduces what it resolved to. It resolves to a number,
which is skipped for the same reason `12` is skipped at compile time.
…operly

I measured every test in this branch against `upstream/main` with the source
reverted, which is the only thing that separates a test that guards the fix
from one that passes either way. Five of the seven the PR originally shipped
were green on `main`; the current tree is 31 red of 45. The result is that the
passengers are now labelled CONTROL where they earn their place, and the native
plane covers the cases only it can answer.

Native plane, 11 new cases. Every one of them is a `var()` route, which is the
half no compiler assertion reaches: the descriptor is identical whatever the
variable holds, so only a render says which family React Native is handed.

  var(--missing, Helvetica)                       -> Helvetica
  var(--missing, Inter, Helvetica)                -> Inter
  var(--a, var(--b, serif))                       -> serif
  var(--a, var(--b, serif)) with --b set          -> Georgia
  var(--missing, Arial), var(--f)                 -> Arial
  --f: "Helvetica Neue", Arial                    -> Helvetica Neue
  --f: "Foo, Bar", Arial                          -> Foo, Bar
  --f: 12, Arial  /  --f: unset, Arial            -> Arial
  provider [[], "Arial"]                          -> Arial
  provider [undefined, "Arial"]                   -> Arial

On `main` each of those hands React Native the array instead: `["Inter",
"Helvetica"]`, `["Helvetica Neue","Arial"]`, `["Foo, Bar","Arial"]`, and so on.

Compiler plane, 4 new cases: the quoted and multi-ident spellings on both the
typed and the unparsed path, so the two paths are pinned to agree on what one
family is; and the deferred descriptor for each fallback shape, which is what
says the compiler plane cannot answer those and the render must.

One known limit, measured rather than assumed. `reduceParseUnparsed` stores a
space-separated ident group and a comma-separated stack in the same array, so
`--f: Helvetica Neue` and `--f: Inter, Helvetica` both compile to
`["f", ["<string>", "<string>"]]`. Nothing downstream can separate them, and the
reduction reads both as a stack, so the first renders as `Helvetica`. Quoting
the name keeps it a single string and it renders whole. Both halves are pinned,
on the plane that can see each: the identical compiled value on the compiler
side, the resulting family on the native side. Joining a multi-token group in
`reduceParseUnparsed` — the change that would lift the limit — reddens exactly
those two and nothing else.

Two controls say out loud that they cannot fail for the reason they look like
they test. The typed-path block guards a refactor no input distinguishes:
restoring `return stack[0]` inside `firstFontFamily` reddens nothing anywhere.
The null-head case cannot be delivered by a render at all — `StyleDescriptor`
has no null member, so writing it fails `tsc`, and `resolveValue`'s own
`isDescriptorArray` would resolve the stack away before `applyValue` saw it.

Mutation-proved, one broken thing at a time: loosening `isStyleFunction` 8 red,
descending into the head instead of flattening 8, dropping the deferred branch
22, removing the unparsed narrowing 11, removing the runtime reduction 19,
reducing the delayed-style marker 15, clearing the key on nothing-usable 2,
applying the reduction to every property 13, joining a space group 2.
Three comment corrections, no behaviour change.

`applyValue`'s nothing-usable branch claimed a family an earlier rule set
survives the cascade. Measured, that holds only on the compile-time path,
where no descriptor is emitted at all: `.b { font-family: Georgia }` then
`.a { font-family: ,; }` keeps `Georgia` here and yields `[]` on main. On
the resolved `var()` path `applyDeclarations` deletes the key before it
resolves, so the same pair with `var(--n)` over `--n: 12` gives `{}` here
and `{ fontFamily: 12 }` on main - better either way, but Georgia is gone
in both. The comment now names which path it is claiming.

`isDelayedMarker`'s null exclusion is unreachable from its one call site,
which turns null into undefined and then excludes undefined. It stays,
because the predicate answers a question about a value rather than about
that caller's ordering, and `typeof null === "object"` is the same trap
being fixed in `isStyleFunction` here. Saying so keeps the next reader
from having to work out whether it is load-bearing.

The Tailwind Font Family block said both `--font-sans` overrides reach a
real face. Only `:root` does, resolving `Georgia`; `.dark` is not active
and resolves `ui-sans-serif`, the same generic as the four controls. Both
still bind, because on main that generic arrives as a seven-entry array.
`isStyleFunction` excludes a null head because `typeof null` is `"object"` and
`Object.keys(null)` throws. `isStyleDescriptorArray`, six lines above it in the
same file, asks the same question from the other side and carried the same
untreated `typeof value[0] === "object"`.

The consequence is quieter than the throw its sibling had, which is why it
survived: a null head sends it into the branch that demands an array, so it
answers `false` for a value that IS a descriptor array. Null is not a function
head — it is a value, a hole the compiler left where an operand could not be
parsed, and it reaches a native runtime as `null` rather than `undefined`
because the sheet goes through `JSON.stringify` on the way.

The predicate is exported and read at ten call sites across `dimension`,
`filters`, `transform-functions`, `box-shadow`, `_expand` and `variables`, so
the misclassification is not local to one caller.

Fixing one copy and leaving the other made this change a partial one. Both are
now the same shape, for the same stated reason.
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.

1 participant