Skip to content

Commit 24943c4

Browse files
annkiit2claudecosmos-explorer
authored
fix: scope local store cache by full user context, not keyId alone (#10)
* fix: scope local store cache by full user context, not keyId alone ReactNativeStore persisted its offline cache keyed only by user.keyId, so re-identifying the same anonymous key with different customizedProperties (e.g. a "region" attribute changing between app sessions) served a stale evaluation cached under the previous attribute set — the sync protocol treats the client as already up-to-date since the flag definition itself hasn't changed. Upstream fixed the same issue for LocalStorageStore/InMemoryStore via hashSerializeUser() (featbit-js-client-sdk#fbcd618). This uses plain serializeUser() instead, since crypto.subtle isn't reliably available on React Native's JS engine without an extra polyfill dependency. Also adds a `prepare` script so this installs correctly as a git dependency (dist/ is gitignored and only built at npm-publish time). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use hashSerializeUser instead of serializeUser * more fix * Update package-lock.json --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: cosmos-explorer <88151306+cosmos-explorer@users.noreply.github.com>
1 parent 2d292eb commit 24943c4

7 files changed

Lines changed: 157 additions & 19 deletions

File tree

‎examples/ExpoApp/package-lock.json‎

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎jest.config.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
module.exports = {
33
testEnvironment: 'node',
44
testMatch: ['**/__tests__/**/*.test.ts'],
5+
// @featbit/react-client-sdk ships ESM-only (no CJS build), which Jest's
6+
// default CJS module system can't require() directly. It publishes its
7+
// TypeScript source alongside dist though, so map the package to that —
8+
// ts-jest (already configured below) transforms it like any other .ts file.
9+
moduleNameMapper: {
10+
'^@featbit/react-client-sdk$': '<rootDir>/node_modules/@featbit/react-client-sdk/src/index.ts',
11+
},
12+
// The mapped path above still lives under node_modules, so it needs an
13+
// explicit carve-out from Jest's default "never transform node_modules" rule.
14+
transformIgnorePatterns: ['/node_modules/(?!@featbit/react-client-sdk)'],
515
transform: {
616
'^.+\\.tsx?$': [
717
'ts-jest',

‎package-lock.json‎

Lines changed: 21 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@featbit/react-native-sdk",
3-
"version": "4.3.0",
3+
"version": "4.3.3",
44
"description": "FeatBit client SDK for React Native",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -12,6 +12,7 @@
1212
"scripts": {
1313
"prebuild": "node -p \"'export const version = \\\"' + require('./package.json').version + '\\\"; export const name = \\\"' + require('./package.json').name + '\\\";'\" > src/version.ts",
1414
"build": "npx tsc",
15+
"prepare": "npm run build",
1516
"test": "jest",
1617
"test:e2e": "cross-env FEATBIT_E2E=1 jest --config jest.e2e.config.js --runInBand"
1718
},
@@ -48,8 +49,8 @@
4849
"ws": "^8.21.0"
4950
},
5051
"dependencies": {
51-
"@featbit/react-client-sdk": "4.3.0",
52-
"@react-native-async-storage/async-storage": "^1.23.1"
52+
"@featbit/react-client-sdk": "4.3.3",
53+
"@react-native-async-storage/async-storage": "^1.23.1 || ^2.0.0"
5354
},
5455
"overrides": {
5556
"js-yaml": "^4.2.0",

‎src/ReactNativeStore.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
IStoreDataStorage,
55
CurrentUserStorageKey,
66
StoreStorageKey,
7-
serializeUser, BaseStore
7+
serializeUser, BaseStore, hashSerializeUser
88
} from "@featbit/react-client-sdk";
99

1010
import AsyncStorage from './ConditionalAsyncStorage';
@@ -42,14 +42,26 @@ export default class ReactNativeStore extends BaseStore {
4242
}
4343
}
4444

45-
protected override async dumpStoreToStorage(): Promise<void> {
46-
const storageKey = `${StoreStorageKey}-${this._user.keyId}`;
45+
// Keyed by the hashed full serialized user (keyId + name + customizedProperties),
46+
// not just keyId. Two identify() calls with the same keyId but different
47+
// attributes (e.g. a custom "region" property changing) must not collide
48+
// on the same cached entry, or a stale evaluation from the previous
49+
// attribute set gets served after the flag's sync protocol treats the
50+
// client as already up to date (see upstream featbit-js-client-sdk#fbcd618,
51+
// which fixed the same issue for LocalStorageStore/InMemoryStore via a
52+
// hashSerializeUser()-keyed cache).
53+
private async storageKeyForCurrentUser(): Promise<string> {
54+
const userHash = await hashSerializeUser(this._user);
55+
return `${StoreStorageKey}-${userHash}`;
56+
}
4757

58+
protected override async dumpStoreToStorage(): Promise<void> {
59+
const storageKey = await this.storageKeyForCurrentUser();
4860
await this.set(storageKey, JSON.stringify(this.store));
4961
}
5062

5163
protected override async loadStoreFromStorage(): Promise<void> {
52-
const storageKey = `${StoreStorageKey}-${this._user.keyId}`;
64+
const storageKey = await this.storageKeyForCurrentUser();
5365
const dataStoreStr = await AsyncStorage.getItem(storageKey);
5466

5567
let store: IStoreDataStorage | null = null;
@@ -71,4 +83,4 @@ export default class ReactNativeStore extends BaseStore {
7183
};
7284
}
7385
}
74-
}
86+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
jest.mock('../ConditionalAsyncStorage', () => {
2+
const map = new Map<string, string>();
3+
return {
4+
__esModule: true,
5+
default: {
6+
getItem: jest.fn((key: string) => Promise.resolve(map.has(key) ? (map.get(key) as string) : null)),
7+
setItem: jest.fn((key: string, value: string) => {
8+
map.set(key, value);
9+
return Promise.resolve();
10+
}),
11+
removeItem: jest.fn((key: string) => {
12+
map.delete(key);
13+
return Promise.resolve();
14+
}),
15+
},
16+
};
17+
});
18+
19+
import ReactNativeStore from '../ReactNativeStore';
20+
21+
const FLAGS_KIND = { namespace: 'flags' };
22+
23+
function buildLogger() {
24+
return { debug: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn() };
25+
}
26+
27+
// Regression coverage for: same keyId re-identifying with different
28+
// customizedProperties (e.g. a "region" attribute changing between app
29+
// sessions) must not be served a stale evaluation cached under the
30+
// previous attribute set. Storage was previously keyed by keyId alone.
31+
describe('ReactNativeStore — storage key scoping', () => {
32+
it('does not serve a previous session\'s cached flags when the same keyId re-identifies with different customizedProperties', async () => {
33+
const store = new ReactNativeStore({ logger: buildLogger() } as any);
34+
35+
// Session 1: keyId 'user-1', region=US — cache a flag value.
36+
await store.identify({
37+
keyId: 'user-1',
38+
customizedProperties: [{ name: 'region', value: 'US' }],
39+
} as any);
40+
await store.upsert(FLAGS_KIND, {
41+
key: 'my-flag',
42+
version: 1,
43+
origin: 'Remote',
44+
variation: 'email-only',
45+
} as any);
46+
expect(store.get(FLAGS_KIND, 'my-flag')?.variation).toBe('email-only');
47+
48+
// Session 2: SAME keyId, DIFFERENT region — re-identify.
49+
await store.identify({
50+
keyId: 'user-1',
51+
customizedProperties: [{ name: 'region', value: 'IN' }],
52+
} as any);
53+
54+
// Must NOT still see the US-cached value.
55+
expect(store.get(FLAGS_KIND, 'my-flag')).toBeNull();
56+
expect(store.version).toBe(0);
57+
});
58+
59+
it('still serves cached flags across app restarts for the exact same user context', async () => {
60+
const storeA = new ReactNativeStore({ logger: buildLogger() } as any);
61+
await storeA.identify({
62+
keyId: 'user-2',
63+
customizedProperties: [{ name: 'region', value: 'US' }],
64+
} as any);
65+
await storeA.upsert(FLAGS_KIND, {
66+
key: 'my-flag',
67+
version: 1,
68+
origin: 'Remote',
69+
variation: 'email-only',
70+
} as any);
71+
72+
// Simulate a fresh app restart: new store instance, identical user context.
73+
const storeB = new ReactNativeStore({ logger: buildLogger() } as any);
74+
await storeB.identify({
75+
keyId: 'user-2',
76+
customizedProperties: [{ name: 'region', value: 'US' }],
77+
} as any);
78+
79+
expect(storeB.get(FLAGS_KIND, 'my-flag')?.variation).toBe('email-only');
80+
});
81+
82+
it('scopes storage independently per keyId even with identical customizedProperties', async () => {
83+
const storeA = new ReactNativeStore({ logger: buildLogger() } as any);
84+
await storeA.identify({
85+
keyId: 'user-a',
86+
customizedProperties: [{ name: 'region', value: 'US' }],
87+
} as any);
88+
await storeA.upsert(FLAGS_KIND, {
89+
key: 'my-flag',
90+
version: 1,
91+
origin: 'Remote',
92+
variation: 'email-only',
93+
} as any);
94+
95+
const storeB = new ReactNativeStore({ logger: buildLogger() } as any);
96+
await storeB.identify({
97+
keyId: 'user-b',
98+
customizedProperties: [{ name: 'region', value: 'US' }],
99+
} as any);
100+
101+
expect(storeB.get(FLAGS_KIND, 'my-flag')).toBeNull();
102+
});
103+
});

‎src/version.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const version = "4.3.0"; export const name = "@featbit/react-native-sdk";
1+
export const version = "4.3.3"; export const name = "@featbit/react-native-sdk";

0 commit comments

Comments
 (0)