diff --git a/src/__tests__/native/className-with-style.test.tsx b/src/__tests__/native/className-with-style.test.tsx index 5ac156d..e514414 100644 --- a/src/__tests__/native/className-with-style.test.tsx +++ b/src/__tests__/native/className-with-style.test.tsx @@ -1,5 +1,6 @@ import { render } from "@testing-library/react-native"; import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; test("className with inline style props should coexist when different properties", () => { @@ -46,3 +47,44 @@ test("only inline style should not create array", () => { // Only inline style should be a flat object expect(component.props.style).toEqual({ color: "blue" }); }); + +test("important should overwrite the inline style", () => { + registerCSS(`.text-red\\! { color: red !important; }`); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toEqual({ color: "#f00" }); +}); + +test("View with multiple className properties where inline style takes precedence", () => { + registerCSS(` + .px-4 { padding-left: 16px; padding-right: 16px; } + .pt-4 { padding-top: 16px; } + .mb-4 { margin-bottom: 16px; } + `); + + const component = render( + , + ).getByTestId(testID); + + // Inline style should override paddingRight from px-4 class + // Other className styles should be preserved in array + expect(component.props.style).toEqual([ + { + paddingLeft: 16, + paddingRight: 16, + paddingTop: 16, + marginBottom: 16, + }, + { + width: 300, + paddingRight: 0, + }, + ]); +}); diff --git a/src/__tests__/native/rightIsInline.test.tsx b/src/__tests__/native/rightIsInline.test.tsx new file mode 100644 index 0000000..dc8bc78 --- /dev/null +++ b/src/__tests__/native/rightIsInline.test.tsx @@ -0,0 +1,852 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * In these tests, we intentionally pass invalid style values (objects with VAR_SYMBOL) + * to test that the runtime filtering works correctly. We use `as any` to bypass + * TypeScript's type checking since we're specifically testing edge cases where + * invalid types might be passed at runtime. + * + * This is a legitimate use of `any` in test code where we need to verify + * runtime behavior with intentionally malformed data. + */ + +import { render } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { VAR_SYMBOL } from "react-native-css/native/reactivity"; + +describe("rightIsInline - CSS Variable Stripping", () => { + test("inline style with CSS variable object should be filtered out", () => { + registerCSS(`.text-blue { color: blue; }`); + + const inlineStyleWithVar = { + fontSize: 16, + color: { [VAR_SYMBOL]: "inline", "--text-color": "red" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // The VAR_SYMBOL object should be filtered out from inline styles + // Only literal values should remain + expect(component.props.style).toEqual([ + { color: "#00f" }, // from className + { fontSize: 16 }, // from inline (color with VAR_SYMBOL filtered out) + ]); + + // Verify that the CSS variable object is NOT in the final output + const styleArray = Array.isArray(component.props.style) + ? component.props.style + : [component.props.style]; + + for (const styleObj of styleArray) { + if (styleObj && typeof styleObj === "object") { + for (const value of Object.values( + styleObj as Record, + )) { + // No value should be an object with VAR_SYMBOL + if (typeof value === "object" && value !== null) { + expect(VAR_SYMBOL in value).toBe(false); + } + } + } + } + }); + + test("inline style array with CSS variable objects should be filtered", () => { + registerCSS(`.container { padding: 10px; }`); + + const inlineStyleArray = [ + { margin: 10 }, + { color: { [VAR_SYMBOL]: "inline", "--color": "red" } }, + { backgroundColor: "white" }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // CSS variable objects should be filtered from the array + expect(component.props.style).toEqual([ + { padding: 10 }, // from className + [ + { margin: 10 }, + // { color: VAR_SYMBOL object } should be filtered out + { backgroundColor: "white" }, + ], + ]); + }); + + test("inline style with only CSS variable object should be filtered to empty", () => { + registerCSS(`.text-red { color: red; }`); + + const onlyVarStyle = { + padding: { [VAR_SYMBOL]: "inline", "--padding": "20px" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // When all properties are filtered, inline style contributes nothing + expect(component.props.style).toEqual({ color: "#f00" }); + }); + + test("nested CSS variable object in inline style should be filtered", () => { + registerCSS(`.box { width: 100px; }`); + + const nestedVarStyle = { + height: 200, + borderRadius: { [VAR_SYMBOL]: "inline", "--radius": "8px" }, + margin: 5, + }; + + const component = render( + , + ).getByTestId(testID); + + // borderRadius with VAR_SYMBOL should be filtered out + expect(component.props.style).toEqual([ + { width: 100 }, + { + height: 200, + margin: 5, + }, + ]); + }); + + test("inline style with only VAR_SYMBOL properties - complete filtering", () => { + registerCSS(`.base { padding: 8px; }`); + + const allVarsStyle = { + color: { [VAR_SYMBOL]: "inline", "--color": "red" }, + fontSize: { [VAR_SYMBOL]: "inline", "--size": "16px" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // All inline properties are VAR_SYMBOL, so none should pass through + expect(component.props.style).toEqual({ padding: 8 }); + }); + + test("array style with all items being CSS variable objects", () => { + registerCSS(`.text { font-weight: bold; }`); + + const allVarsArray = [ + { color: { [VAR_SYMBOL]: "inline", "--color": "red" } }, + { padding: { [VAR_SYMBOL]: "inline", "--padding": "10px" } }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // When entire array contains only VAR_SYMBOL objects, nothing passes through + expect(component.props.style).toEqual({ fontWeight: "bold" }); + }); + + test("mixed inline style object with some properties having VAR_SYMBOL values", () => { + registerCSS(`.container { margin: 5px; }`); + + const mixedStyle = { + width: 100, // literal + height: { [VAR_SYMBOL]: "inline", "--height": "200px" }, // var + padding: 10, // literal + borderWidth: { [VAR_SYMBOL]: "inline", "--border": "1px" }, // var + flex: 1, // literal + }; + + const component = render( + , + ).getByTestId(testID); + + // Only literal values should pass through + expect(component.props.style).toEqual([ + { margin: 5 }, + { + width: 100, + padding: 10, + flex: 1, + }, + ]); + }); + + test("deeply nested array of styles with VAR_SYMBOL objects at different levels", () => { + registerCSS(`.base { color: blue; }`); + + const deepNestedStyle = [ + { margin: 4 }, + [ + { padding: 8 }, + { fontSize: { [VAR_SYMBOL]: "inline", "--size": "14px" } }, + ], + { lineHeight: 20 }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // VAR_SYMBOL should be filtered at all nesting levels + expect(component.props.style).toEqual([ + { color: "#00f" }, + [{ margin: 4 }, [{ padding: 8 }], { lineHeight: 20 }], + ]); + }); +}); + +describe("rightIsInline - Source Property Cleanup", () => { + test("className prop should be removed when inline style is provided", () => { + registerCSS(`.text-red { color: red; }`); + + const component = render( + , + ).getByTestId(testID); + + // className should not appear in final props (only style) + expect(component.props.className).toBeUndefined(); + expect(component.props.style).toBeDefined(); + }); + + test("className prop should be removed even without inline style", () => { + registerCSS(`.text-red { color: red; }`); + + const component = render( + , + ).getByTestId(testID); + + // className should be mapped to style and removed + expect(component.props.className).toBeUndefined(); + expect(component.props.style).toEqual({ color: "#f00" }); + }); + + test("source prop should be cleaned when different from target", () => { + registerCSS(`.container { padding: 20px; }`); + + const component = render( + , + ).getByTestId(testID); + + // Verify className is not in final props + expect(component.props.className).toBeUndefined(); + // Verify style is properly merged + expect(component.props.style).toEqual([{ padding: 20 }, { margin: 10 }]); + }); +}); + +describe("rightIsInline - Mixed Scenarios", () => { + test("inline style with both literal values and CSS variables", () => { + registerCSS(` + .text-style { + font-size: 14px; + font-weight: bold; + } + `); + + const mixedStyle = { + color: "green", // literal - should stay + lineHeight: 20, // literal - should stay + textShadowColor: { + [VAR_SYMBOL]: "inline", + "--shadow": "rgba(0,0,0,0.5)", + }, // var - should be filtered + }; + + const component = render( + , + ).getByTestId(testID); + + // Literal values stay, CSS variable filtered + expect(component.props.style).toEqual([ + { fontSize: 14, fontWeight: "bold" }, + { color: "green", lineHeight: 20 }, + ]); + }); + + test("important styles should override inline styles with VAR_SYMBOL correctly", () => { + registerCSS(`.text-important { color: red !important; }`); + + const inlineWithVar = { + color: "blue", + fontSize: { [VAR_SYMBOL]: "inline", "--size": "16px" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // Important styles win, and CSS variable is filtered + expect(component.props.style).toEqual({ color: "#f00" }); + }); + + test("multiple components with different inline CSS variable scenarios", () => { + registerCSS(` + .parent { background-color: gray; } + .child { color: black; } + `); + + const parentStyle = { + padding: 10, + margin: { [VAR_SYMBOL]: "inline", "--margin": "5px" }, + }; + + const childStyle = { + fontSize: 14, + }; + + const { getByTestId } = render( + + + , + ); + + const parent = getByTestId("parent"); + const child = getByTestId("child"); + + // Parent: CSS variable filtered, literal value kept + expect(parent.props.style).toEqual([ + { backgroundColor: "#808080" }, + { padding: 10 }, + ]); + + // Child: no CSS variables, works normally + expect(child.props.style).toEqual([{ color: "#000" }, { fontSize: 14 }]); + }); + + test("empty array after filtering all CSS variables from array style", () => { + registerCSS(`.box { width: 100px; }`); + + const allVarsStyle = [ + { color: { [VAR_SYMBOL]: "inline", "--color": "red" } }, + { padding: { [VAR_SYMBOL]: "inline", "--padding": "10px" } }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // When all inline styles are CSS variables, they're all filtered + // Only className styles remain + expect(component.props.style).toEqual({ width: 100 }); + }); + + test("style override with same property - inline without VAR_SYMBOL should win", () => { + registerCSS(`.text-red { color: red; }`); + + const component = render( + , + ).getByTestId(testID); + + // Inline literal value should override className + expect(component.props.style).toEqual({ color: "green" }); + }); + + test("style override with same property - inline with VAR_SYMBOL should be filtered", () => { + registerCSS(`.text-red { color: red; }`); + + const inlineWithVar = { + color: { [VAR_SYMBOL]: "inline", "--color": "green" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // VAR_SYMBOL filtered, className color remains + expect(component.props.style).toEqual({ color: "#f00" }); + }); +}); + +describe("rightIsInline - Performance and Edge Cases", () => { + test("deeply nested inline style with CSS variables", () => { + registerCSS(`.container { padding: 10px; }`); + + const deepStyle = { + margin: 5, + shadowOffset: { + [VAR_SYMBOL]: "inline", + "--offset": "{ width: 0, height: 2 }", + }, + borderWidth: 1, + }; + + const component = render( + , + ).getByTestId(testID); + + // CSS variable object filtered, literals kept + expect(component.props.style).toEqual([ + { padding: 10 }, + { margin: 5, borderWidth: 1 }, + ]); + }); + + test("null and undefined inline styles should be handled gracefully", () => { + registerCSS(`.text { color: blue; }`); + + const component1 = render( + , + ).getByTestId("test1"); + + const component2 = render( + , + ).getByTestId("test2"); + + // Both should handle null/undefined gracefully + expect(component1.props.style).toEqual({ color: "#00f" }); + expect(component2.props.style).toEqual({ color: "#00f" }); + }); + + test("inline style overrides className with same property, no CSS variables", () => { + registerCSS(`.text-red { color: red; }`); + + const component = render( + , + ).getByTestId(testID); + + // Normal override behavior (no CSS variables involved) + expect(component.props.style).toEqual({ color: "green" }); + }); + + test("style array with mix of objects, nulls, undefined, and CSS variables", () => { + registerCSS(`.base { padding: 8px; }`); + + const styleArray = [ + { margin: 4 }, // literal object + { color: { [VAR_SYMBOL]: "inline", "--color": "blue" } }, // CSS variable object + null, // null in array (valid in RN) + { fontSize: 14 }, // literal object + undefined, // undefined in array (valid in RN) + { borderWidth: { [VAR_SYMBOL]: "inline", "--border": "2px" } }, // CSS variable + ]; + + const component = render( + , + ).getByTestId(testID); + + // CSS variables filtered, null/undefined preserved, literals kept + expect(component.props.style).toEqual([ + { padding: 8 }, + [{ margin: 4 }, null, { fontSize: 14 }, undefined], + ]); + }); + + test("empty object in inline style", () => { + registerCSS(`.container { margin: 10px; }`); + + const component = render( + , + ).getByTestId(testID); + + // Empty inline style should not affect className styles + expect(component.props.style).toEqual({ margin: 10 }); + }); + + test("VAR_SYMBOL as only property in inline object", () => { + registerCSS(`.text { font-size: 12px; }`); + + const varOnlyStyle = { + [VAR_SYMBOL]: "inline", + "--custom": "value", + }; + + const component = render( + , + ).getByTestId(testID); + + // Entire object should be filtered + expect(component.props.style).toEqual({ fontSize: 12 }); + }); + + test("complex nested array with alternating literals and VAR_SYMBOL objects", () => { + registerCSS(`.complex { color: red; }`); + + const complexStyle = [ + { padding: 5 }, + [{ margin: 2 }, { width: { [VAR_SYMBOL]: "inline", "--w": "100px" } }], + { height: 50 }, + [ + { flex: { [VAR_SYMBOL]: "inline", "--flex": "1" } }, + { borderRadius: 8 }, + ], + ]; + + const component = render( + , + ).getByTestId(testID); + + // All VAR_SYMBOL objects filtered at all levels + expect(component.props.style).toEqual([ + { color: "#f00" }, + [{ padding: 5 }, [{ margin: 2 }], { height: 50 }, [{ borderRadius: 8 }]], + ]); + }); + + test("multiple properties with VAR_SYMBOL in different formats", () => { + registerCSS(`.base { padding: 5px; }`); + + const multiVarStyle = { + color: { [VAR_SYMBOL]: "inline", "--color": "red" }, + fontSize: 16, // literal + lineHeight: { [VAR_SYMBOL]: "inline", "--lh": "24px" }, + fontWeight: "bold", // literal + margin: { [VAR_SYMBOL]: "inline" }, // VAR_SYMBOL without other props + }; + + const component = render( + , + ).getByTestId(testID); + + // Only literals pass through + expect(component.props.style).toEqual([ + { padding: 5 }, + { + fontSize: 16, + fontWeight: "bold", + }, + ]); + }); + + test("no className, only inline style with VAR_SYMBOL", () => { + const varStyle = { + color: { [VAR_SYMBOL]: "inline", "--color": "blue" }, + fontSize: 14, + }; + + const component = render( + , + ).getByTestId(testID); + + // VAR_SYMBOL filtered even without className + expect(component.props.style).toEqual({ fontSize: 14 }); + }); + + test("no className, inline style with only VAR_SYMBOL properties", () => { + const allVarStyle = { + color: { [VAR_SYMBOL]: "inline", "--color": "red" }, + padding: { [VAR_SYMBOL]: "inline", "--pad": "10px" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // All filtered, style should be undefined or empty + expect(component.props.style).toBeUndefined(); + }); +}); + +describe("rightIsInline - Important Style Interactions", () => { + test("important styles with inline VAR_SYMBOL objects", () => { + registerCSS(` + .important-color { color: red !important; } + .normal-size { font-size: 12px; } + `); + + const inlineWithVars = { + color: { [VAR_SYMBOL]: "inline", "--color": "blue" }, + fontSize: { [VAR_SYMBOL]: "inline", "--size": "20px" }, + lineHeight: 24, + }; + + const component = render( + , + ).getByTestId(testID); + + // Important overrides, VAR_SYMBOL filtered, literal kept + expect(component.props.style).toEqual({ + color: "#f00", + fontSize: 12, + lineHeight: 24, + }); + }); + + test("multiple important properties with mixed inline styles", () => { + registerCSS(` + .important-multi { + color: red !important; + font-size: 14px !important; + font-weight: bold !important; + } + `); + + const inlineStyle = { + color: "blue", + fontSize: 20, + fontWeight: "normal" as const, + lineHeight: 24, + }; + + const component = render( + , + ).getByTestId(testID); + + // All important properties override inline + expect(component.props.style).toEqual({ + color: "#f00", + fontSize: 14, + fontWeight: "bold", + lineHeight: 24, + }); + }); +}); + +describe("rightIsInline - Real-World Scenarios", () => { + test("conditional inline styles with potential VAR_SYMBOL leaks", () => { + registerCSS(`.button { padding: 10px; }`); + + const dynamicStyle = { + backgroundColor: { [VAR_SYMBOL]: "inline", "--bg": "blue" }, + }; + + const component = render( + , + ).getByTestId(testID); + + // Dynamic VAR_SYMBOL should be filtered + expect(component.props.style).toEqual({ padding: 10 }); + }); + + test("spreading props that might contain VAR_SYMBOL", () => { + registerCSS(`.container { margin: 5px; }`); + + const externalProps = { + style: { + padding: 10, + color: { [VAR_SYMBOL]: "inline", "--color": "green" }, + }, + }; + + const component = render( + , + ).getByTestId(testID); + + // VAR_SYMBOL from spread props should be filtered + expect(component.props.style).toEqual([{ margin: 5 }, { padding: 10 }]); + }); + + test("style prop from component composition", () => { + registerCSS(` + .parent { background-color: white; } + .child { color: black; } + `); + + const childStyle = { + fontSize: 14, + fontWeight: { [VAR_SYMBOL]: "inline", "--weight": "bold" }, + }; + + const { getByTestId } = render( + + + , + ); + + const child = getByTestId("child"); + + // Nested component should also filter VAR_SYMBOL + expect(child.props.style).toEqual([{ color: "#000" }, { fontSize: 14 }]); + }); +}); + +describe("rightIsInline - Red Team Edge Cases", () => { + test("handles circular references with depth limit", () => { + registerCSS(`.container { padding: 10px; }`); + + const circular: any = { color: "red", padding: 5 }; + circular.self = circular; + + const component = render( + , + ).getByTestId(testID); + + // Should not crash, depth limit prevents infinite recursion + expect(component.props.style).toBeDefined(); + // Circular reference is preserved up to depth limit, just verify no crash + }); + + test("handles sparse arrays without crashes", () => { + registerCSS(`.base { margin: 5px; }`); + + const sparseArray = [ + { color: "red" }, + undefined, + undefined, + { padding: 10 }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // Should handle undefined holes in array + expect(component.props.style).toBeDefined(); + }); + + test("handles objects with only Symbol properties", () => { + registerCSS(`.text { font-size: 14px; }`); + + const onlySymbols = { + [Symbol("test")]: "value", + [Symbol("another")]: "data", + }; + + const component = render( + , + ).getByTestId(testID); + + // Symbols should be filtered, only className remains + expect(component.props.style).toEqual({ fontSize: 14 }); + }); + + test("handles mixed null/undefined/falsy values in arrays", () => { + registerCSS(`.container { width: 100px; }`); + + const mixedArray = [ + null, + undefined, + { opacity: 0 }, // 0 is valid + { display: false as any }, // false might be valid + { padding: 10 }, + ]; + + const component = render( + , + ).getByTestId(testID); + + // Should preserve falsy values like 0 and false in objects + expect(component.props.style).toBeDefined(); + const flatStyle = Array.isArray(component.props.style) + ? component.props.style.flat() + : [component.props.style]; + const hasOpacity = flatStyle.some( + (s) => s && typeof s === "object" && "opacity" in s, + ); + expect(hasOpacity).toBe(true); + }); + + test("handles frozen objects without modification errors", () => { + registerCSS(`.frozen { margin: 5px; }`); + + const frozen = Object.freeze({ color: "blue", padding: 10 }); + + const component = render( + , + ).getByTestId(testID); + + // Should not crash on frozen objects + expect(component.props.style).toBeDefined(); + }); + + test("handles very deep nesting beyond depth limit", () => { + registerCSS(`.deep { color: red; }`); + + // Create nesting beyond the 100 level limit + let veryDeep: any = { value: 1 }; + for (let i = 0; i < 105; i++) { + veryDeep = { nested: veryDeep }; + } + + const component = render( + , + ).getByTestId(testID); + + // Should hit depth limit and return gracefully + expect(component.props.style).toBeDefined(); + }); + + test("handles __proto__ without prototype pollution", () => { + registerCSS(`.safe { padding: 10px; }`); + + // Attempt prototype pollution + const malicious = { + color: "red", + __proto__: { isAdmin: true }, + }; + + const component = render( + , + ).getByTestId(testID); + + // Should not pollute prototype + expect(component.props.style).toBeDefined(); + expect(({} as any).isAdmin).toBeUndefined(); + }); + + test("handles arrays with custom properties", () => { + registerCSS(`.custom { margin: 5px; }`); + + const arrayWithProps: any = [{ color: "red" }, { padding: 10 }]; + arrayWithProps.customProp = "should be ignored"; + + const component = render( + , + ).getByTestId(testID); + + // Custom array properties should not cause issues + expect(component.props.style).toBeDefined(); + }); + + test("handles empty arrays and objects correctly", () => { + registerCSS(`.empty { width: 50px; }`); + + const emptyArray: any[] = []; + const emptyObject = {}; + + const component1 = render( + , + ).getByTestId("test1"); + + const component2 = render( + , + ).getByTestId("test2"); + + // Empty styles should not break rendering + expect(component1.props.style).toBeDefined(); + expect(component2.props.style).toBeDefined(); + }); + + test("handles numeric string keys without issues", () => { + registerCSS(`.numeric { padding: 5px; }`); + + const numericKeys = { + "0": "value0", + "1": "value1", + "color": "red", + }; + + const component = render( + , + ).getByTestId(testID); + + // Numeric string keys should be handled + expect(component.props.style).toBeDefined(); + }); +}); diff --git a/src/native/styles/index.ts b/src/native/styles/index.ts index 0e2319a..a1d9e3b 100644 --- a/src/native/styles/index.ts +++ b/src/native/styles/index.ts @@ -17,6 +17,149 @@ import { } from "../reactivity"; import { calculateProps } from "./calculate-props"; +/** + * Checks if the left style object has any properties that are not present in the right style object. + * This is used to determine if styles need to be preserved in an array or if right can completely override left. + * + * Note: This intentionally only checks one direction (left → right). We don't need to check if right has + * properties that left doesn't have, because right will always be applied/merged. The question we're + * answering is: "Does left have any properties that would be lost if we just used right?" If yes, we + * create a style array [left, right] to preserve both. If no, right can safely replace left entirely. + * + * @param left - The left style object to check + * @param right - The right style object to compare against + * @returns true if left has at least one property key that doesn't exist in right, false otherwise + * + * @example + * hasNonOverlappingProperties({color: 'red', fontSize: 12}, {color: 'blue'}) // true - fontSize is not in right + * hasNonOverlappingProperties({color: 'red'}, {color: 'blue', fontSize: 12}) // false - all left keys exist in right + */ +function hasNonOverlappingProperties( + left: Record, + right: Record, +): boolean { + // Null safety check + if (!left || !right) { + return false; + } + + // Only check own properties to avoid prototype pollution + for (const key in left) { + if (Object.prototype.hasOwnProperty.call(left, key)) { + if (!Object.prototype.hasOwnProperty.call(right, key)) { + return true; + } + } + } + return false; +} + +/** + * Flattens a style array into a single object when possible, with rightmost values taking precedence. + * Returns the original array if it contains any non-plain objects, arrays, or CSS variable objects. + * + * Note: This function assumes the input array has already been filtered (e.g., by filterCssVariables), + * so empty arrays should not reach this function. If they do, they will be treated as non-flattenable. + * + * @param styleArray - The style array to potentially flatten + * @returns A single merged object if all items are plain objects, otherwise the original array + */ +function flattenStyleArray(styleArray: any[]): any { + // Check if we can flatten to a single object (all items are plain objects) + for (const item of styleArray) { + // Use explicit null check instead of !item to allow falsy values like 0 or false + if ( + item == null || + typeof item !== "object" || + Array.isArray(item) || + Object.prototype.hasOwnProperty.call(item, VAR_SYMBOL) + ) { + return styleArray; + } + } + + // Use reduce to avoid spread operator performance issues with large arrays + return styleArray.reduce((acc, item) => Object.assign(acc, item), {}); +} + +/** + * Recursively filters out CSS variable objects (with VAR_SYMBOL) from style values. + * This prevents CSS variable runtime objects from leaking into React Native component props. + * + * @param value - The value to filter (can be any type: object, array, primitive, etc.) + * @param depth - Internal recursion depth counter to prevent stack overflow (max 100) + * @returns The filtered value with CSS variables removed, or `undefined` if the entire value + * should be filtered out (e.g., empty arrays, objects with only VAR_SYMBOL properties) + * + * Filtering behavior: + * - Objects with VAR_SYMBOL property: returns `undefined` (completely filtered) + * - Arrays: filters out VAR_SYMBOL objects, returns `undefined` if empty after filtering + * - Objects: recursively filters properties, returns `undefined` if no properties remain + * - Primitives (null, undefined, numbers, strings, booleans): returned as-is + * - Symbol properties: intentionally filtered out for React Native compatibility + * + * @example + * filterCssVariables({fontSize: 16, color: {[VAR_SYMBOL]: true}}) // {fontSize: 16} + * filterCssVariables([{margin: 10}, {[VAR_SYMBOL]: true}]) // [{margin: 10}] + * filterCssVariables({color: {[VAR_SYMBOL]: true}}) // undefined (all props filtered) + */ +function filterCssVariables(value: any, depth = 0): any | undefined { + // Prevent stack overflow on deeply nested structures + if (depth > 100) { + return value; + } + + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + // Single-pass filter with map operation + const filtered: any[] = []; + + for (const item of value) { + const filteredItem = filterCssVariables(item, depth + 1); + if ( + filteredItem !== undefined && + !( + typeof filteredItem === "object" && + filteredItem !== null && + Object.prototype.hasOwnProperty.call(filteredItem, VAR_SYMBOL) + ) + ) { + filtered.push(filteredItem); + } + } + + return filtered.length > 0 ? filtered : undefined; + } + + if (typeof value === "object") { + // If the object itself has VAR_SYMBOL, filter it out (check own property only) + if (Object.prototype.hasOwnProperty.call(value, VAR_SYMBOL)) { + return undefined; + } + + // Otherwise, filter VAR_SYMBOL properties from nested objects + const filtered: Record = {}; + let hasProperties = false; + + // Use Object.keys to only iterate own string properties (not inherited, not Symbols) + // This intentionally filters out Symbol properties for React Native compatibility + for (const key of Object.keys(value)) { + const filteredValue = filterCssVariables(value[key], depth + 1); + if (filteredValue !== undefined) { + filtered[key] = filteredValue; + hasProperties = true; + } + } + + return hasProperties ? filtered : undefined; + } + + return value; +} + export const stylesFamily = family( ( hash: string, @@ -145,24 +288,75 @@ function deepMergeConfig( ) { // Special handling for style target when we have inline styles result = { ...left, ...right }; - // More performant approach - check for non-overlapping properties without Sets - if (left?.style && right?.style && rightIsInline) { - const leftStyle = left.style; - const rightStyle = right.style; - - // Quick check: do any left properties NOT exist in right? - let hasNonOverlappingProperties = false; - for (const key in leftStyle) { - if (!(key in rightStyle)) { - hasNonOverlappingProperties = true; - break; // Early exit for performance + + // Handle null/undefined inline styles + if (right?.style === null || right?.style === undefined) { + if (left?.style) { + result.style = left.style; + } + } else if (rightIsInline && right?.style) { + // Filter inline styles if rightIsInline is true + const filteredRightStyle = filterCssVariables(right.style); + + if (left?.style) { + if (!filteredRightStyle) { + // All inline styles were CSS variables, only use left + result.style = left.style; + } else { + 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 (hasNonOverlappingProperties(leftStyle, filteredRightStyle)) { + result.style = [leftStyle, filteredRightStyle]; + } else { + // All left properties are in right, right overrides + result.style = filteredRightStyle; + } + } else { + // One or both are arrays, merge them + result.style = [leftStyle, filteredRightStyle]; + } + } + } else { + // No left style, just use filtered right + if (filteredRightStyle) { + result.style = filteredRightStyle; + } else { + // All filtered out, remove style prop + delete result.style; } } - - if (hasNonOverlappingProperties) { - result.style = [leftStyle, rightStyle]; + } else if (!rightIsInline && right?.style) { + // Merging non-inline styles (e.g., important styles) + if (left?.style) { + // If left.style is an array, append right.style + if (Array.isArray(left.style)) { + const combined = [...left.style, right.style]; + result.style = flattenStyleArray(combined); + } else if ( + typeof left.style === "object" && + typeof right.style === "object" + ) { + // Both are objects, check for overlaps + if (hasNonOverlappingProperties(left.style, right.style)) { + result.style = flattenStyleArray([left.style, right.style]); + } else { + // All left properties are overridden by right + result.style = right.style; + } + } else { + // One or both are arrays/mixed types + const combined = [left.style, right.style]; + result.style = flattenStyleArray(combined); + } } - // Otherwise, Object.assign above will handle the override correctly } } else { result = Object.assign({}, left, right); @@ -211,23 +405,8 @@ function deepMergeConfig( let rightValue = right?.[target]; // Strip any inline variables from the target - if (rightIsInline && rightValue) { - if (Array.isArray(rightValue)) { - rightValue = rightValue.filter((v) => { - return typeof v !== "object" || !(v && VAR_SYMBOL in v); - }); - - if (rightValue.length === 0) { - rightValue = undefined; - } - } else if ( - typeof rightValue === "object" && - rightValue && - VAR_SYMBOL in rightValue - ) { - rightValue = undefined; - delete result[target][VAR_SYMBOL]; - } + if (rightIsInline && rightValue !== undefined) { + rightValue = filterCssVariables(rightValue); } if (rightValue !== undefined) {