From 2ecf37ff016d4bb1ca1a646f1df7f72148e7cf72 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 01:59:54 +0300 Subject: [PATCH 1/4] fix(native): narrow a var()-valued font-family to its first family React Native's fontFamily is one family name, and an array is a value Fabric refuses. LightningCSS types most font-family declarations and the compiler takes their first family, but a stack it cannot type, such as one with a trailing comma, reached the style as an array, and so did a stack arriving through a var(). narrowFontFamily reduces a stack to its first entry that names a family, reading nested groups in place and skipping an entry that names none, as a browser skips a family it cannot use. The compiler applies it to the unparsed path, leaving a stack led by a var() for render, and applyValue applies it to the value the var() resolves to. A stack with no usable family sets nothing, so a family from an earlier rule stands. --- src/__tests__/compiler/font-family.test.ts | 186 +++++++++++++++ .../native/font-family-stack.test.ts | 83 +++++++ src/__tests__/native/font-family.test.tsx | 219 ++++++++++++++++++ src/__tests__/utilities/font-family.test.ts | 83 +++++++ .../utilities/style-descriptor.test.ts | 51 ++++ .../vendor/tailwind/typography.test.tsx | 49 ++++ src/compiler/declarations.ts | 22 +- src/native/objects.ts | 17 ++ src/utilities/font-family.ts | 35 +++ src/utilities/index.ts | 1 + 10 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/compiler/font-family.test.ts create mode 100644 src/__tests__/native/font-family-stack.test.ts create mode 100644 src/__tests__/native/font-family.test.tsx create mode 100644 src/__tests__/utilities/font-family.test.ts create mode 100644 src/__tests__/utilities/style-descriptor.test.ts create mode 100644 src/utilities/font-family.ts diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts new file mode 100644 index 00000000..461eb428 --- /dev/null +++ b/src/__tests__/compiler/font-family.test.ts @@ -0,0 +1,186 @@ +import { compile } from "react-native-css/compiler"; + +const declarationsFor = (css: string, className: string) => { + const rules = new Map(compile(css).stylesheet().s ?? []).get(className); + + return (rules ?? []).flatMap((rule) => rule.d ?? []); +}; + +const variablesFor = (css: string, className: string) => { + const rules = new Map(compile(css).stylesheet().s ?? []).get(className); + + return (rules ?? []).flatMap((rule) => rule.v ?? []); +}; + +describe("the typed path", () => { + test("a literal stack narrows to its first family", () => { + expect( + declarationsFor(`.a { font-family: Inter, Helvetica, sans-serif; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("the `font` shorthand narrows to its first family", () => { + expect( + declarationsFor(`.a { font: italic 12px Inter, Helvetica; }`, "a"), + ).toStrictEqual([ + { + fontFamily: "Inter", + fontSize: 12, + fontStyle: "italic", + fontWeight: "normal", + }, + ]); + }); + + test("a name that is not a bare ident survives whole", () => { + // LightningCSS unquotes a quoted family, and a multi-ident name is one family + expect( + declarationsFor(`.a { font-family: "Helvetica Neue", Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + expect( + declarationsFor(`.a { font-family: "Foo, Bar", Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Foo, Bar" }]); + expect( + declarationsFor(`.a { font-family: Helvetica Neue, Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + }); + + test("no warning is emitted for the families that are dropped", () => { + expect( + compile(`.a { font-family: Inter, Helvetica, sans-serif; }`).warnings(), + ).toStrictEqual({}); + expect( + compile(`.a { font-family: Inter, Helvetica,; }`).warnings(), + ).toStrictEqual({}); + }); +}); + +describe("the unparsed path", () => { + test.each([ + ["a trailing comma", `.a { font-family: Inter, Helvetica,; }`], + ["a leading comma", `.a { font-family: ,Inter, Helvetica; }`], + ["a doubled comma", `.a { font-family: Inter,,Helvetica; }`], + ["quoted families", `.a { font-family: "Inter", "Helvetica",; }`], + ])("%s still narrows to the first family", (_name, css) => { + expect(declarationsFor(css, "a")).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a quoted name keeps its spaces and its commas", () => { + expect( + declarationsFor(`.a { font-family: "Helvetica Neue", Arial,; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + expect( + declarationsFor(`.a { font-family: "Foo, Bar", Arial,; }`, "a"), + ).toStrictEqual([{ fontFamily: "Foo, Bar" }]); + }); + + test("an entry that cannot name a family is skipped", () => { + expect( + declarationsFor(`.a { font-family: 12, Inter; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a stack with nothing usable emits no declaration at all", () => { + expect(declarationsFor(`.a { font-family: ,; }`, "a")).toStrictEqual([]); + }); + + test("a keyframe narrows too", () => { + expect( + compile( + `@keyframes k { from { font-family: Inter, Helvetica,; } to { font-family: Georgia; } }`, + ).stylesheet().k, + ).toStrictEqual([ + [ + "k", + [ + ["from", [{ fontFamily: "Inter" }]], + ["to", [{ fontFamily: "Georgia" }]], + ], + ], + ]); + }); +}); + +describe("the var() path", () => { + // A variable declared once is inlined by the compiler, so these declare --stack twice + + test("a stack the compiler cannot see is left for the runtime", () => { + expect( + declarationsFor( + `:root { --stack: Inter, Helvetica; } .other { --stack: Georgia, serif; } .a { font-family: var(--stack); }`, + "a", + ), + ).toStrictEqual([[[{}, "var", "stack", 1], "fontFamily", 1]]); + }); + + test("a var() behind a literal is narrowed away", () => { + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, var(--x); }`, + "a", + ), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a var() in front of a literal is left whole", () => { + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: var(--x), Helvetica; }`, + "a", + ), + ).toStrictEqual([[[[{}, "var", "x", 1], "Helvetica"], "fontFamily"]]); + }); + + test("a fallback is emitted whole, whatever shape it has", () => { + expect( + declarationsFor(`.a { font-family: var(--missing, Helvetica); }`, "a"), + ).toStrictEqual([ + [[{}, "var", ["missing", "Helvetica"], 1], "fontFamily", 1], + ]); + + expect( + declarationsFor( + `.a { font-family: var(--missing, Inter, Helvetica); }`, + "a", + ), + ).toStrictEqual([ + [[{}, "var", ["missing", ["Inter", "Helvetica"]], 1], "fontFamily", 1], + ]); + + expect( + declarationsFor( + `.a { font-family: var(--missing-a, var(--missing-b, serif)); }`, + "a", + ), + ).toStrictEqual([ + [ + [{}, "var", ["missing-a", [{}, "var", ["missing-b", "serif"], 1]], 1], + "fontFamily", + 1, + ], + ]); + }); +}); + +describe("what a var() cannot carry", () => { + test("KNOWN LIMIT: a space group and a comma group compile to the same value", () => { + const spaceGroup = variablesFor( + `.b { --f: Helvetica Neue; } .c { --f: x; }`, + "b", + ); + const commaGroup = variablesFor( + `.b { --f: Inter, Helvetica; } .c { --f: x; }`, + "b", + ); + + expect(spaceGroup).toStrictEqual([["f", ["Helvetica", "Neue"]]]); + expect(commaGroup).toStrictEqual([["f", ["Inter", "Helvetica"]]]); + expect(spaceGroup.map(([, value]) => typeof value)).toStrictEqual( + commaGroup.map(([, value]) => typeof value), + ); + + expect( + variablesFor(`.b { --f: "Helvetica Neue"; } .c { --f: x; }`, "b"), + ).toStrictEqual([["f", "Helvetica Neue"]]); + }); +}); diff --git a/src/__tests__/native/font-family-stack.test.ts b/src/__tests__/native/font-family-stack.test.ts new file mode 100644 index 00000000..3b3a7761 --- /dev/null +++ b/src/__tests__/native/font-family-stack.test.ts @@ -0,0 +1,83 @@ +import { applyValue } from "../../native/objects"; + +/** A real Tailwind `--font-sans`, which is why the stack is the common case. */ +const FONT_SANS_STACK = [ + "Inter", + "Inter Fallback", + "ui-sans-serif", + "system-ui", + "sans-serif", +] as const; + +const applyFontFamily = (value: unknown): Record => { + const target: Record = {}; + applyValue(target, "fontFamily", value); + return target; +}; + +describe("the reduction", () => { + test("a resolved stack reduces to its first family, as a string", () => { + const target = applyFontFamily([...FONT_SANS_STACK]); + + expect(target.fontFamily).toBe("Inter"); + expect(typeof target.fontFamily).toBe("string"); + }); + + test("a nested stack is flattened, not descended into", () => { + expect(applyFontFamily([[...FONT_SANS_STACK]]).fontFamily).toBe("Inter"); + expect(applyFontFamily([[], "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([[[]], "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([["Inter"], "Arial"]).fontFamily).toBe("Inter"); + }); + + test("an entry that cannot name a family is skipped", () => { + expect(applyFontFamily([12, "Inter"]).fontFamily).toBe("Inter"); + expect(applyFontFamily([null, "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([undefined, "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([true, "Arial"]).fontFamily).toBe("Arial"); + }); + + test("a single family passes through untouched", () => { + expect(applyFontFamily("fisona-icons").fontFamily).toBe("fisona-icons"); + }); + + test("a stack with nothing usable sets nothing", () => { + expect("fontFamily" in applyFontFamily([])).toBe(false); + expect("fontFamily" in applyFontFamily([12])).toBe(false); + expect("fontFamily" in applyFontFamily([[], [null]])).toBe(false); + + const inherited: Record = { fontFamily: "Inter" }; + applyValue(inherited, "fontFamily", []); + expect(inherited.fontFamily).toBe("Inter"); + }); +}); + +describe("what the reduction must not disturb", () => { + test("the reduction is scoped to fontFamily", () => { + // fontVariant is a list on React Native, so only fontFamily is reduced + const target: Record = {}; + applyValue(target, "fontVariant", ["small-caps"]); + + expect(target.fontVariant).toStrictEqual(["small-caps"]); + }); + + test("both sentinel meanings survive the reduction", () => { + const untouched: Record = {}; + applyValue(untouched, "fontFamily", undefined); + expect("fontFamily" in untouched).toBe(false); + + const cleared: Record = { fontFamily: "Inter" }; + applyValue(cleared, "fontFamily", null); + expect("fontFamily" in cleared).toBe(true); + expect(cleared.fontFamily).toBeUndefined(); + }); + + test("the delayed-style marker passes through by identity", () => { + // applyDeclarations parks { fontFamily: true } on the target while a delayed value resolves + const marker = { fontFamily: true }; + const target: Record = {}; + applyValue(target, "fontFamily", marker); + + expect(target.fontFamily).toBe(marker); + }); +}); diff --git a/src/__tests__/native/font-family.test.tsx b/src/__tests__/native/font-family.test.tsx new file mode 100644 index 00000000..6cfd7517 --- /dev/null +++ b/src/__tests__/native/font-family.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { VariableContextProvider } from "react-native-css/native"; + +const styleOf = (className: string, css: string): unknown => { + registerCSS(css); + render(); + return screen.getByTestId(testID).props.style; +}; + +describe("a stack the compiler could read", () => { + test("a static stack it could not type arrives as one family", () => { + // The trailing comma puts this on the unparsed path, which applyDeclarations copies straight onto the style + expect( + styleOf("a", `.a { font-family: Inter, Helvetica,; }`), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a static stack with nothing usable produces no style at all", () => { + // The compiler drops the declaration, and it was the rule's only one. + expect(styleOf("a", `.a { font-family: ,; }`)).toBeUndefined(); + }); +}); + +describe("a stack behind a var()", () => { + test("a var() holding a stack arrives as one family", () => { + expect( + styleOf( + "a", + `:root { --stack: Inter, Helvetica; } + .other { --stack: Georgia, serif; } + .a { font-family: var(--stack); }`, + ), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a var() holding something that cannot name a family sets no family", () => { + expect( + styleOf( + "a", + `:root { --n: 12; } .other { --n: 13; } .a { font-family: var(--n); }`, + ), + ).toStrictEqual({}); + }); + + test("an unusable entry inside the resolved stack is skipped", () => { + expect( + styleOf( + "a", + `:root { --f: 12, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + expect( + styleOf( + "a", + `:root { --f: unset, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + }); + + test("a var() that resolves to nothing falls through to the next family", () => { + expect( + styleOf("a", `.a { font-family: var(--missing), Helvetica; }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); + }); + + test("a function that resolves to something unusable falls through too", () => { + expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual( + { + fontFamily: "Inter", + }, + ); + }); +}); + +describe("a var() fallback", () => { + // A var() fallback is chosen while resolving, so these compile to the same deferred descriptor + + test("an undefined var falls back to the literal in its own parentheses", () => { + // CONTROL — passes on `main`: one family resolves to a string. + expect( + styleOf("a", `.a { font-family: var(--missing, Helvetica); }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); + }); + + test("a fallback that is itself a stack narrows to its first family", () => { + expect( + styleOf("a", `.a { font-family: var(--missing, Inter, Helvetica); }`), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a nested fallback resolves to the innermost literal", () => { + // CONTROL — passes on `main` for the same reason. + expect( + styleOf( + "a", + `.a { font-family: var(--missing-a, var(--missing-b, serif)); }`, + ), + ).toStrictEqual({ fontFamily: "serif" }); + }); + + test("a nested fallback stops at the first var() that has a value", () => { + // CONTROL — passes on `main` for the same reason. + expect( + styleOf( + "a", + `:root { --b: Georgia; } + .other { --b: Verdana; } + .a { font-family: var(--missing-a, var(--b, serif)); }`, + ), + ).toStrictEqual({ fontFamily: "Georgia" }); + }); + + test("a fallback in the head still lets a later family be reached", () => { + expect( + styleOf( + "a", + `:root { --f: Inter; } + .other { --f: Georgia; } + .a { font-family: var(--missing, Arial), var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + }); +}); + +describe("a family name that is not a bare ident", () => { + test("a quoted name containing spaces survives the reduction", () => { + expect( + styleOf( + "a", + `:root { --f: "Helvetica Neue", Arial; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica Neue" }); + }); + + test("a quoted name containing a comma is one family, not two", () => { + expect( + styleOf( + "a", + `:root { --f: "Foo, Bar", Arial; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Foo, Bar" }); + }); + + test("KNOWN LIMIT: an UNQUOTED multi-word name behind a var() loses its tail", () => { + // The compiler stores an ident group and a comma stack as the same array, so an unquoted multi-word name reads as a stack + expect( + styleOf( + "a", + `:root { --f: Helvetica Neue; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica" }); + + expect( + styleOf( + "a", + `:root { --f: "Helvetica Neue"; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica Neue" }); + }); +}); + +describe("a stack supplied at render", () => { + test("it arrives as one family, and stays current", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + const element = screen.getByTestId(testID); + expect(element.props.style).toStrictEqual({ fontFamily: "Inter" }); + + screen.rerender( + + + , + ); + expect(element.props.style).toStrictEqual({ fontFamily: "Georgia" }); + }); + + test("an empty group in front of a family does not swallow it", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontFamily: "Arial", + }); + }); + + test("MEASURED: a null head never reaches the reduction on this plane", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontFamily: "Arial", + }); + }); +}); diff --git a/src/__tests__/utilities/font-family.test.ts b/src/__tests__/utilities/font-family.test.ts new file mode 100644 index 00000000..645bef8f --- /dev/null +++ b/src/__tests__/utilities/font-family.test.ts @@ -0,0 +1,83 @@ +import { narrowFontFamily } from "react-native-css/utilities"; + +describe("narrowFontFamily", () => { + test("a family is itself", () => { + expect(narrowFontFamily("Inter")).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("a stack reduces to its first family", () => { + expect( + narrowFontFamily(["Inter", "Helvetica", "sans-serif"]), + ).toStrictEqual({ kind: "family", family: "Inter" }); + }); + + test("a nested group is read in place, not descended into", () => { + expect(narrowFontFamily([[], "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + expect(narrowFontFamily([[[]], "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + expect(narrowFontFamily([["Inter"], "Arial"])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("an entry that cannot name a family is skipped", () => { + for (const unusable of [12, null, undefined, true]) { + expect(narrowFontFamily([unusable, "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + } + }); + + test("nothing usable is `none`", () => { + expect(narrowFontFamily([])).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily([12, null])).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily(undefined)).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily(42)).toStrictEqual({ kind: "none" }); + }); + + test("a variable reference is `deferred`, at any depth of the stack", () => { + expect(narrowFontFamily([{}, "var", "font-sans", 1])).toStrictEqual({ + kind: "deferred", + }); + expect( + narrowFontFamily([[{}, "var", "font-sans", 1], "Helvetica"]), + ).toStrictEqual({ kind: "deferred" }); + }); + + test("a family in front of a variable reference wins", () => { + expect(narrowFontFamily(["Inter", [{}, "var", "x", 1]])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("an array is a comma-separated stack, never one multi-word name", () => { + // The compiler stores an ident group and a comma stack as the same array, so the reduction reads both as a stack + expect(narrowFontFamily(["Helvetica", "Neue"])).toStrictEqual({ + kind: "family", + family: "Helvetica", + }); + expect(narrowFontFamily("Helvetica Neue")).toStrictEqual({ + kind: "family", + family: "Helvetica Neue", + }); + }); + + test("the reduction is idempotent", () => { + const once = narrowFontFamily(["Inter", "Helvetica"]); + expect(once.kind).toBe("family"); + expect( + narrowFontFamily(once.kind === "family" ? once.family : undefined), + ).toStrictEqual(once); + }); +}); diff --git a/src/__tests__/utilities/style-descriptor.test.ts b/src/__tests__/utilities/style-descriptor.test.ts new file mode 100644 index 00000000..9e379ea1 --- /dev/null +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -0,0 +1,51 @@ +import { + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; + +describe("isStyleFunction", () => { + test("a style function is one", () => { + expect(isStyleFunction([{}, "var"])).toBe(true); + expect(isStyleFunction([{}, "var", "font-sans", 1])).toBe(true); + }); + + test("a plain descriptor array is not", () => { + // CONTROL — passes on `main`, for the same reason. + expect(isStyleFunction(["Inter", "Helvetica"])).toBe(false); + expect(isStyleFunction([])).toBe(false); + expect(isStyleFunction("Inter")).toBe(false); + expect(isStyleFunction(undefined)).toBe(false); + }); + + test("an array headed by an empty array is not", () => { + expect(isStyleFunction([[], "Arial"])).toBe(false); + expect(isStyleFunction([["Inter"], "Arial"])).toBe(false); + }); + + test("an array headed by null is not, and does not throw", () => { + // `typeof null` is `"object"`, and `Object.keys(null)` throws. + expect(isStyleFunction([null, "Arial"])).toBe(false); + }); +}); + +describe("isStyleDescriptorArray", () => { + test("a plain descriptor array is one", () => { + // CONTROL — passes on `main`. Says the answer did not move. + expect(isStyleDescriptorArray(["Inter", "Helvetica"])).toBe(true); + expect(isStyleDescriptorArray([1, 2])).toBe(true); + }); + + test("a style function is not one", () => { + // CONTROL — the discrimination this predicate exists to make. + expect(isStyleDescriptorArray([{}, "var", "font-sans"])).toBe(false); + }); + + test("an array headed by an array is one", () => { + // A nested group is a descriptor, not a function head. + expect(isStyleDescriptorArray([["Inter"], "Arial"])).toBe(true); + }); + + test("an array headed by null is one", () => { + expect(isStyleDescriptorArray([null, "Arial"])).toBe(true); + }); +}); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..cb64a40c 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -1,5 +1,54 @@ import { renderCurrentTest, renderSimple } from "./_tailwind"; +describe("Typography - Font Family", () => { + // React Native takes one family, so each stack renders as its first + test("font-sans", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-sans-serif" } }, + }); + }); + test("font-serif", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-serif" } }, + }); + }); + test("font-mono", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-monospace" } }, + }); + }); + test("font-[Inter]", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "Inter" } }, + }); + }); + + test("font-sans with an overridden --font-sans", async () => { + // A second definition stops the compiler inlining the variable, so the stack reaches render + expect( + await renderSimple({ + className: "font-sans", + sourceInline: ["font-sans"], + extraCss: `.dark { --font-sans: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: "ui-sans-serif" } }, + }); + }); + + test("font-sans overridden at :root", async () => { + expect( + await renderSimple({ + className: "font-sans", + sourceInline: ["font-sans"], + extraCss: `:root { --font-sans: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: "Georgia" } }, + }); + }); +}); + describe("Typography - Font Size", () => { test("text-xs", async () => { expect(await renderCurrentTest()).toStrictEqual({ diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..a2ebed2e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, narrowFontFamily } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -992,7 +992,25 @@ export function parseUnparsedDeclaration( builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); } } else { - const value = parseUnparsed(declaration.value.value, builder, property); + let value = parseUnparsed(declaration.value.value, builder, property); + + if (property === "font-family") { + // A stack LightningCSS could not type is still one family, so only a leading var() waits for render + const narrowing = narrowFontFamily(value); + + switch (narrowing.kind) { + case "family": + value = narrowing.family; + break; + case "none": + value = undefined; + break; + case "deferred": + break; + default: + narrowing satisfies never; + } + } builder.addDescriptor(property, value); diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..41ae3d6e 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -1,4 +1,6 @@ /* eslint-disable */ +import { narrowFontFamily } from "react-native-css/utilities"; + import { ShortHandSymbol } from "../native/styles/constants"; import { transformKeys } from "../native/styles/defaults"; @@ -48,6 +50,11 @@ export function applyShorthand(value: any) { return target; } +// applyDeclarations parks { [prop]: true } on the target while a delayed value resolves +function isDelayedMarker(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function applyValue( target: Record, prop: string, @@ -84,6 +91,16 @@ export function applyValue( return; } + // React Native takes one family, and a var() value is the one stack the compiler could not narrow + if (prop === "fontFamily" && value !== undefined && !isDelayedMarker(value)) { + const narrowing = narrowFontFamily(value); + + if (narrowing.kind === "family") { + target[prop] = narrowing.family; + } + return; + } + target[prop] = value; } diff --git a/src/utilities/font-family.ts b/src/utilities/font-family.ts new file mode 100644 index 00000000..655da7ad --- /dev/null +++ b/src/utilities/font-family.ts @@ -0,0 +1,35 @@ +import { isStyleFunction } from "./style-descriptor"; + +// deferred: the first entry that could name a family is a var(), which resolves only at render +export type FontFamilyNarrowing = + | { readonly kind: "family"; readonly family: string } + | { readonly kind: "deferred" } + | { readonly kind: "none" }; + +const DEFERRED: FontFamilyNarrowing = { kind: "deferred" }; +const NONE: FontFamilyNarrowing = { kind: "none" }; + +// The first entry that names a family, reading nested groups in place and skipping one that names none +export function narrowFontFamily(value: unknown): FontFamilyNarrowing { + if (typeof value === "string") { + return { kind: "family", family: value }; + } + + if (!Array.isArray(value)) { + return NONE; + } + + if (isStyleFunction(value)) { + return DEFERRED; + } + + for (const entry of value) { + const narrowing = narrowFontFamily(entry); + + if (narrowing.kind !== "none") { + return narrowing; + } + } + + return NONE; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..df751998 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; +export * from "./font-family"; export * from "./dot-notation.types"; From cb5cdf7e5aa049d5f02c98282852ee9f42fc4c8a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 12:30:59 +0300 Subject: [PATCH 2/4] test: name a neutral icon font in the pass-through case --- src/__tests__/native/font-family-stack.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/native/font-family-stack.test.ts b/src/__tests__/native/font-family-stack.test.ts index 3b3a7761..62639d38 100644 --- a/src/__tests__/native/font-family-stack.test.ts +++ b/src/__tests__/native/font-family-stack.test.ts @@ -38,7 +38,7 @@ describe("the reduction", () => { }); test("a single family passes through untouched", () => { - expect(applyFontFamily("fisona-icons").fontFamily).toBe("fisona-icons"); + expect(applyFontFamily("brand-icons").fontFamily).toBe("brand-icons"); }); test("a stack with nothing usable sets nothing", () => { From 76e3d0ff08504259e6c0e6908b7b7e7e73622b96 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 02:19:23 +0300 Subject: [PATCH 3/4] fix: a font-family stack with an entry that names no family is invalid Browsers ignore a written stack with an empty entry, a number, a function or an unquoted reserved ident, and make a stack a variable fills invalid at computed-value time the same way, so the element inherits its family. The narrowing skipped such an entry and took the next family, and it narrowed a stack at compile time even when a later entry was a var(), whose value can still invalidate it. Now any such entry invalidates the stack, a var() anywhere defers it to render, and the compiler reads the written tokens for the empty entries and reserved idents parseUnparsed drops. --- src/__tests__/compiler/font-family.test.ts | 43 +++++++------- .../native/font-family-stack.test.ts | 11 ++-- src/__tests__/native/font-family.test.tsx | 45 ++++++++------- src/__tests__/utilities/font-family.test.ts | 57 +++++++++++++------ src/compiler/declarations.ts | 44 +++++++++++++- src/utilities/font-family.ts | 43 +++++++++++--- 6 files changed, 167 insertions(+), 76 deletions(-) diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts index 461eb428..0540ce84 100644 --- a/src/__tests__/compiler/font-family.test.ts +++ b/src/__tests__/compiler/font-family.test.ts @@ -55,36 +55,35 @@ describe("the typed path", () => { }); }); +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a stack with an entry that names no family describe("the unparsed path", () => { test.each([ ["a trailing comma", `.a { font-family: Inter, Helvetica,; }`], ["a leading comma", `.a { font-family: ,Inter, Helvetica; }`], ["a doubled comma", `.a { font-family: Inter,,Helvetica; }`], - ["quoted families", `.a { font-family: "Inter", "Helvetica",; }`], - ])("%s still narrows to the first family", (_name, css) => { - expect(declarationsFor(css, "a")).toStrictEqual([{ fontFamily: "Inter" }]); - }); - - test("a quoted name keeps its spaces and its commas", () => { - expect( - declarationsFor(`.a { font-family: "Helvetica Neue", Arial,; }`, "a"), - ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); - expect( - declarationsFor(`.a { font-family: "Foo, Bar", Arial,; }`, "a"), - ).toStrictEqual([{ fontFamily: "Foo, Bar" }]); - }); - - test("an entry that cannot name a family is skipped", () => { - expect( - declarationsFor(`.a { font-family: 12, Inter; }`, "a"), - ).toStrictEqual([{ fontFamily: "Inter" }]); + [ + "quoted families and a trailing comma", + `.a { font-family: "Inter", "Helvetica",; }`, + ], + ["a number", `.a { font-family: 12, Inter; }`], + ["a function", `.a { font-family: calc(1px), Inter; }`], + [ + "a CSS-wide keyword", + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, inherit, var(--x); }`, + ], + [ + "a reserved ident in any case", + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, DEFAULT, var(--x); }`, + ], + ])("%s invalidates the stack", (_name, css) => { + expect(declarationsFor(css, "a")).toStrictEqual([]); }); test("a stack with nothing usable emits no declaration at all", () => { expect(declarationsFor(`.a { font-family: ,; }`, "a")).toStrictEqual([]); }); - test("a keyframe narrows too", () => { + test("a keyframe drops an invalid stack too", () => { expect( compile( `@keyframes k { from { font-family: Inter, Helvetica,; } to { font-family: Georgia; } }`, @@ -93,7 +92,7 @@ describe("the unparsed path", () => { [ "k", [ - ["from", [{ fontFamily: "Inter" }]], + ["from", []], ["to", [{ fontFamily: "Georgia" }]], ], ], @@ -113,13 +112,13 @@ describe("the var() path", () => { ).toStrictEqual([[[{}, "var", "stack", 1], "fontFamily", 1]]); }); - test("a var() behind a literal is narrowed away", () => { + test("a var() behind a literal is left whole, because its value can invalidate the stack", () => { expect( declarationsFor( `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, var(--x); }`, "a", ), - ).toStrictEqual([{ fontFamily: "Inter" }]); + ).toStrictEqual([[["Inter", [{}, "var", "x", 1]], "fontFamily"]]); }); test("a var() in front of a literal is left whole", () => { diff --git a/src/__tests__/native/font-family-stack.test.ts b/src/__tests__/native/font-family-stack.test.ts index 62639d38..51d2a214 100644 --- a/src/__tests__/native/font-family-stack.test.ts +++ b/src/__tests__/native/font-family-stack.test.ts @@ -25,16 +25,13 @@ describe("the reduction", () => { test("a nested stack is flattened, not descended into", () => { expect(applyFontFamily([[...FONT_SANS_STACK]]).fontFamily).toBe("Inter"); - expect(applyFontFamily([[], "Arial"]).fontFamily).toBe("Arial"); - expect(applyFontFamily([[[]], "Arial"]).fontFamily).toBe("Arial"); expect(applyFontFamily([["Inter"], "Arial"]).fontFamily).toBe("Inter"); }); - test("an entry that cannot name a family is skipped", () => { - expect(applyFontFamily([12, "Inter"]).fontFamily).toBe("Inter"); - expect(applyFontFamily([null, "Arial"]).fontFamily).toBe("Arial"); - expect(applyFontFamily([undefined, "Arial"]).fontFamily).toBe("Arial"); - expect(applyFontFamily([true, "Arial"]).fontFamily).toBe("Arial"); + test("an entry that cannot name a family sets nothing", () => { + for (const unusable of [12, null, undefined, true, [], [[]]]) { + expect("fontFamily" in applyFontFamily([unusable, "Arial"])).toBe(false); + } }); test("a single family passes through untouched", () => { diff --git a/src/__tests__/native/font-family.test.tsx b/src/__tests__/native/font-family.test.tsx index 6cfd7517..24082dcf 100644 --- a/src/__tests__/native/font-family.test.tsx +++ b/src/__tests__/native/font-family.test.tsx @@ -9,12 +9,12 @@ const styleOf = (className: string, css: string): unknown => { return screen.getByTestId(testID).props.style; }; +// Every expected value is what Chromium 153, Firefox 155 and WebKit 26.6 compute: a stack with an entry that names no family is invalid describe("a stack the compiler could read", () => { - test("a static stack it could not type arrives as one family", () => { - // The trailing comma puts this on the unparsed path, which applyDeclarations copies straight onto the style + test("a static stack with an empty entry is ignored", () => { expect( styleOf("a", `.a { font-family: Inter, Helvetica,; }`), - ).toStrictEqual({ fontFamily: "Inter" }); + ).toBeUndefined(); }); test("a static stack with nothing usable produces no style at all", () => { @@ -44,33 +44,40 @@ describe("a stack behind a var()", () => { ).toStrictEqual({}); }); - test("an unusable entry inside the resolved stack is skipped", () => { + test("an entry in the resolved stack that names no family sets no family", () => { expect( styleOf( "a", `:root { --f: 12, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, ), - ).toStrictEqual({ fontFamily: "Arial" }); + ).toStrictEqual({}); + }); + + test("a var() behind a family still waits, and a value that names no family invalidates the stack", () => { expect( styleOf( "a", - `:root { --f: unset, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + `:root { --f: Georgia; } .other { --f: Verdana; } .a { font-family: Inter, var(--f); }`, ), - ).toStrictEqual({ fontFamily: "Arial" }); + ).toStrictEqual({ fontFamily: "Inter" }); + expect( + styleOf( + "a", + `:root { --f: 12; } .other { --f: Verdana; } .a { font-family: Inter, var(--f); }`, + ), + ).toStrictEqual({}); }); - test("a var() that resolves to nothing falls through to the next family", () => { + test("a function in a stack invalidates it", () => { expect( - styleOf("a", `.a { font-family: var(--missing), Helvetica; }`), - ).toStrictEqual({ fontFamily: "Helvetica" }); + styleOf("a", `.a { font-family: calc(1px), Inter; }`), + ).toBeUndefined(); }); - test("a function that resolves to something unusable falls through too", () => { - expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual( - { - fontFamily: "Inter", - }, - ); + test("an empty fallback substitutes nothing inside an entry", () => { + expect( + styleOf("a", `.a { font-family: var(--missing,) Helvetica; }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); }); }); @@ -189,7 +196,7 @@ describe("a stack supplied at render", () => { expect(element.props.style).toStrictEqual({ fontFamily: "Georgia" }); }); - test("an empty group in front of a family does not swallow it", () => { + test("an empty group in front of a family invalidates the stack, as an empty entry does", () => { registerCSS(`.a { font-family: var(--stack); }`); render( @@ -198,9 +205,7 @@ describe("a stack supplied at render", () => { , ); - expect(screen.getByTestId(testID).props.style).toStrictEqual({ - fontFamily: "Arial", - }); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); }); test("MEASURED: a null head never reaches the reduction on this plane", () => { diff --git a/src/__tests__/utilities/font-family.test.ts b/src/__tests__/utilities/font-family.test.ts index 645bef8f..7997d86e 100644 --- a/src/__tests__/utilities/font-family.test.ts +++ b/src/__tests__/utilities/font-family.test.ts @@ -1,4 +1,7 @@ -import { narrowFontFamily } from "react-native-css/utilities"; +import { + isReservedFamilyIdent, + narrowFontFamily, +} from "react-native-css/utilities"; describe("narrowFontFamily", () => { test("a family is itself", () => { @@ -15,29 +18,30 @@ describe("narrowFontFamily", () => { }); test("a nested group is read in place, not descended into", () => { - expect(narrowFontFamily([[], "Arial"])).toStrictEqual({ - kind: "family", - family: "Arial", - }); - expect(narrowFontFamily([[[]], "Arial"])).toStrictEqual({ - kind: "family", - family: "Arial", - }); expect(narrowFontFamily([["Inter"], "Arial"])).toStrictEqual({ kind: "family", family: "Inter", }); }); - test("an entry that cannot name a family is skipped", () => { - for (const unusable of [12, null, undefined, true]) { + // Chromium 153, Firefox 155 and WebKit 26.6 ignore a stack with an entry that names no family + test("an entry that cannot name a family invalidates the stack", () => { + for (const unusable of [12, null, undefined, true, [], [[]]]) { expect(narrowFontFamily([unusable, "Arial"])).toStrictEqual({ - kind: "family", - family: "Arial", + kind: "none", + }); + expect(narrowFontFamily(["Arial", unusable])).toStrictEqual({ + kind: "none", }); } }); + test("a function other than var() names no family", () => { + expect(narrowFontFamily([[{}, "calc", [1]], "Inter"])).toStrictEqual({ + kind: "none", + }); + }); + test("nothing usable is `none`", () => { expect(narrowFontFamily([])).toStrictEqual({ kind: "none" }); expect(narrowFontFamily([12, null])).toStrictEqual({ kind: "none" }); @@ -54,13 +58,34 @@ describe("narrowFontFamily", () => { ).toStrictEqual({ kind: "deferred" }); }); - test("a family in front of a variable reference wins", () => { + test("a variable reference behind a family still waits, because its value can invalidate the stack", () => { expect(narrowFontFamily(["Inter", [{}, "var", "x", 1]])).toStrictEqual({ - kind: "family", - family: "Inter", + kind: "deferred", + }); + }); + + test("an entry that names no family invalidates a stack that also waits", () => { + expect(narrowFontFamily([[{}, "var", "x", 1], 12])).toStrictEqual({ + kind: "none", }); }); + test("a reserved ident is never a family name", () => { + for (const reserved of [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + "default", + "INHERIT", + ]) { + expect(isReservedFamilyIdent(reserved)).toBe(true); + } + expect(isReservedFamilyIdent("Inter")).toBe(false); + expect(isReservedFamilyIdent("serif")).toBe(false); + }); + test("an array is a comma-separated stack, never one multi-word name", () => { // The compiler stores an ident group and a comma stack as the same array, so the reduction reads both as a stack expect(narrowFontFamily(["Helvetica", "Neue"])).toStrictEqual({ diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index a2ebed2e..3380826a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,11 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction, narrowFontFamily } from "../utilities"; +import { + isReservedFamilyIdent, + isStyleFunction, + narrowFontFamily, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -995,8 +999,10 @@ export function parseUnparsedDeclaration( let value = parseUnparsed(declaration.value.value, builder, property); if (property === "font-family") { - // A stack LightningCSS could not type is still one family, so only a leading var() waits for render - const narrowing = narrowFontFamily(value); + // A stack LightningCSS could not type is still one family, so only a stack holding a var() waits for render + const narrowing = hasEveryFontFamilyEntry(declaration.value.value) + ? narrowFontFamily(value) + : ({ kind: "none" } as const); switch (narrowing.kind) { case "family": @@ -1030,6 +1036,38 @@ export function parseUnparsedDeclaration( } } +// css-fonts-4 §4.2: an empty entry or an unquoted reserved ident invalidates a written stack, and parseUnparsed keeps neither +function hasEveryFontFamilyEntry(tokens: readonly TokenOrValue[]): boolean { + let entryHoldsSomething = false; + + for (const token of tokens) { + if (token.type === "token") { + switch (token.value.type) { + case "comma": + if (!entryHoldsSomething) { + return false; + } + entryHoldsSomething = false; + continue; + case "white-space": + case "comment": + continue; + case "ident": + if (isReservedFamilyIdent(token.value.value)) { + return false; + } + break; + default: + break; + } + } + + entryHoldsSomething = true; + } + + return entryHoldsSomething; +} + export function parseCustomDeclaration( declaration: Extract, builder: StylesheetBuilder, diff --git a/src/utilities/font-family.ts b/src/utilities/font-family.ts index 655da7ad..c2f222bb 100644 --- a/src/utilities/font-family.ts +++ b/src/utilities/font-family.ts @@ -1,6 +1,6 @@ import { isStyleFunction } from "./style-descriptor"; -// deferred: the first entry that could name a family is a var(), which resolves only at render +// deferred: the stack holds a var(), which resolves only at render and can still invalidate it export type FontFamilyNarrowing = | { readonly kind: "family"; readonly family: string } | { readonly kind: "deferred" } @@ -9,7 +9,21 @@ export type FontFamilyNarrowing = const DEFERRED: FontFamilyNarrowing = { kind: "deferred" }; const NONE: FontFamilyNarrowing = { kind: "none" }; -// The first entry that names a family, reading nested groups in place and skipping one that names none +// css-values-4 §6.1: an unquoted family name is never a CSS-wide keyword or `default` +const RESERVED_IDENTS: ReadonlySet = new Set([ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + "default", +]); + +export function isReservedFamilyIdent(ident: string): boolean { + return RESERVED_IDENTS.has(ident.toLowerCase()); +} + +// css-fonts-4 §4.2: a stack is valid only when every entry names a family, and React Native takes its first export function narrowFontFamily(value: unknown): FontFamilyNarrowing { if (typeof value === "string") { return { kind: "family", family: value }; @@ -20,16 +34,29 @@ export function narrowFontFamily(value: unknown): FontFamilyNarrowing { } if (isStyleFunction(value)) { - return DEFERRED; + return value[1] === "var" ? DEFERRED : NONE; } - for (const entry of value) { - const narrowing = narrowFontFamily(entry); + let narrowing: FontFamilyNarrowing = NONE; - if (narrowing.kind !== "none") { - return narrowing; + for (const entry of value) { + const entryNarrowing = narrowFontFamily(entry); + + switch (entryNarrowing.kind) { + case "none": + return NONE; + case "deferred": + narrowing = DEFERRED; + break; + case "family": + if (narrowing.kind === "none") { + narrowing = entryNarrowing; + } + break; + default: + entryNarrowing satisfies never; } } - return NONE; + return narrowing; } From cea15923e1d1199474d969d387b16bd5213f4c3d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 02:30:17 +0300 Subject: [PATCH 4/4] test: run the serif and mono stacks through a kept variable too --- .../vendor/tailwind/typography.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index cb64a40c..1846a59d 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -36,6 +36,22 @@ describe("Typography - Font Family", () => { }); }); + test.each([ + ["font-serif", "--font-serif", "ui-serif"], + ["font-mono", "--font-mono", "ui-monospace"], + ])("%s with an overridden %s", async (className, variable, family) => { + // A second definition stops the compiler inlining the variable, so the stack reaches render + expect( + await renderSimple({ + className, + sourceInline: [className], + extraCss: `.dark { ${variable}: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: family } }, + }); + }); + test("font-sans overridden at :root", async () => { expect( await renderSimple({