Skip to content

Commit 36d0721

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

9 files changed

Lines changed: 276 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 '../shallow';
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: 40 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,57 @@ export function setState<K extends keyof BrownieStores>(
9596
}
9697
}
9798

99+
const identity = <T>(x: T): T => x;
100+
101+
export { shallow } from './shallow';
102+
98103
/**
99-
* React hook for subscribing to a native store.
104+
* React hook for subscribing to a native store with optional selector.
100105
* @param key Store key registered in StoreManager
101106
* @returns Tuple of [state, setState] for the store
102107
*/
103108
export function useBrownieStore<K extends keyof BrownieStores>(
104109
key: K
105-
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
110+
): [BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void];
111+
112+
/**
113+
* React hook for subscribing to a native store with selector.
114+
* @param key Store key registered in StoreManager
115+
* @param selector Function to select a slice of state
116+
* @param equalityFn Optional equality function for comparing selected values
117+
* @returns Tuple of [selectedState, setState] for the store
118+
*/
119+
export function useBrownieStore<K extends keyof BrownieStores, U>(
120+
key: K,
121+
selector: (state: BrownieStores[K]) => U,
122+
equalityFn?: (a: U, b: U) => boolean
123+
): [U, (action: SetStateAction<BrownieStores[K]>) => void];
124+
125+
export function useBrownieStore<K extends keyof BrownieStores, U>(
126+
key: K,
127+
selector?: (state: BrownieStores[K]) => U,
128+
equalityFn?: (a: U, b: U) => boolean
129+
): [U | BrownieStores[K], (action: SetStateAction<BrownieStores[K]>) => void] {
106130
const sub = useCallback(
107131
(listener: () => void) => subscribe(key, listener),
108132
[key]
109133
);
110134
const snap = useCallback(() => getSnapshot(key), [key]);
111-
const state = useSyncExternalStore(sub, snap, snap);
135+
136+
const slice = useSyncExternalStoreWithSelector(
137+
sub,
138+
snap,
139+
snap,
140+
selector ?? (identity as (state: BrownieStores[K]) => U),
141+
equalityFn
142+
);
143+
144+
useDebugValue(slice);
145+
112146
const boundSetState = useCallback(
113147
(action: SetStateAction<BrownieStores[K]>) => setState(key, action),
114148
[key]
115149
);
116-
return [state, boundSetState];
150+
151+
return [slice, boundSetState];
117152
}

packages/brownie/src/shallow.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
const isIterable = (obj: object): obj is Iterable<unknown> =>
2+
Symbol.iterator in obj;
3+
4+
const hasIterableEntries = (
5+
value: Iterable<unknown>
6+
): value is Iterable<unknown> & { entries(): Iterable<[unknown, unknown]> } =>
7+
'entries' in value;
8+
9+
const compareEntries = (
10+
valueA: { entries(): Iterable<[unknown, unknown]> },
11+
valueB: { entries(): Iterable<[unknown, unknown]> }
12+
) => {
13+
const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries());
14+
const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries());
15+
if (mapA.size !== mapB.size) {
16+
return false;
17+
}
18+
for (const [key, value] of mapA) {
19+
if (!mapB.has(key) || !Object.is(value, mapB.get(key))) {
20+
return false;
21+
}
22+
}
23+
return true;
24+
};
25+
26+
const compareIterables = (
27+
valueA: Iterable<unknown>,
28+
valueB: Iterable<unknown>
29+
) => {
30+
const iteratorA = valueA[Symbol.iterator]();
31+
const iteratorB = valueB[Symbol.iterator]();
32+
let nextA = iteratorA.next();
33+
let nextB = iteratorB.next();
34+
while (!nextA.done && !nextB.done) {
35+
if (!Object.is(nextA.value, nextB.value)) {
36+
return false;
37+
}
38+
nextA = iteratorA.next();
39+
nextB = iteratorB.next();
40+
}
41+
return !!nextA.done && !!nextB.done;
42+
};
43+
44+
/**
45+
* Shallow equality comparison for use with useBrownieStore selector.
46+
* Compares objects by their top-level properties.
47+
*/
48+
export function shallow<T>(valueA: T, valueB: T): boolean {
49+
if (Object.is(valueA, valueB)) {
50+
return true;
51+
}
52+
if (
53+
typeof valueA !== 'object' ||
54+
valueA === null ||
55+
typeof valueB !== 'object' ||
56+
valueB === null
57+
) {
58+
return false;
59+
}
60+
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
61+
return false;
62+
}
63+
if (isIterable(valueA) && isIterable(valueB)) {
64+
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
65+
return compareEntries(valueA, valueB);
66+
}
67+
return compareIterables(valueA, valueB);
68+
}
69+
return compareEntries(
70+
{ entries: () => Object.entries(valueA) },
71+
{ entries: () => Object.entries(valueB) }
72+
);
73+
}

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)