Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7cfbc42
fix(native): a target:false config gets its own scratch space, not `s…
YevheniiKotyrlo Oct 10, 2026
7f2c144
fix(native): map every config before merging, on new objects that lea…
YevheniiKotyrlo Oct 20, 2026
dd55a09
fix(native): map each computed props object once, and merge a mapped …
YevheniiKotyrlo Oct 20, 2026
4555a79
refactor(native): read the Image adapter's props without any
YevheniiKotyrlo Oct 20, 2026
9910354
fix(native): keep a target:false class's own props under it, and leav…
YevheniiKotyrlo Oct 20, 2026
b65afa8
refactor(native): build the Image adapter's style with Object.assign,…
YevheniiKotyrlo Oct 20, 2026
aadb78c
refactor(native): model a target:false collection as a rule target, a…
YevheniiKotyrlo Oct 20, 2026
7268574
perf(native): memoize only the Image props the adapter changes, and t…
YevheniiKotyrlo Oct 20, 2026
ba06957
test(native): pin inline values beside a target:false mapping into th…
YevheniiKotyrlo Oct 20, 2026
f404253
refactor(native): type the configs a mapping runs on, so the mapping …
YevheniiKotyrlo Oct 20, 2026
a5fed08
fix(native): keep an inline value beside an !important mapping, and m…
YevheniiKotyrlo Oct 21, 2026
b683a48
refactor(native): strip mapped keys through withoutKeysAt, which keep…
YevheniiKotyrlo Oct 21, 2026
9b81512
test(native): name the mapping order case for what it holds
YevheniiKotyrlo Oct 21, 2026
e593109
fix(native): settle a prop no config targets once, normal then inline…
YevheniiKotyrlo Oct 21, 2026
be320c5
perf(native): build a component's owned props once, and unset a prop …
YevheniiKotyrlo Oct 21, 2026
03866a6
refactor(native): reach every branch of the settling and of withDeepP…
YevheniiKotyrlo Oct 21, 2026
d8d50ee
refactor(native): assign each settled prop as it is, and test useNati…
YevheniiKotyrlo Oct 21, 2026
363f16b
fix(native): settle each prop off the targets by its full path, and l…
YevheniiKotyrlo Oct 21, 2026
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
750 changes: 748 additions & 2 deletions src/__tests__/native/components.test.tsx

Large diffs are not rendered by default.

52 changes: 52 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,55 @@ 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");
},
);

test("keeps the same style object while its classes stay the same", () => {
registerCSS(`.contain { object-fit: contain; } .tint { color: red; }`);
const element = () => (
<TintedImage
source={{ uri: "test-image" }}
className="contain"
tintClassName="tint"
/>
);

render(element());
const firstStyle = props().style;
screen.rerender(element());

expect(props().style).toBe(firstStyle);
});
});
168 changes: 168 additions & 0 deletions src/__tests__/native/objects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {
isPlainObject,
valueAtPath,
withDeepPaths,
withoutKeys,
withoutKeysAt,
} from "../../native/objects";

describe("withDeepPaths", () => {
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 = withDeepPaths(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(withDeepPaths({}, [[["outer", "inner"], 1]])).toStrictEqual({
outer: { inner: 1 },
});
});

test("keeps the symbol-keyed properties of the objects on its path", () => {
const marker = Symbol("marker");
const source = { [marker]: true, style: { [marker]: true, color: "red" } };

expect(withDeepPaths(source, [[["style", "opacity"], 0.5]])).toStrictEqual({
[marker]: true,
style: { [marker]: true, color: "red", opacity: 0.5 },
});
});

test("returns its source when no write has a path", () => {
const source = { style: { color: "red" } };

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

test("writes in order, a later write replacing what an earlier one wrote at its path", () => {
expect(
withDeepPaths({}, [
[["style", "color"], "red"],
[["style", "opacity"], 0.5],
]),
).toStrictEqual({ style: { color: "red", opacity: 0.5 } });
expect(
withDeepPaths({}, [
[["style", "color"], "red"],
[["style"], { opacity: 0.5 }],
]),
).toStrictEqual({ style: { opacity: 0.5 } });
});

test("copies an object a write placed before a later write reaches into it", () => {
const written = { opacity: 0.5 };

const result = withDeepPaths({}, [
[["style"], written],
[["style", "color"], "red"],
]);

expect(result).toStrictEqual({ style: { opacity: 0.5, color: "red" } });
expect(written).toStrictEqual({ opacity: 0.5 });
});

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

const first = withDeepPaths(source, [[["style", "opacity"], 0.5]]);
const second = withDeepPaths(source, [[["style", "opacity"], 0.5]]);

expect(first).toStrictEqual(second);
expect(first).not.toBe(second);
expect(source).toStrictEqual({ style: { color: "red" } });
});
});

describe("isPlainObject", () => {
test.each([
["an object literal", { color: "red" }, true],
["an object with no prototype", Object.create(null) as unknown, true],
["an array", [1], false],
["a function", () => ({}), false],
["a class instance", new Map(), false],
["null", null, false],
["a string", "red", false],
])("%s is plain: %s", (_, value, expected) => {
expect(isPlainObject(value)).toBe(expected);
});
});

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("keeps a symbol-keyed property", () => {
const marker = Symbol("marker");
const source = { [marker]: true, color: "red", opacity: 1 };

expect(withoutKeys(source, ["color"])).toStrictEqual({
[marker]: true,
opacity: 1,
});
});

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

describe("valueAtPath", () => {
test("reads the value at the end of its path, through objects and arrays", () => {
const source = { style: { transform: [{ rotate: "45deg" }] } };

expect(valueAtPath(source, ["style", "transform", "0", "rotate"])).toBe(
"45deg",
);
});

test("answers undefined for a key the path misses or a value it cannot read into", () => {
expect(valueAtPath({ style: {} }, ["style", "color"])).toBeUndefined();
expect(valueAtPath({ style: "red" }, ["style", "color"])).toBeUndefined();
expect(valueAtPath(null, ["style"])).toBeUndefined();
});

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

expect(valueAtPath(source, [])).toBe(source);
});
});

describe("withoutKeysAt", () => {
test("drops the keys from a copy of the object at its path, leaving the source untouched", () => {
const style = { color: "red", opacity: 1 };
const source = { nested: { style }, testID: "view" };

const result = withoutKeysAt(source, ["nested", "style"], ["color"]);

expect(result).toStrictEqual({
nested: { style: { opacity: 1 } },
testID: "view",
});
expect(source).toStrictEqual({ nested: { style }, testID: "view" });
});

test("returns its source when its path meets no object or the object has none of the keys", () => {
const source = { style: { opacity: 1 }, label: "x" };

expect(withoutKeysAt(source, ["label", "style"], ["color"])).toBe(source);
expect(withoutKeysAt(source, ["missing"], ["color"])).toBe(source);
expect(withoutKeysAt(source, ["style"], ["color"])).toBe(source);
});
});
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
7 changes: 6 additions & 1 deletion src/compiler/compiler.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface StyleRule {
dv?: number;

// Target override
target?: string | string[] | false;
target?: string | readonly string[] | CollectedTarget;

/**
* Conditionals
Expand All @@ -96,6 +96,11 @@ export interface StyleRule {
a?: boolean;
}

// A target:false config's rules collect their declarations, and the props those name, under its source key, where its mapping reads them
export interface CollectedTarget {
readonly collect: string;
}

export type StyleDeclaration =
/** This is a static style object */
| Record<string, StyleDescriptor>
Expand Down
Loading