diff --git a/src/__tests__/compiler/inline-variables.test.ts b/src/__tests__/compiler/inline-variables.test.ts new file mode 100644 index 00000000..537694c8 --- /dev/null +++ b/src/__tests__/compiler/inline-variables.test.ts @@ -0,0 +1,382 @@ +import type { + ReactNativeCssStyleSheet, + StyleDescriptor, + StyleRule, +} from "react-native-css/compiler"; +import { compile } from "react-native-css/compiler"; + +function rulesFor( + sheet: ReactNativeCssStyleSheet, + className: string, +): StyleRule[] { + return sheet.s?.find(([name]) => name === className)?.[1] ?? []; +} + +function foldedValue( + sheet: ReactNativeCssStyleSheet, + className: string, + property: string, +) { + for (const rule of rulesFor(sheet, className)) { + for (const declaration of rule.d ?? []) { + if (!Array.isArray(declaration) && property in declaration) { + return declaration[property]; + } + } + } + return undefined; +} + +function runtimeValue( + sheet: ReactNativeCssStyleSheet, + className: string, + property: string, +): StyleDescriptor { + for (const rule of rulesFor(sheet, className)) { + for (const declaration of rule.d ?? []) { + if (Array.isArray(declaration) && declaration[1] === property) { + return declaration[0]; + } + } + } + return undefined; +} + +function readsAtRuntime(sheet: ReactNativeCssStyleSheet, className: string) { + return rulesFor(sheet, className).some((rule) => rule.dv === 1); +} + +function declaredValue( + sheet: ReactNativeCssStyleSheet, + className: string, + name: string, +) { + for (const rule of rulesFor(sheet, className)) { + for (const [declared, value] of rule.v ?? []) { + if (declared === name) return value; + } + } + return undefined; +} + +describe("a variable is folded only where its value is provable", () => { + test("a class-scoped variable does not reach another rule", () => { + const sheet = compile( + `.parent { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + // An element carrying only `.child` never matched `.parent`, so it has no `--x` + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + expect(declaredValue(sheet, "parent", "x")).toBe(10); + }); + + test("the same block is provable, and still folds", () => { + const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(readsAtRuntime(sheet, "a")).toBe(false); + }); + + test.each([":root", ":host", "*", "html"])( + "%s is universal, so its variables fold anywhere", + (selector) => { + const sheet = compile( + `${selector} { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + expect(readsAtRuntime(sheet, "child")).toBe(false); + }, + ); + + test.each([ + ["div", "every element descends from html, from no other element name"], + [":hover", "a state is not a scope"], + [".theme", "a class is the case this whole rule exists for"], + ])("%s is not universal: %s", (selector) => { + const sheet = compile( + `${selector} { --x: 10px; } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + }); + + test.each([ + [":root", 10], + [".theme", undefined], + ])("a nested `&` under %s takes its parent's answer", (parent, expected) => { + const sheet = compile( + `${parent} { & { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(expected); + }); + + test("a nested selector under :root names its descendants, not every element", () => { + const sheet = compile( + `:root { * { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + }); + + test("@layer does not narrow a universal scope", () => { + const sheet = compile( + `@layer theme { :root, :host { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); +}); + +describe("a folded declaration is removed once nothing reads it", () => { + test("a declaration whose every reference folded is removed", () => { + const sheet = compile(`.a { --x: 10px; width: var(--x); }`).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + }); + + test("a declaration another rule reads at runtime is kept", () => { + const sheet = compile( + `.a { --x: 10px; width: var(--x); } .b { height: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(readsAtRuntime(sheet, "b")).toBe(true); + expect(declaredValue(sheet, "a", "x")).toBe(10); + }); + + test.each([ + ["an rgb() alpha", `.a { --c: 0.5; color: rgb(255 0 0 / var(--c)); }`], + [ + "a light-dark() branch", + `.a { --c: 0.5; color: light-dark(rgb(255 0 0 / var(--c)), #00f); }`, + ], + ])("a reference inside %s keeps its declaration", (_name, css) => { + const sheet = compile(css).stylesheet(); + + expect(declaredValue(sheet, "a", "c")).toBe(0.5); + }); + + test("a variable declared initial folds to the reference's fallback", () => { + const sheet = compile( + `.a { --x: initial; width: var(--x, 10px); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); + + test("every declaration of a fully folded chain is removed", () => { + const sheet = compile( + `.a { --y: 10px; --x: var(--y); width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + expect(declaredValue(sheet, "a", "y")).toBeUndefined(); + }); + + test("a declaration read only by a removed declaration is removed", () => { + const sheet = compile( + `.a { --x: var(--y); height: 1px; } .b { --y: 10px; height: 2px; }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBeUndefined(); + expect(declaredValue(sheet, "b", "y")).toBeUndefined(); + }); + + test("a declaration read by a kept declaration is kept", () => { + const sheet = compile( + `.a { --x: var(--y); height: 1px; } .b { --y: 10px; height: 2px; } .c { width: var(--x); }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toStrictEqual([{}, "var", "y", 1]); + expect(declaredValue(sheet, "b", "y")).toBe(10); + }); +}); + +describe("a conditional scope is not a universal one", () => { + test.each([ + ["@media (min-width: 1px)", "media"], + ["@container (min-width: 1px)", "container"], + ])("%s wrapping :root", (atRule) => { + const sheet = compile( + `${atRule} { :root { --x: 10px; } } .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "child")).toBe(true); + }); + + test("a conditional block still folds into itself", () => { + // A query that switches the declaration off switches the reference off with it + const sheet = compile( + `@media (min-width: 1px) { .a { --x: 10px; width: var(--x); } }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); +}); + +describe("@supports is decided when the stylesheet compiles", () => { + test("a kept block's declarations apply unconditionally", () => { + const sheet = compile( + `@supports (color: color-mix(in lab, red, red)) { :root { --x: 10px; } } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); + + test("a dropped block's declarations do not apply", () => { + const sheet = compile( + `@supports (display: grid) { :root { --x: 10px; } } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBeUndefined(); + }); + + test("a dropped block's declaration is not a second definition", () => { + const sheet = compile( + `@supports (display: grid) { :root { --x: 20px; } } + :root { --x: 10px; } + .child { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "child", "width")).toBe(10); + }); +}); + +describe("a registered property", () => { + test("inherits: false keeps a :root declaration from reaching other elements", () => { + // An element other than the root holds the registered initial value, not the one `:root` declares + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + :root { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); + + test("inherits: true leaves the universal fold alone", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: true; initial-value: 10px; } + :root { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(20); + }); + + test("inherits: false does not narrow a declaration on every element", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + * { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(20); + }); + + test("an undeclared property folds to its initial value", () => { + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(10); + }); + + test("the initial value wins over a fallback block the compiler drops", () => { + // Tailwind's shape: the fallback serves browsers without @property, and the compiler drops it + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) { + *, ::before, ::after, ::backdrop { --x: 20px; } + } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBe(10); + }); + + test("a typed initial value is left to the runtime", () => { + const sheet = compile( + `@property --x { syntax: ""; inherits: false; initial-value: 10px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); + + test("a declared property does not fold its initial value across rules", () => { + const sheet = compile( + `@property --x { syntax: "*"; inherits: false; initial-value: 10px; } + .a { --x: 20px; } + .b { width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "b", "width")).toBeUndefined(); + expect(readsAtRuntime(sheet, "b")).toBe(true); + }); +}); + +describe("a variable's own value is held to the same terms", () => { + test("a nested reference does not cross a block boundary", () => { + const sheet = compile( + `.a { --x: var(--y); width: var(--x); } .b { --y: 10px; }`, + ).stylesheet(); + + // `--y` belongs to `.b`, and folding it into `.a` would carry it to elements that never matched `.b` + expect(foldedValue(sheet, "a", "width")).toBeUndefined(); + expect(runtimeValue(sheet, "a", "width")).toStrictEqual([ + {}, + "var", + "y", + 1, + ]); + expect(declaredValue(sheet, "b", "y")).toBe(10); + }); + + test("a nested reference in the same block folds", () => { + const sheet = compile( + `.a { --y: 10px; --x: var(--y); width: var(--x); }`, + ).stylesheet(); + + expect(foldedValue(sheet, "a", "width")).toBe(10); + }); + + test("a universal nested reference folds into a kept declaration", () => { + const sheet = compile( + `:root { --y: 10px; } .a { --x: var(--y); } .b { width: var(--x); }`, + ).stylesheet(); + + expect(declaredValue(sheet, "a", "x")).toBe(10); + }); +}); + +test("the fold does not depend on the order the rules are written in", () => { + // `--y` is declared twice, so it is not a candidate, and the winning `2px` is left to the runtime + const declarations = `:root { --y: 1px; } :root { --y: 2px; }`; + const reference = `.a { --x: var(--y); width: var(--x); }`; + + const varFirst = compile(`${reference} ${declarations}`).stylesheet(); + const varLast = compile(`${declarations} ${reference}`).stylesheet(); + + for (const sheet of [varFirst, varLast]) { + expect(foldedValue(sheet, "a", "width")).toBeUndefined(); + expect(runtimeValue(sheet, "a", "width")).toStrictEqual([ + {}, + "var", + "y", + 1, + ]); + } +}); diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 85a809a5..b0479b58 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -383,10 +383,7 @@ describe("shadow values from CSS variables", () => { }); }); - // Compile-time path: lightningcss inlines the variable and processes the - // value through its CSS parser. The shorthand handler converts to native - // format without filtering — transparent shadows pass through as-is. - test("transparent shadow is preserved", () => { + test("a transparent shadow is dropped when its variable folds, as when it does not", () => { registerCSS(` :root { --my-shadow: 0 0 0 0 #0000; @@ -397,18 +394,38 @@ describe("shadow values from CSS variables", () => { render(); const component = screen.getByTestId(testID); - expect(component.props.style).toStrictEqual({ + expect(component.props.style).toStrictEqual({ boxShadow: [] }); + }); + + test("only the transparent shadows of a list are dropped", () => { + registerCSS(`.test { box-shadow: 0 0 #0000, 0 4px 6px -1px #0000001a; }`); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ boxShadow: [ { offsetX: 0, - offsetY: 0, - blurRadius: 0, - spreadDistance: 0, - color: "#0000", + offsetY: 4, + blurRadius: 6, + spreadDistance: -1, + color: "#0000001a", }, ], }); }); + + test("a list of transparent shadows still replaces an earlier one", () => { + registerCSS( + `.shadow { box-shadow: 1px 1px #f00; } .test { box-shadow: 0 0 transparent; }`, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + boxShadow: [], + }); + }); }); describe("shadow values from runtime variables", () => { @@ -526,12 +543,7 @@ describe("shadow values from runtime variables", () => { ]); }); - // Runtime path: variables resolve at runtime, so the shorthand handler - // processes the raw values. Transparent shadows (e.g., Tailwind v4's - // @property --tw-inset-shadow { initial-value: 0 0 0 0 #0000 }) are - // filtered out to avoid creating invisible native shadow objects that - // the renderer would still process. This is intentionally different - // from the compile-time path which preserves them. + // A transparent shadow paints nothing, so neither path hands React Native one to process test("transparent shadow is filtered", () => { registerCSS( `.test { diff --git a/src/__tests__/native/variable-fold-parity.test.tsx b/src/__tests__/native/variable-fold-parity.test.tsx new file mode 100644 index 00000000..b6232711 --- /dev/null +++ b/src/__tests__/native/variable-fold-parity.test.tsx @@ -0,0 +1,305 @@ +import { StyleSheet } from "react-native"; + +import { render } from "@testing-library/react-native"; +import type { TokenOrValue } from "lightningcss"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +// Keyed by lightningcss's own unions, so a value kind it adds fails to compile until it is listed here +type ValueKind = TokenOrValue["type"]; +type RawTokenKind = Extract["value"]["type"]; + +interface Shape { + readonly property: string; + readonly value: string; + readonly knownDivergence?: { + readonly folded: Record; + readonly unfolded: Record; + readonly because: string; + }; +} + +// Normalising the variable store instead would turn `font-family: var(--v)` over `--v: red` into "#f00" +const namedColour = (folded: string, unfolded: string) => ({ + folded: { color: folded }, + unfolded: { color: unfolded }, + because: + "a named colour is an ident until a property gives it a type; the variable store has no property", +}); + +const TOKEN_CENSUS: Record = { + "ident": [ + { property: "position", value: "absolute" }, + { + property: "color", + value: "red", + knownDivergence: namedColour("#f00", "red"), + }, + { + property: "color", + value: "transparent", + knownDivergence: namedColour("#0000", "transparent"), + }, + { + property: "color", + value: "rebeccapurple", + knownDivergence: namedColour("#639", "rebeccapurple"), + }, + ], + "string": [{ property: "font-family", value: '"Inter"' }], + "number": [ + { property: "flex-grow", value: "2" }, + { property: "width", value: "0" }, + { property: "opacity", value: "0" }, + { property: "z-index", value: "3" }, + ], + "percentage": [ + { property: "width", value: "50%" }, + { + property: "scale", + value: "50%", + knownDivergence: { + folded: { transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }] }, + unfolded: { transform: [{ scale: 0.5 }] }, + because: + "the static parser writes a uniform scale as both axes and the runtime one as `scale`; fold-independent, and closing it is a runtime transform change", + }, + }, + ], + "dimension": [ + { + property: "transition-duration", + value: "3s", + knownDivergence: { + folded: {}, + unfolded: { transitionDuration: 3000 }, + because: + "folding routes the duration to the animation system, which paints nothing; not folding leaks it into the static style. A transition gap, not a variable one", + }, + }, + ], + "delim": [ + { property: "aspect-ratio", value: "16 / 9" }, + // The property parser writes a square ratio as 1 + { property: "aspect-ratio", value: "3 / 3" }, + ], + "hash": [{ property: "color", value: "#123456" }], + // Not reachable as the whole value of a custom property a style consumes. + "at-keyword": [], + "id-hash": [], + "unquoted-url": [], + "white-space": [], + "comment": [], + "colon": [], + "semicolon": [], + "comma": [], + "include-match": [], + "dash-match": [], + "prefix-match": [], + "suffix-match": [], + "substring-match": [], + "cdo": [], + "cdc": [], + "function": [], + "parenthesis-block": [], + "square-bracket-block": [], + "curly-bracket-block": [], + "bad-url": [], + "bad-string": [], + "close-parenthesis": [], + "close-square-bracket": [], + "close-curly-bracket": [], +}; + +const VALUE_CENSUS: Record = { + "token": Object.values(TOKEN_CENSUS).flat(), + "color": [ + { property: "color", value: "#12345678" }, + { property: "color", value: "rgba(255, 0, 0, 0.5)" }, + { property: "color", value: "oklch(63.7% 0.237 25.331)" }, + ], + "length": [ + { property: "width", value: "10px" }, + { property: "width", value: "0px" }, + { property: "margin-top", value: "-4px" }, + { property: "width", value: "1rem" }, + ], + "angle": [ + { + property: "rotate", + value: "45deg", + knownDivergence: { + folded: { transform: [{ rotateZ: "45deg" }] }, + unfolded: { transform: [{ rotate: "45deg" }] }, + because: + "`rotate` is renamed to `rotateZ` by the static parser and left alone by the runtime one; fold-independent, and closing it is a runtime shorthand change", + }, + }, + ], + "function": [ + { property: "width", value: "calc(10px + 2px)" }, + { property: "transform", value: "translateX(10px)" }, + ], + "var": [ + { + property: "color", + value: "var(--inner)", + knownDivergence: namedColour("#008080", "teal"), + }, + ], + // Both paths share parseUnresolvedColor's channel scale, so only their agreement is claimed here + "unresolved-color": [ + { property: "color", value: "rgb(255 0 0 / var(--alpha))" }, + ], + // Not reachable, or not a style value React Native consumes. + "url": [], + "env": [], + "time": [], + "resolution": [], + "dashed-ident": [], + "animation-name": [], +}; + +// The static parser expands a shorthand into its longhands, and the runtime one hands React Native the list +const SHORTHANDS: readonly Shape[] = ( + [ + [ + "margin", + "1px 2px", + "marginTop", + "marginBottom", + "marginLeft", + "marginRight", + ], + [ + "padding", + "1px 2px", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + ], + [ + "border-width", + "1px 2px", + "borderTopWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderRightWidth", + ], + ] as const +).map(([property, value, top, bottom, left, right]) => ({ + property, + value, + knownDivergence: { + folded: { [top]: 1, [bottom]: 1, [left]: 2, [right]: 2 }, + unfolded: { [property.replace("-w", "W")]: [1, 2] }, + because: + "the static parser expands the shorthand, the runtime one does not; fold-independent, and closing it needs a runtime shorthand handler", + }, +})); + +const SHADOW_LISTS: readonly Shape[] = [ + { property: "box-shadow", value: "1px 2px 3px 4px #123456" }, + { property: "box-shadow", value: "0 0 #0000, 1px 2px 3px 4px #123456" }, + { property: "box-shadow", value: "0 0 transparent" }, +]; + +const SHAPES: readonly Shape[] = [ + ...Object.values(VALUE_CENSUS).flat(), + ...SHORTHANDS, + ...SHADOW_LISTS, +]; + +function renderShape( + shape: Shape, + inlineVariables: boolean, +): Record { + // `--inner` and `--alpha` back the shapes whose value is itself a reference + const css = `.a { --inner: teal; --alpha: 0.5; --v: ${shape.value}; ${shape.property}: var(--v); }`; + StyleCollection.styles.clear(); + registerCSS(css, inlineVariables ? {} : { inlineVariables: false }); + const { style } = render().getByTestId( + testID, + ).props as { style?: Record | Record[] }; + + return StyleSheet.flatten>(style ?? {}); +} + +test("the census covers something", () => { + expect(SHAPES.length).toBeGreaterThan(20); +}); + +describe.each(SHAPES)("$property: var(--v) over $value", (shape) => { + test("the fold decision is unobservable", () => { + const folded = renderShape(shape, true); + const unfolded = renderShape(shape, false); + + if (shape.knownDivergence) { + expect(folded).toStrictEqual(shape.knownDivergence.folded); + expect(unfolded).toStrictEqual(shape.knownDivergence.unfolded); + return; + } + + // A shape that renders nothing on both paths would agree vacuously + expect(folded).not.toStrictEqual({}); + expect(unfolded).toStrictEqual(folded); + }); +}); + +function renderStylesheet( + css: string, + className: string, + inlineVariables: boolean, +): Record { + StyleCollection.styles.clear(); + registerCSS(css, inlineVariables ? {} : { inlineVariables: false }); + const { style } = render( + , + ).getByTestId(testID).props as { + style?: Record | Record[]; + }; + + return StyleSheet.flatten>(style ?? {}); +} + +// Each case names its own properties, because root and universal variables outlive a test +test.each([ + [ + "a declaration in a rule the element does not match", + `.p { --scope-a: 10px; } .a { width: var(--scope-a); height: 5px; }`, + "a", + ], + [ + "a declaration another rule on the element also reads", + `.a { --scope-b: 10px; width: var(--scope-b); } .b { height: var(--scope-b); }`, + "a b", + ], + [ + "a registered initial value beside a fallback block the compiler drops", + `@property --scope-c { syntax: "*"; inherits: false; initial-value: 10px; } + @supports (display: grid) { *, ::before { --scope-c: 20px; } } + .a { width: var(--scope-c); }`, + "a", + ], + [ + "a declaration inside an @supports block the compiler keeps", + `@supports (color: color-mix(in lab, red, red)) { :root { --scope-d: 10px; } } + .a { width: var(--scope-d); }`, + "a", + ], + [ + "a declaration on every element, registered inherits: false", + `@property --scope-e { syntax: ""; inherits: false; initial-value: 0px; } + * { --scope-e: 10px; } + .a { width: var(--scope-e); }`, + "a", + ], +])("%s renders the same whether or not it folds", (_name, css, className) => { + const folded = renderStylesheet(css, className, true); + const unfolded = renderStylesheet(css, className, false); + + expect(folded).not.toStrictEqual({}); + expect(unfolded).toStrictEqual(folded); +}); diff --git a/src/__tests__/native/variable-inlining.test.tsx b/src/__tests__/native/variable-inlining.test.tsx new file mode 100644 index 00000000..a02c2cd8 --- /dev/null +++ b/src/__tests__/native/variable-inlining.test.tsx @@ -0,0 +1,165 @@ +import { act, render, screen } from "@testing-library/react-native"; +import { vars } from "react-native-css"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { dimensions } from "../../native/reactivity"; + +// Each test names its own properties, because root variables outlive a test + +test("a class-scoped variable does not leak into an unrelated rule", () => { + registerCSS(`.parent { --leak-a: 10px; } .child { width: var(--leak-a); }`); + + render(); + + // No `.parent` anywhere above it, so there is no `--leak-a` to read. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a class-scoped variable still reaches a descendant", () => { + registerCSS(`.parent { --leak-b: 10px; } .child { width: var(--leak-b); }`); + + render( + + + , + ); + + // The declaration has to survive the fold for this to resolve at all. + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a variable's own value does not leak across blocks", () => { + registerCSS( + `.a { --nest-a: var(--nest-b); width: var(--nest-a); } .b { --nest-b: 10px; }`, + ); + + render(); + + // `--nest-b` belongs to `.b`. An element carrying only `.a` never had it. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a conditional :root does not fold into an unconditional rule", () => { + registerCSS(` + @media (min-width: 1000px) { :root { --cond-off: 10px; } } + .child { width: var(--cond-off); } + `); + + act(() => { + dimensions.set({ width: 500, height: 500, scale: 1, fontScale: 1 }); + }); + render(); + + // The declaration does not apply at this width, so neither does the value. + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +test("a conditional :root still applies when its query matches", () => { + registerCSS(` + @media (min-width: 1000px) { :root { --cond-on: 10px; } } + .child { width: var(--cond-on); } + `); + + act(() => { + dimensions.set({ width: 1200, height: 500, scale: 1, fontScale: 1 }); + }); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a property registered with inherits: false resolves at runtime", () => { + registerCSS(` + @property --registered { syntax: ""; inherits: false; initial-value: 10px; } + :root { --registered: 20px; } + .b { width: var(--registered); } + `); + + render(); + + // Known limit: CSS gives `.b` the initial 10, but the runtime has no non-inherited custom property and resolves :root's 20 + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 20 }); +}); + +test("a registered initial value applies where nothing declares the property", () => { + registerCSS(` + @property --initial-a { syntax: "*"; inherits: false; initial-value: 10px; } + @supports (display: grid) { *, ::before { --initial-a: 20px; } } + .a { width: var(--initial-a); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("a declaration another rule reads survives its own fold", () => { + registerCSS( + `.a { --kept-a: 10px; width: var(--kept-a); } .b { height: var(--kept-a); }`, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ height: 10 }); +}); + +test("a runtime write reaches a rule that reads the variable", () => { + registerCSS(`.parent { --write-a: red; } .child { color: var(--write-a); }`); + + render( + + + , + ); + + // An inline write outranks a class declaration, which a value folded into `.child` could not express + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +test("a runtime write cannot reach a reference in the declaring block", () => { + registerCSS(`.a { --write-b: red; color: var(--write-b); }`); + + render( + , + ); + + // Known limit: CSS lets the inline write win, but the fold leaves no read for it; `exclude` restores it + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("excluding a variable restores the runtime write", () => { + registerCSS(`.a { --write-c: red; color: var(--write-c); }`, { + inlineVariables: { exclude: ["--write-c"] }, + }); + + render( + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..8de82e1d 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -171,8 +171,9 @@ test("can apply and set new variables", () => { expect(screen.getByTestId(testIDs.two).props.style).toStrictEqual({ color: "#f00", }); + // `.another-class` inherits `--another-var` from `.my-class` at runtime, which carries the name as written expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ - color: "#008000", + color: "green", }); }); @@ -198,8 +199,9 @@ test("variables will be inherited", () => { , ); + // Inherited from `.green` at runtime, which carries the name as written expect(screen.getByTestId(testIDs.three).props.style).toStrictEqual({ - color: "#008000", + color: "green", }); }); @@ -269,5 +271,6 @@ test("variable overriding with classes", () => { ); const component = screen.getByTestId(testID); - expect(component.props.style).toStrictEqual({ color: "#f00" }); + // `.test` inherits `--tier-500` from `.tier-red`, whose value the `:root` tier folded into + expect(component.props.style).toStrictEqual({ color: "red" }); }); diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts index 2a7cf239..0ff41545 100644 --- a/src/__tests__/vendor/tailwind/transform.test.ts +++ b/src/__tests__/vendor/tailwind/transform.test.ts @@ -26,7 +26,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0 }], + transform: [{ scaleX: 0 }, { scaleY: 0 }], }, }, }); @@ -53,7 +53,7 @@ describe("Transforms - Scale", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - transform: [{ scale: 0.5 }], + transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }], }, }, }); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..f591410b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -22,7 +22,6 @@ import type { ContainerQuery, StyleDescriptor, StyleRuleMapping, - UniqueVarInfo, } from "./compiler.types"; import { parseContainerCondition } from "./container-query"; import { @@ -85,8 +84,6 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { * Due to the above issue, we run lightningcss twice */ - const vars = new Map(); - // Determine the effective rem multiplier for compile-time inlining. // If inlineRem is explicitly set, use that. Otherwise, scan for // :root { font-size: Npx } in the CSS to allow CSS-based configuration. @@ -148,25 +145,8 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { if (options.inlineVariables !== false) { const exclusionList: string[] = options.inlineVariables?.exclude ?? []; - firstPassVisitor.Declaration = (decl) => { - if ( - decl.property === "custom" && - decl.value.name.startsWith("--") && - !exclusionList.includes(decl.value.name) - ) { - const entry = vars.get(decl.value.name) ?? { - count: 0, - value: [ - ...decl.value.value, - { type: "token", value: { type: "white-space", value: " " } }, - ], - }; - entry.count++; - vars.set(decl.value.name, entry); - } - }; firstPassVisitor.StyleSheetExit = (sheet) => { - return inlineVariables(sheet, vars); + return inlineVariables(sheet, exclusionList); }; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..e60a8983 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -160,7 +160,6 @@ export type InlineVariable = { }; export type UniqueVarInfo = { - count: number; value: TokenOrValue[] | undefined; flat?: true; }; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..109ce61c 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, isTransparentColor } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -1078,6 +1078,26 @@ export function parseCustomDeclaration( } } +function isRatioGroup( + group: readonly unknown[], +): group is [number, "/", number] { + return ( + group.length === 3 && + typeof group[0] === "number" && + group[1] === "/" && + typeof group[2] === "number" + ); +} + +// The value parseAspectRatio produces, so folding a variable that holds a ratio is invisible +function ratioDescriptor([width, , height]: [ + number, + "/", + number, +]): StyleDescriptor { + return width === height ? 1 : `${width}/${height}`; +} + export function reduceParseUnparsed( tokenOrValues: TokenOrValue[], builder: StylesheetBuilder, @@ -1123,17 +1143,8 @@ export function reduceParseUnparsed( } else { return [first]; } - } else if ( - // This is a special case for values - group.includes("/") && - group.every((item) => - typeof item === "string" && item === "/" - ? item - : typeof item === "number", - ) - ) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return [group.join(" ")]; + } else if (isRatioGroup(group)) { + return [ratioDescriptor(group)]; } else { return [group]; } @@ -1198,7 +1209,8 @@ export function parseUnparsed( property, allowAuto, ); - if (!args) return; + // `0` and `false` are values, not absences + if (args === undefined) return; if (Array.isArray(args) && args.length === 1) { return args[0]; } else if ( @@ -2459,11 +2471,18 @@ export function parseBoxShadow( { value }: DeclarationType<"box-shadow">, builder: StylesheetBuilder, ) { - for (const [index, shadow] of value.entries()) { - builder.addDescriptor( - `&.boxShadow.[${index}].color`, - parseColor(shadow.color, builder), - ); + const shadows = value + .map((shadow) => ({ shadow, color: parseColor(shadow.color, builder) })) + .filter(({ color }) => !isTransparentColor(color)); + + // A transparent shadow paints nothing, so it is dropped as the runtime drops one it reads from a variable + if (shadows.length === 0 && value.length > 0) { + builder.addDescriptor("box-shadow", [{}, "boxShadow", [], 1]); + return; + } + + for (const [index, { shadow, color }] of shadows.entries()) { + builder.addDescriptor(`&.boxShadow.[${index}].color`, color); builder.addDescriptor( `&.boxShadow.[${index}].offsetX`, parseLength(shadow.xOffset, builder), diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..f33759b7 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -1,25 +1,62 @@ import type { Declaration, DeclarationBlock, + Rule, + Selector, StyleSheet, TokenOrValue, } from "lightningcss"; import type { UniqueVarInfo } from "./compiler.types"; +import { supportsConditionValid } from "./supports"; +// A custom property is scoped to the elements its rule matches (css-variables-1 §2), so a value folds only where every matching element provably holds it export function inlineVariables( stylesheet: StyleSheet, - vars: Map, + exclude: readonly string[], ) { - for (const [name, info] of [...vars]) { - if (info.count !== 1) { - vars.delete(name); - } else { - flattenVar(name, vars); - } + const scope = collectVariableScope(stylesheet, exclude); + + for (const name of [...scope.vars.keys()]) { + flattenVar(name, scope); } - stylesheet.rules = stylesheet.rules.map(function checkRule(rule) { + forEachDeclarationBlock(stylesheet.rules, (block) => { + foldDeclarationBlock(block, scope); + }); + + const reads = collectRuntimeReads(stylesheet.rules, scope); + + forEachDeclarationBlock(stylesheet.rules, (block) => { + removeUnreadDeclarations(block, scope, reads); + }); + + return stylesheet; +} + +interface VariableScope { + readonly vars: Map; + readonly declaringBlocks: Map; + readonly universalNames: Set; +} + +// Every matching element holds the property when its declaration is universal and unconditional, or when the reference shares its block +function canFold( + name: string, + block: DeclarationBlock | undefined, + scope: VariableScope, +) { + return ( + scope.universalNames.has(name) || + (block !== undefined && scope.declaringBlocks.get(name) === block) + ); +} + +function forEachDeclarationBlock( + rules: Rule[], + visit: (block: DeclarationBlock) => void, +): void { + for (const rule of rules) { switch (rule.type) { case "custom": case "font-face": @@ -40,107 +77,162 @@ export function inlineVariables( case "custom-media": case "scope": case "starting-style": - return rule; - - case "media": - rule.value.rules = rule.value.rules.map((rule) => checkRule(rule)); - return rule; + break; case "keyframes": - rule.value.keyframes = rule.value.keyframes.map((keyframe) => { - keyframe.declarations = - replaceDeclarationBlock(keyframe.declarations, vars) ?? - keyframe.declarations; - - return keyframe; - }); - return rule; + for (const keyframe of rule.value.keyframes) { + visit(keyframe.declarations); + } + break; case "style": - rule.value.declarations = replaceDeclarationBlock( - rule.value.declarations, - vars, - ); - - rule.value.rules = rule.value.rules?.flatMap((rule) => checkRule(rule)); - - return rule; + if (rule.value.declarations) { + visit(rule.value.declarations); + } + forEachDeclarationBlock(rule.value.rules ?? [], visit); + break; case "nested-declarations": - rule.value.declarations = - replaceDeclarationBlock(rule.value.declarations, vars) ?? {}; - return rule; + visit(rule.value.declarations); + break; + case "media": case "supports": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; case "layer-block": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; case "container": - rule.value.rules = rule.value.rules.flatMap((rule) => checkRule(rule)); - return rule; + forEachDeclarationBlock(rule.value.rules, visit); + break; } - }); + } +} - return stylesheet; +function declarationsOf(block: DeclarationBlock): Declaration[] { + return [ + ...(block.declarations ?? []), + ...(block.importantDeclarations ?? []), + ]; } -function replaceDeclarationBlock( - block: DeclarationBlock | undefined, - vars: Map, -) { - if (!block) return; +function foldDeclarationBlock(block: DeclarationBlock, scope: VariableScope) { + for (const declaration of declarationsOf(block)) { + if ( + declaration.property === "unparsed" || + declaration.property === "custom" + ) { + declaration.value.value = declaration.value.value.flatMap((part) => { + return flattenPart(part, scope, block); + }); + } + } +} - block.declarations = block.declarations - ?.map((decl) => { - return replaceDeclaration(decl, vars); - }) - .filter((d) => !!d); +function collectRuntimeReads(rules: Rule[], scope: VariableScope) { + const reads = new Set(); + const readsOfCandidates = new Map(); + const pending: string[] = []; - block.importantDeclarations = block.importantDeclarations - ?.map((decl) => { - return replaceDeclaration(decl, vars); - }) - .filter((d) => !!d); + const read = (name: string) => { + if (!reads.has(name)) { + reads.add(name); + pending.push(name); + } + }; - return block; -} + forEachDeclarationBlock(rules, (block) => { + for (const declaration of declarationsOf(block)) { + if (declaration.property === "custom") { + const { name, value } = declaration.value; + + // A candidate's own value is read only while something reads the candidate + if (scope.vars.has(name)) { + const names = readsOfCandidates.get(name) ?? []; + forEachReadName(value, (readName) => names.push(readName)); + readsOfCandidates.set(name, names); + } else { + forEachReadName(value, read); + } + } else if (declaration.property === "unparsed") { + forEachReadName(declaration.value.value, read); + } + } + }); -function replaceDeclaration( - declaration: Declaration, - vars: Map, -) { - if ( - declaration.property !== "unparsed" && - declaration.property !== "custom" - ) { - return declaration; + for (let name = pending.pop(); name !== undefined; name = pending.pop()) { + for (const readName of readsOfCandidates.get(name) ?? []) { + read(readName); + } } - if (declaration.property === "custom" && vars.has(declaration.value.name)) { - return; + return reads; +} + +function forEachReadName( + parts: TokenOrValue[], + visit: (name: string) => void, +): void { + for (const part of parts) { + switch (part.type) { + case "var": + visit(part.value.name.ident); + forEachReadName(part.value.fallback ?? [], visit); + break; + case "env": + forEachReadName(part.value.fallback ?? [], visit); + break; + case "function": + forEachReadName(part.value.arguments, visit); + break; + case "unresolved-color": + switch (part.value.type) { + case "rgb": + case "hsl": + forEachReadName(part.value.alpha, visit); + break; + case "light-dark": + forEachReadName(part.value.light, visit); + forEachReadName(part.value.dark, visit); + break; + default: + part.value satisfies never; + } + break; + default: + break; + } } +} - declaration.value.value = declaration.value.value.flatMap((part) => { - return flattenPart(part, vars); - }); +function removeUnreadDeclarations( + block: DeclarationBlock, + scope: VariableScope, + reads: Set, +) { + const isKept = (declaration: Declaration) => { + return ( + declaration.property !== "custom" || + !scope.vars.has(declaration.value.name) || + reads.has(declaration.value.name) + ); + }; - return declaration; + block.declarations = block.declarations?.filter(isKept); + block.importantDeclarations = block.importantDeclarations?.filter(isKept); } function flattenPart( part: TokenOrValue, - vars: Map, + scope: VariableScope, + block: DeclarationBlock | undefined, ): TokenOrValue | TokenOrValue[] { if (part.type === "var") { - const varInfo = vars.get(part.value.name.ident); + const name = part.value.name.ident; + const varInfo = scope.vars.get(name); - if (!varInfo) { + if (!varInfo || !canFold(name, block, scope)) { part.value.fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, scope, block); }); return part; } else if (varInfo.value === undefined) { const fallback = part.value.fallback?.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, scope, block); }); return fallback ?? []; @@ -149,7 +241,7 @@ function flattenPart( return varInfo.value; } else if (part.type === "function") { part.value.arguments = part.value.arguments.flatMap((arg) => { - return flattenPart(arg, vars); + return flattenPart(arg, scope, block); }); } @@ -158,33 +250,38 @@ function flattenPart( function flattenVar( name: string, - vars: Map, + scope: VariableScope, seen = new Set(), ) { if (seen.has(name)) { - vars.delete(name); + scope.vars.delete(name); } seen.add(name); - let varInfo = vars.get(name); + let varInfo = scope.vars.get(name); if (!varInfo || varInfo.flat) { return; } + // A value is substituted only into its declaring block, so a var() inside it folds on that block's terms + const declaringBlock = scope.declaringBlocks.get(name); + let varInfoValue = varInfo.value?.flatMap((part) => { if (part.type === "var") { - const name = part.value.name.ident; + const nestedName = part.value.name.ident; - flattenVar(name, vars, seen); + flattenVar(nestedName, scope, seen); - const nestedVarInfo = vars.get(part.value.name.ident); - if (nestedVarInfo?.value) { - return nestedVarInfo.value; + if (canFold(nestedName, declaringBlock, scope)) { + const nestedVarInfo = scope.vars.get(nestedName); + if (nestedVarInfo?.value) { + return nestedVarInfo.value; + } } } - return flattenPart(part, vars); + return flattenPart(part, scope, declaringBlock); }); // If the variable is shorthand for "initial", substitute it for undefined @@ -200,10 +297,197 @@ function flattenVar( } varInfo = { - count: 1, flat: true, value: varInfoValue, }; - vars.set(name, varInfo); + scope.vars.set(name, varInfo); +} + +type Reach = "every-element" | "inherited-from-root" | "matched-elements"; + +interface RuleScope { + readonly reach: Reach; + readonly conditional: boolean; +} + +interface CustomDeclaration { + readonly block: DeclarationBlock; + readonly reach: Reach; + readonly value: TokenOrValue[]; +} + +const DOCUMENT_SCOPE: RuleScope = { + reach: "matched-elements", + conditional: false, +}; + +// Separates a value spliced between tokens, as the source's own whitespace did +const VALUE_SEPARATOR: TokenOrValue = { + type: "token", + value: { type: "white-space", value: " " }, +}; + +function collectVariableScope( + stylesheet: StyleSheet, + exclude: readonly string[], +): VariableScope { + const declared = new Map(); + const notInherited = new Set(); + const initialValues = new Map(); + + const annotateBlock = (block: DeclarationBlock, ruleScope: RuleScope) => { + for (const declaration of declarationsOf(block)) { + if (declaration.property !== "custom") continue; + + const { name, value } = declaration.value; + if (!name.startsWith("--") || exclude.includes(name)) continue; + + const declarations = declared.get(name) ?? []; + declarations.push({ + block, + reach: ruleScope.conditional ? "matched-elements" : ruleScope.reach, + value, + }); + declared.set(name, declarations); + } + }; + + const annotateRule = (rule: Rule, ruleScope: RuleScope): void => { + switch (rule.type) { + case "style": { + const nested: RuleScope = { + ...ruleScope, + reach: selectorReach(rule.value.selectors, ruleScope), + }; + if (rule.value.declarations) { + annotateBlock(rule.value.declarations, nested); + } + for (const child of rule.value.rules ?? []) { + annotateRule(child, nested); + } + return; + } + case "nested-declarations": + annotateBlock(rule.value.declarations, ruleScope); + return; + case "keyframes": + for (const keyframe of rule.value.keyframes) { + annotateBlock(keyframe.declarations, { + ...ruleScope, + reach: "matched-elements", + }); + } + return; + case "media": + case "container": + for (const child of rule.value.rules) { + annotateRule(child, { + reach: "matched-elements", + conditional: true, + }); + } + return; + case "supports": + // The compiler keeps or drops an @supports block at build time, so its declarations apply unconditionally or not at all + if (supportsConditionValid(rule.value.condition)) { + for (const child of rule.value.rules) { + annotateRule(child, ruleScope); + } + } + return; + case "layer-block": + for (const child of rule.value.rules) { + annotateRule(child, ruleScope); + } + return; + case "property": + if (!rule.value.inherits) { + notInherited.add(rule.value.name); + } + if ( + rule.value.initialValue?.type === "token-list" && + !ruleScope.conditional + ) { + initialValues.set(rule.value.name, rule.value.initialValue.value); + } + return; + default: + return; + } + }; + + for (const rule of stylesheet.rules) annotateRule(rule, DOCUMENT_SCOPE); + + const scope: VariableScope = { + vars: new Map(), + declaringBlocks: new Map(), + universalNames: new Set(), + }; + + for (const [name, declarations] of declared) { + // Which of two declarations wins depends on the element + const [declaration, ...others] = declarations; + if (!declaration || others.length > 0) continue; + + scope.vars.set(name, { value: [...declaration.value, VALUE_SEPARATOR] }); + scope.declaringBlocks.set(name, declaration.block); + + if ( + declaration.reach === "every-element" || + (declaration.reach === "inherited-from-root" && !notInherited.has(name)) + ) { + scope.universalNames.add(name); + } + } + + // Where nothing declares a registered property, every element holds its initial value (css-properties-values-api-1 §3.3) + for (const [name, initialValue] of initialValues) { + if (declared.has(name) || exclude.includes(name)) continue; + + scope.vars.set(name, { value: [...initialValue, VALUE_SEPARATOR] }); + scope.universalNames.add(name); + } + + return scope; +} + +// A `*` in the list declares on every element, and a nested rule reaches only as far as its parent +function selectorReach(selectors: Selector[], ruleScope: RuleScope): Reach { + const reaches = selectors.map((selector): Reach => { + const meaningful = selector.filter( + (component) => component.type !== "nesting", + ); + + if (meaningful.length === 0) { + return ruleScope.reach; + } + + if (meaningful.length !== 1) { + // A compound or combined selector, such as `:root .theme`, matches less than the whole document + return "matched-elements"; + } + + const [component] = meaningful; + + switch (component?.type) { + case "universal": + return "every-element"; + case "type": + // Every element descends from html, and no other element name can be relied on + return component.name === "html" + ? "inherited-from-root" + : "matched-elements"; + case "pseudo-class": + return component.kind === "root" || component.kind === "host" + ? "inherited-from-root" + : "matched-elements"; + default: + return "matched-elements"; + } + }); + + if (reaches.includes("every-element")) return "every-element"; + if (reaches.includes("inherited-from-root")) return "inherited-from-root"; + return "matched-elements"; } diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index 20936e49..fb18f004 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -1,5 +1,8 @@ import type { StyleDescriptor } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + isTransparentColor, +} from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; @@ -83,7 +86,7 @@ function flattenShadowDescriptor(arg: StyleDescriptor): StyleDescriptor[] { function omitTransparentShadows(style: unknown) { if (typeof style === "object" && style && "color" in style) { - if (style.color === "#0000" || style.color === "transparent") { + if (isTransparentColor(style.color)) { return; } } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..02a4afc7 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; +export * from "./transparent-color"; export * from "./dot-notation.types"; diff --git a/src/utilities/transparent-color.ts b/src/utilities/transparent-color.ts new file mode 100644 index 00000000..f8dbe4d8 --- /dev/null +++ b/src/utilities/transparent-color.ts @@ -0,0 +1,4 @@ +// lightningcss writes every fully transparent colour as #0000, and a variable can still carry the keyword +export function isTransparentColor(color: unknown): boolean { + return color === "#0000" || color === "transparent"; +}