diff --git a/.gitignore b/.gitignore index abbbd6e..2e5e672 100755 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ lib/ node_modules/ yarn.lock .idea +src/index.d.ts \ No newline at end of file diff --git a/benchmark/results.js b/benchmark/results.js index 0893c9a..90ad7fa 100644 --- a/benchmark/results.js +++ b/benchmark/results.js @@ -1,6 +1,6 @@ // eslint-disable-next-line no-unused-vars const results = { - Shakl: 0.029_811_424_241_520_11, - Emotion: 0.037_209_380_871_224_86, - 'Styled Components': 0.046_611_316_695_394_33, + Shakl: 0.012_900_377_880_803_662, + Emotion: 0.015_465_369_501_094_81, + 'Styled Components': 0.021_032_191_611_905_397, } diff --git a/package.json b/package.json index 4f7e354..e31d362 100755 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "license": "MIT", "name": "shakl", "description": "A utility to create styled components in React Native.", - "version": "0.0.22", + "version": "1.0.0", "main": "lib/index.js", "react-native": "lib/rn.js", "types": "lib/rn.d.ts", diff --git a/src/index.ts b/src/index.ts index 2b46605..164f3e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,30 @@ -import React from 'react' +import React, { useRef } from 'react' import type { StyleProp } from 'react-native' +const shallowEqual = (a: any, b: any): boolean => { + if (a === b) return true + if (!a || !b || typeof a !== typeof b) return false + if (Array.isArray(a)) { + return Array.isArray(b) && a.length === b.length && a.every((v, i) => shallowEqual(v, b[i])) + } + + if (typeof a === 'object') { + const keysA = Object.keys(a) + return keysA.length === Object.keys(b).length && keysA.every((k) => a[k] === b[k]) + } + + return false +} + +const useStableStyle = (style: any) => { + const ref = useRef(style) + if (!shallowEqual(ref.current, style)) { + ref.current = style + } + + return ref.current +} + export interface Config

{ name?: string props?: Partial

@@ -100,6 +124,8 @@ const styled = } } + style = useStableStyle(style) + omitProps.forEach((excludeProp) => { if ((restProps as Record)[excludeProp]) { delete (restProps as Record)[excludeProp] diff --git a/test/memo-style.test.js b/test/memo-style.test.js new file mode 100644 index 0000000..50063b0 --- /dev/null +++ b/test/memo-style.test.js @@ -0,0 +1,123 @@ +import React, { memo, useState } from 'react' +import { View } from 'react-native' +import { act, create } from 'react-test-renderer' + +import s from '../src/rn' + +it('does not re-render memoized child when parent re-renders with same style', () => { + let childRenderCount = 0 + + const StyledChild = s.View({ flex: 1 }) + const MemoChild = memo(StyledChild) + + const Parent = () => { + const [count, setCount] = useState(0) + Parent.setCount = setCount + return ( + + + {count} + + ) + } + + // eslint-disable-next-line no-unused-vars + let root + act(() => { + root = create() + }) + + childRenderCount = 0 + + // spy on re-renders by wrapping createElement + const originalCreateElement = React.createElement + React.createElement = (...args) => { + if (args[0] === View && args[1]?.style?.flex === 1) { + childRenderCount++ + } + + return originalCreateElement(...args) + } + + act(() => { + Parent.setCount(1) + }) + + React.createElement = originalCreateElement + + // MemoChild should not re-render because useStableStyle returns the same reference + expect(childRenderCount).toBe(0) +}) + +it('does not re-render memoized child when dynamic style resolves to same values', () => { + let childRenderCount = 0 + + const StyledChild = s.View((p) => ({ padding: p.big ? 20 : 10 })) + const MemoChild = memo(StyledChild) + + const Parent = () => { + const [count, setCount] = useState(0) + Parent.setCount = setCount + return ( + + + {count} + + ) + } + + // eslint-disable-next-line no-unused-vars + let root + act(() => { + root = create() + }) + + childRenderCount = 0 + + const originalCreateElement = React.createElement + React.createElement = (...args) => { + if (args[0] === View && args[1]?.style?.padding === 10) { + childRenderCount++ + } + + return originalCreateElement(...args) + } + + act(() => { + Parent.setCount(1) + }) + + React.createElement = originalCreateElement + + expect(childRenderCount).toBe(0) +}) + +it('re-renders memoized child when style actually changes', () => { + const StyledChild = s.View((p) => ({ padding: p.big ? 20 : 10 })) + const MemoChild = memo(StyledChild) + + const Parent = () => { + const [big, setBig] = useState(false) + Parent.setBig = setBig + return ( + + + + ) + } + + let root + act(() => { + root = create() + }) + + const tree1 = root.toJSON() + expect(tree1.children[0].props.style).toEqual({ padding: 10 }) + + act(() => { + Parent.setBig(true) + }) + + const tree2 = root.toJSON() + expect(tree2.children[0].props.style).toEqual({ padding: 20 }) +})