diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx
index c9565d16..b5875d9d 100644
--- a/src/__tests__/native/animations.test.tsx
+++ b/src/__tests__/native/animations.test.tsx
@@ -133,6 +133,22 @@ test("an invalid numeric animation name is omitted without throwing", () => {
expect(style()?.animationName).toBeUndefined();
});
+// Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it
+test.each([undefined, false] as const)(
+ "a written animation-duration after a variable animation applies with inlineVariables=%s",
+ (inlineVariables) => {
+ registerCSS(
+ `${keyframes} .other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); } .later { animation-duration: 2s; }`,
+ { inlineVariables },
+ );
+ render();
+ expect(style()).toMatchObject({
+ animationName: horizontal,
+ animationDuration: [2000],
+ });
+ },
+);
+
test("multiple animation names preserve keyframe order and timing metadata", () => {
registerCSS(`${keyframes} .motion {
animation: slide-in 1s linear 250ms 2 alternate both,
diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx
new file mode 100644
index 00000000..8c51cc84
--- /dev/null
+++ b/src/__tests__/native/deferred-declarations.test.tsx
@@ -0,0 +1,258 @@
+import { render } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { registerCSS, testID } from "react-native-css/jest";
+
+// A second definition keeps each variable from being inlined, so it resolves at runtime
+const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`;
+
+function renderText(css: string, className = "my-class") {
+ registerCSS(`${decoy} ${css}`);
+
+ return render().getByTestId(
+ testID,
+ ).props;
+}
+
+// A declaration that reads a variable resolves against the target it was declared on
+describe("a deferred declaration beside a nested one", () => {
+ test("color: var() beside a box-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ boxShadow: [
+ {
+ color: "#00f",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+ });
+
+ test("color: var() beside a text-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ textShadowColor: "#00f",
+ textShadowRadius: 2,
+ textShadowOffset: { width: 1, height: 1 },
+ });
+ });
+
+ test("color: var() beside a prop", () => {
+ const props = renderText(
+ `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+
+ test("a deferred prop beside a style", () => {
+ const props = renderText(
+ `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+
+ test("a text-shadow with no colour takes the element's colour", () => {
+ expect(
+ renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style,
+ ).toStrictEqual({
+ color: "#00f",
+ textShadowColor: "#00f",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 2, height: 2 },
+ });
+ });
+
+ test("a box-shadow's first shadow with no colour takes the inherited colour", () => {
+ registerCSS(
+ `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`,
+ );
+
+ const child = render(
+
+
+ ,
+ ).getByTestId(testID);
+
+ expect(child.props.style).toStrictEqual({
+ boxShadow: [
+ {
+ color: "#123456",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ inset: true,
+ },
+ {
+ color: "#654321",
+ offsetX: 2,
+ offsetY: 2,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves
+describe("a deferred shorthand keeps no key a later declaration set", () => {
+ test.each([
+ [
+ "border: var() then border-color, in one rule",
+ ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), in one rule",
+ ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border: var() then border-color, across rules",
+ ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), across rules",
+ ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border-inline-width: var() then border-inline-start-width",
+ ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }",
+ "my-class",
+ { borderStartWidth: 5, borderEndWidth: 3 },
+ ],
+ [
+ "border-inline-start-width then border-inline-width: var()",
+ ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }",
+ "my-class",
+ { borderStartWidth: 2, borderEndWidth: 3 },
+ ],
+ [
+ "text-shadow: var() then text-shadow, across rules",
+ ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }",
+ "earlier my-class",
+ {
+ textShadowColor: "#f00",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 1, height: 1 },
+ },
+ ],
+ [
+ "text-shadow then text-shadow: var(), across rules",
+ ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }",
+ "earlier my-class",
+ {
+ textShadowColor: "#00f",
+ textShadowRadius: 0,
+ textShadowOffset: { width: 2, height: 2 },
+ },
+ ],
+ ] as const)("%s", (_name, css, className, style) => {
+ expect(renderText(css, className).style).toStrictEqual(style);
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written
+test("a deferred transform composes with an individual transform property as a written one does", () => {
+ registerCSS(
+ `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`,
+ );
+
+ const screen = render(
+ <>
+
+
+ >,
+ );
+
+ const written = screen.getByTestId("written").props.style as {
+ transform: unknown[];
+ };
+ expect(written.transform).toEqual(
+ expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]),
+ );
+ expect(screen.getByTestId("held").props.style).toStrictEqual(written);
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone
+test("a var() with no value resets an earlier rule's value", () => {
+ expect(
+ renderText(
+ ".earlier { width: 10px; } .my-class { width: var(--c-missing); }",
+ "earlier my-class",
+ ).style,
+ ).toStrictEqual({});
+});
+
+// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands
+test("a written value the runtime cannot read leaves an earlier rule's value", () => {
+ expect(
+ renderText(
+ ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }",
+ "earlier my-class",
+ ).style,
+ ).toStrictEqual({ width: 10 });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it
+test.each([
+ [
+ "before a transform",
+ ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }",
+ ],
+ [
+ "after a transform",
+ ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }",
+ ],
+])(
+ "an individual transform property declared %s composes with it",
+ (_order, css) => {
+ const { transform } = renderText(css, "earlier my-class").style as {
+ transform: unknown[];
+ };
+
+ expect(transform).toHaveLength(2);
+ expect(transform).toEqual(
+ expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]),
+ );
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property
+test("an individual transform property beside a box-shadow", () => {
+ expect(
+ renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style,
+ ).toStrictEqual({
+ transform: [{ rotateZ: "45deg" }],
+ boxShadow: [
+ {
+ color: "#00f",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+});
diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts
index 00e08785..38c3c046 100644
--- a/src/compiler/compiler.types.ts
+++ b/src/compiler/compiler.types.ts
@@ -101,7 +101,7 @@ export type StyleDeclaration =
| Record
/** A style that needs to be set */
| [StyleDescriptor, string | string[]]
- /** A value that can only be computed at runtime, and only after styles have been calculated */
+ /** A value that can only be computed at runtime */
| [StyleDescriptor, string | string[], 1];
export type StyleDescriptor =
diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..f6dbeade 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -7,7 +7,7 @@ import type {
import { Specificity as S } from "react-native-css/utilities";
import type { RenderGuard } from "../conditions/guards";
-import { applyValue, getDeepPath } from "../objects";
+import { applyValue } from "../objects";
import {
VAR_SYMBOL,
type Getter,
@@ -30,9 +30,9 @@ export function calculateProps(
let normal: Record | undefined;
let important: Record | undefined;
- const delayedStyles: (() => void)[] = [];
const transformStyles: (() => void)[] = [];
+ // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves
for (const rule of rules) {
if (VAR_SYMBOL in rule) {
if (typeof rule[VAR_SYMBOL] === "string") {
@@ -40,47 +40,43 @@ export function calculateProps(
} else {
Object.assign(inheritedVariables, rule);
}
- continue;
- }
-
- if (rule.v) {
+ } else if (rule.v) {
for (const variable of rule.v) {
inlineVariables[variable[0]] = variable[1];
}
}
+ }
- if (rule.d) {
- let topLevelTarget = rule.s?.[S.Important]
- ? (important ??= {})
- : (normal ??= {});
- let target = topLevelTarget;
+ for (const rule of rules) {
+ if (VAR_SYMBOL in rule || !rule.d) {
+ continue;
+ }
- const ruleTarget = rule.target || "style";
+ let topLevelTarget = rule.s?.[S.Important]
+ ? (important ??= {})
+ : (normal ??= {});
+ let target = topLevelTarget;
- if (typeof ruleTarget === "string") {
- target = target[ruleTarget] ??= {};
- } else if (ruleTarget) {
- for (const path of ruleTarget) {
- target = target[path] ??= {};
- }
- }
+ const ruleTarget = rule.target || "style";
- applyDeclarations(
- get,
- rule.d,
- inlineVariables,
- inheritedVariables,
- delayedStyles,
- transformStyles,
- guards,
- target,
- topLevelTarget,
- );
+ if (typeof ruleTarget === "string") {
+ target = target[ruleTarget] ??= {};
+ } else if (ruleTarget) {
+ for (const path of ruleTarget) {
+ target = target[path] ??= {};
+ }
}
- }
- for (const delayedStyle of delayedStyles) {
- delayedStyle();
+ applyDeclarations(
+ get,
+ rule.d,
+ inlineVariables,
+ inheritedVariables,
+ transformStyles,
+ guards,
+ target,
+ topLevelTarget,
+ );
}
for (const transformStyle of transformStyles) {
@@ -99,20 +95,18 @@ export function applyDeclarations(
declarations: StyleDeclaration[],
inlineVariables: InlineVariable,
inheritedVariables: VariableContextValue,
- delayedStyles: (() => void)[] = [],
transformStyles: (() => void)[] = [],
guards: RenderGuard[] = [],
target: Record = {},
topLevelTarget = target,
) {
- const originalTarget = target;
-
for (const declaration of declarations) {
- target = originalTarget;
+ // Each declaration's own binding, since its transform closure runs after the walk has moved on
+ let declarationTarget = target;
if (!Array.isArray(declaration)) {
// Static styles
- Object.assign(target, declaration);
+ Object.assign(declarationTarget, declaration);
} else {
// Dynamic styles
let value: any = declaration[0];
@@ -131,7 +125,7 @@ export function applyDeclarations(
if (final) {
if (first !== "&") {
topLevelTarget[first] ??= {};
- target = topLevelTarget[first];
+ declarationTarget = topLevelTarget[first];
}
let previousProp: string | number = first;
@@ -143,65 +137,42 @@ export function applyDeclarations(
if (!Array.isArray(previousTarget[previousProp])) {
previousTarget[previousProp] = [];
- target = previousTarget[previousProp];
+ declarationTarget = previousTarget[previousProp];
}
}
- previousTarget = target;
+ previousTarget = declarationTarget;
previousProp = prop;
- target[prop] ??= {};
- target = target[prop];
+ declarationTarget[prop] ??= {};
+ declarationTarget = declarationTarget[prop];
}
prop = final;
} else {
- target = topLevelTarget;
+ declarationTarget = topLevelTarget;
prop = first;
}
} else {
prop = propPath;
}
- const shouldDelay = declaration[2];
-
- if (shouldDelay || transformKeys.has(prop)) {
- /**
- * We need to delay the resolution of this value until after all
- * styles have been calculated. But another style might override
- * this value. So we set a placeholder value and only override
- * if the placeholder is preserved
- *
- * This also ensures the props exist, so setValue will properly
- * mutate the props object and not create a new one
- */
+ // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then
+ if (transformKeys.has(prop)) {
const originalValue = value;
- // This needs to be a object with the [prop] so we can discover in transform arrays
+ // An object keyed by the prop lets the transform array find this entry
value = { [prop]: true };
- if (transformKeys.has(prop)) {
- transformStyles.push(() => {
- value = resolveValue(originalValue, get, {
- inlineVariables,
- inheritedVariables,
- renderGuards: guards,
- calculateProps,
- });
- applyValue(target, prop, value);
- });
- } else {
- delayedStyles.push(() => {
- if (getDeepPath(target, prop) === value) {
- delete target[prop];
- value = resolveValue(originalValue, get, {
- inlineVariables,
- inheritedVariables,
- renderGuards: guards,
- calculateProps,
- });
- applyValue(target, prop, value);
- }
+ transformStyles.push(() => {
+ value = resolveValue(originalValue, get, {
+ inlineVariables,
+ inheritedVariables,
+ renderGuards: guards,
+ calculateProps,
});
- }
+ applyValue(declarationTarget, prop, value);
+ });
+
+ applyValue(declarationTarget, prop, value);
} else {
value = resolveValue(value, get, {
inlineVariables,
@@ -209,9 +180,14 @@ export function applyDeclarations(
renderGuards: guards,
calculateProps,
});
- }
- applyValue(target, prop, value);
+ // A runtime value that resolves to nothing removes the property, as css-variables-1 §3.1 resets one a var() leaves without a value
+ if (value === undefined && declaration[2]) {
+ delete declarationTarget[prop];
+ } else {
+ applyValue(declarationTarget, prop, value);
+ }
+ }
}
}
}