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); + } + } } } }