Skip to content

Commit

Permalink
Use local storage to serve gate requests while init pending (#102)
Browse files Browse the repository at this point in the history
* Use local storage to serve gate requests while the network request is pending
  • Loading branch information
tore-statsig authored Oct 19, 2021
1 parent b177efd commit 88dad64
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 11 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "statsig-js",
"version": "4.4.0-beta.1",
"version": "4.4.0",
"description": "Statsig JavaScript client SDK for single user environments.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
25 changes: 16 additions & 9 deletions src/StatsigClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @throws Error if initialize() is not called first, or gateName is not a string
*/
public checkGate(gateName: string): boolean {
if (!this.ready) {
throw new Error('Call and wait for initialize() to finish first.');
}
this.ensureStoreLoaded();
if (typeof gateName !== 'string' || gateName.length === 0) {
throw new Error('Must pass a valid string as the gateName.');
}
Expand All @@ -214,9 +212,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @throws Error if initialize() is not called first, or configName is not a string
*/
public getConfig(configName: string): DynamicConfig {
if (!this.ready) {
throw new Error('Call and wait for initialize() to finish first.');
}
this.ensureStoreLoaded();
if (typeof configName !== 'string' || configName.length === 0) {
throw new Error('Must pass a valid string as the configName.');
}
Expand All @@ -234,9 +230,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
experimentName: string,
keepDeviceValue: boolean = false,
): DynamicConfig {
if (!this.ready) {
throw new Error('Call and wait for initialize() to finish first.');
}
this.ensureStoreLoaded();
if (typeof experimentName !== 'string' || experimentName.length === 0) {
throw new Error('Must pass a valid string as the experimentName.');
}
Expand Down Expand Up @@ -326,6 +320,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @param value the value to override the gate to
*/
public overrideGate(gateName: string, value: boolean): void {
this.ensureStoreLoaded();
this.store.overrideGate(gateName, value);
}

Expand All @@ -335,6 +330,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @param value the json value to override the config to
*/
public overrideConfig(configName: string, value: Record<string, any>): void {
this.ensureStoreLoaded();
this.store.overrideConfig(configName, value);
}

Expand All @@ -343,6 +339,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @param gateName
*/
public removeGateOverride(gateName?: string): void {
this.ensureStoreLoaded();
this.store.removeGateOverride(gateName);
}

Expand All @@ -351,6 +348,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @param configName
*/
public removeConfigOverride(configName?: string): void {
this.ensureStoreLoaded();
this.store.removeConfigOverride(configName);
}

Expand All @@ -360,6 +358,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @param gateName
*/
public removeOverride(gateName?: string): void {
this.ensureStoreLoaded();
this.store.removeGateOverride(gateName);
}

Expand All @@ -368,13 +367,15 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
* @returns Gate overrides
*/
public getOverrides(): Record<string, any> {
this.ensureStoreLoaded();
return this.store.getAllOverrides().gates;
}

/**
* @returns The local gate and config overrides
*/
public getAllOverrides(): StatsigOverrides {
this.ensureStoreLoaded();
return this.store.getAllOverrides();
}

Expand Down Expand Up @@ -482,4 +483,10 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
}
return user;
}

private ensureStoreLoaded(): void {
if (!this.store.isLoaded()) {
throw new Error('Call and wait for initialize() to finish first.');
}
}
}
9 changes: 8 additions & 1 deletion src/StatsigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default class StatsigStore {
configs: {},
};

private loaded: boolean;
private values: APIInitializeData;
private stickyUserExperiments: StickyUserExperiments;
private stickyDeviceExperiments: Record<string, APIDynamicConfig>;
Expand All @@ -65,6 +66,7 @@ export default class StatsigStore {
experiments: {},
};
this.stickyDeviceExperiments = {};
this.loaded = false;
}

public async loadFromAsyncStorage(): Promise<void> {
Expand All @@ -73,6 +75,7 @@ export default class StatsigStore {
await StatsigAsyncStorage.getItemAsync(STICKY_USER_EXPERIMENTS_KEY),
await StatsigAsyncStorage.getItemAsync(STICKY_DEVICE_EXPERIMENTS_KEY),
);
this.loaded = true;
}

public loadFromLocalStorage(): void {
Expand All @@ -81,6 +84,11 @@ export default class StatsigStore {
StatsigLocalStorage.getItem(STICKY_USER_EXPERIMENTS_KEY),
StatsigLocalStorage.getItem(STICKY_DEVICE_EXPERIMENTS_KEY),
);
this.loaded = true;
}

public isLoaded(): boolean {
return this.loaded;
}

private parseCachedValues(
Expand Down Expand Up @@ -148,7 +156,6 @@ export default class StatsigStore {

public async save(jsonConfigs: Record<string, any>): Promise<void> {
this.values = jsonConfigs as APIInitializeData;

if (StatsigAsyncStorage.asyncStorage) {
await StatsigAsyncStorage.setItemAsync(
INTERNAL_STORE_KEY,
Expand Down
122 changes: 122 additions & 0 deletions src/__tests__/StatsigClientCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @jest-environment jsdom
*/

import StatsigClient from '../StatsigClient';
import StatsigAsyncStorage from '../utils/StatsigAsyncLocalStorage';
import StatsigStore from '../StatsigStore';
describe('Verify behavior of StatsigClient', () => {
const sdkKey = 'client-clienttestkey';
jest.useFakeTimers();

class LocalStorageMock {
private store: Record<string, string>;
constructor() {
this.store = {
STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V3: JSON.stringify({
feature_gates: {
'AoZS0F06Ub+W2ONx+94rPTS7MRxuxa+GnXro5Q1uaGY=': {
value: true,
rule_id: 'cache',
},
},
dynamic_configs: {
'RMv0YJlLOBe7cY7HgZ3Jox34R0Wrk7jLv3DZyBETA7I=': {
value: {
param: 'cache',
},
rule_id: 'cache',
},
},
}),
};
}

clear() {
this.store = {};
}

getItem(key: string) {
return this.store[key] || null;
}

setItem(key: string, value: string) {
this.store[key] = String(value);
}

removeItem(key: string) {
delete this.store[key];
}
}
const localStorage = new LocalStorageMock();
// @ts-ignore
Object.defineProperty(window, 'localStorage', {
value: localStorage,
});

// @ts-ignore
global.fetch = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
// @ts-ignore
resolve({
ok: true,
json: () =>
Promise.resolve({
feature_gates: {
'AoZS0F06Ub+W2ONx+94rPTS7MRxuxa+GnXro5Q1uaGY=': {
value: false,
rule_id: 'network',
},
},
dynamic_configs: {
'RMv0YJlLOBe7cY7HgZ3Jox34R0Wrk7jLv3DZyBETA7I=': {
value: {
param: 'network',
},
rule_id: 'network',
},
},
}),
});
}, 1000);
});
});

test('Test constructor', () => {
expect.assertions(4);
const client = new StatsigClient();
expect(() => {
client.checkGate('gate');
}).toThrowError('Call and wait for initialize() to finish first.');
expect(() => {
client.getConfig('config');
}).toThrowError('Call and wait for initialize() to finish first.');
expect(() => {
client.getExperiment('experiment');
}).toThrowError('Call and wait for initialize() to finish first.');
expect(() => {
client.logEvent('event');
}).toThrowError('Must initialize() before logging events.');
});

test('cache used before initialize resolves, then network result used', async () => {
expect.assertions(4);
const statsig = new StatsigClient();
const init = statsig.initializeAsync(sdkKey, { userID: '123' });

// test_gate is true from the cache
expect(statsig.checkGate('test_gate')).toBe(true);
expect(
statsig.getConfig('test_config').get<string>('param', 'default'),
).toEqual('cache');

jest.advanceTimersByTime(2000);
await init;

expect(statsig.checkGate('test_gate')).toBe(false);
expect(
statsig.getConfig('test_config').get<string>('param', 'default'),
).toEqual('network');
});
});

0 comments on commit 88dad64

Please sign in to comment.