diff --git a/src/__tests__/compiler/font-family.test.ts b/src/__tests__/compiler/font-family.test.ts new file mode 100644 index 00000000..0540ce84 --- /dev/null +++ b/src/__tests__/compiler/font-family.test.ts @@ -0,0 +1,185 @@ +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({}); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a stack with an entry that names no family +describe("the unparsed path", () => { + test.each([ + ["a trailing comma", `.a { font-family: Inter, Helvetica,; }`], + ["a leading comma", `.a { font-family: ,Inter, Helvetica; }`], + ["a doubled comma", `.a { font-family: Inter,,Helvetica; }`], + [ + "quoted families and a trailing comma", + `.a { font-family: "Inter", "Helvetica",; }`, + ], + ["a number", `.a { font-family: 12, Inter; }`], + ["a function", `.a { font-family: calc(1px), Inter; }`], + [ + "a CSS-wide keyword", + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, inherit, var(--x); }`, + ], + [ + "a reserved ident in any case", + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, DEFAULT, var(--x); }`, + ], + ])("%s invalidates the stack", (_name, css) => { + expect(declarationsFor(css, "a")).toStrictEqual([]); + }); + + test("a stack with nothing usable emits no declaration at all", () => { + expect(declarationsFor(`.a { font-family: ,; }`, "a")).toStrictEqual([]); + }); + + test("a keyframe drops an invalid stack too", () => { + expect( + compile( + `@keyframes k { from { font-family: Inter, Helvetica,; } to { font-family: Georgia; } }`, + ).stylesheet().k, + ).toStrictEqual([ + [ + "k", + [ + ["from", []], + ["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 left whole, because its value can invalidate the stack", () => { + expect( + declarationsFor( + `:root { --x: Georgia; } .other { --x: Verdana; } .a { font-family: Inter, var(--x); }`, + "a", + ), + ).toStrictEqual([[["Inter", [{}, "var", "x", 1]], "fontFamily"]]); + }); + + 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..51d2a214 --- /dev/null +++ b/src/__tests__/native/font-family-stack.test.ts @@ -0,0 +1,80 @@ +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([["Inter"], "Arial"]).fontFamily).toBe("Inter"); + }); + + test("an entry that cannot name a family sets nothing", () => { + for (const unusable of [12, null, undefined, true, [], [[]]]) { + expect("fontFamily" in applyFontFamily([unusable, "Arial"])).toBe(false); + } + }); + + test("a single family passes through untouched", () => { + 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..24082dcf --- /dev/null +++ b/src/__tests__/native/font-family.test.tsx @@ -0,0 +1,224 @@ +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; +}; + +// Every expected value is what Chromium 153, Firefox 155 and WebKit 26.6 compute: a stack with an entry that names no family is invalid +describe("a stack the compiler could read", () => { + test("a static stack with an empty entry is ignored", () => { + expect( + styleOf("a", `.a { font-family: Inter, Helvetica,; }`), + ).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("an entry in the resolved stack that names no family sets no family", () => { + expect( + styleOf( + "a", + `:root { --f: 12, Arial; } .other { --f: Georgia; } .a { font-family: var(--f); }`, + ), + ).toStrictEqual({}); + }); + + test("a var() behind a family still waits, and a value that names no family invalidates the stack", () => { + expect( + styleOf( + "a", + `:root { --f: Georgia; } .other { --f: Verdana; } .a { font-family: Inter, var(--f); }`, + ), + ).toStrictEqual({ fontFamily: "Inter" }); + expect( + styleOf( + "a", + `:root { --f: 12; } .other { --f: Verdana; } .a { font-family: Inter, var(--f); }`, + ), + ).toStrictEqual({}); + }); + + test("a function in a stack invalidates it", () => { + expect( + styleOf("a", `.a { font-family: calc(1px), Inter; }`), + ).toBeUndefined(); + }); + + test("an empty fallback substitutes nothing inside an entry", () => { + expect( + styleOf("a", `.a { font-family: var(--missing,) Helvetica; }`), + ).toStrictEqual({ fontFamily: "Helvetica" }); + }); +}); + +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 invalidates the stack, as an empty entry does", () => { + registerCSS(`.a { font-family: var(--stack); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); + + 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..7997d86e --- /dev/null +++ b/src/__tests__/utilities/font-family.test.ts @@ -0,0 +1,108 @@ +import { + isReservedFamilyIdent, + 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([["Inter"], "Arial"])).toStrictEqual({ + kind: "family", + family: "Inter", + }); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 ignore a stack with an entry that names no family + test("an entry that cannot name a family invalidates the stack", () => { + for (const unusable of [12, null, undefined, true, [], [[]]]) { + expect(narrowFontFamily([unusable, "Arial"])).toStrictEqual({ + kind: "none", + }); + expect(narrowFontFamily(["Arial", unusable])).toStrictEqual({ + kind: "none", + }); + } + }); + + test("a function other than var() names no family", () => { + expect(narrowFontFamily([[{}, "calc", [1]], "Inter"])).toStrictEqual({ + kind: "none", + }); + }); + + test("nothing usable is `none`", () => { + expect(narrowFontFamily([])).toStrictEqual({ kind: "none" }); + expect(narrowFontFamily([12, null])).toStrictEqual({ kind: "none" }); + 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 variable reference behind a family still waits, because its value can invalidate the stack", () => { + expect(narrowFontFamily(["Inter", [{}, "var", "x", 1]])).toStrictEqual({ + kind: "deferred", + }); + }); + + test("an entry that names no family invalidates a stack that also waits", () => { + expect(narrowFontFamily([[{}, "var", "x", 1], 12])).toStrictEqual({ + kind: "none", + }); + }); + + test("a reserved ident is never a family name", () => { + for (const reserved of [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + "default", + "INHERIT", + ]) { + expect(isReservedFamilyIdent(reserved)).toBe(true); + } + expect(isReservedFamilyIdent("Inter")).toBe(false); + expect(isReservedFamilyIdent("serif")).toBe(false); + }); + + test("an array is a comma-separated stack, never one multi-word name", () => { + // The compiler stores an ident group and a comma stack as the same array, so the reduction reads both as a stack + expect(narrowFontFamily(["Helvetica", "Neue"])).toStrictEqual({ + 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..1846a59d 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -1,5 +1,70 @@ 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.each([ + ["font-serif", "--font-serif", "ui-serif"], + ["font-mono", "--font-mono", "ui-monospace"], + ])("%s with an overridden %s", async (className, variable, family) => { + // A second definition stops the compiler inlining the variable, so the stack reaches render + expect( + await renderSimple({ + className, + sourceInline: [className], + extraCss: `.dark { ${variable}: Georgia, serif; }`, + }), + ).toStrictEqual({ + props: { style: { fontFamily: family } }, + }); + }); + + test("font-sans overridden at :root", async () => { + expect( + await renderSimple({ + 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..3380826a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,11 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { + isReservedFamilyIdent, + isStyleFunction, + narrowFontFamily, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -992,7 +996,27 @@ 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 stack holding a var() waits for render + const narrowing = hasEveryFontFamilyEntry(declaration.value.value) + ? narrowFontFamily(value) + : ({ kind: "none" } as const); + + 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); @@ -1012,6 +1036,38 @@ export function parseUnparsedDeclaration( } } +// css-fonts-4 §4.2: an empty entry or an unquoted reserved ident invalidates a written stack, and parseUnparsed keeps neither +function hasEveryFontFamilyEntry(tokens: readonly TokenOrValue[]): boolean { + let entryHoldsSomething = false; + + for (const token of tokens) { + if (token.type === "token") { + switch (token.value.type) { + case "comma": + if (!entryHoldsSomething) { + return false; + } + entryHoldsSomething = false; + continue; + case "white-space": + case "comment": + continue; + case "ident": + if (isReservedFamilyIdent(token.value.value)) { + return false; + } + break; + default: + break; + } + } + + entryHoldsSomething = true; + } + + return entryHoldsSomething; +} + export function parseCustomDeclaration( declaration: Extract, builder: StylesheetBuilder, diff --git a/src/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..c2f222bb --- /dev/null +++ b/src/utilities/font-family.ts @@ -0,0 +1,62 @@ +import { isStyleFunction } from "./style-descriptor"; + +// deferred: the stack holds a var(), which resolves only at render and can still invalidate it +export type FontFamilyNarrowing = + | { readonly kind: "family"; readonly family: string } + | { readonly kind: "deferred" } + | { readonly kind: "none" }; + +const DEFERRED: FontFamilyNarrowing = { kind: "deferred" }; +const NONE: FontFamilyNarrowing = { kind: "none" }; + +// css-values-4 §6.1: an unquoted family name is never a CSS-wide keyword or `default` +const RESERVED_IDENTS: ReadonlySet = new Set([ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", + "default", +]); + +export function isReservedFamilyIdent(ident: string): boolean { + return RESERVED_IDENTS.has(ident.toLowerCase()); +} + +// css-fonts-4 §4.2: a stack is valid only when every entry names a family, and React Native takes its first +export function narrowFontFamily(value: unknown): FontFamilyNarrowing { + if (typeof value === "string") { + return { kind: "family", family: value }; + } + + if (!Array.isArray(value)) { + return NONE; + } + + if (isStyleFunction(value)) { + return value[1] === "var" ? DEFERRED : NONE; + } + + let narrowing: FontFamilyNarrowing = NONE; + + for (const entry of value) { + const entryNarrowing = narrowFontFamily(entry); + + switch (entryNarrowing.kind) { + case "none": + return NONE; + case "deferred": + narrowing = DEFERRED; + break; + case "family": + if (narrowing.kind === "none") { + narrowing = entryNarrowing; + } + break; + default: + entryNarrowing satisfies never; + } + } + + return narrowing; +} 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";