Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/__tests__/native/deferred-declarations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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; }`;

function renderText(css: string, className = "my-class") {
registerCSS(`${decoy} ${css}`);

return render(<Text testID={testID} className={className} />).getByTestId(
testID,
).props;
}

// A deferred declaration resolves after every other one in its rule, 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);
});
});

// 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 },
],
] as const)("%s", (_name, css, className, style) => {
expect(renderText(css, className).style).toStrictEqual(style);
});
});
179 changes: 129 additions & 50 deletions src/native/styles/calculate-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type Getter,
type VariableContextValue,
} from "../reactivity";
import { ShortHandSymbol } from "./constants";
import { transformKeys } from "./defaults";
import { resolveValue } from "./resolve";

Expand All @@ -32,6 +33,7 @@ export function calculateProps(

const delayedStyles: (() => void)[] = [];
const transformStyles: (() => void)[] = [];
const cascade = createCascade();

for (const rule of rules) {
if (VAR_SYMBOL in rule) {
Expand Down Expand Up @@ -75,6 +77,7 @@ export function calculateProps(
guards,
target,
topLevelTarget,
cascade,
);
}
}
Expand All @@ -94,6 +97,73 @@ export function calculateProps(
};
}

// The cascade position of the declaration that last set each key of each target, since a deferred declaration resolves after every later one
interface Cascade {
readonly writers: WeakMap<object, Map<string | number, number>>;
readonly next: () => number;
}

function createCascade(): Cascade {
let position = 0;

return { writers: new WeakMap(), next: () => position++ };
}

function claim(
cascade: Cascade,
target: object,
key: string | number,
position: number,
): boolean {
let writers = cascade.writers.get(target);

if (writers === undefined) {
writers = new Map();
cascade.writers.set(target, writers);
}

const holder = writers.get(key);

if (holder !== undefined && holder > position) {
return false;
}

writers.set(key, position);
return true;
}

function isShorthandObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" && value !== null && ShortHandSymbol in value
);
}

// A shorthand learns its keys only once it resolves, so each key is claimed on its own
function applyInCascade(
cascade: Cascade,
target: Record<string, any>,
prop: string | number,
value: unknown,
position: number,
) {
if (!isShorthandObject(value)) {
if (claim(cascade, target, prop, position)) {
applyValue(target, prop as string, value);
}
return;
}

const kept: Record<string | symbol, unknown> = { [ShortHandSymbol]: true };

for (const [key, keyValue] of Object.entries(value)) {
if (claim(cascade, target, key, position)) {
kept[key] = keyValue;
}
}

applyValue(target, prop as string, kept);
}

export function applyDeclarations(
get: Getter,
declarations: StyleDeclaration[],
Expand All @@ -104,15 +174,20 @@ export function applyDeclarations(
guards: RenderGuard[] = [],
target: Record<string, any> = {},
topLevelTarget = target,
cascade: Cascade = createCascade(),
) {
const originalTarget = target;

for (const declaration of declarations) {
target = originalTarget;
// Each declaration's own binding, since its deferred closure runs after the walk has moved on
let declarationTarget = target;
const position = cascade.next();

if (!Array.isArray(declaration)) {
// Static styles
Object.assign(target, declaration);
for (const key of Object.keys(declaration)) {
claim(cascade, declarationTarget, key, position);
}

Object.assign(declarationTarget, declaration);
} else {
// Dynamic styles
let value: any = declaration[0];
Expand All @@ -131,7 +206,7 @@ export function applyDeclarations(
if (final) {
if (first !== "&") {
topLevelTarget[first] ??= {};
target = topLevelTarget[first];
declarationTarget = topLevelTarget[first];
}

let previousProp: string | number = first;
Expand All @@ -143,75 +218,79 @@ 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
*/
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);
transformStyles.push(() => {
value = resolveValue(originalValue, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
});
} else {
delayedStyles.push(() => {
if (getDeepPath(target, prop) === value) {
delete target[prop];
value = resolveValue(originalValue, get, {
applyValue(declarationTarget, prop, value);
});

applyValue(declarationTarget, prop, value);
} else if (declaration[2]) {
const originalValue = value;
// Resolved once every declaration is walked; a later declaration of the same key replaces the placeholder
const placeholder = { [prop]: true };

delayedStyles.push(() => {
if (getDeepPath(declarationTarget, prop) === placeholder) {
delete declarationTarget[prop];
applyInCascade(
cascade,
declarationTarget,
prop,
resolveValue(originalValue, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
});
applyValue(target, prop, value);
}
});
}
} else {
value = resolveValue(value, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
}),
position,
);
}
});
}

applyValue(target, prop, value);
applyInCascade(cascade, declarationTarget, prop, placeholder, position);
} else {
applyInCascade(
cascade,
declarationTarget,
prop,
resolveValue(value, get, {
inlineVariables,
inheritedVariables,
renderGuards: guards,
calculateProps,
}),
position,
);
}
}
}
}