diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts new file mode 100644 index 00000000..461eb428 --- /dev/null +++ b/src/__tests__/compiler/font-family.test.ts @@ -0,0 +1,186 @@ +import { compile } from "react-native-css/compiler"; + +const declarationsFor = (css: string, className: string) => { + const rules = new Map(compile(css).stylesheet().s ?? []).get(className); + + return (rules ?? []).flatMap((rule) => rule.d ?? []); +}; + +const variablesFor = (css: string, className: string) => { + const rules = new Map(compile(css).stylesheet().s ?? []).get(className); + + return (rules ?? []).flatMap((rule) => rule.v ?? []); +}; + +describe("the typed path", () => { + test("a literal stack narrows to its first family", () => { + expect( + declarationsFor(`.a { font-family: Inter, Helvetica, sans-serif; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("the `font` shorthand narrows to its first family", () => { + expect( + declarationsFor(`.a { font: italic 12px Inter, Helvetica; }`, "a"), + ).toStrictEqual([ + { + fontFamily: "Inter", + fontSize: 12, + fontStyle: "italic", + fontWeight: "normal", + }, + ]); + }); + + test("a name that is not a bare ident survives whole", () => { + // LightningCSS unquotes a quoted family, and a multi-ident name is one family + expect( + declarationsFor(`.a { font-family: "Helvetica Neue", Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + expect( + declarationsFor(`.a { font-family: "Foo, Bar", Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Foo, Bar" }]); + expect( + declarationsFor(`.a { font-family: Helvetica Neue, Arial; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + }); + + test("no warning is emitted for the families that are dropped", () => { + expect( + compile(`.a { font-family: Inter, Helvetica, sans-serif; }`).warnings(), + ).toStrictEqual({}); + expect( + compile(`.a { font-family: Inter, Helvetica,; }`).warnings(), + ).toStrictEqual({}); + }); +}); + +describe("the unparsed path", () => { + test.each([ + ["a trailing comma", `.a { font-family: Inter, Helvetica,; }`], + ["a leading comma", `.a { font-family: ,Inter, Helvetica; }`], + ["a doubled comma", `.a { font-family: Inter,,Helvetica; }`], + ["quoted families", `.a { font-family: "Inter", "Helvetica",; }`], + ])("%s still narrows to the first family", (_name, css) => { + expect(declarationsFor(css, "a")).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a quoted name keeps its spaces and its commas", () => { + expect( + declarationsFor(`.a { font-family: "Helvetica Neue", Arial,; }`, "a"), + ).toStrictEqual([{ fontFamily: "Helvetica Neue" }]); + expect( + declarationsFor(`.a { font-family: "Foo, Bar", Arial,; }`, "a"), + ).toStrictEqual([{ fontFamily: "Foo, Bar" }]); + }); + + test("an entry that cannot name a family is skipped", () => { + expect( + declarationsFor(`.a { font-family: 12, Inter; }`, "a"), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a stack with nothing usable emits no declaration at all", () => { + expect(declarationsFor(`.a { font-family: ,; }`, "a")).toStrictEqual([]); + }); + + test("a keyframe narrows too", () => { + expect( + compile( + `@keyframes k { from { font-family: Inter, Helvetica,; } to { font-family: Georgia; } }`, + ).stylesheet().k, + ).toStrictEqual([ + [ + "k", + [ + ["from", [{ fontFamily: "Inter" }]], + ["to", [{ fontFamily: "Georgia" }]], + ], + ], + ]); + }); +}); + +describe("the var() path", () => { + // A variable declared once is inlined by the compiler, so these declare --stack twice + + test("a stack the compiler cannot see is left for the runtime", () => { + expect( + declarationsFor( + `:root { --stack: Inter, Helvetica; } .other { --stack: Georgia, serif; } .a { font-family: var(--stack); }`, + "a", + ), + ).toStrictEqual([[[{}, "var", "stack", 1], "fontFamily", 1]]); + }); + + test("a var() behind a literal is narrowed away", () => { + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, var(--x); }`, + "a", + ), + ).toStrictEqual([{ fontFamily: "Inter" }]); + }); + + test("a var() in front of a literal is left whole", () => { + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: var(--x), Helvetica; }`, + "a", + ), + ).toStrictEqual([[[[{}, "var", "x", 1], "Helvetica"], "fontFamily"]]); + }); + + test("a fallback is emitted whole, whatever shape it has", () => { + expect( + declarationsFor(`.a { font-family: var(--missing, Helvetica); }`, "a"), + ).toStrictEqual([ + [[{}, "var", ["missing", "Helvetica"], 1], "fontFamily", 1], + ]); + + expect( + declarationsFor( + `.a { font-family: var(--missing, Inter, Helvetica); }`, + "a", + ), + ).toStrictEqual([ + [[{}, "var", ["missing", ["Inter", "Helvetica"]], 1], "fontFamily", 1], + ]); + + expect( + declarationsFor( + `.a { font-family: var(--missing-a, var(--missing-b, serif)); }`, + "a", + ), + ).toStrictEqual([ + [ + [{}, "var", ["missing-a", [{}, "var", ["missing-b", "serif"], 1]], 1], + "fontFamily", + 1, + ], + ]); + }); +}); + +describe("what a var() cannot carry", () => { + test("KNOWN LIMIT: a space group and a comma group compile to the same value", () => { + const spaceGroup = variablesFor( + `.b { --f: Helvetica Neue; } .c { --f: x; }`, + "b", + ); + const commaGroup = variablesFor( + `.b { --f: Inter, Helvetica; } .c { --f: x; }`, + "b", + ); + + expect(spaceGroup).toStrictEqual([["f", ["Helvetica", "Neue"]]]); + expect(commaGroup).toStrictEqual([["f", ["Inter", "Helvetica"]]]); + expect(spaceGroup.map(([, value]) => typeof value)).toStrictEqual( + commaGroup.map(([, value]) => typeof value), + ); + + expect( + variablesFor(`.b { --f: "Helvetica Neue"; } .c { --f: x; }`, "b"), + ).toStrictEqual([["f", "Helvetica Neue"]]); + }); +}); diff --git a/src/__tests__/native/font-family-stack.test.ts b/src/__tests__/native/font-family-stack.test.ts new file mode 100644 index 00000000..62639d38 --- /dev/null +++ b/src/__tests__/native/font-family-stack.test.ts @@ -0,0 +1,83 @@ +import { applyValue } from "../../native/objects"; + +/** A real Tailwind `--font-sans`, which is why the stack is the common case. */ +const FONT_SANS_STACK = [ + "Inter", + "Inter Fallback", + "ui-sans-serif", + "system-ui", + "sans-serif", +] as const; + +const applyFontFamily = (value: unknown): Record => { + const target: Record = {}; + applyValue(target, "fontFamily", value); + return target; +}; + +describe("the reduction", () => { + test("a resolved stack reduces to its first family, as a string", () => { + const target = applyFontFamily([...FONT_SANS_STACK]); + + expect(target.fontFamily).toBe("Inter"); + expect(typeof target.fontFamily).toBe("string"); + }); + + test("a nested stack is flattened, not descended into", () => { + expect(applyFontFamily([[...FONT_SANS_STACK]]).fontFamily).toBe("Inter"); + expect(applyFontFamily([[], "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([[[]], "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([["Inter"], "Arial"]).fontFamily).toBe("Inter"); + }); + + test("an entry that cannot name a family is skipped", () => { + expect(applyFontFamily([12, "Inter"]).fontFamily).toBe("Inter"); + expect(applyFontFamily([null, "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([undefined, "Arial"]).fontFamily).toBe("Arial"); + expect(applyFontFamily([true, "Arial"]).fontFamily).toBe("Arial"); + }); + + test("a single family passes through untouched", () => { + expect(applyFontFamily("brand-icons").fontFamily).toBe("brand-icons"); + }); + + test("a stack with nothing usable sets nothing", () => { + expect("fontFamily" in applyFontFamily([])).toBe(false); + expect("fontFamily" in applyFontFamily([12])).toBe(false); + expect("fontFamily" in applyFontFamily([[], [null]])).toBe(false); + + const inherited: Record = { fontFamily: "Inter" }; + applyValue(inherited, "fontFamily", []); + expect(inherited.fontFamily).toBe("Inter"); + }); +}); + +describe("what the reduction must not disturb", () => { + test("the reduction is scoped to fontFamily", () => { + // fontVariant is a list on React Native, so only fontFamily is reduced + const target: Record = {}; + applyValue(target, "fontVariant", ["small-caps"]); + + expect(target.fontVariant).toStrictEqual(["small-caps"]); + }); + + test("both sentinel meanings survive the reduction", () => { + const untouched: Record = {}; + applyValue(untouched, "fontFamily", undefined); + expect("fontFamily" in untouched).toBe(false); + + const cleared: Record = { fontFamily: "Inter" }; + applyValue(cleared, "fontFamily", null); + expect("fontFamily" in cleared).toBe(true); + expect(cleared.fontFamily).toBeUndefined(); + }); + + test("the delayed-style marker passes through by identity", () => { + // applyDeclarations parks { fontFamily: true } on the target while a delayed value resolves + const marker = { fontFamily: true }; + const target: Record = {}; + applyValue(target, "fontFamily", marker); + + expect(target.fontFamily).toBe(marker); + }); +}); diff --git a/src/__tests__/native/font-family.test.tsx b/src/__tests__/native/font-family.test.tsx new file mode 100644 index 00000000..6cfd7517 --- /dev/null +++ b/src/__tests__/native/font-family.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { VariableContextProvider } from "react-native-css/native"; + +const styleOf = (className: string, css: string): unknown => { + registerCSS(css); + render(); + return screen.getByTestId(testID).props.style; +}; + +describe("a stack the compiler could read", () => { + test("a static stack it could not type arrives as one family", () => { + // The trailing comma puts this on the unparsed path, which applyDeclarations copies straight onto the style + expect( + styleOf("a", `.a { font-family: Inter, Helvetica,; }`), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a static stack with nothing usable produces no style at all", () => { + // The compiler drops the declaration, and it was the rule's only one. + expect(styleOf("a", `.a { font-family: ,; }`)).toBeUndefined(); + }); +}); + +describe("a stack behind a var()", () => { + test("a var() holding a stack arrives as one family", () => { + expect( + styleOf( + "a", + `:root { --stack: Inter, Helvetica; } + .other { --stack: Georgia, serif; } + .a { font-family: var(--stack); }`, + ), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a var() holding something that cannot name a family sets no family", () => { + expect( + styleOf( + "a", + `:root { --n: 12; } .other { --n: 13; } .a { font-family: var(--n); }`, + ), + ).toStrictEqual({}); + }); + + test("an unusable entry inside the resolved stack is skipped", () => { + expect( + styleOf( + "a", + `:root { --f: 12, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + expect( + styleOf( + "a", + `:root { --f: unset, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + }); + + test("a var() that resolves to nothing falls through to the next family", () => { + expect( + styleOf("a", `.a { font-family: var(--missing), Helvetica; }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); + }); + + test("a function that resolves to something unusable falls through too", () => { + expect(styleOf("a", `.a { font-family: calc(1px), Inter; }`)).toStrictEqual( + { + fontFamily: "Inter", + }, + ); + }); +}); + +describe("a var() fallback", () => { + // A var() fallback is chosen while resolving, so these compile to the same deferred descriptor + + test("an undefined var falls back to the literal in its own parentheses", () => { + // CONTROL — passes on `main`: one family resolves to a string. + expect( + styleOf("a", `.a { font-family: var(--missing, Helvetica); }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); + }); + + test("a fallback that is itself a stack narrows to its first family", () => { + expect( + styleOf("a", `.a { font-family: var(--missing, Inter, Helvetica); }`), + ).toStrictEqual({ fontFamily: "Inter" }); + }); + + test("a nested fallback resolves to the innermost literal", () => { + // CONTROL — passes on `main` for the same reason. + expect( + styleOf( + "a", + `.a { font-family: var(--missing-a, var(--missing-b, serif)); }`, + ), + ).toStrictEqual({ fontFamily: "serif" }); + }); + + test("a nested fallback stops at the first var() that has a value", () => { + // CONTROL — passes on `main` for the same reason. + expect( + styleOf( + "a", + `:root { --b: Georgia; } + .other { --b: Verdana; } + .a { font-family: var(--missing-a, var(--b, serif)); }`, + ), + ).toStrictEqual({ fontFamily: "Georgia" }); + }); + + test("a fallback in the head still lets a later family be reached", () => { + expect( + styleOf( + "a", + `:root { --f: Inter; } + .other { --f: Georgia; } + .a { font-family: var(--missing, Arial), var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Arial" }); + }); +}); + +describe("a family name that is not a bare ident", () => { + test("a quoted name containing spaces survives the reduction", () => { + expect( + styleOf( + "a", + `:root { --f: "Helvetica Neue", Arial; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica Neue" }); + }); + + test("a quoted name containing a comma is one family, not two", () => { + expect( + styleOf( + "a", + `:root { --f: "Foo, Bar", Arial; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Foo, Bar" }); + }); + + test("KNOWN LIMIT: an UNQUOTED multi-word name behind a var() loses its tail", () => { + // The compiler stores an ident group and a comma stack as the same array, so an unquoted multi-word name reads as a stack + expect( + styleOf( + "a", + `:root { --f: Helvetica Neue; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica" }); + + expect( + styleOf( + "a", + `:root { --f: "Helvetica Neue"; } + .other { --f: Georgia; } + .a { font-family: var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Helvetica Neue" }); + }); +}); + +describe("a stack supplied at render", () => { + test("it arrives as one family, and stays current", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + const element = screen.getByTestId(testID); + expect(element.props.style).toStrictEqual({ fontFamily: "Inter" }); + + screen.rerender( + + + , + ); + expect(element.props.style).toStrictEqual({ fontFamily: "Georgia" }); + }); + + test("an empty group in front of a family does not swallow it", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontFamily: "Arial", + }); + }); + + test("MEASURED: a null head never reaches the reduction on this plane", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontFamily: "Arial", + }); + }); +}); diff --git a/src/__tests__/utilities/font-family.test.ts b/src/__tests__/utilities/font-family.test.ts new file mode 100644 index 00000000..645bef8f --- /dev/null +++ b/src/__tests__/utilities/font-family.test.ts @@ -0,0 +1,83 @@ +import { narrowFontFamily } from "react-native-css/utilities"; + +describe("narrowFontFamily", () => { + test("a family is itself", () => { + expect(narrowFontFamily("Inter")).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("a stack reduces to its first family", () => { + expect( + narrowFontFamily(["Inter", "Helvetica", "sans-serif"]), + ).toStrictEqual({ kind: "family", family: "Inter" }); + }); + + test("a nested group is read in place, not descended into", () => { + expect(narrowFontFamily([[], "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + expect(narrowFontFamily([[[]], "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + expect(narrowFontFamily([["Inter"], "Arial"])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("an entry that cannot name a family is skipped", () => { + for (const unusable of [12, null, undefined, true]) { + expect(narrowFontFamily([unusable, "Arial"])).toStrictEqual({ + kind: "family", + family: "Arial", + }); + } + }); + + test("nothing usable is `none`", () => { + expect(narrowFontFamily([])).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily([12, null])).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily(undefined)).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily(42)).toStrictEqual({ kind: "none" }); + }); + + test("a variable reference is `deferred`, at any depth of the stack", () => { + expect(narrowFontFamily([{}, "var", "font-sans", 1])).toStrictEqual({ + kind: "deferred", + }); + expect( + narrowFontFamily([[{}, "var", "font-sans", 1], "Helvetica"]), + ).toStrictEqual({ kind: "deferred" }); + }); + + test("a family in front of a variable reference wins", () => { + expect(narrowFontFamily(["Inter", [{}, "var", "x", 1]])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + test("an array is a comma-separated stack, never one multi-word name", () => { + // The compiler stores an ident group and a comma stack as the same array, so the reduction reads both as a stack + expect(narrowFontFamily(["Helvetica", "Neue"])).toStrictEqual({ + kind: "family", + family: "Helvetica", + }); + expect(narrowFontFamily("Helvetica Neue")).toStrictEqual({ + kind: "family", + family: "Helvetica Neue", + }); + }); + + test("the reduction is idempotent", () => { + const once = narrowFontFamily(["Inter", "Helvetica"]); + expect(once.kind).toBe("family"); + expect( + narrowFontFamily(once.kind === "family" ? once.family : undefined), + ).toStrictEqual(once); + }); +}); diff --git a/src/__tests__/utilities/style-descriptor.test.ts b/src/__tests__/utilities/style-descriptor.test.ts new file mode 100644 index 00000000..9e379ea1 --- /dev/null +++ b/src/__tests__/utilities/style-descriptor.test.ts @@ -0,0 +1,51 @@ +import { + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; + +describe("isStyleFunction", () => { + test("a style function is one", () => { + expect(isStyleFunction([{}, "var"])).toBe(true); + expect(isStyleFunction([{}, "var", "font-sans", 1])).toBe(true); + }); + + test("a plain descriptor array is not", () => { + // CONTROL — passes on `main`, for the same reason. + expect(isStyleFunction(["Inter", "Helvetica"])).toBe(false); + expect(isStyleFunction([])).toBe(false); + expect(isStyleFunction("Inter")).toBe(false); + expect(isStyleFunction(undefined)).toBe(false); + }); + + test("an array headed by an empty array is not", () => { + expect(isStyleFunction([[], "Arial"])).toBe(false); + expect(isStyleFunction([["Inter"], "Arial"])).toBe(false); + }); + + test("an array headed by null is not, and does not throw", () => { + // `typeof null` is `"object"`, and `Object.keys(null)` throws. + expect(isStyleFunction([null, "Arial"])).toBe(false); + }); +}); + +describe("isStyleDescriptorArray", () => { + test("a plain descriptor array is one", () => { + // CONTROL — passes on `main`. Says the answer did not move. + expect(isStyleDescriptorArray(["Inter", "Helvetica"])).toBe(true); + expect(isStyleDescriptorArray([1, 2])).toBe(true); + }); + + test("a style function is not one", () => { + // CONTROL — the discrimination this predicate exists to make. + expect(isStyleDescriptorArray([{}, "var", "font-sans"])).toBe(false); + }); + + test("an array headed by an array is one", () => { + // A nested group is a descriptor, not a function head. + expect(isStyleDescriptorArray([["Inter"], "Arial"])).toBe(true); + }); + + test("an array headed by null is one", () => { + expect(isStyleDescriptorArray([null, "Arial"])).toBe(true); + }); +}); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..cb64a40c 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -1,5 +1,54 @@ import { renderCurrentTest, renderSimple } from "./_tailwind"; +describe("Typography - Font Family", () => { + // React Native takes one family, so each stack renders as its first + test("font-sans", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-sans-serif" } }, + }); + }); + test("font-serif", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-serif" } }, + }); + }); + test("font-mono", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "ui-monospace" } }, + }); + }); + test("font-[Inter]", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { style: { fontFamily: "Inter" } }, + }); + }); + + test("font-sans with an overridden --font-sans", async () => { + // A second definition stops the compiler inlining the variable, so the stack reaches render + expect( + await renderSimple({ + className: "font-sans", + sourceInline: ["font-sans"], + extraCss: `.dark { --font-sans: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: "ui-sans-serif" } }, + }); + }); + + test("font-sans overridden at :root", async () => { + expect( + await renderSimple({ + className: "font-sans", + sourceInline: ["font-sans"], + extraCss: `:root { --font-sans: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: "Georgia" } }, + }); + }); +}); + describe("Typography - Font Size", () => { test("text-xs", async () => { expect(await renderCurrentTest()).toStrictEqual({ diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..a2ebed2e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, narrowFontFamily } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -992,7 +992,25 @@ export function parseUnparsedDeclaration( builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); } } else { - const value = parseUnparsed(declaration.value.value, builder, property); + let value = parseUnparsed(declaration.value.value, builder, property); + + if (property === "font-family") { + // A stack LightningCSS could not type is still one family, so only a leading var() waits for render + const narrowing = narrowFontFamily(value); + + switch (narrowing.kind) { + case "family": + value = narrowing.family; + break; + case "none": + value = undefined; + break; + case "deferred": + break; + default: + narrowing satisfies never; + } + } builder.addDescriptor(property, value); diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..41ae3d6e 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -1,4 +1,6 @@ /* eslint-disable */ +import { narrowFontFamily } from "react-native-css/utilities"; + import { ShortHandSymbol } from "../native/styles/constants"; import { transformKeys } from "../native/styles/defaults"; @@ -48,6 +50,11 @@ export function applyShorthand(value: any) { return target; } +// applyDeclarations parks { [prop]: true } on the target while a delayed value resolves +function isDelayedMarker(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function applyValue( target: Record, prop: string, @@ -84,6 +91,16 @@ export function applyValue( return; } + // React Native takes one family, and a var() value is the one stack the compiler could not narrow + if (prop === "fontFamily" && value !== undefined && !isDelayedMarker(value)) { + const narrowing = narrowFontFamily(value); + + if (narrowing.kind === "family") { + target[prop] = narrowing.family; + } + return; + } + target[prop] = value; } diff --git a/src/utilities/font-family.ts b/src/utilities/font-family.ts new file mode 100644 index 00000000..655da7ad --- /dev/null +++ b/src/utilities/font-family.ts @@ -0,0 +1,35 @@ +import { isStyleFunction } from "./style-descriptor"; + +// deferred: the first entry that could name a family is a var(), which resolves only at render +export type FontFamilyNarrowing = + | { readonly kind: "family"; readonly family: string } + | { readonly kind: "deferred" } + | { readonly kind: "none" }; + +const DEFERRED: FontFamilyNarrowing = { kind: "deferred" }; +const NONE: FontFamilyNarrowing = { kind: "none" }; + +// The first entry that names a family, reading nested groups in place and skipping one that names none +export function narrowFontFamily(value: unknown): FontFamilyNarrowing { + if (typeof value === "string") { + return { kind: "family", family: value }; + } + + if (!Array.isArray(value)) { + return NONE; + } + + if (isStyleFunction(value)) { + return DEFERRED; + } + + for (const entry of value) { + const narrowing = narrowFontFamily(entry); + + if (narrowing.kind !== "none") { + return narrowing; + } + } + + return NONE; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..df751998 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; +export * from "./font-family"; export * from "./dot-notation.types";