diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts new file mode 100644 index 00000000..95f2542e --- /dev/null +++ b/src/__tests__/compiler/font-family.test.ts @@ -0,0 +1,256 @@ +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. + * + * 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) => { + 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", () => { + // 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"), + ).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", () => { + // 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", () => { + // 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({}); + expect( + compile(`.a { font-family: Inter, Helvetica,; }`).warnings(), + ).toStrictEqual({}); + }); +}); + +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("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. + 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", () => { + // 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); }`, + "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", () => { + // 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; }`, + "a", + ), + ).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 new file mode 100644 index 00000000..4574d616 --- /dev/null +++ b/src/__tests__/native/font-family-stack.test.ts @@ -0,0 +1,123 @@ +import { applyValue } from "../../native/objects"; + +/** + * `font-family` reaches React Native as ONE family, whichever route it took. + * + * 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. + * + * `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. */ +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"); + // 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 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("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"); + // 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"); + }); + + 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); + + const inherited: Record = { fontFamily: "Inter" }; + applyValue(inherited, "fontFamily", []); + expect(inherited.fontFamily).toBe("Inter"); + }); +}); + +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. + 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..d66d39ed --- /dev/null +++ b/src/__tests__/native/font-family.test.tsx @@ -0,0 +1,281 @@ +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. + * + * 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. + */ + +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 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(); + }); +}); + +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", () => { + // 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", + }, + ); + }); +}); + +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" }); + }); +}); + +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" }); + }); +}); + +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 new file mode 100644 index 00000000..04ee81ed --- /dev/null +++ b/src/__tests__/utilities/font-family.test.ts @@ -0,0 +1,98 @@ +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("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"); + 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..c0aafd1d --- /dev/null +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -0,0 +1,71 @@ +import { + isStyleDescriptorArray, + 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", () => { + // 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); + 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); + }); +}); + +/** + * 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/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..96af2ead 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -1,5 +1,78 @@ 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 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. + * + * 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({ + 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({ 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/native/objects.ts b/src/native/objects.ts index 12e69ccd..abaf515c 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,21 @@ export function applyShorthand(value: any) { return target; } +/** + * `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); +} + export function applyValue( target: Record, prop: string, @@ -84,6 +101,31 @@ export function applyValue( return; } + // 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, 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; + } + return; + } + target[prop] = value; } 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"; diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 1310d62b..5b624989 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -4,19 +4,31 @@ 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; } -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; }