Skip to content

Commit 1a38943

Browse files
committed
feat(brownie): add selector and equality fn support to useBrownieStore
1 parent b93ef12 commit 1a38943

8 files changed

Lines changed: 275 additions & 23 deletions

File tree

apps/TesterIntegrated/App.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
22
import { StyleSheet, Text, View, Button, TextInput } from 'react-native';
3-
import { useBrownieStore } from '@callstack/brownie';
3+
import { useBrownieStore, shallow } from '@callstack/brownie';
44
import {
55
createNativeStackNavigator,
66
type NativeStackScreenProps,
@@ -34,7 +34,11 @@ const theme = getRandomTheme();
3434

3535
function HomeScreen({ navigation, route }: HomeScreenProps) {
3636
const colors = route.params?.theme || theme;
37-
const [state, setState] = useBrownieStore('BrownfieldStore');
37+
const [counter, setState] = useBrownieStore(
38+
'BrownfieldStore',
39+
(s) => s.counter
40+
);
41+
const [user] = useBrownieStore('BrownfieldStore', (s) => s.user, shallow);
3842

3943
useEffect(() => {
4044
const unsubscribe = navigation.addListener('focus', () => {
@@ -51,12 +55,12 @@ function HomeScreen({ navigation, route }: HomeScreenProps) {
5155
</Text>
5256

5357
<Text style={[styles.text, { color: colors.secondary }]}>
54-
Count: {state.counter}
58+
Count: {counter}
5559
</Text>
5660

5761
<TextInput
5862
style={styles.input}
59-
value={state.user.name}
63+
value={user.name}
6064
onChangeText={(text) =>
6165
setState((prev) => ({ user: { ...prev.user, name: text } }))
6266
}

packages/brownie/jest.config.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
/** @type {import('jest').Config} */
22
module.exports = {
3-
preset: 'ts-jest',
4-
testEnvironment: 'node',
5-
testMatch: ['<rootDir>/scripts/**/*.test.ts'],
6-
moduleFileExtensions: ['ts', 'js'],
7-
clearMocks: true,
8-
transform: {
9-
'^.+\\.ts$': [
10-
'ts-jest',
11-
{
12-
tsconfig: '<rootDir>/scripts/__tests__/tsconfig.json',
3+
projects: [
4+
{
5+
displayName: 'scripts',
6+
preset: 'ts-jest',
7+
testEnvironment: 'node',
8+
testMatch: ['<rootDir>/scripts/**/*.test.ts'],
9+
moduleFileExtensions: ['ts', 'js'],
10+
clearMocks: true,
11+
transform: {
12+
'^.+\\.ts$': [
13+
'ts-jest',
14+
{
15+
tsconfig: '<rootDir>/scripts/__tests__/tsconfig.json',
16+
},
17+
],
1318
},
14-
],
15-
},
19+
},
20+
{
21+
displayName: 'src',
22+
preset: 'ts-jest',
23+
testEnvironment: 'node',
24+
testMatch: ['<rootDir>/src/**/*.test.ts'],
25+
moduleFileExtensions: ['ts', 'js'],
26+
clearMocks: true,
27+
moduleNameMapper: {
28+
'^./NativeBrownieModule$':
29+
'<rootDir>/src/__tests__/__mocks__/NativeBrownieModule.ts',
30+
},
31+
transform: {
32+
'^.+\\.ts$': [
33+
'ts-jest',
34+
{
35+
tsconfig: '<rootDir>/src/__tests__/tsconfig.json',
36+
},
37+
],
38+
},
39+
},
40+
],
1641
};

packages/brownie/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"dependencies": {
6464
"quicktype-core": "^23.0.170",
6565
"quicktype-typescript-input": "^23.0.170",
66-
"ts-morph": "^25.0.0"
66+
"ts-morph": "^25.0.0",
67+
"use-sync-external-store": "^1.4.0"
6768
},
6869
"devDependencies": {
6970
"@babel/core": "^7.25.2",
@@ -74,6 +75,7 @@
7475
"@types/jest": "^29.5.14",
7576
"@types/node": "^22.0.0",
7677
"@types/react": "^19.1.1",
78+
"@types/use-sync-external-store": "^0.0.6",
7779
"jest": "^29.7.0",
7880
"react": "19.1.1",
7981
"react-native": "0.82.1",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
nativeStoreDidChange: jest.fn(),
3+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { shallow } from '../index';
2+
3+
describe('shallow', () => {
4+
it('returns true for identical primitives', () => {
5+
expect(shallow(1, 1)).toBe(true);
6+
expect(shallow('a', 'a')).toBe(true);
7+
expect(shallow(true, true)).toBe(true);
8+
expect(shallow(null, null)).toBe(true);
9+
expect(shallow(undefined, undefined)).toBe(true);
10+
});
11+
12+
it('returns false for different primitives', () => {
13+
expect(shallow(1, 2)).toBe(false);
14+
expect(shallow('a', 'b')).toBe(false);
15+
expect(shallow(true, false)).toBe(false);
16+
});
17+
18+
it('returns true for same object reference', () => {
19+
const obj = { a: 1 };
20+
expect(shallow(obj, obj)).toBe(true);
21+
});
22+
23+
it('returns true for objects with same top-level values', () => {
24+
expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
25+
});
26+
27+
it('returns false for objects with different top-level values', () => {
28+
expect(shallow({ a: 1 }, { a: 2 })).toBe(false);
29+
expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false);
30+
});
31+
32+
it('does not deeply compare nested objects', () => {
33+
const nested1 = { a: { b: 1 } };
34+
const nested2 = { a: { b: 1 } };
35+
expect(shallow(nested1, nested2)).toBe(false);
36+
});
37+
38+
it('returns true for same nested object reference', () => {
39+
const inner = { b: 1 };
40+
expect(shallow({ a: inner }, { a: inner })).toBe(true);
41+
});
42+
43+
it('returns true for equal arrays', () => {
44+
expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true);
45+
});
46+
47+
it('returns false for arrays with different values', () => {
48+
expect(shallow([1, 2], [1, 3])).toBe(false);
49+
expect(shallow([1, 2], [1, 2, 3])).toBe(false);
50+
});
51+
52+
it('returns true for equal Maps', () => {
53+
const mapA = new Map([
54+
['a', 1],
55+
['b', 2],
56+
]);
57+
const mapB = new Map([
58+
['a', 1],
59+
['b', 2],
60+
]);
61+
expect(shallow(mapA, mapB)).toBe(true);
62+
});
63+
64+
it('returns false for Maps with different values', () => {
65+
const mapA = new Map([['a', 1]]);
66+
const mapB = new Map([['a', 2]]);
67+
expect(shallow(mapA, mapB)).toBe(false);
68+
});
69+
70+
it('returns true for equal Sets', () => {
71+
const setA = new Set([1, 2, 3]);
72+
const setB = new Set([1, 2, 3]);
73+
expect(shallow(setA, setB)).toBe(true);
74+
});
75+
76+
it('returns false for Sets with different values', () => {
77+
const setA = new Set([1, 2]);
78+
const setB = new Set([1, 3]);
79+
expect(shallow(setA, setB)).toBe(false);
80+
});
81+
82+
it('returns false for different prototypes', () => {
83+
class A {
84+
x = 1;
85+
}
86+
class B {
87+
x = 1;
88+
}
89+
expect(shallow(new A(), new B())).toBe(false);
90+
});
91+
92+
it('returns false when comparing object to null', () => {
93+
expect(shallow({ a: 1 }, null)).toBe(false);
94+
expect(shallow(null, { a: 1 })).toBe(false);
95+
});
96+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "../../../../tsconfig",
3+
"compilerOptions": {
4+
"verbatimModuleSyntax": false
5+
}
6+
}

packages/brownie/src/index.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useCallback, useSyncExternalStore } from 'react';
1+
import { useCallback, useDebugValue } from 'react';
2+
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
23
import BrownieModule from './NativeBrownieModule';
34

45
/**
@@ -95,23 +96,129 @@ export function setState<K extends keyof BrownieStores>(
9596
}
9697
}
9798

99+
const identity = <T>(x: T): T => x;
100+
101+
const isIterable = (obj: object): obj is Iterable<unknown> =>
102+
Symbol.iterator in obj;
103+
104+
const hasIterableEntries = (
105+
value: Iterable<unknown>
106+
): value is Iterable<unknown> & { entries(): Iterable<[unknown, unknown]> } =>
107+
'entries' in value;
108+
109+
const compareEntries = (
110+
valueA: { entries(): Iterable<[unknown, unknown]> },
111+
valueB: { entries(): Iterable<[unknown, unknown]> }
112+
) => {
113+
const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries());
114+
const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries());
115+
if (mapA.size !== mapB.size) {
116+
return false;
117+
}
118+
for (const [key, value] of mapA) {
119+
if (!mapB.has(key) || !Object.is(value, mapB.get(key))) {
120+
return false;
121+
}
122+
}
123+
return true;
124+
};
125+
126+
const compareIterables = (
127+
valueA: Iterable<unknown>,
128+
valueB: Iterable<unknown>
129+
) => {
130+
const iteratorA = valueA[Symbol.iterator]();
131+
const iteratorB = valueB[Symbol.iterator]();
132+
let nextA = iteratorA.next();
133+
let nextB = iteratorB.next();
134+
while (!nextA.done && !nextB.done) {
135+
if (!Object.is(nextA.value, nextB.value)) {
136+
return false;
137+
}
138+
nextA = iteratorA.next();
139+
nextB = iteratorB.next();
140+
}
141+
return !!nextA.done && !!nextB.done;
142+
};
143+
144+
/**
145+
* Shallow equality comparison for use with useBrownieStore selector.
146+
* Compares objects by their top-level properties.
147+
*/
148+
export function shallow<T>(valueA: T, valueB: T): boolean {
149+
if (Object.is(valueA, valueB)) {
150+
return true;
151+
}
152+
if (
153+
typeof valueA !== 'object' ||
154+
valueA === null ||
155+
typeof valueB !== 'object' ||
156+
valueB === null
157+
) {
158+
return false;
159+
}
160+
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
161+
return false;
162+
}
163+
if (isIterable(valueA) && isIterable(valueB)) {
164+
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
165+
return compareEntries(valueA, valueB);
166+
}
167+
return compareIterables(valueA, valueB);
168+
}
169+
return compareEntries(
170+
{ entries: () => Object.entries(valueA) },
171+
{ entries: () => Object.entries(valueB) }
172+
);
173+
}
174+
98175
/**
99-
* React hook for subscribing to a native store.
176+
* React hook for subscribing to a native store with optional selector.
100177
* @param key Store key registered in StoreManager
101178
* @returns Tuple of [state, setState] for the store
102179
*/
103180
export function useBrownieStore<K extends keyof BrownieStores>(
104181
key: K
105-
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
182+
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void];
183+
184+
/**
185+
* React hook for subscribing to a native store with selector.
186+
* @param key Store key registered in StoreManager
187+
* @param selector Function to select a slice of state
188+
* @param equalityFn Optional equality function for comparing selected values
189+
* @returns Tuple of [selectedState, setState] for the store
190+
*/
191+
export function useBrownieStore<K extends keyof BrownieStores, U>(
192+
key: K,
193+
selector: (state: BrownieStores[K]) => U,
194+
equalityFn?: (a: U, b: U) => boolean
195+
): [U, (action: SetStateAction<BrownieStores[K]>) => void];
196+
197+
export function useBrownieStore<K extends keyof BrownieStores, U>(
198+
key: K,
199+
selector?: (state: BrownieStores[K]) => U,
200+
equalityFn?: (a: U, b: U) => boolean
201+
): [U | BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
106202
const sub = useCallback(
107203
(listener: () => void) => subscribe(key, listener),
108204
[key]
109205
);
110206
const snap = useCallback(() => getSnapshot(key), [key]);
111-
const state = useSyncExternalStore(sub, snap, snap);
207+
208+
const slice = useSyncExternalStoreWithSelector(
209+
sub,
210+
snap,
211+
snap,
212+
selector ?? (identity as (state: BrownieStores[K]) => U),
213+
equalityFn
214+
);
215+
216+
useDebugValue(slice);
217+
112218
const boundSetState = useCallback(
113219
(action: SetStateAction<BrownieStores[K]>) => setState(key, action),
114220
[key]
115221
);
116-
return [state, boundSetState];
222+
223+
return [slice, boundSetState];
117224
}

yarn.lock

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,7 @@ __metadata:
16071607
"@types/jest": "npm:^29.5.14"
16081608
"@types/node": "npm:^22.0.0"
16091609
"@types/react": "npm:^19.1.1"
1610+
"@types/use-sync-external-store": "npm:^0.0.6"
16101611
jest: "npm:^29.7.0"
16111612
quicktype-core: "npm:^23.0.170"
16121613
quicktype-typescript-input: "npm:^23.0.170"
@@ -1616,6 +1617,7 @@ __metadata:
16161617
ts-jest: "npm:^29.2.5"
16171618
ts-morph: "npm:^25.0.0"
16181619
typescript: "npm:5.8.3"
1620+
use-sync-external-store: "npm:^1.4.0"
16191621
peerDependencies:
16201622
react: "*"
16211623
react-native: "*"
@@ -4465,6 +4467,13 @@ __metadata:
44654467
languageName: node
44664468
linkType: hard
44674469

4470+
"@types/use-sync-external-store@npm:^0.0.6":
4471+
version: 0.0.6
4472+
resolution: "@types/use-sync-external-store@npm:0.0.6"
4473+
checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049
4474+
languageName: node
4475+
linkType: hard
4476+
44684477
"@types/yargs-parser@npm:*":
44694478
version: 21.0.3
44704479
resolution: "@types/yargs-parser@npm:21.0.3"
@@ -15299,7 +15308,7 @@ __metadata:
1529915308
languageName: node
1530015309
linkType: hard
1530115310

15302-
"use-sync-external-store@npm:^1.5.0":
15311+
"use-sync-external-store@npm:^1.4.0, use-sync-external-store@npm:^1.5.0":
1530315312
version: 1.6.0
1530415313
resolution: "use-sync-external-store@npm:1.6.0"
1530515314
peerDependencies:

0 commit comments

Comments
 (0)