Skip to content
Closed
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
35 changes: 35 additions & 0 deletions src/__tests__/native/image-fit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,38 @@ test("important image fitting overrides inline style and preserves its siblings"
);
expect(style()).toMatchObject({ objectFit: "cover", opacity: 0.5 });
});

describe("a mapping on an image with object-fit", () => {
const TintedImage = styled(RNImage, {
tintClassName: {
target: "style",
nativeStyleMapping: { color: "tintColor" },
},
className: "style",
});

test.each([
["a normal", `.tint { color: red; }`],
["an important", `.tint { color: red !important; }`],
])(
"keeps %s mapped value when another config follows it, on every render",
(_, tintCss) => {
registerCSS(`.contain { object-fit: contain; } ${tintCss}`);
const element = (
<TintedImage
source={{ uri: "test-image" }}
className="contain"
tintClassName="tint"
/>
);

render(element);
expect(props().tintColor).toBe("#f00");
expect(style()?.objectFit).toBe("contain");

screen.rerender(element);
expect(props().tintColor).toBe("#f00");
expect(style()?.objectFit).toBe("contain");
},
);
});
52 changes: 52 additions & 0 deletions src/__tests__/native/objects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { withDeepPath, withoutKeys } from "../../native/objects";

describe("withDeepPath", () => {
test("sets a nested value on copies of the objects on its path, leaving the source untouched", () => {
const style = { color: "red" };
const source = { style, testID: "image" };

const result = withDeepPath(source, ["style", "tintColor"], "blue");

expect(result).toStrictEqual({
style: { color: "red", tintColor: "blue" },
testID: "image",
});
expect(result.style).not.toBe(style);
expect(source).toStrictEqual({ style: { color: "red" }, testID: "image" });
});

test("creates the objects its path is missing", () => {
expect(withDeepPath({}, ["outer", "inner"], 1)).toStrictEqual({
outer: { inner: 1 },
});
});

test("returns its source for an empty path", () => {
const source = { style: { color: "red" } };

expect(withDeepPath(source, [], "blue")).toBe(source);
});

test("gives the same result for the same input", () => {
const source = { style: { color: "red" } };

expect(withDeepPath(source, ["style", "opacity"], 0.5)).toStrictEqual(
withDeepPath(source, ["style", "opacity"], 0.5),
);
});
});

describe("withoutKeys", () => {
test("returns the source itself when it has none of the keys", () => {
const source = { color: "red" };

expect(withoutKeys(source, ["tintColor"])).toBe(source);
});

test("drops the keys from a copy, leaving the source untouched", () => {
const source = { color: "red", opacity: 1 };

expect(withoutKeys(source, ["color"])).toStrictEqual({ opacity: 1 });
expect(source).toStrictEqual({ color: "red", opacity: 1 });
});
});
33 changes: 33 additions & 0 deletions src/__tests__/native/styled.test.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,39 @@ test.each([false, true])(
},
);

test("a mapping onto a nested target waits for its class, then maps on every render", () => {
registerCSS(`.faded { opacity: 0.5; } .label { color: blue; }`);
const Base = (props: {
style?: StyleProp<ViewStyle>;
nested?: { style?: StyleProp<ViewStyle> };
accessibilityLabel?: string;
}) => (
<View
testID={testID}
accessibilityLabel={props.accessibilityLabel}
style={[props.style, props.nested?.style]}
/>
);
const Labelled = styled(Base, {
className: "style",
labelClassName: {
target: "nested.style",
nativeStyleMapping: { color: "accessibilityLabel" },
},
});

render(<Labelled className="faded" />);
expect(screen.getByTestId(testID).props.accessibilityLabel).toBeUndefined();

for (let round = 0; round < 2; round++) {
screen.rerender(<Labelled className="faded" labelClassName="label" />);
expect(screen.getByTestId(testID).props.accessibilityLabel).toBe("#00f");
expect(StyleSheet.flatten(screen.getByTestId(testID).props.style)).toEqual({
opacity: 0.5,
});
}
});

test("passThrough resolves three segment targets and updates without leaking root props", () => {
registerCSS(`.first { width: 40px; } .second { width: 80px; }`);
interface Props {
Expand Down
39 changes: 39 additions & 0 deletions src/native/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,42 @@ export function setDeepPath(

return target;
}

export function isPlainObject(
value: unknown,
): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function withDeepPath(
source: Readonly<Record<string, unknown>>,
paths: readonly string[],
value: unknown,
): Readonly<Record<string, unknown>> {
const [path, ...rest] = paths;
if (path === undefined) {
return source;
}

const child = source[path];
return {
...source,
[path]:
rest.length === 0
? value
: withDeepPath(isPlainObject(child) ? child : {}, rest, value),
};
}

export function withoutKeys(
source: Readonly<Record<string, unknown>>,
keys: readonly string[],
): Readonly<Record<string, unknown>> {
if (!keys.some((key) => key in source)) {
return source;
}

return Object.fromEntries(
Object.entries(source).filter(([key]) => !keys.includes(key)),
);
}
102 changes: 46 additions & 56 deletions src/native/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
import type { InlineVariable, StyleRule } from "react-native-css/compiler";
import { specificityCompareFn } from "react-native-css/utilities";

import {
getDeepPath,
isPlainObject,
withDeepPath,
withoutKeys,
} from "../objects";
import { getInteractionHandler } from "../react/interaction";
import type { ComponentState, Config } from "../react/useNativeCss";
import {
Expand Down Expand Up @@ -200,20 +206,17 @@ export function getStyledProps(
const computedTargets: { path: string[]; value: unknown }[] = [];
const consumedSources: string[] = [];

// The computed props are shared by every render, so each config maps the props the config before it produced
let normal = adaptProps(styledProps?.normal);
let important = adaptProps(styledProps?.important);

for (const config of state.configs) {
result = deepMergeConfig(
config,
nativeStyleMapping(config, adaptProps(styledProps?.normal)),
inline,
true,
);

if (styledProps?.important) {
result = deepMergeConfig(
config,
result,
nativeStyleMapping(config, adaptProps(styledProps.important)),
);
normal = nativeStyleMapping(config, normal);
result = deepMergeConfig(config, normal, inline, true);

if (important) {
important = nativeStyleMapping(config, important);
result = deepMergeConfig(config, result, important);
}

if (result && config.target) {
Expand Down Expand Up @@ -545,61 +548,48 @@ function deepMergeConfig(

function nativeStyleMapping(
config: Config,
props: Record<string, any> | undefined,
props: Readonly<Record<string, unknown>> | undefined,
) {
if (!props) {
return props;
}
if (!config.nativeStyleMapping) {
if (config.target === false) delete props.style;
return props;
}

let source: Record<string, any> | undefined;

if (typeof config.target === "string") {
source = props[config.target];
} else if (config.target === false) {
source = props["style"];
} else {
const tokens = [...config.target];
const lastToken = tokens.pop()!;

source = props;
for (const token of tokens) {
source = source[token];
if (!source) {
return props;
}
}
const mapped = config.nativeStyleMapping
? mapNativeStyles(config, config.nativeStyleMapping, props)
: props;

source = source[lastToken];
}
return config.target === false ? withoutKeys(mapped, ["style"]) : mapped;
}

if (!source) {
function mapNativeStyles(
config: Config,
mapping: Readonly<Record<string, string>>,
props: Readonly<Record<string, unknown>>,
) {
const sourcePath =
config.target === false
? ["style"]
: typeof config.target === "string"
? [config.target]
: config.target;
const source: unknown = getDeepPath(props, sourcePath);

if (!isPlainObject(source)) {
return props;
}

for (const [key, path] of Object.entries(config.nativeStyleMapping)) {
const styleValue = source[key];

delete source[key];
const remaining = withoutKeys(source, Object.keys(mapping));
let result =
config.target === false || remaining === source
? props
: withDeepPath(props, sourcePath, remaining);

if (styleValue === undefined) {
continue;
}

let target = props;
const tokens = path.split(".");
const lastToken = tokens.pop();
for (const token of tokens) {
target[token] ??= {};
target = target[token];
for (const [key, path] of Object.entries(mapping)) {
const value = source[key];
if (value !== undefined) {
result = withDeepPath(result, path.split("."), value);
}

target[lastToken!] = styleValue;
}

if (config.target === false) delete props.style;
return props;
return result;
}