diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ce58467..5125ac7c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -47,7 +47,7 @@ This enables `className` prop support on all React Native components. - **Reactive style system** (`src/native/reactivity.ts`) — custom observables for media queries, container queries, color scheme changes - **StyleCollection singleton** (`src/native-internal/`) — isolated to prevent circular dependencies between native runtime and CSS file imports - **Platform-aware compilation** — different outputs for native (JSON) vs web (browser CSS) -- **Inline variable optimization** — single-use CSS variables are inlined at compile time; multi-use preserved +- **Inline variable optimization** — single-use CSS variables are inlined at compile time; multi-use, those an `@property` registers `inherits: false` (unless declared only on `*` outside `@media` and `@container`), and those whose value refers to one, preserved ## Package Entry Points diff --git a/README.md b/README.md index bafb2302..e6ba9ca6 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ export default withReactNativeCSS(defaultConfig, { Custom properties (sometimes referred to as CSS variables or cascading variables) are a way to store values that can be reused throughout a CSS document. They are defined using a property name that starts with `--`, and their values can be accessed using the `var()` function. -To improve performance, Custom properties that are only set **once** in the CSS file are inlined at build time. +To improve performance, Custom properties that are only set **once** in the CSS file are inlined at build time. A property an `@property` rule registers with `inherits: false` is inlined only when that one declaration is on a universal selector outside a media or container query, and a property whose value refers to such a property is not inlined. For example diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 119c85d7..31228b7a 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -9,15 +9,12 @@ test("@property with length initial value", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.has("tw-translate-x")).toBe(true); - expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-translate-x", false, 0], + ]); }); -test("@property without initial value is skipped", () => { +test("@property without an initial value registers none", () => { const compiled = compile(` @property --tw-ring-color { syntax: "*"; @@ -26,6 +23,7 @@ test("@property without initial value is skipped", () => { `); const result = compiled.stylesheet(); + expect(result.vp).toStrictEqual([["tw-ring-color", false]]); expect(result.vr).toBeUndefined(); }); @@ -38,11 +36,9 @@ test("@property with number initial value", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.get("tw-backdrop-opacity")).toStrictEqual([[1]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-backdrop-opacity", false, 1], + ]); }); test("@property with color initial value", () => { @@ -54,11 +50,9 @@ test("@property with color initial value", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.get("tw-ring-offset-color")).toStrictEqual([["#fff"]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-ring-offset-color", false, "#fff"], + ]); }); test("@property with token-list initial value (shadow)", () => { @@ -70,14 +64,12 @@ test("@property with token-list initial value (shadow)", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.get("tw-shadow")).toStrictEqual([[[0, 0, "#0000"]]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-shadow", false, [0, 0, "#0000"]], + ]); }); -test("@property defaults are root variables, not universal", () => { +test("@property defaults are neither root nor universal variables", () => { const compiled = compile(` @property --tw-shadow { syntax: "*"; @@ -87,10 +79,27 @@ test("@property defaults are root variables, not universal", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vp).toStrictEqual([["tw-shadow", false, [0, 0, "#0000"]]]); + expect(result.vr).toBeUndefined(); expect(result.vu).toBeUndefined(); }); +test("a :root declaration and a registered default land in different slots", () => { + const compiled = compile(` +@property --my-var { + syntax: ""; + inherits: true; + initial-value: 0px; +} +:root { --my-var: 50px; } +:root { --my-var: 50px; } +`); + + const result = compiled.stylesheet(); + expect(new Map(result.vr).get("my-var")).toStrictEqual([[50]]); + expect(result.vp).toStrictEqual([["my-var", true, 0]]); +}); + test("@supports -moz-orient fallback no longer fires", () => { const compiled = compile(` @supports (-moz-orient: inline) { @@ -139,7 +148,13 @@ test("@property + class override produces valid stylesheet", () => { `); const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); + expect(result.vp).toStrictEqual([ + ["tw-shadow", false, [0, 0, "#0000"]], + ["tw-inset-shadow", false, [0, 0, "#0000"]], + ["tw-ring-shadow", false, [0, 0, "#0000"]], + ["tw-inset-ring-shadow", false, [0, 0, "#0000"]], + ["tw-ring-offset-shadow", false, [0, 0, "#0000"]], + ]); expect(result.s).toBeDefined(); const shadowRule = result.s?.find(([name]) => name === "shadow-md"); @@ -155,11 +170,9 @@ test("@property with percentage initial value", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.get("tw-shadow-alpha")).toStrictEqual([["100%"]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-shadow-alpha", false, "100%"], + ]); }); test("multiple @property declarations with verified values", () => { @@ -181,13 +194,11 @@ test("multiple @property declarations with verified values", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); - expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); - expect(vrMap.get("tw-translate-y")).toStrictEqual([[0]]); - expect(vrMap.get("tw-rotate")).toStrictEqual([["0deg"]]); + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-translate-x", false, 0], + ["tw-translate-y", false, 0], + ["tw-rotate", false, "0deg"], + ]); }); test("@property with repeated single-child unwraps to scalar", () => { @@ -199,13 +210,260 @@ test("@property with repeated single-child unwraps to scalar", () => { } `); - const result = compiled.stylesheet(); - expect(result.vr).toBeDefined(); - - const vrMap = new Map(result.vr); // Single-child repeated (+ with one value) should unwrap // to the same shape as a direct type - expect(vrMap.get("my-offset")).toStrictEqual([[10]]); + expect(compiled.stylesheet().vp).toStrictEqual([["my-offset", false, 10]]); +}); + +test("@property inherits: false is recorded, initial value or not", () => { + const compiled = compile(` +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +`); + + expect(compiled.stylesheet().vp).toStrictEqual([ + ["tw-ring-shadow", false, [0, 0, "#0000"]], + ["tw-ring-color", false], + ]); +}); + +test("vp carries each registration's inherits", () => { + const declarations: [name: string, inherits: boolean][] = [ + ["--a-off", false], + ["--b-on", true], + ["--c-off", false], + ["--d-on", true], + ]; + + const compiled = compile( + declarations + .map( + ([name, inherits]) => + `@property ${name} { syntax: ""; inherits: ${inherits}; initial-value: 0px; }`, + ) + .join("\n"), + ); + + expect( + compiled.stylesheet().vp?.map(([name, inherits]) => [name, inherits]), + ).toStrictEqual( + declarations.map(([name, inherits]) => [name.slice(2), inherits]), + ); +}); + +test("a non-inheriting property is left to the runtime, however few rules declare it", () => { + const compiled = compile(` +@property --pinned { + syntax: ""; + inherits: false; + initial-value: 0px; +} +.parent { --pinned: 10px; } +.child { width: var(--pinned); } +`); + + expect(compiled.stylesheet().s).toStrictEqual([ + ["parent", [{ s: [1, 1], v: [["pinned", 10]] }]], + [ + "child", + [{ s: [2, 1], d: [[[{}, "var", "pinned", 1], "width", 1]], dv: 1 }], + ], + ]); +}); + +test("a single declaration that refers to a non-inheriting property is left to the runtime", () => { + const compiled = compile(` +@property --my-var { + syntax: ""; + inherits: false; + initial-value: 0px; +} +.parent { --alias: var(--my-var); --my-var: 10px; } +.child { width: var(--alias); } +`); + + expect(compiled.stylesheet().s).toStrictEqual([ + [ + "parent", + [ + { + s: [1, 1], + v: [ + ["alias", [{}, "var", "my-var", 1]], + ["my-var", 10], + ], + }, + ], + ], + [ + "child", + [{ s: [2, 1], d: [[[{}, "var", "alias", 1], "width", 1]], dv: 1 }], + ], + ]); +}); + +test("an inheriting property with one declaration is still inlined", () => { + const compiled = compile(` +@property --folded { + syntax: ""; + inherits: true; + initial-value: 0px; +} +.parent { --folded: 10px; } +.child { width: var(--folded); } +`); + + expect(compiled.stylesheet().s).toStrictEqual([ + ["child", [{ s: [1, 1], d: [{ width: 10 }] }]], + ]); +}); + +test("@property inherits: true is recorded as inheriting", () => { + const compiled = compile(` +@property --my-brand { + syntax: ""; + inherits: true; + initial-value: red; +} +`); + + expect( + compiled.stylesheet().vp?.map(([name, inherits]) => [name, inherits]), + ).toStrictEqual([["my-brand", true]]); +}); + +test("an unregistered custom property is not recorded", () => { + // Declared twice, since a single declaration is inlined away and the sheet would be empty + const compiled = compile(` +.my-class { --my-var: 10px; } +.other { --my-var: 20px; } +`); + + const result = compiled.stylesheet(); + expect(result.s).toBeDefined(); + expect(result.vp).toBeUndefined(); +}); + +test("@property records a name once, however many rules declare it", () => { + // Different syntaxes, since lightningcss collapses identical @property rules + const compiled = compile(` +@property --dup { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --dup { + syntax: "*"; + inherits: false; +} +`); + + expect(compiled.stylesheet().vp).toStrictEqual([["dup", false]]); +}); + +test("the last @property declaration of a name decides inherits", () => { + const inheritsLast = compile(` +@property --flip { syntax: "*"; inherits: false; } +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +`); + expect(inheritsLast.stylesheet().vp).toStrictEqual([["flip", true, 0]]); + + const nonInheritingLast = compile(` +@property --flip { syntax: ""; inherits: true; initial-value: 0px; } +@property --flip { syntax: "*"; inherits: false; } +`); + expect(nonInheritingLast.stylesheet().vp).toStrictEqual([["flip", false]]); +}); + +test("an ignored @property leaves the earlier registration of its name", () => { + const result = compile(` +@property --x { syntax: ""; inherits: false; initial-value: 3px; } +@property --x { syntax: ""; inherits: true; initial-value: 2em; } +`).stylesheet(); + + expect(result.vp).toStrictEqual([["x", false, 3]]); +}); + +test.each([ + ["