diff --git a/apps/TesterIntegrated/App.tsx b/apps/TesterIntegrated/App.tsx index c83b9b13..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 } from '@callstack/brownie'; +import { useStore } from '@callstack/brownie'; import { createNativeStackNavigator, type NativeStackScreenProps, @@ -34,7 +34,8 @@ const theme = getRandomTheme(); function HomeScreen({ navigation, route }: HomeScreenProps) { const colors = route.params?.theme || theme; - const [state, setState] = useBrownieStore('BrownfieldStore'); + const [counter, setState] = useStore('BrownfieldStore', (s) => s.counter); + const [user] = useStore('BrownfieldStore', (s) => s.user); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { @@ -51,12 +52,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..0e40ff75 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -308,8 +308,8 @@ packages/brownie/ ### React Hook ```ts -// useState-like API for store access -const [state, setState] = useBrownieStore('BrownfieldStore'); +// Full state (useState-like API) +const [state, setState] = useStore('BrownfieldStore'); // Read state console.log(state.counter); @@ -321,6 +321,26 @@ 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 { useStore } from '@callstack/brownie'; + +// Select primitive - re-renders only when counter changes +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 `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 ```ts diff --git a/packages/brownie/jest.config.js b/packages/brownie/jest.config.js index 1fe00600..fc17b2f0 100644 --- a/packages/brownie/jest.config.js +++ b/packages/brownie/jest.config.js @@ -1,16 +1,45 @@ /** @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', + testEnvironment: 'node', + testMatch: ['/scripts/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + clearMocks: true, + transform: { + '^.+\\.ts$': [ + 'babel-jest', + { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], + }, + ], }, - ], - }, + }, + { + displayName: 'src', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js'], + clearMocks: true, + moduleNameMapper: { + '^./NativeBrownieModule$': + '/src/__tests__/__mocks__/NativeBrownieModule.ts', + }, + transform: { + '^.+\\.ts$': [ + 'babel-jest', + { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], + }, + ], + }, + }, + ], }; diff --git a/packages/brownie/package.json b/packages/brownie/package.json index 8bb5c953..794b01b8 100644 --- a/packages/brownie/package.json +++ b/packages/brownie/package.json @@ -68,6 +68,7 @@ "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", @@ -78,7 +79,6 @@ "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__/__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__/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..726c1ecb 100644 --- a/packages/brownie/src/index.ts +++ b/packages/brownie/src/index.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from 'react'; +import { useCallback, useDebugValue, useSyncExternalStore } from 'react'; import BrownieModule from './NativeBrownieModule'; /** @@ -95,23 +95,54 @@ export function setState( } } +const identity = (x: T): T => x; + /** - * React hook for subscribing to a native store. + * 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] { +): [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 + * @returns Tuple of [selectedState, setState] for the store + */ +export function useStore( + key: K, + selector: (state: BrownieStores[K]) => U +): [U, (action: SetStateAction) => void]; + +export function useStore( + key: K, + 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 state = useSyncExternalStore(sub, snap, snap); + 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( (action: SetStateAction) => setState(key, action), [key] ); - return [state, boundSetState]; + + return [slice, boundSetState]; } diff --git a/yarn.lock b/yarn.lock index 70d67a06..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,6 +1601,7 @@ __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" @@ -1613,7 +1614,6 @@ __metadata: 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" peerDependencies: @@ -5388,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" @@ -7529,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 @@ -8153,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: @@ -10358,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" @@ -10525,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 @@ -14643,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" @@ -14853,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"