From 2f2ed685dab439589b33ed4d37590206fd0f2676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 9 Jan 2026 13:13:55 +0100 Subject: [PATCH 1/2] feat(brownie): add selector and equality fn support to useBrownieStore --- apps/TesterIntegrated/App.tsx | 12 ++- packages/brownie/ArchitectureOverview.md | 43 ++++++++- packages/brownie/jest.config.js | 49 +++++++--- packages/brownie/package.json | 4 +- .../__mocks__/NativeBrownieModule.ts | 3 + .../brownie/src/__tests__/shallow.test.ts | 96 +++++++++++++++++++ packages/brownie/src/__tests__/tsconfig.json | 6 ++ packages/brownie/src/index.ts | 45 ++++++++- packages/brownie/src/shallow.ts | 73 ++++++++++++++ yarn.lock | 11 ++- 10 files changed, 318 insertions(+), 24 deletions(-) create mode 100644 packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts create mode 100644 packages/brownie/src/__tests__/shallow.test.ts create mode 100644 packages/brownie/src/__tests__/tsconfig.json create mode 100644 packages/brownie/src/shallow.ts diff --git a/apps/TesterIntegrated/App.tsx b/apps/TesterIntegrated/App.tsx index c83b9b13..173aaad3 100644 --- a/apps/TesterIntegrated/App.tsx +++ b/apps/TesterIntegrated/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { StyleSheet, Text, View, Button, TextInput } from 'react-native'; -import { useBrownieStore } from '@callstack/brownie'; +import { useBrownieStore, shallow } from '@callstack/brownie'; import { createNativeStackNavigator, type NativeStackScreenProps, @@ -34,7 +34,11 @@ const theme = getRandomTheme(); function HomeScreen({ navigation, route }: HomeScreenProps) { const colors = route.params?.theme || theme; - const [state, setState] = useBrownieStore('BrownfieldStore'); + const [counter, setState] = useBrownieStore( + 'BrownfieldStore', + (s) => s.counter + ); + const [user] = useBrownieStore('BrownfieldStore', (s) => s.user, shallow); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { @@ -51,12 +55,12 @@ function HomeScreen({ navigation, route }: HomeScreenProps) { - Count: {state.counter} + Count: {counter} setState((prev) => ({ user: { ...prev.user, name: text } })) } diff --git a/packages/brownie/ArchitectureOverview.md b/packages/brownie/ArchitectureOverview.md index 20c3c200..724bb16f 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -308,7 +308,7 @@ packages/brownie/ ### React Hook ```ts -// useState-like API for store access +// Full state (useState-like API) const [state, setState] = useBrownieStore('BrownfieldStore'); // Read state @@ -321,6 +321,47 @@ setState({ counter: state.counter + 1 }); setState((prev) => ({ counter: prev.counter + 1 })); ``` +### Selectors + +Selectors allow subscribing to a slice of state, reducing unnecessary re-renders: + +```ts +import { useBrownieStore, shallow } from '@callstack/brownie'; + +// Select primitive - re-renders only when counter changes +const [counter, setState] = useBrownieStore( + 'BrownfieldStore', + (s) => s.counter +); + +// Select object with shallow equality +const [user, setState] = useBrownieStore( + 'BrownfieldStore', + (s) => s.user, + shallow +); +``` + +**How it works:** + +1. Native side notifies JS on every state change (full state) +2. Each `useBrownieStore` subscribes to full state but extracts only what it needs via selector +3. `useSyncExternalStoreWithSelector` compares previous vs new selected value - skips re-render if equal + +**Equality functions:** + +| Equality | Use case | +| ----------- | ------------------------------------------------ | +| `Object.is` | Default. Works for primitives (`s => s.counter`) | +| `shallow` | Objects/arrays - compares top-level props only | +| Custom `fn` | Deep nesting or complex comparison logic | + +**Why `shallow` is needed:** + +Without it, selecting an object (`s => s.user`) returns a new reference every time (JS creates new object on each native→JS crossing), causing re-renders even if contents are identical. `shallow` compares top-level properties to detect actual changes. + +**Limitation:** `shallow` only goes one level deep. For nested objects like `{ user: { profile: { name } } }`, if `profile` object changes identity but has same contents, shallow won't catch it. Use more granular selectors or a custom `equalityFn` for deep nesting. + ### Core Functions ```ts diff --git a/packages/brownie/jest.config.js b/packages/brownie/jest.config.js index 1fe00600..aee80287 100644 --- a/packages/brownie/jest.config.js +++ b/packages/brownie/jest.config.js @@ -1,16 +1,41 @@ /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['/scripts/**/*.test.ts'], - moduleFileExtensions: ['ts', 'js'], - clearMocks: true, - transform: { - '^.+\\.ts$': [ - 'ts-jest', - { - tsconfig: '/scripts/__tests__/tsconfig.json', + projects: [ + { + displayName: 'scripts', + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/scripts/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + clearMocks: true, + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/scripts/__tests__/tsconfig.json', + }, + ], }, - ], - }, + }, + { + displayName: 'src', + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + clearMocks: true, + moduleNameMapper: { + '^./NativeBrownieModule$': + '/src/__tests__/__mocks__/NativeBrownieModule.ts', + }, + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: '/src/__tests__/tsconfig.json', + }, + ], + }, + }, + ], }; diff --git a/packages/brownie/package.json b/packages/brownie/package.json index 8bb5c953..6e608c9f 100644 --- a/packages/brownie/package.json +++ b/packages/brownie/package.json @@ -63,7 +63,8 @@ "dependencies": { "quicktype-core": "^23.0.170", "quicktype-typescript-input": "^23.0.170", - "ts-morph": "^25.0.0" + "ts-morph": "^25.0.0", + "use-sync-external-store": "^1.4.0" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -74,6 +75,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.0.0", "@types/react": "^19.1.1", + "@types/use-sync-external-store": "^0.0.6", "jest": "^29.7.0", "react": "19.1.1", "react-native": "0.82.1", diff --git a/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts b/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts new file mode 100644 index 00000000..6a4fba2a --- /dev/null +++ b/packages/brownie/src/__tests__/__mocks__/NativeBrownieModule.ts @@ -0,0 +1,3 @@ +export default { + nativeStoreDidChange: jest.fn(), +}; diff --git a/packages/brownie/src/__tests__/shallow.test.ts b/packages/brownie/src/__tests__/shallow.test.ts new file mode 100644 index 00000000..919f4019 --- /dev/null +++ b/packages/brownie/src/__tests__/shallow.test.ts @@ -0,0 +1,96 @@ +import { shallow } from '../shallow'; + +describe('shallow', () => { + it('returns true for identical primitives', () => { + expect(shallow(1, 1)).toBe(true); + expect(shallow('a', 'a')).toBe(true); + expect(shallow(true, true)).toBe(true); + expect(shallow(null, null)).toBe(true); + expect(shallow(undefined, undefined)).toBe(true); + }); + + it('returns false for different primitives', () => { + expect(shallow(1, 2)).toBe(false); + expect(shallow('a', 'b')).toBe(false); + expect(shallow(true, false)).toBe(false); + }); + + it('returns true for same object reference', () => { + const obj = { a: 1 }; + expect(shallow(obj, obj)).toBe(true); + }); + + it('returns true for objects with same top-level values', () => { + expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + }); + + it('returns false for objects with different top-level values', () => { + expect(shallow({ a: 1 }, { a: 2 })).toBe(false); + expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false); + }); + + it('does not deeply compare nested objects', () => { + const nested1 = { a: { b: 1 } }; + const nested2 = { a: { b: 1 } }; + expect(shallow(nested1, nested2)).toBe(false); + }); + + it('returns true for same nested object reference', () => { + const inner = { b: 1 }; + expect(shallow({ a: inner }, { a: inner })).toBe(true); + }); + + it('returns true for equal arrays', () => { + expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it('returns false for arrays with different values', () => { + expect(shallow([1, 2], [1, 3])).toBe(false); + expect(shallow([1, 2], [1, 2, 3])).toBe(false); + }); + + it('returns true for equal Maps', () => { + const mapA = new Map([ + ['a', 1], + ['b', 2], + ]); + const mapB = new Map([ + ['a', 1], + ['b', 2], + ]); + expect(shallow(mapA, mapB)).toBe(true); + }); + + it('returns false for Maps with different values', () => { + const mapA = new Map([['a', 1]]); + const mapB = new Map([['a', 2]]); + expect(shallow(mapA, mapB)).toBe(false); + }); + + it('returns true for equal Sets', () => { + const setA = new Set([1, 2, 3]); + const setB = new Set([1, 2, 3]); + expect(shallow(setA, setB)).toBe(true); + }); + + it('returns false for Sets with different values', () => { + const setA = new Set([1, 2]); + const setB = new Set([1, 3]); + expect(shallow(setA, setB)).toBe(false); + }); + + it('returns false for different prototypes', () => { + class A { + x = 1; + } + class B { + x = 1; + } + expect(shallow(new A(), new B())).toBe(false); + }); + + it('returns false when comparing object to null', () => { + expect(shallow({ a: 1 }, null)).toBe(false); + expect(shallow(null, { a: 1 })).toBe(false); + }); +}); diff --git a/packages/brownie/src/__tests__/tsconfig.json b/packages/brownie/src/__tests__/tsconfig.json new file mode 100644 index 00000000..b8b1c359 --- /dev/null +++ b/packages/brownie/src/__tests__/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../../tsconfig", + "compilerOptions": { + "verbatimModuleSyntax": false + } +} diff --git a/packages/brownie/src/index.ts b/packages/brownie/src/index.ts index 6643e57d..66b1e399 100644 --- a/packages/brownie/src/index.ts +++ b/packages/brownie/src/index.ts @@ -1,4 +1,5 @@ -import { useCallback, useSyncExternalStore } from 'react'; +import { useCallback, useDebugValue } from 'react'; +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'; import BrownieModule from './NativeBrownieModule'; /** @@ -95,23 +96,57 @@ export function setState( } } +const identity = (x: T): T => x; + +export { shallow } from './shallow'; + /** - * React hook for subscribing to a native store. + * React hook for subscribing to a native store with optional selector. * @param key Store key registered in StoreManager * @returns Tuple of [state, setState] for the store */ export function useBrownieStore( key: K -): [BrownieStores[K], (action: SetStateAction) => void] { +): [BrownieStores[K], (action: SetStateAction) => void]; + +/** + * React hook for subscribing to a native store with selector. + * @param key Store key registered in StoreManager + * @param selector Function to select a slice of state + * @param equalityFn Optional equality function for comparing selected values + * @returns Tuple of [selectedState, setState] for the store + */ +export function useBrownieStore( + key: K, + selector: (state: BrownieStores[K]) => U, + equalityFn?: (a: U, b: U) => boolean +): [U, (action: SetStateAction) => void]; + +export function useBrownieStore( + key: K, + selector?: (state: BrownieStores[K]) => U, + equalityFn?: (a: U, b: U) => boolean +): [U | BrownieStores[K], (action: SetStateAction) => void] { const sub = useCallback( (listener: () => void) => subscribe(key, listener), [key] ); const snap = useCallback(() => getSnapshot(key), [key]); - const state = useSyncExternalStore(sub, snap, snap); + + const slice = useSyncExternalStoreWithSelector( + sub, + snap, + snap, + selector ?? (identity as (state: BrownieStores[K]) => U), + equalityFn + ); + + useDebugValue(slice); + const boundSetState = useCallback( (action: SetStateAction) => setState(key, action), [key] ); - return [state, boundSetState]; + + return [slice, boundSetState]; } diff --git a/packages/brownie/src/shallow.ts b/packages/brownie/src/shallow.ts new file mode 100644 index 00000000..70459abe --- /dev/null +++ b/packages/brownie/src/shallow.ts @@ -0,0 +1,73 @@ +const isIterable = (obj: object): obj is Iterable => + Symbol.iterator in obj; + +const hasIterableEntries = ( + value: Iterable +): value is Iterable & { entries(): Iterable<[unknown, unknown]> } => + 'entries' in value; + +const compareEntries = ( + valueA: { entries(): Iterable<[unknown, unknown]> }, + valueB: { entries(): Iterable<[unknown, unknown]> } +) => { + const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries()); + const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries()); + if (mapA.size !== mapB.size) { + return false; + } + for (const [key, value] of mapA) { + if (!mapB.has(key) || !Object.is(value, mapB.get(key))) { + return false; + } + } + return true; +}; + +const compareIterables = ( + valueA: Iterable, + valueB: Iterable +) => { + const iteratorA = valueA[Symbol.iterator](); + const iteratorB = valueB[Symbol.iterator](); + let nextA = iteratorA.next(); + let nextB = iteratorB.next(); + while (!nextA.done && !nextB.done) { + if (!Object.is(nextA.value, nextB.value)) { + return false; + } + nextA = iteratorA.next(); + nextB = iteratorB.next(); + } + return !!nextA.done && !!nextB.done; +}; + +/** + * Shallow equality comparison for use with useBrownieStore selector. + * Compares objects by their top-level properties. + */ +export function shallow(valueA: T, valueB: T): boolean { + if (Object.is(valueA, valueB)) { + return true; + } + if ( + typeof valueA !== 'object' || + valueA === null || + typeof valueB !== 'object' || + valueB === null + ) { + return false; + } + if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) { + return false; + } + if (isIterable(valueA) && isIterable(valueB)) { + if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) { + return compareEntries(valueA, valueB); + } + return compareIterables(valueA, valueB); + } + return compareEntries( + { entries: () => Object.entries(valueA) }, + { entries: () => Object.entries(valueB) } + ); +} diff --git a/yarn.lock b/yarn.lock index 70d67a06..28c21a7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1607,6 +1607,7 @@ __metadata: "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.0.0" "@types/react": "npm:^19.1.1" + "@types/use-sync-external-store": "npm:^0.0.6" jest: "npm:^29.7.0" quicktype-core: "npm:^23.0.170" quicktype-typescript-input: "npm:^23.0.170" @@ -1616,6 +1617,7 @@ __metadata: ts-jest: "npm:^29.2.5" ts-morph: "npm:^25.0.0" typescript: "npm:5.8.3" + use-sync-external-store: "npm:^1.4.0" peerDependencies: react: "*" react-native: "*" @@ -4465,6 +4467,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -15299,7 +15308,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.5.0": +"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" peerDependencies: From a461f1eb6cb7b972fad4b6cb735882cf83497548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 9 Jan 2026 14:31:14 +0100 Subject: [PATCH 2/2] refactor(brownie): simplify useStore, drop external deps - rename useBrownieStore -> useStore - use React's useSyncExternalStore instead of shim - remove shallow equality fn (zustand-style API) - replace ts-jest with babel-jest --- apps/TesterIntegrated/App.tsx | 9 +- packages/brownie/ArchitectureOverview.md | 37 ++----- packages/brownie/jest.config.js | 16 ++-- packages/brownie/package.json | 6 +- .../brownie/src/__tests__/shallow.test.ts | 96 ------------------- packages/brownie/src/index.ts | 36 ++++--- packages/brownie/src/shallow.ts | 73 -------------- yarn.lock | 84 ++-------------- 8 files changed, 45 insertions(+), 312 deletions(-) delete mode 100644 packages/brownie/src/__tests__/shallow.test.ts delete mode 100644 packages/brownie/src/shallow.ts diff --git a/apps/TesterIntegrated/App.tsx b/apps/TesterIntegrated/App.tsx index 173aaad3..9f42ef60 100644 --- a/apps/TesterIntegrated/App.tsx +++ b/apps/TesterIntegrated/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { StyleSheet, Text, View, Button, TextInput } from 'react-native'; -import { useBrownieStore, shallow } from '@callstack/brownie'; +import { useStore } from '@callstack/brownie'; import { createNativeStackNavigator, type NativeStackScreenProps, @@ -34,11 +34,8 @@ const theme = getRandomTheme(); function HomeScreen({ navigation, route }: HomeScreenProps) { const colors = route.params?.theme || theme; - const [counter, setState] = useBrownieStore( - 'BrownfieldStore', - (s) => s.counter - ); - const [user] = useBrownieStore('BrownfieldStore', (s) => s.user, shallow); + const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter); + const [user] = useStore('BrownfieldStore', (s) => s.user); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { diff --git a/packages/brownie/ArchitectureOverview.md b/packages/brownie/ArchitectureOverview.md index 724bb16f..0e40ff75 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -309,7 +309,7 @@ packages/brownie/ ```ts // Full state (useState-like API) -const [state, setState] = useBrownieStore('BrownfieldStore'); +const [state, setState] = useStore('BrownfieldStore'); // Read state console.log(state.counter); @@ -326,41 +326,20 @@ setState((prev) => ({ counter: prev.counter + 1 })); Selectors allow subscribing to a slice of state, reducing unnecessary re-renders: ```ts -import { useBrownieStore, shallow } from '@callstack/brownie'; +import { useStore } from '@callstack/brownie'; // Select primitive - re-renders only when counter changes -const [counter, setState] = useBrownieStore( - 'BrownfieldStore', - (s) => s.counter -); - -// Select object with shallow equality -const [user, setState] = useBrownieStore( - 'BrownfieldStore', - (s) => s.user, - shallow -); +const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter); + +// Select object +const [user, setState] = useStore('BrownfieldStore', (s) => s.user); ``` **How it works:** 1. Native side notifies JS on every state change (full state) -2. Each `useBrownieStore` subscribes to full state but extracts only what it needs via selector -3. `useSyncExternalStoreWithSelector` compares previous vs new selected value - skips re-render if equal - -**Equality functions:** - -| Equality | Use case | -| ----------- | ------------------------------------------------ | -| `Object.is` | Default. Works for primitives (`s => s.counter`) | -| `shallow` | Objects/arrays - compares top-level props only | -| Custom `fn` | Deep nesting or complex comparison logic | - -**Why `shallow` is needed:** - -Without it, selecting an object (`s => s.user`) returns a new reference every time (JS creates new object on each native→JS crossing), causing re-renders even if contents are identical. `shallow` compares top-level properties to detect actual changes. - -**Limitation:** `shallow` only goes one level deep. For nested objects like `{ user: { profile: { name } } }`, if `profile` object changes identity but has same contents, shallow won't catch it. Use more granular selectors or a custom `equalityFn` for deep nesting. +2. Each `useStore` subscribes to full state but extracts only what it needs via selector +3. React's `useSyncExternalStore` compares previous vs new selected value - skips re-render if equal ### Core Functions diff --git a/packages/brownie/jest.config.js b/packages/brownie/jest.config.js index aee80287..fc17b2f0 100644 --- a/packages/brownie/jest.config.js +++ b/packages/brownie/jest.config.js @@ -3,23 +3,24 @@ module.exports = { projects: [ { displayName: 'scripts', - preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/scripts/**/*.test.ts'], moduleFileExtensions: ['ts', 'js'], clearMocks: true, transform: { '^.+\\.ts$': [ - 'ts-jest', + 'babel-jest', { - tsconfig: '/scripts/__tests__/tsconfig.json', + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], }, ], }, }, { displayName: 'src', - preset: 'ts-jest', testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], moduleFileExtensions: ['ts', 'js'], @@ -30,9 +31,12 @@ module.exports = { }, transform: { '^.+\\.ts$': [ - 'ts-jest', + 'babel-jest', { - tsconfig: '/src/__tests__/tsconfig.json', + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], }, ], }, diff --git a/packages/brownie/package.json b/packages/brownie/package.json index 6e608c9f..794b01b8 100644 --- a/packages/brownie/package.json +++ b/packages/brownie/package.json @@ -63,24 +63,22 @@ "dependencies": { "quicktype-core": "^23.0.170", "quicktype-typescript-input": "^23.0.170", - "ts-morph": "^25.0.0", - "use-sync-external-store": "^1.4.0" + "ts-morph": "^25.0.0" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", + "@babel/preset-typescript": "^7.27.1", "@babel/runtime": "^7.25.0", "@react-native/babel-preset": "0.82.1", "@react-native/eslint-config": "0.82.1", "@types/jest": "^29.5.14", "@types/node": "^22.0.0", "@types/react": "^19.1.1", - "@types/use-sync-external-store": "^0.0.6", "jest": "^29.7.0", "react": "19.1.1", "react-native": "0.82.1", "react-native-builder-bob": "^0.40.14", - "ts-jest": "^29.2.5", "typescript": "5.8.3" }, "codegenConfig": { diff --git a/packages/brownie/src/__tests__/shallow.test.ts b/packages/brownie/src/__tests__/shallow.test.ts deleted file mode 100644 index 919f4019..00000000 --- a/packages/brownie/src/__tests__/shallow.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { shallow } from '../shallow'; - -describe('shallow', () => { - it('returns true for identical primitives', () => { - expect(shallow(1, 1)).toBe(true); - expect(shallow('a', 'a')).toBe(true); - expect(shallow(true, true)).toBe(true); - expect(shallow(null, null)).toBe(true); - expect(shallow(undefined, undefined)).toBe(true); - }); - - it('returns false for different primitives', () => { - expect(shallow(1, 2)).toBe(false); - expect(shallow('a', 'b')).toBe(false); - expect(shallow(true, false)).toBe(false); - }); - - it('returns true for same object reference', () => { - const obj = { a: 1 }; - expect(shallow(obj, obj)).toBe(true); - }); - - it('returns true for objects with same top-level values', () => { - expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); - }); - - it('returns false for objects with different top-level values', () => { - expect(shallow({ a: 1 }, { a: 2 })).toBe(false); - expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false); - }); - - it('does not deeply compare nested objects', () => { - const nested1 = { a: { b: 1 } }; - const nested2 = { a: { b: 1 } }; - expect(shallow(nested1, nested2)).toBe(false); - }); - - it('returns true for same nested object reference', () => { - const inner = { b: 1 }; - expect(shallow({ a: inner }, { a: inner })).toBe(true); - }); - - it('returns true for equal arrays', () => { - expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true); - }); - - it('returns false for arrays with different values', () => { - expect(shallow([1, 2], [1, 3])).toBe(false); - expect(shallow([1, 2], [1, 2, 3])).toBe(false); - }); - - it('returns true for equal Maps', () => { - const mapA = new Map([ - ['a', 1], - ['b', 2], - ]); - const mapB = new Map([ - ['a', 1], - ['b', 2], - ]); - expect(shallow(mapA, mapB)).toBe(true); - }); - - it('returns false for Maps with different values', () => { - const mapA = new Map([['a', 1]]); - const mapB = new Map([['a', 2]]); - expect(shallow(mapA, mapB)).toBe(false); - }); - - it('returns true for equal Sets', () => { - const setA = new Set([1, 2, 3]); - const setB = new Set([1, 2, 3]); - expect(shallow(setA, setB)).toBe(true); - }); - - it('returns false for Sets with different values', () => { - const setA = new Set([1, 2]); - const setB = new Set([1, 3]); - expect(shallow(setA, setB)).toBe(false); - }); - - it('returns false for different prototypes', () => { - class A { - x = 1; - } - class B { - x = 1; - } - expect(shallow(new A(), new B())).toBe(false); - }); - - it('returns false when comparing object to null', () => { - expect(shallow({ a: 1 }, null)).toBe(false); - expect(shallow(null, { a: 1 })).toBe(false); - }); -}); diff --git a/packages/brownie/src/index.ts b/packages/brownie/src/index.ts index 66b1e399..726c1ecb 100644 --- a/packages/brownie/src/index.ts +++ b/packages/brownie/src/index.ts @@ -1,5 +1,4 @@ -import { useCallback, useDebugValue } from 'react'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'; +import { useCallback, useDebugValue, useSyncExternalStore } from 'react'; import BrownieModule from './NativeBrownieModule'; /** @@ -98,49 +97,46 @@ export function setState( const identity = (x: T): T => x; -export { shallow } from './shallow'; - /** * React hook for subscribing to a native store with optional selector. + * Inspired by Zustand's useStore implementation. * @param key Store key registered in StoreManager * @returns Tuple of [state, setState] for the store */ -export function useBrownieStore( +export function useStore( key: K ): [BrownieStores[K], (action: SetStateAction) => void]; /** * React hook for subscribing to a native store with selector. + * Inspired by Zustand's useStore implementation. * @param key Store key registered in StoreManager * @param selector Function to select a slice of state - * @param equalityFn Optional equality function for comparing selected values * @returns Tuple of [selectedState, setState] for the store */ -export function useBrownieStore( +export function useStore( key: K, - selector: (state: BrownieStores[K]) => U, - equalityFn?: (a: U, b: U) => boolean + selector: (state: BrownieStores[K]) => U ): [U, (action: SetStateAction) => void]; -export function useBrownieStore( +export function useStore( key: K, - selector?: (state: BrownieStores[K]) => U, - equalityFn?: (a: U, b: U) => boolean + selector?: (state: BrownieStores[K]) => U ): [U | BrownieStores[K], (action: SetStateAction) => void] { const sub = useCallback( (listener: () => void) => subscribe(key, listener), [key] ); - const snap = useCallback(() => getSnapshot(key), [key]); - - const slice = useSyncExternalStoreWithSelector( - sub, - snap, - snap, - selector ?? (identity as (state: BrownieStores[K]) => U), - equalityFn + const snap = useCallback( + () => + (selector ?? (identity as (state: BrownieStores[K]) => U))( + getSnapshot(key) + ), + [key, selector] ); + const slice = useSyncExternalStore(sub, snap, snap); + useDebugValue(slice); const boundSetState = useCallback( diff --git a/packages/brownie/src/shallow.ts b/packages/brownie/src/shallow.ts deleted file mode 100644 index 70459abe..00000000 --- a/packages/brownie/src/shallow.ts +++ /dev/null @@ -1,73 +0,0 @@ -const isIterable = (obj: object): obj is Iterable => - Symbol.iterator in obj; - -const hasIterableEntries = ( - value: Iterable -): value is Iterable & { entries(): Iterable<[unknown, unknown]> } => - 'entries' in value; - -const compareEntries = ( - valueA: { entries(): Iterable<[unknown, unknown]> }, - valueB: { entries(): Iterable<[unknown, unknown]> } -) => { - const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries()); - const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries()); - if (mapA.size !== mapB.size) { - return false; - } - for (const [key, value] of mapA) { - if (!mapB.has(key) || !Object.is(value, mapB.get(key))) { - return false; - } - } - return true; -}; - -const compareIterables = ( - valueA: Iterable, - valueB: Iterable -) => { - const iteratorA = valueA[Symbol.iterator](); - const iteratorB = valueB[Symbol.iterator](); - let nextA = iteratorA.next(); - let nextB = iteratorB.next(); - while (!nextA.done && !nextB.done) { - if (!Object.is(nextA.value, nextB.value)) { - return false; - } - nextA = iteratorA.next(); - nextB = iteratorB.next(); - } - return !!nextA.done && !!nextB.done; -}; - -/** - * Shallow equality comparison for use with useBrownieStore selector. - * Compares objects by their top-level properties. - */ -export function shallow(valueA: T, valueB: T): boolean { - if (Object.is(valueA, valueB)) { - return true; - } - if ( - typeof valueA !== 'object' || - valueA === null || - typeof valueB !== 'object' || - valueB === null - ) { - return false; - } - if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) { - return false; - } - if (isIterable(valueA) && isIterable(valueB)) { - if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) { - return compareEntries(valueA, valueB); - } - return compareIterables(valueA, valueB); - } - return compareEntries( - { entries: () => Object.entries(valueA) }, - { entries: () => Object.entries(valueB) } - ); -} diff --git a/yarn.lock b/yarn.lock index 28c21a7b..23ae96ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1458,7 +1458,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:^7.24.7": +"@babel/preset-typescript@npm:^7.24.7, @babel/preset-typescript@npm:^7.27.1": version: 7.28.5 resolution: "@babel/preset-typescript@npm:7.28.5" dependencies: @@ -1601,23 +1601,21 @@ __metadata: dependencies: "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.25.3" + "@babel/preset-typescript": "npm:^7.27.1" "@babel/runtime": "npm:^7.25.0" "@react-native/babel-preset": "npm:0.82.1" "@react-native/eslint-config": "npm:0.82.1" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.0.0" "@types/react": "npm:^19.1.1" - "@types/use-sync-external-store": "npm:^0.0.6" jest: "npm:^29.7.0" quicktype-core: "npm:^23.0.170" quicktype-typescript-input: "npm:^23.0.170" react: "npm:19.1.1" react-native: "npm:0.82.1" react-native-builder-bob: "npm:^0.40.14" - ts-jest: "npm:^29.2.5" ts-morph: "npm:^25.0.0" typescript: "npm:5.8.3" - use-sync-external-store: "npm:^1.4.0" peerDependencies: react: "*" react-native: "*" @@ -4467,13 +4465,6 @@ __metadata: languageName: node linkType: hard -"@types/use-sync-external-store@npm:^0.0.6": - version: 0.0.6 - resolution: "@types/use-sync-external-store@npm:0.0.6" - checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 - languageName: node - linkType: hard - "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -5397,15 +5388,6 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:^0.2.6": - version: 0.2.6 - resolution: "bs-logger@npm:0.2.6" - dependencies: - fast-json-stable-stringify: "npm:2.x" - checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212 - languageName: node - linkType: hard - "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -7538,7 +7520,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": +"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e @@ -8162,7 +8144,7 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.7, handlebars@npm:^4.7.8": +"handlebars@npm:^4.7.7": version: 4.7.8 resolution: "handlebars@npm:4.7.8" dependencies: @@ -10367,13 +10349,6 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:^4.1.2": - version: 4.1.2 - resolution: "lodash.memoize@npm:4.1.2" - checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da - languageName: node - linkType: hard - "lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -10534,7 +10509,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1, make-error@npm:^1.3.6": +"make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -14652,46 +14627,6 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.2.5": - version: 29.4.6 - resolution: "ts-jest@npm:29.4.6" - dependencies: - bs-logger: "npm:^0.2.6" - fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.8" - json5: "npm:^2.2.3" - lodash.memoize: "npm:^4.1.2" - make-error: "npm:^1.3.6" - semver: "npm:^7.7.3" - type-fest: "npm:^4.41.0" - yargs-parser: "npm:^21.1.1" - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 || ^30.0.0 - "@jest/types": ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <6" - peerDependenciesMeta: - "@babel/core": - optional: true - "@jest/transform": - optional: true - "@jest/types": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - bin: - ts-jest: cli.js - checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e - languageName: node - linkType: hard - "ts-morph@npm:^25.0.0": version: 25.0.1 resolution: "ts-morph@npm:25.0.1" @@ -14862,13 +14797,6 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.41.0": - version: 4.41.0 - resolution: "type-fest@npm:4.41.0" - checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 - languageName: node - linkType: hard - "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -15308,7 +15236,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0": +"use-sync-external-store@npm:^1.5.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" peerDependencies: