From a86bd66add2ef24c31b131b6c1c0884b4bc13db8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 2 Aug 2026 00:47:03 +0300 Subject: [PATCH 01/10] fix: narrow a var()-valued font-family to its first family 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). --- .../native/font-family-stack.test.ts | 74 +++++++++++++++++++ src/native/objects.ts | 27 +++++++ 2 files changed, 101 insertions(+) create mode 100644 src/__tests__/native/font-family-stack.test.ts 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..8c40d583 --- /dev/null +++ b/src/__tests__/native/font-family-stack.test.ts @@ -0,0 +1,74 @@ +import { applyValue } from "../../native/objects"; + +/** + * `font-family` reaches React Native as ONE family, whichever route it took. + * + * The parsed path already narrows a stack to its first family and warns about + * the rest. A value arriving through a `var()` never reaches that parser — the + * declaration compiles unparsed and the variable is read at render — so the + * narrowing has to exist on the runtime side too, in the one place the property + * name and the resolved value are both in hand. + * + * Without it the runtime hands Fabric an array where `TextStyle.fontFamily` is + * a `string`, and the declaration is refused outright: the element renders in + * the platform default rather than in the family the stylesheet asked for. That + * is the shape a bundled typeface disappears in, and `font-family: + * var(--font-sans)` is how Tailwind's own default theme spells it. + */ + +/** 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; + +test("a resolved font stack reduces to its first family, as a string", () => { + const target: Record = {}; + applyValue(target, "fontFamily", [...FONT_SANS_STACK]); + + expect(target.fontFamily).toBe("Inter"); + // The type matters as much as the value: React Native's `fontFamily` is a + // `string`, and an array is what Fabric refuses. + expect(typeof target.fontFamily).toBe("string"); +}); + +test("a singly wrapped stack is unwrapped too", () => { + // A resolved variable can arrive as the list inside a list, which is why the + // reduction loops rather than taking `[0]` once. + const target: Record = {}; + applyValue(target, "fontFamily", [[...FONT_SANS_STACK]]); + + expect(target.fontFamily).toBe("Inter"); +}); + +test("a single family passes through untouched", () => { + const target: Record = {}; + applyValue(target, "fontFamily", "fisona-icons"); + + expect(target.fontFamily).toBe("fisona-icons"); +}); + +test("the reduction is scoped to fontFamily", () => { + // `fontVariant` is legitimately a list on React Native, so reducing every + // array-valued property would trade one silent failure for another. + const target: Record = {}; + applyValue(target, "fontVariant", ["small-caps"]); + + expect(target.fontVariant).toStrictEqual(["small-caps"]); +}); + +test("both sentinel meanings survive the reduction", () => { + // The reduction sits before the final assignment, so it must not disturb what + // `applyValue` already means: `undefined` is "set nothing", and the null + // literal is "clear this value", which React Native spells as `undefined`. + const untouched: Record = {}; + applyValue(untouched, "fontFamily", undefined); + expect("fontFamily" in untouched).toBe(false); + + const cleared: Record = { fontFamily: "Inter" }; + applyValue(cleared, "fontFamily", null); + expect(cleared.fontFamily).toBeUndefined(); +}); diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..4adc8693 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -48,6 +48,23 @@ export function applyShorthand(value: any) { return target; } +/** + * The first family of a resolved `font-family` stack. + * + * The loop walks nested arrays because a resolved variable can arrive singly + * wrapped — `var(--font-sans)` whose variable holds a stack resolves to the + * list inside a list. + */ +function firstFontFamily(stack: readonly unknown[]): unknown { + let candidate: unknown = stack; + + while (Array.isArray(candidate)) { + candidate = candidate[0]; + } + + return candidate; +} + export function applyValue( target: Record, prop: string, @@ -84,6 +101,16 @@ export function applyValue( return; } + // React Native's `fontFamily` is ONE family, not a stack, and this is the one + // place the property name and the resolved value are both in hand. The parsed + // path already narrows a stack to its first family; a value arriving through + // a `var()` never reaches that parser, so without this the runtime hands + // Fabric an array and the declaration is refused outright. + if (prop === "fontFamily" && Array.isArray(value)) { + target[prop] = firstFontFamily(value); + return; + } + target[prop] = value; } From 7ee4bf9e0b8ee37a6f9c76cbd5c33b29f4503938 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 20:43:07 +0300 Subject: [PATCH 02/10] test(compiler): pin the font-family narrowing the runtime fix relies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/compiler/font-family.test.ts | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/__tests__/compiler/font-family.test.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..abf9a933 --- /dev/null +++ b/src/__tests__/compiler/font-family.test.ts @@ -0,0 +1,27 @@ +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 ?? []); +}; + +test("a literal font-family stack narrows to its first family", () => { + // React Native's fontFamily is one family, not a stack. parseFontFamily takes + // value.family[0], so the narrowing happens here and never reaches the runtime + expect( + declarationsFor(`.a { font-family: Inter, Helvetica, sans-serif; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); +}); + +test("a var()-valued font-family reaches the runtime unnarrowed", () => { + // The counterpart the runtime has to handle: the compiler emits a var reference, + // so parseFontFamily never sees the stack. --stack is declared twice because a + // single-definition variable is inlined and would be narrowed here after all + expect( + declarationsFor( + `:root { --stack: Inter, Helvetica; } .other { --stack: Georgia, serif; } .a { font-family: var(--stack); }`, + "a", + ), + ).toStrictEqual([[[{}, "var", "stack", 1], "fontFamily", 1]]); +}); From b695110498192a82decfdb660e5bb4fe68d2a37e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:10:29 +0300 Subject: [PATCH 03/10] fix(utilities): stop isStyleFunction misreading a nested or null-headed array A style function's head is `Record` - 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. --- .../utilities/style-descriptor.test.ts | 33 +++++++++++++++++++ src/utilities/style-descriptor.ts | 13 +++++--- 2 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/utilities/style-descriptor.test.ts diff --git a/src/__tests__/utilities/style-descriptor.test.ts b/src/__tests__/utilities/style-descriptor.test.ts new file mode 100644 index 00000000..8e840006 --- /dev/null +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -0,0 +1,33 @@ +import { isStyleFunction } from "react-native-css/utilities"; + +/** + * A style function is a descriptor the runtime evaluates - `[{}, "var", …]`. + * Its head is `Record`: a plain object with no keys. Two other + * shapes reach `typeof "object"` at index 0 without being one, and both occur + * in a resolved value. + */ +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", () => { + 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", () => { + // `Object.keys([])` is also empty, so an empty first GROUP reads as a + // function head unless the array case is excluded first. + 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); + }); +}); diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 1310d62b..dd62a69d 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -11,12 +11,15 @@ export function isStyleDescriptorArray( return false; } -export function isStyleFunction( - value: StyleDescriptor, -): value is StyleFunction { +export function isStyleFunction(value: unknown): value is StyleFunction { if (Array.isArray(value)) { - return typeof value[0] === "object" - ? Object.keys(value[0]).length === 0 + // A style function's head is `Record` - a plain object with + // no keys. A nested stack (`[[], "Arial"]`) and a null entry both reach + // `typeof "object"` without being one. + const head: unknown = value[0]; + + return typeof head === "object" && head !== null && !Array.isArray(head) + ? Object.keys(head).length === 0 : false; } From 0fee5a58864a6e62bdc409edc27a495a29f13495 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:10:46 +0300 Subject: [PATCH 04/10] fix(compiler): narrow every font-family stack to a single family 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. --- src/__tests__/compiler/font-family.test.ts | 135 +++++++++++++++++--- src/__tests__/utilities/font-family.test.ts | 82 ++++++++++++ src/compiler/declarations.ts | 47 ++++++- src/utilities/font-family.ts | 49 +++++++ src/utilities/index.ts | 1 + 5 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/utilities/font-family.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 index abf9a933..15f0a27d 100644 --- a/src/__tests__/compiler/font-family.test.ts +++ b/src/__tests__/compiler/font-family.test.ts @@ -1,27 +1,130 @@ import { compile } from "react-native-css/compiler"; +/** + * React Native's `fontFamily` is one family name, never a stack, so every + * compiler path that produces `font-family` has to reduce a stack to a single + * usable family. There are three of them — the typed parser, the `font` + * shorthand, and the unparsed path a declaration falls to when LightningCSS + * cannot type it — and only the value a `var()` supplies is left for the + * runtime, because it does not exist until render. + */ + const declarationsFor = (css: string, className: string) => { const rules = new Map(compile(css).stylesheet().s ?? []).get(className); return (rules ?? []).flatMap((rule) => rule.d ?? []); }; -test("a literal font-family stack narrows to its first family", () => { - // React Native's fontFamily is one family, not a stack. parseFontFamily takes - // value.family[0], so the narrowing happens here and never reaches the runtime - expect( - declarationsFor(`.a { font-family: Inter, Helvetica, sans-serif; }`, "a"), - ).toStrictEqual([{ fontFamily: "Inter" }]); +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("no warning is emitted for the families that are dropped", () => { + // React Native can only use one, so the rest are not a mistake the author + // can correct. `warnings()` stays empty for every stack spelling. + expect( + compile(`.a { font-family: Inter, Helvetica, sans-serif; }`).warnings(), + ).toStrictEqual({}); + expect( + compile(`.a { font-family: Inter, Helvetica,; }`).warnings(), + ).toStrictEqual({}); + }); }); -test("a var()-valued font-family reaches the runtime unnarrowed", () => { - // The counterpart the runtime has to handle: the compiler emits a var reference, - // so parseFontFamily never sees the stack. --stack is declared twice because a - // single-definition variable is inlined and would be narrowed here after all - expect( - declarationsFor( - `:root { --stack: Inter, Helvetica; } .other { --stack: Georgia, serif; } .a { font-family: var(--stack); }`, - "a", - ), - ).toStrictEqual([[[{}, "var", "stack", 1], "fontFamily", 1]]); +describe("the unparsed path", () => { + // LightningCSS cannot type any of these, so they reach `parseUnparsed` and + // come out of the compiler as a static value rather than a typed one. Each + // spelling is plain CSS: no casts, no runtime shape, and no `var()`. + 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("an entry that cannot name a family is skipped", () => { + // A browser skips a family it cannot use and moves to the next one. `12` is + // not a family name, so `Inter` is the first that is. + expect( + declarationsFor(`.a { font-family: 12, Inter; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a stack with nothing usable emits no declaration at all", () => { + // Not `fontFamily: []`, and not `fontFamily: undefined` either: the + // declaration is dropped, so a family set by a lower-specificity rule + // survives the way the cascade says it should. + 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", () => { + // `--stack` is declared twice in each of these: a single-definition variable + // is inlined by the compiler and would be narrowed above after all. + + 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", () => { + // This is what separates narrowing from "the compiler emits a var + // reference": the first family is known, so the var can never be used and + // the declaration stops being reactive. + 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", () => { + // The opposite case, and the reason the compiler cannot simply take the + // first entry: whether `Helvetica` is reached depends on what `--x` holds. + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: var(--x), Helvetica; }`, + "a", + ), + ).toStrictEqual([[[[{}, "var", "x", 1], "Helvetica"], "fontFamily"]]); + }); }); diff --git a/src/__tests__/utilities/font-family.test.ts b/src/__tests__/utilities/font-family.test.ts new file mode 100644 index 00000000..7bcd4758 --- /dev/null +++ b/src/__tests__/utilities/font-family.test.ts @@ -0,0 +1,82 @@ +import { narrowFontFamily } from "react-native-css/utilities"; + +/** + * The one reduction both planes read. The compiler applies it to what it can + * see and the runtime applies it again to what only exists at render, so the + * three outcomes have to be stated where both can find them. + */ +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", () => { + // Not `{}`: an empty object in front of a string IS a style function, and + // the case below says so. + 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", () => { + // The compiler stops here and emits the descriptor whole; the runtime runs + // the reduction again once the variable has a value. + 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", () => { + // React Native only reaches the first entry, so the variable can never be + // used and the answer does not depend on it. + expect(narrowFontFamily(["Inter", [{}, "var", "x", 1]])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + 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/compiler/declarations.ts b/src/compiler/declarations.ts index 13013642..3d3f6610 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 type { StyleDescriptor, StyleFunction, @@ -676,7 +676,7 @@ function parseFont( { value }: DeclarationType<"font">, builder: StylesheetBuilder, ) { - builder.addDescriptor("font-family", value.family[0]); + builder.addDescriptor("font-family", firstFontFamily(value.family)); builder.addDescriptor( "line-height", parseLineHeight(value.lineHeight, builder), @@ -941,7 +941,29 @@ 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") { + /** + * The other half of `parseFontFamily`. A `font-family` LightningCSS could + * not type reaches here instead, and it is still one family to React + * Native - `font-family: Inter, Helvetica,` is a stack whichever parser + * saw it. Only a stack whose first usable entry is a `var()` survives to + * render, because that is the only value the compiler cannot read. + */ + const narrowing = narrowFontFamily(value); + + switch (narrowing.kind) { + case "family": + value = narrowing.family; + break; + case "none": + value = undefined; + break; + case "deferred": + break; + } + } builder.addDescriptor(property, value); @@ -2226,9 +2248,22 @@ export function parseVerticalAlign( return undefined; } -function parseFontFamily({ value }: DeclarationType<"font-family">) { - // React Native only allows one font family - better hope this is the right one :) - return value[0]; +function parseFontFamily({ + value, +}: DeclarationType<"font-family">): StyleDescriptor { + return firstFontFamily(value); +} + +/** + * React Native only allows one font family, so every path that produces + * `font-family` narrows the stack it was given. This one is reached when + * LightningCSS could type the declaration, which means every entry is a family + * name and the answer is always the first of them. + */ +function firstFontFamily(stack: readonly string[]): StyleDescriptor { + const narrowing = narrowFontFamily(stack); + + return narrowing.kind === "family" ? narrowing.family : undefined; } export function parseLineHeightDeclaration( diff --git a/src/utilities/font-family.ts b/src/utilities/font-family.ts new file mode 100644 index 00000000..3c258739 --- /dev/null +++ b/src/utilities/font-family.ts @@ -0,0 +1,49 @@ +import { isStyleFunction } from "./style-descriptor"; + +/** + * What a `font-family` stack reduces to. + * + * `deferred` is the answer the compiler cannot give: the first entry that could + * name a family is a variable reference, and its value only exists 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" }; + +/** + * React Native's `fontFamily` is one family name, never a stack, so every + * `font-family` a stylesheet produces has to reduce to a single family. + * + * The reduction is flatten-then-first-usable: the stack is read left to right, + * 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. Taking `[0]` and descending into it instead loses every + * sibling standing behind an unusable first entry. + */ +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 5e4906a2effe19962bd35dfe1cbb4fdee71a7ff6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:11:00 +0300 Subject: [PATCH 05/10] fix(native): reduce a resolved font-family the same way the compiler 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. --- .../native/font-family-stack.test.ts | 130 +++++++++++------- src/__tests__/native/font-family.test.tsx | 82 +++++++++++ src/native/objects.ts | 39 +++--- 3 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/native/font-family.test.tsx diff --git a/src/__tests__/native/font-family-stack.test.ts b/src/__tests__/native/font-family-stack.test.ts index 8c40d583..85762a95 100644 --- a/src/__tests__/native/font-family-stack.test.ts +++ b/src/__tests__/native/font-family-stack.test.ts @@ -3,17 +3,14 @@ import { applyValue } from "../../native/objects"; /** * `font-family` reaches React Native as ONE family, whichever route it took. * - * The parsed path already narrows a stack to its first family and warns about - * the rest. A value arriving through a `var()` never reaches that parser — the - * declaration compiles unparsed and the variable is read at render — so the - * narrowing has to exist on the runtime side too, in the one place the property - * name and the resolved value are both in hand. + * Every compiler path narrows the stacks it can see. What it cannot see is the + * value behind a `var()`, which only exists at render — so the same reduction + * runs again here, where the property name and the resolved value are both in + * hand for the first time on that path. * - * Without it the runtime hands Fabric an array where `TextStyle.fontFamily` is - * a `string`, and the declaration is refused outright: the element renders in - * the platform default rather than in the family the stylesheet asked for. That - * is the shape a bundled typeface disappears in, and `font-family: - * var(--font-sans)` is how Tailwind's own default theme spells it. + * `src/__tests__/native/font-family.test.tsx` drives the same reduction through + * a real render; these cases reach `applyValue` directly so each rule of the + * reduction can be stated on its own. */ /** A real Tailwind `--font-sans`, which is why the stack is the common case. */ @@ -25,50 +22,91 @@ const FONT_SANS_STACK = [ "sans-serif", ] as const; -test("a resolved font stack reduces to its first family, as a string", () => { +const applyFontFamily = (value: unknown): Record => { const target: Record = {}; - applyValue(target, "fontFamily", [...FONT_SANS_STACK]); + applyValue(target, "fontFamily", value); + return target; +}; - expect(target.fontFamily).toBe("Inter"); - // The type matters as much as the value: React Native's `fontFamily` is a - // `string`, and an array is what Fabric refuses. - expect(typeof target.fontFamily).toBe("string"); -}); +describe("the reduction", () => { + test("a resolved stack reduces to its first family, as a string", () => { + const target = applyFontFamily([...FONT_SANS_STACK]); -test("a singly wrapped stack is unwrapped too", () => { - // A resolved variable can arrive as the list inside a list, which is why the - // reduction loops rather than taking `[0]` once. - const target: Record = {}; - applyValue(target, "fontFamily", [[...FONT_SANS_STACK]]); + expect(target.fontFamily).toBe("Inter"); + // The type matters as much as the value: React Native's `fontFamily` is a + // `string`, and an array is what Fabric refuses. + expect(typeof target.fontFamily).toBe("string"); + }); - expect(target.fontFamily).toBe("Inter"); -}); + test("a nested stack is flattened, not descended into", () => { + // Descending into the first entry and staying there loses every sibling + // behind an empty group. Flattening reaches them. + 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("a single family passes through untouched", () => { - const target: Record = {}; - applyValue(target, "fontFamily", "fisona-icons"); + test("an entry that cannot name a family is skipped", () => { + // A browser skips a family it cannot use and moves to the next. Assigning + // one is worse than skipping it: `fontFamily` is typed `string`, so a + // number or a null reaches Fabric as a value it has no rule for. + 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"); + }); - expect(target.fontFamily).toBe("fisona-icons"); -}); + test("a single family passes through untouched", () => { + expect(applyFontFamily("fisona-icons").fontFamily).toBe("fisona-icons"); + }); -test("the reduction is scoped to fontFamily", () => { - // `fontVariant` is legitimately a list on React Native, so reducing every - // array-valued property would trade one silent failure for another. - const target: Record = {}; - applyValue(target, "fontVariant", ["small-caps"]); + test("a stack with nothing usable sets nothing", () => { + // `applyValue` already separates "set nothing" (leave the key absent) from + // "clear" (set the key to `undefined`). A stack with no usable entry is a + // declaration that failed, so it takes the first door and leaves whatever + // an earlier rule put there standing. + expect("fontFamily" in applyFontFamily([])).toBe(false); + expect("fontFamily" in applyFontFamily([12])).toBe(false); + expect("fontFamily" in applyFontFamily([[], [null]])).toBe(false); - expect(target.fontVariant).toStrictEqual(["small-caps"]); + const inherited: Record = { fontFamily: "Inter" }; + applyValue(inherited, "fontFamily", []); + expect(inherited.fontFamily).toBe("Inter"); + }); }); -test("both sentinel meanings survive the reduction", () => { - // The reduction sits before the final assignment, so it must not disturb what - // `applyValue` already means: `undefined` is "set nothing", and the null - // literal is "clear this value", which React Native spells as `undefined`. - const untouched: Record = {}; - applyValue(untouched, "fontFamily", undefined); - expect("fontFamily" in untouched).toBe(false); - - const cleared: Record = { fontFamily: "Inter" }; - applyValue(cleared, "fontFamily", null); - expect(cleared.fontFamily).toBeUndefined(); +describe("what the reduction must not disturb", () => { + test("the reduction is scoped to fontFamily", () => { + // `fontVariant` is legitimately a list on React Native, so reducing every + // array-valued property would trade one silent failure for another. + const target: Record = {}; + applyValue(target, "fontVariant", ["small-caps"]); + + expect(target.fontVariant).toStrictEqual(["small-caps"]); + }); + + test("both sentinel meanings survive the reduction", () => { + // `undefined` is "set nothing", and the null literal is "clear this value", + // which React Native spells as `undefined`. + 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 and reclaims it by identity. Reducing it away + // would strand every `var()`-valued font-family, unresolved forever. + 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..418d5d0d --- /dev/null +++ b/src/__tests__/native/font-family.test.tsx @@ -0,0 +1,82 @@ +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"; + +/** + * The end-to-end half of `font-family-stack.test.ts`: a stack written in CSS + * has to reach the props as one family name whichever route it takes through + * the compiler, and a `var()` is the route that only resolves at render. + * + * A variable is declared twice in each `var()` case on purpose — a variable + * with a single definition is inlined by the compiler, which narrows it there + * and never exercises the runtime. + */ + +const styleOf = (className: string, css: string): unknown => { + registerCSS(css); + render(); + return screen.getByTestId(testID).props.style; +}; + +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 static stack the compiler could not type arrives as one family", () => { + // The trailing comma is what pushes this declaration onto the unparsed path. + // It never reaches `applyValue` — `applyDeclarations` copies a static style + // straight onto the target — so the runtime reduction cannot save it and the + // compiler has to. + 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(); +}); + +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("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 stack supplied at render arrives as one family, and stays current", () => { + // A variable set at render rather than in the stylesheet takes the same + // route, and it is the one a stack can be written into directly. + 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" }); +}); diff --git a/src/native/objects.ts b/src/native/objects.ts index 4adc8693..e1d5f294 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"; @@ -49,20 +51,12 @@ export function applyShorthand(value: any) { } /** - * The first family of a resolved `font-family` stack. - * - * The loop walks nested arrays because a resolved variable can arrive singly - * wrapped — `var(--font-sans)` whose variable holds a stack resolves to the - * list inside a list. + * `applyDeclarations` parks `{ [prop]: true }` on the target while a delayed + * value resolves, and later reclaims it by identity. It is machinery, never a + * style value, so it has to reach the target untouched. */ -function firstFontFamily(stack: readonly unknown[]): unknown { - let candidate: unknown = stack; - - while (Array.isArray(candidate)) { - candidate = candidate[0]; - } - - return candidate; +function isDelayedMarker(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); } export function applyValue( @@ -101,13 +95,18 @@ export function applyValue( return; } - // React Native's `fontFamily` is ONE family, not a stack, and this is the one - // place the property name and the resolved value are both in hand. The parsed - // path already narrows a stack to its first family; a value arriving through - // a `var()` never reaches that parser, so without this the runtime hands - // Fabric an array and the declaration is refused outright. - if (prop === "fontFamily" && Array.isArray(value)) { - target[prop] = firstFontFamily(value); + // React Native's `fontFamily` is ONE family, not a stack. The compiler + // narrows every stack it can read; a value arriving through a `var()` is the + // one it cannot, and this is the first place on that path where the property + // name and the resolved value are both in hand. + if (prop === "fontFamily" && value !== undefined && !isDelayedMarker(value)) { + const narrowing = narrowFontFamily(value); + + // Nothing usable leaves the key alone rather than clearing it, so a family + // an earlier rule set survives the way the cascade says it should. + if (narrowing.kind === "family") { + target[prop] = narrowing.family; + } return; } From 823099241e39b353b6d39f55c498e04b8e6eb870 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:11:11 +0300 Subject: [PATCH 06/10] test(tailwind): add the missing Typography - Font Family block 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. --- .../vendor/tailwind/typography.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..9e40fad6 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -1,5 +1,66 @@ import { renderCurrentTest, renderSimple } from "./_tailwind"; +describe("Typography - Font Family", () => { + /** + * Every one of these is a stack in the CSS and one family in the props, + * because React Native's `fontFamily` is a single family name. + * + * The default theme's own values are the CSS generics — no typeface is + * registered under `ui-sans-serif` on either platform, so `font-sans` renders + * in the platform default whether or not the stack was narrowed. Narrowing is + * what makes the OVERRIDE below work, which is how a bundled typeface is + * actually reached. + */ + 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 is what stops the compiler inlining the variable, so + // this is the case where the stack survives to render and the runtime has + // to reduce it. It is also the realistic one: a bundled typeface is set by + // overriding the theme variable, not by the default theme. + 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({ From a5d39f2a171373cda70f7f1613bed85610e5ffa1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:15:14 +0300 Subject: [PATCH 07/10] test(native): pin the non-var deferred head `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. --- src/__tests__/native/font-family.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/__tests__/native/font-family.test.tsx b/src/__tests__/native/font-family.test.tsx index 418d5d0d..4095e4f1 100644 --- a/src/__tests__/native/font-family.test.tsx +++ b/src/__tests__/native/font-family.test.tsx @@ -60,6 +60,14 @@ test("a var() that resolves to nothing falls through to the next family", () => ).toStrictEqual({ fontFamily: "Helvetica" }); }); +test("a function that resolves to something unusable falls through too", () => { + // Not every deferred head is a `var()`. `calc()` resolves to a number, which + // is skipped at render for the same reason `12` is skipped at compile time. + expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual({ + fontFamily: "Inter", + }); +}); + test("a stack supplied at render arrives as one family, and stays current", () => { // A variable set at render rather than in the stylesheet takes the same // route, and it is the one a stack can be written into directly. From 50657d6d487394c6e56a6a0db8fb4c0e82770e4a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:48:31 +0300 Subject: [PATCH 08/10] test: measure which of these tests bind, and cover the var() plane properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", ["", ""]]`. 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. --- src/__tests__/compiler/font-family.test.ts | 134 +++++++- .../native/font-family-stack.test.ts | 11 + src/__tests__/native/font-family.test.tsx | 303 ++++++++++++++---- src/__tests__/utilities/font-family.test.ts | 16 + .../utilities/style-descriptor.test.ts | 3 + .../vendor/tailwind/typography.test.tsx | 6 + 6 files changed, 413 insertions(+), 60 deletions(-) diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts index 15f0a27d..95f2542e 100644 --- a/src/__tests__/compiler/font-family.test.ts +++ b/src/__tests__/compiler/font-family.test.ts @@ -7,6 +7,10 @@ import { compile } from "react-native-css/compiler"; * shorthand, and the unparsed path a declaration falls to when LightningCSS * cannot type it — and only the value a `var()` supplies is left for the * runtime, because it does not exist until render. + * + * This plane can only say which descriptor was emitted. Which family React + * Native is handed is `src/__tests__/native/font-family.test.tsx`, and for a + * `var()` that is the only plane that can answer it. */ const declarationsFor = (css: string, className: string) => { @@ -15,7 +19,23 @@ const declarationsFor = (css: string, className: string) => { 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", () => { + // CONTROL. LightningCSS types these, and `parseFontFamily` already took + // `value[0]` before this change — every case in this block passes on `main`. + // + // Measured: putting `return stack[0]` back inside `firstFontFamily` reddens + // nothing here or anywhere else, because a typed `value.family` is a list of + // family names and its first entry is always usable. So this block guards a + // refactor that no input can distinguish, and says so rather than implying it + // caught something. What it does catch is the typed path drifting away from + // the shared reduction the other two producers read. + test("a literal stack narrows to its first family", () => { expect( declarationsFor(`.a { font-family: Inter, Helvetica, sans-serif; }`, "a"), @@ -35,9 +55,25 @@ describe("the typed path", () => { ]); }); + test("a name that is not a bare ident survives whole", () => { + // Quotes are how CSS spells a family name containing a space or a comma, + // and LightningCSS hands back the unquoted string. A multi-ident name is + // joined for the same reason: it is one family, not two. + 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", () => { - // React Native can only use one, so the rest are not a mistake the author - // can correct. `warnings()` stays empty for every stack spelling. + // CONTROL, and the reason there is nothing to warn about: React Native can + // only use one, so the rest are not a mistake the author can correct. + // `warnings()` stays empty for every stack spelling. expect( compile(`.a { font-family: Inter, Helvetica, sans-serif; }`).warnings(), ).toStrictEqual({}); @@ -60,6 +96,18 @@ describe("the unparsed path", () => { expect(declarationsFor(css, "a")).toStrictEqual([{ fontFamily: "Inter" }]); }); + test("a quoted name keeps its spaces and its commas", () => { + // The twin of the typed-path case above: the two paths have to agree on + // what one family is, or the same stylesheet renders differently depending + // on whether LightningCSS could type the declaration. + 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", () => { // A browser skips a family it cannot use and moves to the next one. `12` is // not a family name, so `Inter` is the first that is. @@ -97,6 +145,10 @@ describe("the var() path", () => { // is inlined by the compiler and would be narrowed above after all. test("a stack the compiler cannot see is left for the runtime", () => { + // CONTROL. This is the premise the runtime reduction rests on rather than a + // consequence of it, so it passes on `main`. If the compiler ever starts + // narrowing here, the runtime half stops being reachable and this goes red + // to say so. expect( declarationsFor( `:root { --stack: Inter, Helvetica; } .other { --stack: Georgia, serif; } .a { font-family: var(--stack); }`, @@ -118,8 +170,8 @@ describe("the var() path", () => { }); test("a var() in front of a literal is left whole", () => { - // The opposite case, and the reason the compiler cannot simply take the - // first entry: whether `Helvetica` is reached depends on what `--x` holds. + // CONTROL, and the reason the compiler cannot simply take the first entry: + // whether `Helvetica` is reached depends on what `--x` holds. expect( declarationsFor( `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: var(--x), Helvetica; }`, @@ -127,4 +179,78 @@ describe("the var() path", () => { ), ).toStrictEqual([[[[{}, "var", "x", 1], "Helvetica"], "fontFamily"]]); }); + + test("a fallback is emitted whole, whatever shape it has", () => { + // CONTROL — passes on `main`, and that is what it is for: it says the + // compiler plane cannot answer any of these, so the family each one lands + // on has to be measured at render. + // + // A fallback lives inside the `var()`, so the compiler cannot narrow it + // either — it does not know yet whether the variable has a value. Each of + // these is one deferred descriptor, and which family lands is decided at + // render: `src/__tests__/native/font-family.test.tsx` has the answers. + 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", () => { + // CONTROL — passes on `main`. It measures what the compiler stores, which + // this change does not touch, and that measurement is the reason the limit + // is a limit rather than a bug in the reduction. + // + // The measurement behind the known limit in + // `src/__tests__/native/font-family.test.tsx`. `reduceParseUnparsed` groups + // an unparsed value by comma and nests a multi-token group, and for + // `font-family` a single-entry stack of two idents and a two-entry stack of + // one ident each collapse onto the identical array. + // + // No reduction downstream can separate them, so `--f: Helvetica Neue` + // renders as `Helvetica`. Quoting the name keeps it a single string, which + // is CSS's own answer for a family name that is not one ident. + 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 index 85762a95..4574d616 100644 --- a/src/__tests__/native/font-family-stack.test.ts +++ b/src/__tests__/native/font-family-stack.test.ts @@ -52,12 +52,18 @@ describe("the reduction", () => { // one is worse than skipping it: `fontFamily` is typed `string`, so a // number or a null reaches Fabric as a value it has no rule for. expect(applyFontFamily([12, "Inter"]).fontFamily).toBe("Inter"); + // The null head is pinned here and nowhere else: `resolveValue`'s own + // `isDescriptorArray` reads it as a style-function call and resolves the + // stack away before `applyValue` sees it, so no render can deliver this + // value. `native/font-family.test.tsx` carries that measurement. 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", () => { + // CONTROL — passes on `main`, where nothing intercepts `fontFamily` at all. + // It is the case the reduction must leave exactly as it found it. expect(applyFontFamily("fisona-icons").fontFamily).toBe("fisona-icons"); }); @@ -77,6 +83,11 @@ describe("the reduction", () => { }); describe("what the reduction must not disturb", () => { + // Every case in this block is a CONTROL: it passes on `main`, where + // `applyValue` has no `fontFamily` branch to get wrong. They are the boundary + // the new branch has to stay inside, and each one goes red for a different + // way of widening it. + test("the reduction is scoped to fontFamily", () => { // `fontVariant` is legitimately a list on React Native, so reducing every // array-valued property would trade one silent failure for another. diff --git a/src/__tests__/native/font-family.test.tsx b/src/__tests__/native/font-family.test.tsx index 4095e4f1..d66d39ed 100644 --- a/src/__tests__/native/font-family.test.tsx +++ b/src/__tests__/native/font-family.test.tsx @@ -8,6 +8,11 @@ import { VariableContextProvider } from "react-native-css/native"; * has to reach the props as one family name whichever route it takes through * the compiler, and a `var()` is the route that only resolves at render. * + * This is the plane that decides the question. A compiler assertion says which + * descriptor was emitted; only a render says which family React Native is + * handed, and for every `var()` spelling below the compiler emits the same + * deferred descriptor whatever the variable holds. + * * A variable is declared twice in each `var()` case on purpose — a variable * with a single definition is inlined by the compiler, which narrows it there * and never exercises the runtime. @@ -19,72 +24,258 @@ const styleOf = (className: string, css: string): unknown => { return screen.getByTestId(testID).props.style; }; -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" }); -}); +describe("a stack the compiler could read", () => { + test("a static stack it could not type arrives as one family", () => { + // The trailing comma is what pushes this declaration onto the unparsed + // path. It never reaches `applyValue` — `applyDeclarations` copies a static + // style straight onto the target — so the runtime reduction cannot save it + // and the compiler has to. + expect( + styleOf("a", `.a { font-family: Inter, Helvetica,; }`), + ).toStrictEqual({ fontFamily: "Inter" }); + }); -test("a static stack the compiler could not type arrives as one family", () => { - // The trailing comma is what pushes this declaration onto the unparsed path. - // It never reaches `applyValue` — `applyDeclarations` copies a static style - // straight onto the target — so the runtime reduction cannot save it and the - // compiler has to. - 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(); }); }); -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("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", () => { + // The reduction runs on what the variable resolved to, so a head the + // stylesheet put there is skipped at render the same way a compile-time one + // is. `unset` resolves to the null literal, `12` to a number. + 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", () => { + // Not every deferred head is a `var()`. `calc()` resolves to a number, + // which is skipped at render for the same reason `12` is skipped at compile + // time. + expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual( + { + fontFamily: "Inter", + }, + ); + }); }); -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" }); +describe("a var() fallback", () => { + // A fallback lives INSIDE the `var()`, so none of these can be answered on + // the compiler plane: every one compiles to the same deferred descriptor + // shape and the family is chosen while resolving it. + // + // The split inside this block is the useful part. A fallback that resolves to + // a single family arrives as a string, which `main` already handled — those + // three are CONTROLS. A fallback that resolves to a stack, or one standing in + // front of another family, arrives as an array and is where `main` hands + // React Native a value it refuses. + + 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" }); + }); }); -test("a function that resolves to something unusable falls through too", () => { - // Not every deferred head is a `var()`. `calc()` resolves to a number, which - // is skipped at render for the same reason `12` is skipped at compile time. - expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual({ - fontFamily: "Inter", +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", () => { + // The quotes are what keep the comma out of the stack. Splitting here would + // invent a family called `Bar`. + 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", () => { + // Measured, and the cause is upstream of every reduction: the compiler + // stores a space-separated ident group and a comma-separated stack in the + // SAME array. `--f: Helvetica Neue` and `--f: Inter, Helvetica` both + // compile to `["f", [, ]]`, which + // `src/__tests__/compiler/font-family.test.ts` pins. Nothing downstream can + // tell them apart, so the reduction reads both as a stack. + // + // Quoting the name is the fix, and it is CSS's own answer for a family name + // that is not a single ident. + 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" }); }); }); -test("a stack supplied at render arrives as one family, and stays current", () => { - // A variable set at render rather than in the stylesheet takes the same - // route, and it is the one a stack can be written into directly. - 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" }); +describe("a stack supplied at render", () => { + test("it arrives as one family, and stays current", () => { + // A variable set at render rather than in the stylesheet takes the same + // route, and it is the one a stack can be written into directly. + 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", () => { + // `[[], "Arial"]` is the shape that reads as a style-function call unless + // an array head is excluded first, and this is the one route that puts it + // in front of the reduction end to end. + 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", () => { + // `applyValue` handles `[null, "Arial"]` and `font-family-stack.test.ts` + // pins it, but no render can deliver that value, for two independent + // reasons measured rather than assumed: + // + // 1. `StyleDescriptor` has no null member, so `value={{ "--stack": [null, + // "Arial"] }}` does not compile. Writing it here fails `yarn typecheck` + // with TS2322 rather than failing this test. + // 2. Even reached past the types, `resolveValue`'s own `isDescriptorArray` + // reads a null head as a style-function call (`typeof null === + // "object"`) and resolves the whole stack to `undefined` before + // `applyValue` sees it. + // + // The second is a separate defect on a shared path, out of this change's + // reach. What this plane does carry is the head the type system allows, + // and it takes the reduction's skip branch: + 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 index 7bcd4758..04ee81ed 100644 --- a/src/__tests__/utilities/font-family.test.ts +++ b/src/__tests__/utilities/font-family.test.ts @@ -72,6 +72,22 @@ describe("narrowFontFamily", () => { }); }); + test("an array is a comma-separated stack, never one multi-word name", () => { + // The rule that decides the known limit. The compiler stores + // `--f: Helvetica Neue` and `--f: Inter, Helvetica` as the same array + // (`compiler/font-family.test.ts` pins that), so the reduction has to pick + // one reading and a stack is the one every other case needs. Quoting the + // name keeps it a string, which is the shape that survives. + 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"); diff --git a/src/__tests__/utilities/style-descriptor.test.ts b/src/__tests__/utilities/style-descriptor.test.ts index 8e840006..72439b30 100644 --- a/src/__tests__/utilities/style-descriptor.test.ts +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -8,11 +8,14 @@ import { isStyleFunction } from "react-native-css/utilities"; */ describe("isStyleFunction", () => { test("a style function is one", () => { + // CONTROL — passes on `main`. Widening the guard is the easy way to fix the + // two cases below, and this is what says the answer did not move. 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); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 9e40fad6..87bc0611 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -10,6 +10,12 @@ describe("Typography - Font Family", () => { * in the platform default whether or not the stack was narrowed. Narrowing is * what makes the OVERRIDE below work, which is how a bundled typeface is * actually reached. + * + * The four default-theme cases are therefore CONTROLS: they pass on `main` + * too, because a single-definition theme variable is inlined and narrowed at + * compile time. They are here because this file is a census of the Typography + * utilities and Font Family was the one block missing from it. The two + * overrides below are the cases that bind. */ test("font-sans", async () => { expect(await renderCurrentTest()).toStrictEqual({ From 916c101a140edde570b51f87109771f297ea1973 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 10:38:08 +0300 Subject: [PATCH 09/10] docs: say what the font-family reduction actually guarantees 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. --- .../vendor/tailwind/typography.test.tsx | 14 +++++++++---- src/native/objects.ts | 20 +++++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 87bc0611..96af2ead 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -8,14 +8,20 @@ describe("Typography - Font Family", () => { * The default theme's own values are the CSS generics — no typeface is * registered under `ui-sans-serif` on either platform, so `font-sans` renders * in the platform default whether or not the stack was narrowed. Narrowing is - * what makes the OVERRIDE below work, which is how a bundled typeface is - * actually reached. + * what lets an override reach a bundled typeface, which is how one is + * actually installed. * * The four default-theme cases are therefore CONTROLS: they pass on `main` * too, because a single-definition theme variable is inlined and narrowed at * compile time. They are here because this file is a census of the Typography - * utilities and Font Family was the one block missing from it. The two - * overrides below are the cases that bind. + * utilities and Font Family was the one block missing from it. + * + * Both overrides below bind, and only one of them reaches a real family. + * `:root` resolves `Georgia`. The `.dark` one is not active, so it resolves + * the theme's own `ui-sans-serif` — the same generic as the controls, with no + * face registered under 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. */ test("font-sans", async () => { expect(await renderCurrentTest()).toStrictEqual({ diff --git a/src/native/objects.ts b/src/native/objects.ts index e1d5f294..abaf515c 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -54,6 +54,12 @@ export function applyShorthand(value: any) { * `applyDeclarations` parks `{ [prop]: true }` on the target while a delayed * value resolves, and later reclaims it by identity. It is machinery, never a * style value, so it has to reach the target untouched. + * + * The null exclusion is unreachable from the one call site below, which has + * already turned a null into `undefined` and then excluded `undefined`. It + * stays because this 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` in this change. */ function isDelayedMarker(value: unknown): boolean { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -102,8 +108,18 @@ export function applyValue( if (prop === "fontFamily" && value !== undefined && !isDelayedMarker(value)) { const narrowing = narrowFontFamily(value); - // Nothing usable leaves the key alone rather than clearing it, so a family - // an earlier rule set survives the way the cascade says it should. + // Nothing usable leaves the key alone rather than clearing it, which + // preserves a family already on the target. That guarantee is narrower than + // it sounds, and the two paths differ: + // + // - compile-time `none` (`font-family: ,;`) emits no descriptor at all, + // so an earlier rule's family stands. Measured under `.b { Georgia }`: + // `Georgia` here, `[]` on `main`. + // - a resolved `var()` has nothing left to preserve, because + // `applyDeclarations` deletes the key before it resolves. Measured on + // the same pair with `var(--n)` over `--n: 12`: `{}` here, + // `{ fontFamily: 12 }` on `main` — better either way, but not a + // survival. if (narrowing.kind === "family") { target[prop] = narrowing.family; } From 74dc9866327b3375ef0cf60e150acfbac75a114a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 20:04:44 +0300 Subject: [PATCH 10/10] fix(utilities): guard the sibling predicate against a null head too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../utilities/style-descriptor.test.ts | 37 ++++++++++++++++++- src/utilities/style-descriptor.ts | 13 ++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/__tests__/utilities/style-descriptor.test.ts b/src/__tests__/utilities/style-descriptor.test.ts index 72439b30..c0aafd1d 100644 --- a/src/__tests__/utilities/style-descriptor.test.ts +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -1,4 +1,7 @@ -import { isStyleFunction } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; /** * A style function is a descriptor the runtime evaluates - `[{}, "var", …]`. @@ -34,3 +37,35 @@ describe("isStyleFunction", () => { expect(isStyleFunction([null, "Arial"])).toBe(false); }); }); + +/** + * The sibling predicate, six lines above `isStyleFunction` in the same file and + * asking the same question from the other side: is this a list of VALUES rather + * than a function to evaluate? It carries the identical `typeof value[0] === + * "object"` trap, so the null case lands on it too. + */ +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", () => { + // `typeof null` is `"object"`, so the raw check falls into the branch that + // demands an array and answers `false`. But `null` is a VALUE — a hole the + // compiler left, which reaches a native runtime as `null` after + // `JSON.stringify` — so this is a descriptor array like any other. + expect(isStyleDescriptorArray([null, "Arial"])).toBe(true); + }); +}); diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index dd62a69d..5b624989 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -4,8 +4,17 @@ export function isStyleDescriptorArray( value: unknown, ): value is StyleDescriptor[] { if (Array.isArray(value)) { - // If its an array and the first item is an object, the only allowed value is an array - return typeof value[0] === "object" ? Array.isArray(value[0]) : true; + // A style function's head is a plain object, so an object at index 0 means + // this is a function unless it is a nested GROUP. `typeof null` is also + // `"object"` and null is neither — it is a value, a hole the compiler left + // that reaches a native runtime as `null` once the sheet has been through + // `JSON.stringify`. Excluding it here is what `isStyleFunction` below does + // for the same reason. + const head: unknown = value[0]; + + return typeof head === "object" && head !== null + ? Array.isArray(head) + : true; } return false;