Skip to content

Commit fa8cefc

Browse files
committed
Add native typecheck and storage fallback
1 parent 3e9187b commit fa8cefc

File tree

10 files changed

+98
-52
lines changed

10 files changed

+98
-52
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build": "lerna run build --stream",
1414
"test:ci": "lerna run test:ci --stream",
1515
"test": "lerna run test --stream",
16+
"typecheck:native": "lerna run typecheck:native --stream",
1617
"format": "lerna run format --stream",
1718
"prettier": "lerna run prettier --stream",
1819
"prettier:fix": "lerna run prettier -- --write",

packages/browser-sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test:e2e": "yarn build && playwright test",
1919
"test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml && yarn test:e2e",
2020
"coverage": "yarn test --coverage",
21+
"typecheck:native": "tsc --project tsconfig.native.json",
2122
"lint": "eslint .",
2223
"lint:ci": "eslint --output-file eslint-report.json --format json .",
2324
"prettier": "prettier --check .",

packages/browser-sdk/src/feedback/ui/index.native.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
import { Position } from "../../ui/types";
2-
31
import { OpenFeedbackFormOptions } from "./types";
42

5-
export const DEFAULT_POSITION: Position = {
6-
type: "DIALOG",
7-
placement: "bottom-right",
8-
};
9-
103
export function openFeedbackForm(_options: OpenFeedbackFormOptions): void {
114
// React Native doesn't support the web feedback UI.
125
// Users should implement their own UI and use `feedback` or `useSendFeedback`.

packages/browser-sdk/src/flag/flagCache.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { StorageAdapter } from "../storage";
22

33
import { RawFlags } from "./flags";
44

5+
const DEFAULT_STORAGE_KEY = "__reflag_fetched_flags";
6+
57
interface cacheEntry {
68
expireAt: number;
79
staleAt: number;
@@ -48,25 +50,36 @@ export interface CacheResult {
4850
stale: boolean;
4951
}
5052

53+
const memoryStorage = (): StorageAdapter => {
54+
let value: string | null = null;
55+
return {
56+
getItem: async () => value,
57+
setItem: async (_key, nextValue) => {
58+
value = nextValue;
59+
},
60+
removeItem: async () => {
61+
value = null;
62+
},
63+
};
64+
};
65+
5166
export class FlagCache {
52-
private storage: StorageAdapter | null;
67+
private storage: StorageAdapter;
5368
private readonly storageKey: string;
5469
private readonly staleTimeMs: number;
5570
private readonly expireTimeMs: number;
5671

5772
constructor({
5873
storage,
59-
storageKey,
6074
staleTimeMs,
6175
expireTimeMs,
6276
}: {
6377
storage: StorageAdapter | null;
64-
storageKey: string;
6578
staleTimeMs: number;
6679
expireTimeMs: number;
6780
}) {
68-
this.storage = storage;
69-
this.storageKey = storageKey;
81+
this.storage = storage ?? memoryStorage();
82+
this.storageKey = DEFAULT_STORAGE_KEY;
7083
this.staleTimeMs = staleTimeMs;
7184
this.expireTimeMs = expireTimeMs;
7285
}
@@ -82,7 +95,6 @@ export class FlagCache {
8295
let cacheData: CacheData = {};
8396

8497
try {
85-
if (!this.storage) return cacheData;
8698
const cachedResponseRaw = await this.storage.getItem(this.storageKey);
8799
if (cachedResponseRaw) {
88100
cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {};
@@ -101,15 +113,13 @@ export class FlagCache {
101113
Object.entries(cacheData).filter(([_k, v]) => v.expireAt > Date.now()),
102114
);
103115

104-
if (!this.storage) return cacheData;
105116
await this.storage.setItem(this.storageKey, JSON.stringify(cacheData));
106117

107118
return cacheData;
108119
}
109120

110121
async get(key: string): Promise<CacheResult | undefined> {
111122
try {
112-
if (!this.storage) return;
113123
const cachedResponseRaw = await this.storage.getItem(this.storageKey);
114124
if (cachedResponseRaw) {
115125
const cachedResponse = validateCacheData(JSON.parse(cachedResponseRaw));

packages/browser-sdk/src/flag/flags.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,9 @@ export interface CheckEvent {
176176
missingContextFields?: string[];
177177
}
178178

179-
const localStorageFetchedFlagsKey = `__reflag_fetched_flags`;
180179
const storageOverridesKey = `__reflag_overrides`;
180+
const REFRESH_LIMIT_COUNT = 10;
181+
const REFRESH_LIMIT_WINDOW_MS = 5 * 60 * 1000;
181182

182183
export type FlagOverrides = Record<string, boolean | undefined>;
183184

@@ -205,6 +206,7 @@ export class FlagsClient {
205206
private flags: RawFlags = {};
206207
private fallbackFlags: FallbackFlags = {};
207208
private storage: StorageAdapter | null;
209+
private refreshEvents: number[] = [];
208210

209211
private config: Config = DEFAULT_FLAGS_CONFIG;
210212

@@ -232,7 +234,11 @@ export class FlagsClient {
232234
this.logger = loggerWithPrefix(logger, "[Flags]");
233235
this.rateLimiter =
234236
rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger);
235-
this.storage = resolveStorageAdapter(cache ? undefined : storage);
237+
const storageResolution = resolveStorageAdapter(
238+
cache ? undefined : storage,
239+
);
240+
this.storage = storageResolution.adapter;
241+
this.logger.debug(`storage adapter: ${storageResolution.type}`);
236242
this.cache =
237243
cache ??
238244
this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs);
@@ -416,6 +422,17 @@ export class FlagsClient {
416422
return;
417423
}
418424

425+
// rate limit refreshes to prevent accidental abuse
426+
const now = Date.now();
427+
this.refreshEvents = this.refreshEvents.filter(
428+
(timestamp) => now - timestamp < REFRESH_LIMIT_WINDOW_MS,
429+
);
430+
if (this.refreshEvents.length >= REFRESH_LIMIT_COUNT) {
431+
this.logger.warn("refresh rate limit exceeded");
432+
return;
433+
}
434+
this.refreshEvents.push(now);
435+
419436
const flags = await this.fetchFlags();
420437
if (flags) {
421438
this.setFetchedFlags(flags);
@@ -533,7 +550,6 @@ export class FlagsClient {
533550
private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) {
534551
return new FlagCache({
535552
storage: this.storage,
536-
storageKey: localStorageFetchedFlagsKey,
537553
staleTimeMs,
538554
expireTimeMs,
539555
});

packages/browser-sdk/src/storage.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ export type StorageAdapter = {
44
removeItem?(key: string): Promise<void>;
55
};
66

7+
export type StorageAdapterType =
8+
| "custom"
9+
| "localStorage"
10+
| "asyncStorage"
11+
| "none";
12+
713
function isLocalStorageUsable() {
814
return (
915
typeof localStorage !== "undefined" &&
@@ -14,17 +20,20 @@ function isLocalStorageUsable() {
1420

1521
export function resolveStorageAdapter(
1622
storage?: StorageAdapter,
17-
): StorageAdapter | null {
18-
if (storage) return storage;
23+
): { adapter: StorageAdapter | null; type: StorageAdapterType } {
24+
if (storage) return { adapter: storage, type: "custom" };
1925
if (isLocalStorageUsable()) {
2026
return {
21-
getItem: async (key) => localStorage.getItem(key),
22-
setItem: async (key, value) => {
23-
localStorage.setItem(key, value);
24-
},
25-
removeItem: async (key) => {
26-
localStorage.removeItem(key);
27+
adapter: {
28+
getItem: async (key) => localStorage.getItem(key),
29+
setItem: async (key, value) => {
30+
localStorage.setItem(key, value);
31+
},
32+
removeItem: async (key) => {
33+
localStorage.removeItem(key);
34+
},
2735
},
36+
type: "localStorage",
2837
};
2938
}
3039
// React Native: try AsyncStorage if available.
@@ -33,10 +42,10 @@ export function resolveStorageAdapter(
3342
const asyncStorage = require("@react-native-async-storage/async-storage");
3443
const adapter = asyncStorage?.default ?? asyncStorage;
3544
if (adapter?.getItem && adapter?.setItem) {
36-
return adapter as StorageAdapter;
45+
return { adapter: adapter as StorageAdapter, type: "asyncStorage" };
3746
}
3847
} catch {
3948
// ignore - not running in React Native or AsyncStorage not installed
4049
}
41-
return null;
50+
return { adapter: null, type: "none" };
4251
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"lib": [],
5+
"types": [],
6+
"skipLibCheck": true,
7+
"noEmit": true
8+
},
9+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.native.ts", "src/**/*.native.tsx"]
10+
}

packages/react-sdk/README.md

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,6 @@ Install via npm:
1414
npm i @reflag/react-sdk
1515
```
1616

17-
### React Native (Expo)
18-
19-
The React SDK works in React Native for flag evaluation and tracking. Web-only UI
20-
features (the toolbar and built-in feedback form) are not supported on React Native.
21-
Use your own UI and call `useSendFeedback` or `client.feedback` instead.
22-
23-
An Expo example app lives at `packages/react-sdk/dev/expo`.
24-
25-
AsyncStorage is automatically detected in React Native if you have
26-
`@react-native-async-storage/async-storage` installed. You can also pass it
27-
explicitly if you prefer:
28-
29-
```tsx
30-
import AsyncStorage from "@react-native-async-storage/async-storage";
31-
32-
<ReflagProvider
33-
publishableKey="{YOUR_PUBLISHABLE_KEY}"
34-
storage={AsyncStorage}
35-
context={{ user: { id: "user_123" } }}
36-
>
37-
{children}
38-
</ReflagProvider>;
39-
```
40-
4117
## Get started
4218

4319
### 1. Add the `ReflagProvider` context provider
@@ -176,6 +152,25 @@ To retrieve flags along with their targeting information, use `useFlag(key: stri
176152
Note that accessing `isEnabled` on the object returned by `useFlag()` automatically
177153
generates a `check` event.
178154

155+
## React Native
156+
157+
The React SDK works in React Native for flag evaluation and tracking.
158+
Web-only UI features (the toolbar and built-in feedback form) are not supported on React Native.
159+
Use your own UI and call `useSendFeedback` or `client.feedback` instead.
160+
161+
An Expo example app lives at `packages/react-sdk/dev/expo`.
162+
163+
AsyncStorage is automatically detected in React Native if you have
164+
`@react-native-async-storage/async-storage` installed.
165+
166+
Since the toolbar is not available in React Native, you can set local overrides
167+
programmatically:
168+
169+
```tsx
170+
const client = useClient();
171+
client.getFlag("my-flag").setIsEnabledOverride(true);
172+
```
173+
179174
## Remote config
180175

181176
Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it.

packages/react-sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"test:watch": "vitest",
1717
"test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml",
1818
"coverage": "yarn test --coverage",
19+
"typecheck:native": "tsc --project tsconfig.native.json",
1920
"lint": "eslint .",
2021
"lint:ci": "eslint --output-file eslint-report.json --format json .",
2122
"prettier": "prettier --check .",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"lib": [],
5+
"types": [],
6+
"skipLibCheck": true,
7+
"noEmit": true
8+
},
9+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.native.ts", "src/**/*.native.tsx"]
10+
}

0 commit comments

Comments
 (0)