From 3947b735e596df54670bffdb3707b7b3cb4d0bd9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 19:39:18 +0300 Subject: [PATCH] fix(native): measure em against the font size each element computes An em length never declared that it reads the em channel an ancestor publishes, so the child of a `font-size: 10px` element sized `2em` as 28 rather than 20. A font size relative to the parent (`2em`, `150%`, a variable holding either) was evaluated against the element's own em channel, which is the size being computed, so it fell back to the root's. And descendants re-resolved an ancestor's `2em` in their own scope. `em` now reads the inherited channel. A font size the runtime computes is wrapped in a `fontSize` function whose argument is evaluated with the parent's em, and the element publishes the number it computed to its descendants. `font-size: 150%` and the `font` shorthand take the same path. The variables an element publishes follow the cascade, so a descendant inherits the declaration that wins rather than the one whose class name comes last. The jest harness resets the root and universal variables between tests, so a `:root` declaration in one test no longer reaches the next. --- src/__tests__/compiler/font-size.test.ts | 52 +++ src/__tests__/native/em-scope.test.tsx | 425 +++++++++++++++++++++++ src/__tests__/native/units.test.tsx | 2 +- src/compiler/declarations.ts | 34 +- src/compiler/stylesheet.ts | 3 +- src/jest/index.ts | 2 + src/native-internal/root.ts | 32 +- src/native/react/rules.ts | 74 +++- src/native/styles/font-size.ts | 24 ++ src/native/styles/resolve.ts | 27 +- src/native/styles/variables.ts | 30 ++ 11 files changed, 672 insertions(+), 33 deletions(-) create mode 100644 src/__tests__/compiler/font-size.test.ts create mode 100644 src/__tests__/native/em-scope.test.tsx create mode 100644 src/native/styles/font-size.ts diff --git a/src/__tests__/compiler/font-size.test.ts b/src/__tests__/compiler/font-size.test.ts new file mode 100644 index 00000000..e4a7efe2 --- /dev/null +++ b/src/__tests__/compiler/font-size.test.ts @@ -0,0 +1,52 @@ +import { compileWithAutoDebug } from "react-native-css/jest"; + +const parentRelative = (size: unknown) => [{}, "fontSize", size, 1]; + +const rulesOf = (css: string) => + compileWithAutoDebug(`.my-class { ${css} }`) + .stylesheet() + .s?.find((rule) => rule[0] === "my-class")?.[1]; + +// prettier-ignore +test.each([ + ["font-size: 2em", parentRelative([{}, "em", 2, 1])], + ["font-size: 150%", parentRelative([{}, "em", 1.5, 1])], + ["font-size: 33.333%", parentRelative([{}, "em", 33.333 / 100, 1])], + ["font: 150% Arial", parentRelative([{}, "em", 1.5, 1])], + ["font-size: var(--size)", parentRelative([{}, "var", "size", 1])], +])("%s is computed against the parent's font size and published as the em", (css, fontSize) => { + const [rule] = rulesOf(css) ?? []; + + expect(rule?.d).toContainEqual([fontSize, "fontSize", 1]); + expect(rule?.v).toContainEqual(["__rn-css-em", fontSize]); + expect(rule?.dv).toBe(1); +}); + +test("an absolute font size is static and published as the em", () => { + expect(rulesOf("font-size: 10px")).toStrictEqual([ + { + d: [{ fontSize: 10 }], + s: [1, 1], + v: [["__rn-css-em", 10]], + }, + ]); +}); + +test("a rule reading em reads the inherited em channel", () => { + expect(rulesOf("width: 2em")).toStrictEqual([ + { + d: [[[{}, "em", 2, 1], "width", 1]], + dv: 1, + s: [1, 1], + }, + ]); +}); + +test("a font size crosses the wire unchanged", () => { + const stylesheet = compileWithAutoDebug( + `.a { font-size: 2em; } .b { font-size: 150%; } .c { font: 150% Arial; } .d { font-size: var(--size); }`, + ).stylesheet(); + const wire = JSON.stringify(stylesheet); + + expect(JSON.parse(wire)).toStrictEqual(stylesheet); +}); diff --git a/src/__tests__/native/em-scope.test.tsx b/src/__tests__/native/em-scope.test.tsx new file mode 100644 index 00000000..04e09fce --- /dev/null +++ b/src/__tests__/native/em-scope.test.tsx @@ -0,0 +1,425 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { VariableContextProvider } from "react-native-css"; +import { compile, type CompilerOptions } from "react-native-css/compiler"; +import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +import { dimensions } from "../../native/reactivity"; + +// Every expected value is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS with a 14px root + +interface Path { + readonly name: string; + readonly declare: (property: string, value: string) => string; + readonly options: CompilerOptions; +} + +const PATHS: readonly Path[] = [ + { + name: "written", + declare: (property, value) => `${property}: ${value}`, + options: {}, + }, + { + name: "through a variable", + declare: (property, value) => + `--value: ${value}; ${property}: var(--value)`, + options: { inlineVariables: false }, + }, +]; + +const DELIVERIES = { + "in memory": (css: string, options: CompilerOptions) => { + registerCSS(css, options); + }, + "over the wire": (css: string, options: CompilerOptions) => { + const stylesheet = compile(css, options).stylesheet(); + StyleCollection.inject( + JSON.parse(JSON.stringify(stylesheet)) as typeof stylesheet, + ); + }, +} as const; + +const ROUTES = PATHS.flatMap((path) => + Object.entries(DELIVERIES).map( + ([delivery, deliver]) => + [`${path.name}, ${delivery}`, path, deliver] as const, + ), +); + +const styleOf = (id = testID) => + screen.getByTestId(id).props.style as Record | undefined; + +test("the variable path keeps its variable for the runtime to resolve", () => { + for (const path of PATHS) { + const css = `.child { ${path.declare("font-size", "2em")}; }`; + const compiled = JSON.stringify(compile(css, path.options).stylesheet()); + + expect(compiled.includes('"var"')).toBe(path.name !== "written"); + } +}); + +describe.each(ROUTES)("%s", (_route, path, deliver) => { + test.each([ + ["2em", { fontSize: 20, width: 40 }], + ["0.5em", { fontSize: 5, width: 10 }], + ["150%", { fontSize: 15, width: 30 }], + ["50%", { fontSize: 5, width: 10 }], + ["20px", { fontSize: 20, width: 40 }], + ["1rem", { fontSize: 14, width: 28 }], + ])( + "font-size: %s measures against the parent's font size and the element's em against its own", + (size, style) => { + deliver( + `.parent { font-size: 10px; } .child { ${path.declare("font-size", size)}; width: 2em; }`, + path.options, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual(style); + }, + ); + + test.each([ + ["2em", 20], + ["150%", 15], + ])( + "a descendant inherits the font size %s computes, not the size", + (size, width) => { + deliver( + `.parent { font-size: 10px; } .middle { ${path.declare("font-size", size)}; } .child { width: 1em; }`, + path.options, + ); + + render( + + + + + , + ); + + expect(styleOf()).toStrictEqual({ width }); + }, + ); + + test("relative font sizes compound down the tree", () => { + deliver( + `.a { font-size: 10px; } .b { ${path.declare("font-size", "2em")}; } .c { ${path.declare("font-size", "150%")}; } .d { width: 1em; }`, + path.options, + ); + + render( + + + + + + + , + ); + + expect(styleOf()).toStrictEqual({ width: 30 }); + }); + + test("a unitless line height measures against the font size the element inherits", () => { + deliver( + `.parent { font-size: 10px; } .child { ${path.declare("line-height", "2")}; }`, + path.options, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ lineHeight: 20 }); + }); + + test("a unitless line height measures against the element's own font size", () => { + deliver( + `.parent { font-size: 10px; } .child { font-size: 2em; ${path.declare("line-height", "2")}; }`, + path.options, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ fontSize: 20, lineHeight: 40 }); + }); + + test("the root font size is the em of an element no ancestor sizes", () => { + deliver( + `:root { ${path.declare("font-size", "20px")}; } .child { width: 2em; }`, + path.options, + ); + + render(); + + expect(styleOf()).toStrictEqual({ width: 40 }); + }); + + test("a relative font size under no sized ancestor measures against the root's", () => { + deliver( + `:root { font-size: 20px; } .middle { ${path.declare("font-size", "2em")}; } .child { width: 1em; }`, + path.options, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ width: 40 }); + }); + + test("a universal font size is the em of an element no ancestor sizes", () => { + deliver( + `* { ${path.declare("font-size", "10px")}; } .child { width: 2em; }`, + path.options, + ); + + render(); + + expect(styleOf()).toStrictEqual({ width: 20 }); + }); + + test.each([":root", "*"])( + "a %s font size relative to its parent leaves an element's other styles", + (selector) => { + deliver( + `${selector} { ${path.declare("font-size", "2em")}; } .child { height: 10px; width: 2em; }`, + path.options, + ); + + render(); + + expect(styleOf()).toMatchObject({ height: 10 }); + }, + ); +}); + +describe.each(Object.entries(DELIVERIES))("%s", (_delivery, deliver) => { + test("a font size that resolves to nothing leaves the element its parent's", () => { + deliver( + `.parent { font-size: 10px; } .middle { font-size: var(--missing); width: 2em; } .child { width: 1em; }`, + {}, + ); + + render( + + + + + , + ); + + expect(styleOf("middle")).toStrictEqual({ width: 20 }); + expect(styleOf()).toStrictEqual({ width: 10 }); + }); + + test("a font size that resolves to nothing is not re-read where a descendant defines its variable", () => { + deliver( + `.middle { font-size: var(--size); } .child { --size: 30px; width: 1em; }`, + { inlineVariables: false }, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ width: 14 }); + }); + + test("a variable holding em measures against the element that reads it", () => { + deliver( + `.parent { font-size: 10px; } .child { --size: 2em; font-size: var(--size); width: var(--size); }`, + { inlineVariables: false }, + ); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ fontSize: 20, width: 40 }); + }); + + test("a font size a provider's variable holds is the em of the element and its descendants", () => { + deliver( + `.middle { font-size: var(--size); width: 2em; } .child { width: 1em; }`, + {}, + ); + + render( + + + + + , + ); + + expect(styleOf("middle")).toStrictEqual({ fontSize: 20, width: 40 }); + expect(styleOf()).toStrictEqual({ width: 20 }); + }); + + test.each(["a b", "b a"])( + "descendants inherit the declaration the cascade picks, for className '%s'", + (className) => { + deliver( + `.b { font-size: 10px; --gap: 1px; } .a { font-size: 20px; --gap: 2px; } .child { width: 1em; height: var(--gap); }`, + {}, + ); + + render( + + + , + ); + + expect(styleOf("parent")).toStrictEqual({ fontSize: 20 }); + expect(styleOf()).toStrictEqual({ width: 20, height: 2 }); + }, + ); +}); + +test("written and variable percentages round alike", () => { + const fontSizes = PATHS.map((path) => { + registerCSS( + `.parent { font-size: 10px; } .child { ${path.declare("font-size", "33.333%")}; }`, + path.options, + ); + + render( + + + , + ); + + return styleOf()?.fontSize; + }); + + expect(fontSizes).toStrictEqual([3.33, 3.33]); +}); + +test("the em descendants inherit follows a viewport-relative font size", () => { + registerCSS(`.parent { font-size: 2vw; } .child { width: 1em; }`); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ width: 10 }); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 1000 }); + }); + + expect(styleOf()).toStrictEqual({ width: 20 }); +}); + +test("the em descendants inherit follows a media query", () => { + registerCSS( + `.parent { font-size: 10px; } @media (min-width: 600px) { .parent { font-size: 20px; } } .child { width: 1em; }`, + ); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + render( + + + , + ); + + expect(styleOf()).toStrictEqual({ width: 10 }); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 1000 }); + }); + + expect(styleOf()).toStrictEqual({ width: 20 }); +}); + +test("rendering again does not compound a relative font size", () => { + registerCSS( + `.parent { font-size: 10px; } .middle { font-size: 2em; } .child { width: 2em; }`, + ); + + const tree = ( + + + + + + ); + + render(tree); + + for (let rerender = 0; rerender < 3; rerender++) { + screen.rerender(tree); + + expect(styleOf()).toStrictEqual({ width: 40 }); + } +}); + +test("descendants follow a parent whose font size changes", () => { + registerCSS( + `.small { font-size: 10px; } .large { font-size: 20px; } .child { width: 2em; }`, + ); + + const tree = (className: string) => ( + + + + ); + + render(tree("small")); + expect(styleOf()).toStrictEqual({ width: 20 }); + + screen.rerender(tree("large")); + expect(styleOf()).toStrictEqual({ width: 40 }); + + screen.rerender(tree("small")); + expect(styleOf()).toStrictEqual({ width: 20 }); +}); + +test.each([ + ["font: 20px Arial", { fontSize: 20, width: 40 }], + ["font: 150% Arial", { fontSize: 15, width: 30 }], + ["font: 2em/2 Arial", { fontSize: 20, lineHeight: 40, width: 40 }], +])("%s measures against the parent's font size", (declaration, style) => { + registerCSS( + `.parent { font-size: 10px; } .child { ${declaration}; width: 2em; }`, + ); + + render( + + + , + ); + + expect(styleOf()).toMatchObject(style); +}); diff --git a/src/__tests__/native/units.test.tsx b/src/__tests__/native/units.test.tsx index 80950021..28c998f2 100644 --- a/src/__tests__/native/units.test.tsx +++ b/src/__tests__/native/units.test.tsx @@ -183,7 +183,7 @@ test("rem - css override", () => { expect(result.current.type).toBe(VariableContext.Provider); expect(result.current.props.value).toStrictEqual({ [VAR_SYMBOL]: true, - [emVariableName]: [{}, "rem", 10], + [emVariableName]: 100, }); expect(result.current.props.children.type).toBe(View); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..0878a1c2 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -727,7 +727,7 @@ function parseFont( "line-height", parseLineHeight(value.lineHeight, builder), ); - builder.addDescriptor("font-size", parseFontSize(value.size, builder)); + addFontSize(parseFontSize(value.size, builder), builder); builder.addDescriptor("font-style", parseFontStyle(value.style, builder)); builder.addDescriptor( "font-variant-caps", @@ -994,10 +994,10 @@ export function parseUnparsedDeclaration( } else { const value = parseUnparsed(declaration.value.value, builder, property); - builder.addDescriptor(property, value); - if (property === "font-size") { - builder.addDescriptor("--__rn-css-em", value); + addFontSize(value, builder); + } else { + builder.addDescriptor(property, value); } if (property === "color") { @@ -2331,15 +2331,31 @@ export function parseFontSizeDeclaration( declaration: DeclarationType<"font-size">, builder: StylesheetBuilder, ) { - const fontSize = parseFontSize(declaration.value, builder); - builder.addDescriptor("fontSize", fontSize); - builder.addDescriptor("--__rn-css-em", fontSize); + addFontSize(parseFontSize(declaration.value, builder), builder); } -export function parseFontSize(value: FontSize, builder: StylesheetBuilder) { +/** + * A font size is also the em its element's lengths and its descendants measure + * against. A size the runtime computes is computed against the parent's em. + */ +function addFontSize(fontSize: StyleDescriptor, builder: StylesheetBuilder) { + const descriptor: StyleDescriptor = isStyleFunction(fontSize) + ? [{}, "fontSize", fontSize, 1] + : fontSize; + + builder.addDescriptor("fontSize", descriptor); + builder.addDescriptor("--__rn-css-em", descriptor); +} + +export function parseFontSize( + value: FontSize, + builder: StylesheetBuilder, +): StyleDescriptor { switch (value.type) { case "length": - return parseLength(value.value, builder); + return value.value.type === "percentage" + ? [{}, "em", round(value.value.value * 100) / 100, 1] + : parseLength(value.value, builder); case "absolute": case "relative": builder.addWarning("value", value.value); diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..7235853b 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -641,7 +641,8 @@ function postProcessStyleFunction(value: StyleDescriptor): [ let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - usesVariables ||= value[1] === "var"; + // `em` reads the em channel an ancestor publishes, like a `var()` + usesVariables ||= value[1] === "var" || value[1] === "em"; shouldDelay ||= value[3] === 1 || usesVariables; if (shouldDelay) { diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..9647447f 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -4,6 +4,7 @@ import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { resetVariableRegistries } from "react-native-css/native-internal"; import { colorScheme, dimensions } from "../native/reactivity"; @@ -20,6 +21,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); + resetVariableRegistries(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); colorScheme.set(null); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..32fe01be 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -32,13 +32,25 @@ const rootVariableFamily = () => { export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); -rootVariables("__rn-css-rem").set([[14]]); -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -rootVariables("__rn-css-color").set([ - [ - Platform.OS === "ios" - ? PlatformColor("label", "labelColor") - : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -] as any); +function seedRootVariables() { + rootVariables("__rn-css-rem").set([[14]]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + rootVariables("__rn-css-color").set([ + [ + Platform.OS === "ios" + ? PlatformColor("label", "labelColor") + : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); +} + +seedRootVariables(); + +// Clearing suffices between tests, where nothing stays mounted to strand +export function resetVariableRegistries() { + rootVariables.clear(); + universalVariables.clear(); + + seedRootVariables(); +} diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..4ad358b8 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native-internal"; +import { specificityCompareFn } from "react-native-css/utilities"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; @@ -15,9 +16,12 @@ import { VAR_SYMBOL, weakFamily, type ContainerContextValue, + type Getter, type VariableContextValue, } from "../reactivity"; import { stylesFamily } from "../styles"; +import { emVariableName } from "../styles/constants"; +import { resolveValue } from "../styles/resolve"; import type { ComponentState, Config } from "./useNativeCss"; export const INLINE_RULE_SYMBOL = Symbol("react-native-css.inlineRule"); @@ -43,6 +47,7 @@ export function updateRules( let variables: VariableContextValue | undefined; let containers: ContainerContextValue | undefined; const inlineVariables = new Set(); + const declaringRules: StyleRule[] = []; let animated = false; let pressable = false; @@ -131,13 +136,7 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { - variables = { ...inheritedVariables }; - } - - for (const v of rule.v) { - variables![v[0]] = v[1]; - } + declaringRules.push(rule); } if (rule.c) { @@ -206,6 +205,19 @@ export function updateRules( } } + const declared: InlineVariable = { [VAR_SYMBOL]: "inline" }; + + if (declaringRules.length) { + // Published in cascade order, so descendants inherit the declaration that wins + for (const rule of declaringRules.sort(specificityCompareFn)) { + for (const [name, value] of rule.v ?? []) { + declared[name] = value; + } + } + + variables = { ...inheritedVariables, ...declared, [VAR_SYMBOL]: true }; + } + pressable = activeFamily.has(state.ruleEffectGetter); if (!rules.size && !state.stylesObs && !inlineVariables.size) { @@ -238,6 +250,16 @@ export function updateRules( } } + if (variables && variables !== inheritedVariables) { + publishComputedFontSize( + variables, + Object.assign(declared, ...inlineVariables), + inheritedVariables, + state.ruleEffectGetter, + guards, + ); + } + // Generate a StyleObservable for this unique set of rules / variables const stylesObs = stylesFamily(generateStateHash(state, rules), rules); @@ -246,7 +268,11 @@ export function updateRules( guards.push(...stylesObs.get().guards); // If these are the same styles with no inline variables, we can skip the update - if (state.stylesObs === stylesObs && !inlineVariables.size) { + if ( + state.stylesObs === stylesObs && + !inlineVariables.size && + state.variables?.[emVariableName] === variables?.[emVariableName] + ) { return state; } @@ -265,6 +291,38 @@ export function updateRules( }; } +/** + * Publish the font size the element computes, in place of the one it declares. + * A size relative to the parent, re-read in a descendant, would measure against + * the descendant's own em, so descendants inherit the number. A size that + * resolves to nothing leaves them the parent's. + */ +function publishComputedFontSize( + variables: VariableContextValue, + declared: InlineVariable, + inheritedVariables: VariableContextValue, + get: Getter, + guards: RenderGuard[], +) { + if (!Object.hasOwn(declared, emVariableName)) { + return; + } + + const fontSize: unknown = resolveValue([{}, "var", [emVariableName]], get, { + inlineVariables: declared, + inheritedVariables, + renderGuards: guards, + }); + + if (typeof fontSize === "number") { + variables[emVariableName] = fontSize; + } else if (Object.hasOwn(inheritedVariables, emVariableName)) { + variables[emVariableName] = inheritedVariables[emVariableName]; + } else { + delete variables[emVariableName]; + } +} + /** * Create variations of a style rule based on the config. * Cache for reference equality. diff --git a/src/native/styles/font-size.ts b/src/native/styles/font-size.ts new file mode 100644 index 00000000..4b4e104f --- /dev/null +++ b/src/native/styles/font-size.ts @@ -0,0 +1,24 @@ +import type { StyleFunctionResolver } from "./resolve"; + +/** + * A font size computed against the parent's: `resolveValue` evaluates the + * argument in `parentFontScopeOptions`, so an `em` inside it and a percentage + * a variable holds both measure against the parent's font size. + */ +export const fontSize: StyleFunctionResolver = (resolve, func) => { + const size = resolve(func[2]); + + if (typeof size === "number") { + return size; + } + + if (typeof size === "string" && size.endsWith("%")) { + const percentage = Number.parseFloat(size); + + return Number.isFinite(percentage) + ? resolve([{}, "em", percentage / 100]) + : undefined; + } + + return; +}; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..53f63716 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -11,12 +11,13 @@ import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; import type { calculateProps } from "./calculate-props"; import { transformKeys } from "./defaults"; +import { fontSize } from "./font-size"; import * as functions from "./functions"; import { lineHeight } from "./line-height"; import { scaleFactor } from "./scale-factor"; import * as shorthands from "./shorthands"; import { em, rem, vh, vw } from "./units"; -import { varResolver } from "./variables"; +import { parentFontScopeOptions, varResolver } from "./variables"; export type SimpleResolveValue = ( value: StyleDescriptor, @@ -40,6 +41,7 @@ export type StyleResolver = ( const functionResolvers = { ...shorthands, ...functions, + fontSize, lineHeight, em, rem, @@ -47,6 +49,16 @@ const functionResolvers = { vw, }; +/** Resolvers whose argument is evaluated in a scope other than the element's own. */ +const argumentScopes: Partial< + Record< + keyof typeof functionResolvers, + (options: ResolveValueOptions) => ResolveValueOptions + > +> = { + fontSize: parentFontScopeOptions, +}; + export type ResolveValueOptions = { castToArray?: boolean; inheritedVariables?: VariableContextValue; @@ -54,6 +66,8 @@ export type ResolveValueOptions = { renderGuards?: RenderGuard[]; variableHistory?: Set; variableCycles?: Set; + /** The scope this element's font size is computed in, once a resolver has entered it */ + parentFontScope?: ResolveValueOptions; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; @@ -111,17 +125,22 @@ export function resolveValue( if (name === "var") { return varResolver(simpleResolve, value, get, options); } else if (name in functionResolvers) { - const fn = functionResolvers[name as keyof typeof functionResolvers]; + const resolverName = name as keyof typeof functionResolvers; + const fn = functionResolvers[resolverName]; if (typeof fn !== "function") { throw new Error(`Unknown function: ${name}`); } + const scope = argumentScopes[resolverName]?.(options) ?? options; + value = fn( - simpleResolve, + scope === options + ? simpleResolve + : (argument) => resolveValue(argument, get, scope), value as StyleFunction, get, - options, + scope, ) as StyleDescriptor; } else if (transformKeys.has(name)) { // translate, rotate, scale, etc. diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 0b785645..0367df89 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -6,6 +6,7 @@ import { import { isStyleDescriptorArray } from "react-native-css/utilities"; import { VAR_SYMBOL, type Getter } from "../reactivity"; +import { emVariableName } from "./constants"; import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; export function varResolver( @@ -96,3 +97,32 @@ export function varResolver( variableHistory.delete(name); } } + +/** + * The scope a font size is computed in. `em` and a percentage in `font-size` + * measure against the parent's font size (css-fonts-4 ยง2.5), so the element's + * own em is left out and the inherited one answers. A font size met inside the + * scope shares it, so a size relative to itself ends as a cycle. + */ +export function parentFontScopeOptions( + options: ResolveValueOptions, +): ResolveValueOptions { + if (options.parentFontScope) { + return options.parentFontScope; + } + + const { [emVariableName]: _ownEm, ...declared } = options.inlineVariables ?? { + [VAR_SYMBOL]: "inline", + }; + + const scope: ResolveValueOptions = { + ...options, + inlineVariables: { ...declared, [VAR_SYMBOL]: "inline" }, + variableHistory: new Set(), + variableCycles: new Set(), + }; + + scope.parentFontScope = scope; + + return scope; +}