diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 4ec527a9..729458aa 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -1,19 +1,24 @@ +import { memo } from "react"; import { + Animated, Button as RNButton, + Image as RNImage, KeyboardAvoidingView as RNKeyboardAvoidingView, + Text as RNText, TextInput as RNTextInput, StyleSheet, View, type ButtonProps, type TextInputProps, + type ViewProps, } from "react-native"; -import { render } from "@testing-library/react-native"; +import { render, screen } from "@testing-library/react-native"; import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; import { KeyboardAvoidingView } from "react-native-css/components/KeyboardAvoidingView"; import { TextInput } from "react-native-css/components/TextInput"; import { registerCSS, testID } from "react-native-css/jest"; -import { useCssElement } from "react-native-css/native"; +import { styled, useCssElement } from "react-native-css/native"; import type { StyledConfiguration, StyledProps, @@ -151,3 +156,494 @@ test("KeyboardAvoidingView maps content classes through replacement, removal, an expect(contentStyle).toEqual({ opacity: 0.5 }); } }); + +interface LabelledProps extends ViewProps { + readonly labelColor?: string; + readonly badgeColor?: string; +} + +// nativeStyleMapping is typed against the target's props, so a target:false config needs a component that has some +const Labelled = (props: LabelledProps) => ; + +test("a target:false config does not take a style-target config's declarations", () => { + registerCSS(`.c1 { color: orange; } .c2 { color: blue; }`); + + const Styled = styled(Labelled, { + className: { target: "style" }, + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#ffa500" }); + expect(component.props.labelColor).toBe("#00f"); +}); + +test("a target:false config emits no style prop", () => { + registerCSS(`.c3 { color: orange; font-size: 20px; }`); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props).not.toHaveProperty("style"); +}); + +test("two target:false configs each keep their own declarations", () => { + registerCSS(`.c4 { color: orange; } .c5 { color: blue; }`); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + badgeClassName: { + target: false, + nativeStyleMapping: { color: "badgeColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props.badgeColor).toBe("#00f"); + expect(component.props).not.toHaveProperty("labelClassName"); + expect(component.props).not.toHaveProperty("badgeClassName"); +}); + +test("declaration order does not change what either config gets", () => { + registerCSS( + `.c6 { color: orange; font-size: 20px; } .c7 { background-color: blue; }`, + ); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + className: { target: "style" }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props.style).toStrictEqual({ backgroundColor: "#00f" }); + expect(component.props).not.toHaveProperty("labelClassName"); +}); + +test("an !important declaration keeps to its own config", () => { + registerCSS(` + .i1 { color: orange !important; } + .i2 { color: blue !important; font-size: 11px !important; } + `); + + const Styled = styled(Labelled, { + className: { target: "style" }, + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#ffa500" }); + expect(component.props.labelColor).toBe("#00f"); +}); + +test("a declaration no mapping entry names is dropped, not moved into style", () => { + registerCSS(`.d1 { transform: translateX(10px); color: orange; }`); + + const Mapped = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + const Shorthand = styled(Labelled, { labelClassName: false }); + + const mapped = render( + , + ).getByTestId(testID); + expect(mapped.props.labelColor).toBe("#ffa500"); + expect(mapped.props).not.toHaveProperty("style"); + + const shorthand = render( + , + ).getByTestId("shorthand"); + expect(shorthand.props).not.toHaveProperty("style"); +}); + +test("a target:false mapping on an image with object-fit holds on every render", () => { + registerCSS( + `.contain { object-fit: contain; width: 10px; } .tint { color: red; }`, + ); + const TintedImage = styled(RNImage, { + tintClassName: { + target: false, + nativeStyleMapping: { color: "tintColor" }, + }, + className: "style", + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + const image = screen.getByTestId(testID); + expect(image.props.tintColor).toBe("#f00"); + expect(StyleSheet.flatten(image.props.style)).toEqual({ + width: 10, + objectFit: "contain", + }); + screen.rerender(element()); + } +}); + +test("a target:false mapping after a style config leaves style alone on every render", () => { + registerCSS(`.base { font-size: 12px; } .selection { color: red; }`); + const SelectableText = styled(RNText, { + className: "style", + selectionClassName: { + target: false, + nativeStyleMapping: { color: "selectionColor" }, + }, + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + const text = screen.getByTestId(testID); + expect(text.props.selectionColor).toBe("#f00"); + expect(text.props.style).toStrictEqual({ fontSize: 12 }); + screen.rerender(element()); + } +}); + +test("a mapping into an earlier config's target keeps its value on every render", () => { + registerCSS(`.faded { opacity: 0.5; } .label { color: blue; }`); + const Bordered = styled(Labelled, { + className: "style", + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + opacity: 0.5, + borderColor: "#00f", + }); + screen.rerender(element()); + } +}); + +test("a target:false mapping into style keeps its value where no inline style meets it", () => { + registerCSS(`.label { color: blue; }`); + const Bordered = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderColor: "#00f", + }); +}); + +test.each([ + ["an inline style object", "style", "style.borderColor", { margin: 1 }], + [ + "an inline style function", + "style", + "style.borderColor", + () => ({ margin: 1 }), + ], + [ + "an inline style holding an Animated value", + "style", + "style.borderColor", + { opacity: new Animated.Value(0.5) }, + ], + [ + "an inline ripple", + "android_ripple", + "android_ripple.color", + { borderless: true }, + ], +] as const)( + "%s reaches the component as it came beside a target:false mapping into it", + (_, prop, destination, inlineValue) => { + for (const css of [ + `.label { color: blue; }`, + `.label { color: blue !important; }`, + ]) { + registerCSS(css); + let received: Readonly> = {}; + const Received = (props: Readonly>) => { + received = props; + return null; + }; + const Mapped = styled(Received, { + labelClassName: { + target: false, + nativeStyleMapping: { color: destination }, + }, + }); + + const { unmount } = render( + , + ); + + expect({ css, value: received[prop] }).toStrictEqual({ + css, + value: inlineValue, + }); + unmount(); + } + }, +); + +test.each([ + [ + "one config's normal and !important declarations", + `.mapped { color: blue; background-color: red !important; }`, + { + labelClassName: { + target: false, + nativeStyleMapping: { + color: "style.borderColor", + backgroundColor: "style.backgroundColor", + }, + }, + }, + { labelClassName: "mapped" }, + { style: { borderColor: "#00f", backgroundColor: "#f00" } }, + ], + [ + "two configs, one of them !important", + `.border { color: blue; } .fill { color: red !important; }`, + { + borderClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + fillClassName: { + target: false, + nativeStyleMapping: { color: "style.backgroundColor" }, + }, + }, + { borderClassName: "border", fillClassName: "fill" }, + { style: { borderColor: "#00f", backgroundColor: "#f00" } }, + ], + [ + "a ripple's normal and !important declarations", + `.ripple { color: blue; border-radius: 4px !important; }`, + { + rippleClassName: { + target: false, + nativeStyleMapping: { + color: "android_ripple.color", + borderRadius: "android_ripple.radius", + }, + }, + }, + { rippleClassName: "ripple" }, + { android_ripple: { color: "#00f", radius: 4 } }, + ], +] as const)( + "%s mapped into one prop by target:false configs meet key by key", + (_, css, mapping, classNames, expected) => { + registerCSS(css); + let received: Readonly> = {}; + const Received = (props: Readonly>) => { + received = props; + return null; + }; + const Mapped = styled(Received, mapping); + + render(); + + expect(received).toStrictEqual(expected); + }, +); + +test("a mapping reads the props as computed, not what another mapping wrote, in either order", () => { + registerCSS(`.base { border-color: green; } .label { color: blue; }`); + const labelClassName = { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + } as const; + const className = { + target: "style", + nativeStyleMapping: { borderColor: "badgeColor" }, + } as const; + + const rendered = [ + { labelClassName, className }, + { className, labelClassName }, + ].map((mapping) => { + let received: Readonly> = {}; + const Received = (props: Readonly>) => { + received = props; + return null; + }; + const Mapped = styled(Received, mapping); + const { unmount } = render( + , + ); + unmount(); + return received; + }); + + expect(rendered).toStrictEqual([ + { style: { borderColor: "#00f" }, badgeColor: "#008000" }, + { style: { borderColor: "#00f" }, badgeColor: "#008000" }, + ]); +}); + +test("a mapping builds its props once while the classes stay the same", () => { + registerCSS(`.c10 { color: orange; opacity: 0.5; } .c11 { color: blue; }`); + let renders = 0; + const CountedLabelled = memo((props: LabelledProps) => { + renders++; + return ; + }); + const Styled = styled(CountedLabelled, { + className: { target: "style", nativeStyleMapping: { color: "labelColor" } }, + borderClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + const element = () => ( + + ); + + render(element()); + screen.rerender(element()); + screen.rerender(element()); + + expect(renders).toBe(1); + expect(screen.getByTestId(testID).props).toMatchObject({ + labelColor: "#ffa500", + style: { opacity: 0.5, borderColor: "#00f" }, + }); +}); + +test("a target:false config follows its class as it is set, removed and set again", () => { + registerCSS(`.c8 { background-color: blue; } .c9 { color: orange; }`); + const Styled = styled(Labelled, { + className: "style", + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + render(); + for (const [labelClassName, labelColor] of [ + ["c9", "#ffa500"], + [undefined, undefined], + ["c9", "#ffa500"], + ] as const) { + screen.rerender( + , + ); + const component = screen.getByTestId(testID); + expect(component.props.labelColor).toBe(labelColor); + expect(component.props.style).toStrictEqual({ backgroundColor: "#00f" }); + } +}); + +// docs/v5-engine-contracts.md: target: false discards unmapped compiled styles while retaining original inline styles +test("a target:false class's declarations that name their own props stay under it on an Image", () => { + registerCSS(`.tint { color: red; object-fit: cover; opacity: 0.5; }`); + const TintedImage = styled(RNImage, { + tintClassName: { + target: false, + nativeStyleMapping: { color: "tintColor" }, + }, + }); + + render( + , + ); + const image = screen.getByTestId(testID); + + expect(image.props.tintColor).toBe("#f00"); + expect(image.props.style).toBeUndefined(); +}); + +test.each([ + ["drops them unmapped", { color: "selectionColor" }, {}], + [ + "maps one it names", + { color: "selectionColor", cursorColor: true }, + { cursorColor: "#008000" }, + ], +] as const)( + "a target:false class's declarations that name their own props stay under it on a TextInput, and the mapping %s", + (_, nativeStyleMapping, expected) => { + registerCSS( + `.label { color: red; caret-color: green; -webkit-line-clamp: 2; }`, + ); + const LabelledInput = styled(RNTextInput, { + labelClassName: { target: false, nativeStyleMapping }, + }); + + render(); + const { selectionColor, cursorColor, numberOfLines } = + screen.getByTestId(testID).props; + + expect({ selectionColor, cursorColor, numberOfLines }).toStrictEqual({ + selectionColor: "#f00", + cursorColor: undefined, + numberOfLines: undefined, + ...expected, + }); + }, +); diff --git a/src/__tests__/native/image-fit.test.tsx b/src/__tests__/native/image-fit.test.tsx index de49f163..2b608300 100644 --- a/src/__tests__/native/image-fit.test.tsx +++ b/src/__tests__/native/image-fit.test.tsx @@ -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 = () => ( + + ); + + 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 = () => ( + + ); + + render(element()); + const firstStyle = props().style; + screen.rerender(element()); + + expect(props().style).toBe(firstStyle); + }); +}); diff --git a/src/__tests__/native/objects.test.ts b/src/__tests__/native/objects.test.ts new file mode 100644 index 00000000..666c259d --- /dev/null +++ b/src/__tests__/native/objects.test.ts @@ -0,0 +1,125 @@ +import { + valueAtPath, + withDeepPath, + withoutKeys, + withoutKeysAt, +} 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("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(withDeepPath(source, ["style", "opacity"], 0.5)).toStrictEqual({ + [marker]: true, + style: { [marker]: true, color: "red", opacity: 0.5 }, + }); + }); + + 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, however often it runs", () => { + const source = { style: { color: "red" } }; + + const first = withDeepPath(source, ["style", "opacity"], 0.5); + const second = withDeepPath(source, ["style", "opacity"], 0.5); + + expect(first).toStrictEqual(second); + expect(first).not.toBe(second); + expect(source).toStrictEqual({ style: { color: "red" } }); + }); +}); + +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); + }); +}); diff --git a/src/__tests__/native/styled.test.ios.tsx b/src/__tests__/native/styled.test.ios.tsx index 3f4ef620..3f958488 100644 --- a/src/__tests__/native/styled.test.ios.tsx +++ b/src/__tests__/native/styled.test.ios.tsx @@ -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; + nested?: { style?: StyleProp }; + accessibilityLabel?: string; + }) => ( + + ); + const Labelled = styled(Base, { + className: "style", + labelClassName: { + target: "nested.style", + nativeStyleMapping: { color: "accessibilityLabel" }, + }, + }); + + render(); + expect(screen.getByTestId(testID).props.accessibilityLabel).toBeUndefined(); + + for (let round = 0; round < 2; round++) { + screen.rerender(); + 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 { diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..d4401b6e 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -73,7 +73,7 @@ export interface StyleRule { dv?: number; // Target override - target?: string | string[] | false; + target?: string | string[] | CollectedTarget; /** * Conditionals @@ -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 diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..2f9ca033 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -12,17 +12,7 @@ export function getDeepPath(source: any, paths: string | string[] | false) { } if (Array.isArray(paths)) { - let target = source; - - for (const path of paths) { - if (typeof target !== "object" || !target || !(path in target)) { - return undefined; - } - - target = target[path]; - } - - return target; + return valueAtPath(source, paths); } else if (transformKeys.has(paths)) { return Array.isArray(source?.transform) ? source.transform.find((t: any) => t[paths] !== undefined) @@ -32,6 +22,20 @@ export function getDeepPath(source: any, paths: string | string[] | false) { } } +export function valueAtPath(source: unknown, path: readonly string[]): unknown { + let value = source; + + for (const key of path) { + if (typeof value !== "object" || value === null || !(key in value)) { + return undefined; + } + + value = Reflect.get(value, key); + } + + return value; +} + export function applyShorthand(value: any) { if (value === undefined) { return; @@ -107,3 +111,59 @@ export function setDeepPath( return target; } + +export function isNonArrayObject( + value: unknown, +): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function withDeepPath( + source: Readonly>, + paths: readonly string[], + value: unknown, +): Readonly> { + const [path, ...rest] = paths; + if (path === undefined) { + return source; + } + + const child = source[path]; + return { + ...source, + [path]: + rest.length === 0 + ? value + : withDeepPath(isNonArrayObject(child) ? child : {}, rest, value), + }; +} + +export function withoutKeys( + source: Readonly>, + keys: readonly string[], +): Readonly> { + return keys.reduce((result, key) => { + if (!(key in result)) return result; + const { [key]: removed, ...rest } = result; + return rest; + }, source); +} + +export function withoutKeysAt( + source: Readonly>, + paths: readonly string[], + keys: readonly string[], +): Readonly> { + const [path, ...rest] = paths; + if (path === undefined) { + return withoutKeys(source, keys); + } + + const child = source[path]; + if (!isNonArrayObject(child)) { + return source; + } + + const updated = withoutKeysAt(child, rest, keys); + return updated === child ? source : { ...source, [path]: updated }; +} diff --git a/src/native/react/collection-target.ts b/src/native/react/collection-target.ts new file mode 100644 index 00000000..e416f12a --- /dev/null +++ b/src/native/react/collection-target.ts @@ -0,0 +1,22 @@ +import type { CollectedTarget, StyleRule } from "react-native-css/compiler"; + +import type { Config } from "./useNativeCss"; + +type RuleTarget = NonNullable; + +// A target:false config collects under its own source prop, which getStyledProps strips once its mapping has run; style belongs to the configs that target it +export function collectionTarget(config: Config): RuleTarget { + return config.target === false ? { collect: config.source } : config.target; +} + +export function targetPath(target: RuleTarget): readonly string[] { + if (typeof target === "string") { + return [target]; + } + + return collectsProps(target) ? [target.collect] : target; +} + +export function collectsProps(target: RuleTarget): target is CollectedTarget { + return typeof target === "object" && "collect" in target; +} diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..0ec2cca5 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -18,6 +18,7 @@ import { type VariableContextValue, } from "../reactivity"; import { stylesFamily } from "../styles"; +import { collectionTarget } from "./collection-target"; import type { ComponentState, Config } from "./useNativeCss"; export const INLINE_RULE_SYMBOL = Symbol("react-native-css.inlineRule"); @@ -270,9 +271,12 @@ export function updateRules( * Cache for reference equality. */ const getRuleVariation = weakFamily((rule: StyleRule) => { - return weakFamily((config: Config): StyleRule => { - return { ...rule, target: config.target }; - }); + return weakFamily( + (config: Config): StyleRule => ({ + ...rule, + target: collectionTarget(config), + }), + ); }); function pushInlineRule( diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index b65165e5..71b512e9 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -8,7 +8,7 @@ import { useState, type ComponentType, } from "react"; -import { Image, Pressable, StyleSheet, View } from "react-native"; +import { Image, Pressable, View } from "react-native"; import { VariableContext } from "react-native-css/native-internal"; @@ -18,6 +18,7 @@ import { cleanupEffect, ContainerContext, hasChangedDependencies, + weakFamily, type ContainerContextValue, type Effect, type Getter, @@ -254,13 +255,16 @@ export function mappingToConfig(mapping: StyledConfiguration) { }); } -// Apply native Image fitting before normal, inline, and important props merge. +// Apply native Image fitting once per computed props object, before normal, inline, and important props merge. // The compiler's contentFit mapping remains available to Expo Image adapters. -function adaptImageProps(props: Record | undefined) { - if (!props || !("contentFit" in props)) return props; - const { contentFit, style, ...rest } = props; - return { +const fittedImagePropsFamily = weakFamily( + ({ contentFit, style, ...rest }: Readonly>) => ({ ...rest, - style: { ...StyleSheet.flatten(style), objectFit: contentFit }, - }; + // The computed style is a plain object, so it needs no flattening + style: Object.assign({}, style, { objectFit: contentFit }), + }), +); + +function adaptImageProps(props: Readonly>) { + return "contentFit" in props ? fittedImagePropsFamily(props) : props; } diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..e5a9779b 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -8,6 +8,7 @@ import { Specificity as S } from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { applyValue, getDeepPath } from "../objects"; +import { collectsProps, targetPath } from "../react/collection-target"; import { VAR_SYMBOL, type Getter, @@ -59,8 +60,8 @@ export function calculateProps( if (typeof ruleTarget === "string") { target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { + } else { + for (const path of targetPath(ruleTarget)) { target = target[path] ??= {}; } } @@ -74,7 +75,7 @@ export function calculateProps( transformStyles, guards, target, - topLevelTarget, + collectsProps(ruleTarget) ? target : topLevelTarget, ); } } diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 6be03daa..7cf9b4d6 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -2,6 +2,13 @@ import type { InlineVariable, StyleRule } from "react-native-css/compiler"; import { specificityCompareFn } from "react-native-css/utilities"; +import { + isNonArrayObject, + valueAtPath, + withDeepPath, + withoutKeysAt, +} from "../objects"; +import { collectionTarget, targetPath } from "../react/collection-target"; import { getInteractionHandler } from "../react/interaction"; import type { ComponentState, Config } from "../react/useNativeCss"; import { @@ -12,6 +19,7 @@ import { hoverFamily, observable, VAR_SYMBOL, + weakFamily, type Effect, type VariableContextValue, } from "../reactivity"; @@ -188,8 +196,8 @@ export function getStyledProps( state: ComponentState, inline: Record | undefined | null, adaptProps: ( - props: Record | undefined, - ) => Record | undefined = (props) => props, + props: Readonly>, + ) => Readonly> = (props) => props, ) { let result: Record | undefined; @@ -197,29 +205,28 @@ export function getStyledProps( // Each config merges a complete props object. Preserve its full target path // before the next config merges unrelated inline props over that object. - const computedTargets: { path: string[]; value: unknown }[] = []; + const computedTargets: { path: readonly string[]; value: unknown }[] = []; const consumedSources: string[] = []; + // The computed props are shared by every render, and every config is mapped before any merges, so each merge and each kept target sees every mapping + const normal = mapEveryConfig(state.configs, adaptProps, styledProps?.normal); + const important = mapEveryConfig( + state.configs, + 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)), - ); + result = deepMergeConfig(config, normal, inline, true); + + if (important) { + result = config.target + ? deepMergeConfig(config, result, important) + : mergeUnownedProps(result, important, inline); } if (result && config.target) { - const path = Array.isArray(config.target) - ? config.target - : [config.target]; + const path = targetPath(config.target); let target = result; for (const key of path.slice(0, -1)) target = target?.[key]; const key = path[path.length - 1]; @@ -340,7 +347,7 @@ function mergeDefinedProps( const value = right[key]; if (value === undefined) continue; // Non-object values (strings, numbers, booleans, null, arrays): always assign - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (!isNonArrayObject(value)) { result[key] = value; continue; } @@ -394,13 +401,10 @@ function deepMergeConfig( const leftStyle = left.style; // For arrays or objects, check if we need to create a style array - const leftIsObject = - typeof leftStyle === "object" && !Array.isArray(leftStyle); - const rightIsObject = - typeof filteredRightStyle === "object" && - !Array.isArray(filteredRightStyle); - - if (leftIsObject && rightIsObject) { + if ( + isNonArrayObject(leftStyle) && + isNonArrayObject(filteredRightStyle) + ) { if (hasNonOverlappingProperties(leftStyle, filteredRightStyle)) { result.style = [leftStyle, filteredRightStyle]; } else { @@ -495,15 +499,7 @@ function deepMergeConfig( } } else if (left && finalKey in left) { const leftValue = left[finalKey]; - const leftIsObj = - typeof leftValue === "object" && - leftValue !== null && - !Array.isArray(leftValue); - const rightIsObj = - typeof rightValue === "object" && - rightValue !== null && - !Array.isArray(rightValue); - if (leftIsObj && rightIsObj) { + if (isNonArrayObject(leftValue) && isNonArrayObject(rightValue)) { if (hasNonOverlappingProperties(leftValue, rightValue)) { result[finalKey] = [leftValue, rightValue]; } else { @@ -543,63 +539,105 @@ function deepMergeConfig( return result; } -function nativeStyleMapping( - config: Config, - props: Record | undefined, +function mapEveryConfig( + configs: readonly Config[], + adaptProps: ( + props: Readonly>, + ) => Readonly>, + props: Readonly> | undefined, ) { if (!props) { return props; } - if (!config.nativeStyleMapping) { - if (config.target === false) delete props.style; - return props; - } - let source: Record | 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; - } - } + return mappedPropsFamily(configs)(adaptProps(props)); +} - source = source[lastToken]; - } +// Mapping builds new objects, so it runs once per configs and computed props, and a render that changes neither maps nothing again +const mappedPropsFamily = weakFamily((configs: readonly Config[]) => { + const mappingConfigs = configs.filter(hasNativeStyleMapping); - if (!source) { - return props; - } + return mappingConfigs.length === 0 + ? (props: Readonly>) => props + : weakFamily((props: Readonly>) => + nativeStyleMapping(mappingConfigs, props), + ); +}); + +type MappingConfig = Readonly & { + readonly nativeStyleMapping: Readonly< + NonNullable + >; +}; + +function hasNativeStyleMapping( + config: Readonly, +): config is MappingConfig { + return config.nativeStyleMapping !== undefined; +} - for (const [key, path] of Object.entries(config.nativeStyleMapping)) { - const styleValue = source[key]; +// Every mapping reads the props as computed, and every mapped key leaves its source before any destination is written, so no mapping reads what another wrote +function nativeStyleMapping( + configs: readonly MappingConfig[], + props: Readonly>, +) { + const sources = configs.flatMap((config) => { + const sourcePath = targetPath(collectionTarget(config)); + const source = valueAtPath(props, sourcePath); + return isNonArrayObject(source) ? [{ config, sourcePath, source }] : []; + }); + + const stripped = sources.reduce( + (result, { config, sourcePath }) => + withoutKeysAt(result, sourcePath, Object.keys(config.nativeStyleMapping)), + props, + ); + + return sources.reduce( + (result, { config, source }) => + Object.entries(config.nativeStyleMapping).reduce( + (written, [key, path]) => { + const value = source[key]; + return value === undefined + ? written + : withDeepPath(written, path.split("."), value); + }, + result, + ), + stripped, + ); +} - delete source[key]; +// A config with no target owns none of the props its mapping writes, so an inline value of one stands, as for a normal declaration, and the config's normal and !important objects meet key by key +function mergeUnownedProps( + merged: Readonly>, + important: Readonly>, + inline: Readonly> | undefined | null, +) { + const result: Record = { ...merged }; - if (styleValue === undefined) { + for (const [key, value] of Object.entries(important)) { + if (value === undefined || suppliesProp(inline, key)) { continue; } - - let target = props; - const tokens = path.split("."); - const lastToken = tokens.pop(); - for (const token of tokens) { - target[token] ??= {}; - target = target[token]; - } - - target[lastToken!] = styleValue; + const current = result[key]; + result[key] = + isNonArrayObject(current) && isNonArrayObject(value) + ? { ...current, ...value } + : value; } - if (config.target === false) delete props.style; - return props; + return result; +} + +// An inline value counts as mergeDefinedProps counts it: not undefined, and no empty plain object +function suppliesProp( + inline: Readonly> | undefined | null, + key: string, +) { + const value = inline?.[key]; + return ( + value !== undefined && + !(isNonArrayObject(value) && Object.keys(value).length === 0) + ); }