From 6d39abfa0f8a3b6d951513c61b150e1eff5a2f8f Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 4 Feb 2026 14:31:56 +0100 Subject: [PATCH 01/15] Add refresh control and RN fixes --- .gitignore | 1 + package.json | 6 + packages/browser-sdk/package.json | 4 +- .../scripts/create-native-entry.cjs | 14 + packages/browser-sdk/src/client.ts | 23 +- packages/browser-sdk/src/config.ts | 3 +- .../src/feedback/ui/index.native.ts | 17 + packages/browser-sdk/src/flag/flagCache.ts | 28 +- packages/browser-sdk/src/flag/flags.ts | 78 +- packages/browser-sdk/src/index.native.ts | 1 + packages/browser-sdk/src/index.ts | 1 + packages/browser-sdk/src/storage.ts | 42 + .../browser-sdk/src/toolbar/index.native.ts | 14 + packages/browser-sdk/src/ui/types.ts | 4 +- packages/browser-sdk/src/utils/eventTarget.ts | 61 + packages/browser-sdk/test/flagCache.test.ts | 21 +- packages/react-sdk/README.md | 24 + packages/react-sdk/dev/expo/App.tsx | 91 + packages/react-sdk/dev/expo/README.md | 37 + packages/react-sdk/dev/expo/app.json | 9 + packages/react-sdk/dev/expo/babel.config.js | 6 + packages/react-sdk/dev/expo/index.js | 5 + packages/react-sdk/dev/expo/metro.config.js | 3 + packages/react-sdk/dev/expo/package.json | 24 + packages/react-sdk/dev/expo/tsconfig.json | 6 + packages/react-sdk/package.json | 17 +- .../react-sdk/scripts/create-native-entry.cjs | 14 + packages/react-sdk/src/index.native.tsx | 1 + packages/react-sdk/src/index.tsx | 9 +- yarn.lock | 4835 ++++++++++++++++- 30 files changed, 5086 insertions(+), 313 deletions(-) create mode 100644 packages/browser-sdk/scripts/create-native-entry.cjs create mode 100644 packages/browser-sdk/src/feedback/ui/index.native.ts create mode 100644 packages/browser-sdk/src/index.native.ts create mode 100644 packages/browser-sdk/src/storage.ts create mode 100644 packages/browser-sdk/src/toolbar/index.native.ts create mode 100644 packages/browser-sdk/src/utils/eventTarget.ts create mode 100644 packages/react-sdk/dev/expo/App.tsx create mode 100644 packages/react-sdk/dev/expo/README.md create mode 100644 packages/react-sdk/dev/expo/app.json create mode 100644 packages/react-sdk/dev/expo/babel.config.js create mode 100644 packages/react-sdk/dev/expo/index.js create mode 100644 packages/react-sdk/dev/expo/metro.config.js create mode 100644 packages/react-sdk/dev/expo/package.json create mode 100644 packages/react-sdk/dev/expo/tsconfig.json create mode 100644 packages/react-sdk/scripts/create-native-entry.cjs create mode 100644 packages/react-sdk/src/index.native.tsx diff --git a/.gitignore b/.gitignore index abc48840..a754d878 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist-ssr !.vscode/settings.json .idea .DS_Store +.expo/ *.suo *.ntvs* *.njsproj diff --git a/package.json b/package.json index a4ba6605..f4b410af 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,16 @@ "devDependencies": { "lerna": "^8.1.3", "prettier": "^3.5.2", + "react": "19.1.0", + "react-dom": "19.1.0", "typedoc": "0.27.6", "typedoc-plugin-frontmatter": "^1.1.2", "typedoc-plugin-markdown": "^4.4.2", "typedoc-plugin-mdn-links": "^4.0.7", "typescript": "^5.7.3" + }, + "resolutions": { + "react": "19.1.0", + "react-dom": "19.1.0" } } diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index 22d10221..07c0fa59 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -12,7 +12,7 @@ }, "scripts": { "dev": "vite", - "build": "tsc --project tsconfig.build.json && vite build", + "build": "tsc --project tsconfig.build.json && vite build && node scripts/create-native-entry.cjs", "test": "vitest run", "test:watch": "vitest", "test:e2e": "yarn build && playwright test", @@ -29,8 +29,10 @@ ], "main": "./dist/reflag-browser-sdk.umd.js", "types": "./dist/types/src/index.d.ts", + "react-native": "./dist/index.native.js", "exports": { ".": { + "react-native": "./dist/index.native.js", "import": "./dist/reflag-browser-sdk.mjs", "require": "./dist/reflag-browser-sdk.umd.js", "types": "./dist/types/src/index.d.ts" diff --git a/packages/browser-sdk/scripts/create-native-entry.cjs b/packages/browser-sdk/scripts/create-native-entry.cjs new file mode 100644 index 00000000..8e7f78dd --- /dev/null +++ b/packages/browser-sdk/scripts/create-native-entry.cjs @@ -0,0 +1,14 @@ +const fs = require("fs"); +const path = require("path"); + +const distDir = path.join(__dirname, "..", "dist"); +const outFile = path.join(distDir, "index.native.js"); + +if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); +} + +const contents = `"use strict";\nmodule.exports = require("./reflag-browser-sdk.umd.js");\n`; +fs.writeFileSync(outFile, contents); + +console.log("Wrote", outFile); diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 08d79122..56f050fc 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -26,10 +26,14 @@ import { ReflagContext, ReflagDeprecatedContext } from "./context"; import { HookArgs, HooksManager, State } from "./hooksManager"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; +import { StorageAdapter } from "./storage"; import { showToolbarToggle } from "./toolbar"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas +const isReactNative = + typeof navigator !== "undefined" && + /ReactNative/i.test(navigator.userAgent ?? ""); /** * (Internal) User context. @@ -297,6 +301,12 @@ export type InitOptions = ReflagDeprecatedContext & { * Pre-fetched flags to be used instead of fetching them from the server. */ bootstrappedFlags?: RawFlags; + + /** + * Optional storage adapter used for caching flags and overrides. + * Useful for React Native (AsyncStorage). + */ + storage?: StorageAdapter; }; const defaultConfig: Config = { @@ -366,7 +376,9 @@ export interface Flag { function shouldShowToolbar(opts: InitOptions) { const toolbarOpts = opts.toolbar; - if (typeof window === "undefined") return false; + if (typeof window === "undefined" || typeof window.location === "undefined") { + return false; + } if (typeof toolbarOpts === "boolean") return toolbarOpts; if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show; return window.location.hostname === "localhost"; @@ -441,6 +453,7 @@ export class ReflagClient { timeoutMs: opts.timeoutMs, fallbackFlags: opts.fallbackFlags, offline: this.config.offline, + storage: opts.storage, }, ); @@ -448,6 +461,7 @@ export class ReflagClient { !this.config.offline && this.context?.user && !isNode && // do not prompt on server-side + !isReactNative && // disable SSE-based auto feedback in React Native opts?.feedback?.enableAutoFeedback !== false // default to on ) { if (isMobile) { @@ -869,6 +883,13 @@ export class ReflagClient { return this.flagsClient.getFlags(); } + /** + * Force refresh flags from the API, bypassing cache. + */ + refresh() { + return this.flagsClient.refreshFlags(); + } + /** * @deprecated Use `getFlag` instead. */ diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/config.ts index 62237641..2a165653 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -10,4 +10,5 @@ export const SDK_VERSION = `browser-sdk/${version}`; export const FLAG_EVENTS_PER_MIN = 1; export const FLAGS_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days -export const IS_SERVER = typeof window === "undefined"; +export const IS_SERVER = + typeof window === "undefined" || typeof document === "undefined"; diff --git a/packages/browser-sdk/src/feedback/ui/index.native.ts b/packages/browser-sdk/src/feedback/ui/index.native.ts new file mode 100644 index 00000000..d8f22efc --- /dev/null +++ b/packages/browser-sdk/src/feedback/ui/index.native.ts @@ -0,0 +1,17 @@ +import { Position } from "../../ui/types"; + +import { OpenFeedbackFormOptions } from "./types"; + +export const DEFAULT_POSITION: Position = { + type: "DIALOG", + placement: "bottom-right", +}; + +export function openFeedbackForm(_options: OpenFeedbackFormOptions): void { + // React Native doesn't support the web feedback UI. + // Users should implement their own UI and use `feedback` or `useSendFeedback`. + console.warn( + "[Reflag] Feedback UI is not supported in React Native. " + + "Use `feedback` or `useSendFeedback` with a custom UI instead.", + ); +} diff --git a/packages/browser-sdk/src/flag/flagCache.ts b/packages/browser-sdk/src/flag/flagCache.ts index 18be2414..37baa709 100644 --- a/packages/browser-sdk/src/flag/flagCache.ts +++ b/packages/browser-sdk/src/flag/flagCache.ts @@ -1,9 +1,6 @@ -import { RawFlags } from "./flags"; +import { StorageAdapter } from "../storage"; -interface StorageItem { - get(): string | null; - set(value: string): void; -} +import { RawFlags } from "./flags"; interface cacheEntry { expireAt: number; @@ -52,25 +49,29 @@ export interface CacheResult { } export class FlagCache { - private storage: StorageItem; + private storage: StorageAdapter | null; + private readonly storageKey: string; private readonly staleTimeMs: number; private readonly expireTimeMs: number; constructor({ storage, + storageKey, staleTimeMs, expireTimeMs, }: { - storage: StorageItem; + storage: StorageAdapter | null; + storageKey: string; staleTimeMs: number; expireTimeMs: number; }) { this.storage = storage; + this.storageKey = storageKey; this.staleTimeMs = staleTimeMs; this.expireTimeMs = expireTimeMs; } - set( + async set( key: string, { flags, @@ -81,7 +82,8 @@ export class FlagCache { let cacheData: CacheData = {}; try { - const cachedResponseRaw = this.storage.get(); + if (!this.storage) return cacheData; + const cachedResponseRaw = await this.storage.getItem(this.storageKey); if (cachedResponseRaw) { cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {}; } @@ -99,14 +101,16 @@ export class FlagCache { Object.entries(cacheData).filter(([_k, v]) => v.expireAt > Date.now()), ); - this.storage.set(JSON.stringify(cacheData)); + if (!this.storage) return cacheData; + await this.storage.setItem(this.storageKey, JSON.stringify(cacheData)); return cacheData; } - get(key: string): CacheResult | undefined { + async get(key: string): Promise { try { - const cachedResponseRaw = this.storage.get(); + if (!this.storage) return; + const cachedResponseRaw = await this.storage.getItem(this.storageKey); if (cachedResponseRaw) { const cachedResponse = validateCacheData(JSON.parse(cachedResponseRaw)); if ( diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index bffb3147..a2064ce2 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -1,10 +1,12 @@ import { deepEqual } from "fast-equals"; -import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS, IS_SERVER } from "../config"; +import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS } from "../config"; import { ReflagContext } from "../context"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; +import { resolveStorageAdapter, StorageAdapter } from "../storage"; +import { createEventTarget } from "../utils/eventTarget"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache"; @@ -184,6 +186,7 @@ type FlagsClientOptions = Partial & { fallbackFlags?: Record | string[]; cache?: FlagCache; rateLimiter?: RateLimiter; + storage?: StorageAdapter; }; /** @@ -201,10 +204,11 @@ export class FlagsClient { private flagOverrides: FlagOverrides = {}; private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; + private storage: StorageAdapter | null; private config: Config = DEFAULT_FLAGS_CONFIG; - private eventTarget = new EventTarget(); + private eventTarget = createEventTarget(); private abortController: AbortController = new AbortController(); constructor( @@ -216,6 +220,7 @@ export class FlagsClient { cache, rateLimiter, fallbackFlags, + storage, ...config }: FlagsClientOptions = {}, ) { @@ -227,6 +232,7 @@ export class FlagsClient { this.logger = loggerWithPrefix(logger, "[Flags]"); this.rateLimiter = rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger); + this.storage = resolveStorageAdapter(cache ? undefined : storage); this.cache = cache ?? this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs); @@ -236,8 +242,6 @@ export class FlagsClient { this.bootstrapped = true; this.setFetchedFlags(bootstrappedFlags, false); } - - this.flagOverrides = this.getOverridesCache(); } async initialize() { @@ -247,6 +251,11 @@ export class FlagsClient { } this.initialized = true; + const cachedOverrides = await this.getOverridesCache(); + if (Object.keys(cachedOverrides).length > 0) { + this.flagOverrides = { ...cachedOverrides, ...this.flagOverrides }; + } + if (!this.bootstrapped) { this.setFetchedFlags((await this.maybeFetchFlags()) || {}); } @@ -300,7 +309,7 @@ export class FlagsClient { } else { this.flagOverrides[key] = isEnabled; } - this.setOverridesCache(this.flagOverrides); + void this.setOverridesCache(this.flagOverrides); this.updateFlags(); } @@ -399,30 +408,45 @@ export class FlagsClient { } } - private setOverridesCache(overrides: FlagOverrides) { - if (IS_SERVER) return; + /** + * Force refresh flags from the API, bypassing cache. + */ + async refreshFlags(): Promise { + if (this.config.offline) { + return; + } + + const flags = await this.fetchFlags(); + if (flags) { + this.setFetchedFlags(flags); + } + return flags; + } + + private async setOverridesCache(overrides: FlagOverrides) { try { - localStorage.setItem(storageOverridesKey, JSON.stringify(overrides)); + if (!this.storage) return; + await this.storage.setItem( + storageOverridesKey, + JSON.stringify(overrides), + ); } catch (error) { this.logger.warn( - "storing flag overrides in localStorage failed, overrides won't persist", + "storing flag overrides failed, overrides won't persist", error, ); } } - private getOverridesCache(): FlagOverrides { - if (IS_SERVER) return {}; + private async getOverridesCache(): Promise { try { - const overridesStored = localStorage.getItem(storageOverridesKey); + if (!this.storage) return {}; + const overridesStored = await this.storage.getItem(storageOverridesKey); const overrides = JSON.parse(overridesStored || "{}"); if (!isObject(overrides)) throw new Error("invalid overrides"); return overrides; } catch (error) { - this.logger.warn( - "getting flag overrides from localStorage failed", - error, - ); + this.logger.warn("getting flag overrides failed", error); return {}; } } @@ -433,7 +457,7 @@ export class FlagsClient { } const cacheKey = this.fetchParams().toString(); - const cachedItem = this.cache.get(cacheKey); + const cachedItem = await this.cache.get(cacheKey); if (cachedItem) { if (!cachedItem.stale) return cachedItem.flags; @@ -442,10 +466,10 @@ export class FlagsClient { if (this.config.staleWhileRevalidate) { // re-fetch in the background, but immediately return last successful value this.fetchFlags() - .then((flags) => { + .then(async (flags) => { if (!flags) return; - this.cache.set(cacheKey, { + await this.cache.set(cacheKey, { flags, }); this.setFetchedFlags(flags); @@ -462,7 +486,7 @@ export class FlagsClient { const fetchedFlags = await this.fetchFlags(); if (fetchedFlags) { - this.cache.set(cacheKey, { + await this.cache.set(cacheKey, { flags: fetchedFlags, }); return fetchedFlags; @@ -503,21 +527,13 @@ export class FlagsClient { } private triggerFlagsUpdated() { - this.eventTarget.dispatchEvent(new Event("flagsUpdated")); + this.eventTarget.dispatchEvent({ type: "flagsUpdated" }); } private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) { return new FlagCache({ - storage: !IS_SERVER - ? { - get: () => localStorage.getItem(localStorageFetchedFlagsKey), - set: (value) => - localStorage.setItem(localStorageFetchedFlagsKey, value), - } - : { - get: () => null, - set: () => void 0, - }, + storage: this.storage, + storageKey: localStorageFetchedFlagsKey, staleTimeMs, expireTimeMs, }); diff --git a/packages/browser-sdk/src/index.native.ts b/packages/browser-sdk/src/index.native.ts new file mode 100644 index 00000000..6d1e0ee8 --- /dev/null +++ b/packages/browser-sdk/src/index.native.ts @@ -0,0 +1 @@ +export * from "./index"; diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index c6071f57..5a1ba1ff 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -42,6 +42,7 @@ export type { } from "./flag/flags"; export type { HookArgs, State, TrackEvent } from "./hooksManager"; export type { Logger } from "./logger"; +export type { StorageAdapter } from "./storage"; export { feedbackContainerId, propagatedEvents } from "./ui/constants"; export type { DialogPlacement, diff --git a/packages/browser-sdk/src/storage.ts b/packages/browser-sdk/src/storage.ts new file mode 100644 index 00000000..247ebd60 --- /dev/null +++ b/packages/browser-sdk/src/storage.ts @@ -0,0 +1,42 @@ +export type StorageAdapter = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem?(key: string): Promise; +}; + +function isLocalStorageUsable() { + return ( + typeof localStorage !== "undefined" && + "setItem" in localStorage && + "removeItem" in localStorage + ); +} + +export function resolveStorageAdapter( + storage?: StorageAdapter, +): StorageAdapter | null { + if (storage) return storage; + if (isLocalStorageUsable()) { + return { + getItem: async (key) => localStorage.getItem(key), + setItem: async (key, value) => { + localStorage.setItem(key, value); + }, + removeItem: async (key) => { + localStorage.removeItem(key); + }, + }; + } + // React Native: try AsyncStorage if available. + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const asyncStorage = require("@react-native-async-storage/async-storage"); + const adapter = asyncStorage?.default ?? asyncStorage; + if (adapter?.getItem && adapter?.setItem) { + return adapter as StorageAdapter; + } + } catch { + // ignore - not running in React Native or AsyncStorage not installed + } + return null; +} diff --git a/packages/browser-sdk/src/toolbar/index.native.ts b/packages/browser-sdk/src/toolbar/index.native.ts new file mode 100644 index 00000000..93e56326 --- /dev/null +++ b/packages/browser-sdk/src/toolbar/index.native.ts @@ -0,0 +1,14 @@ +import { ReflagClient } from "../client"; +import { ToolbarPosition } from "../ui/types"; + +type ShowToolbarToggleOptions = { + reflagClient: ReflagClient; + position?: ToolbarPosition; +}; + +export const DEFAULT_PLACEMENT = "bottom-right" as const; + +export function showToolbarToggle(_options: ShowToolbarToggleOptions) { + // React Native doesn't support the Reflag toolbar UI. + console.warn("[Reflag] Toolbar UI is not supported in React Native."); +} diff --git a/packages/browser-sdk/src/ui/types.ts b/packages/browser-sdk/src/ui/types.ts index 80512fe7..60c6b571 100644 --- a/packages/browser-sdk/src/ui/types.ts +++ b/packages/browser-sdk/src/ui/types.ts @@ -28,7 +28,9 @@ export type Position = } | { type: "POPOVER"; - anchor: HTMLElement | null; + // In browsers this is expected to be an HTMLElement, but we keep it + // loosely typed to avoid React Native type issues. + anchor: any | null; placement?: PopoverPlacement; }; diff --git a/packages/browser-sdk/src/utils/eventTarget.ts b/packages/browser-sdk/src/utils/eventTarget.ts new file mode 100644 index 00000000..ca293f8a --- /dev/null +++ b/packages/browser-sdk/src/utils/eventTarget.ts @@ -0,0 +1,61 @@ +export type EventListener = () => void; + +export type EventTargetLike = { + addEventListener( + type: string, + listener: EventListener, + options?: boolean | { signal?: AbortSignal | null }, + ): void; + removeEventListener(type: string, listener: EventListener): void; + dispatchEvent(event: { type: string }): void; +}; + +class SimpleEventTarget implements EventTargetLike { + private listeners = new Map>(); + + addEventListener( + type: string, + listener: EventListener, + options?: boolean | { signal?: AbortSignal | null }, + ) { + let bucket = this.listeners.get(type); + if (!bucket) { + bucket = new Set(); + this.listeners.set(type, bucket); + } + + bucket.add(listener); + + if (options && typeof options === "object" && options.signal) { + const signal = options.signal; + if (signal.aborted) { + bucket.delete(listener); + return; + } + + const onAbort = () => { + bucket?.delete(listener); + signal.removeEventListener?.("abort", onAbort); + }; + + signal.addEventListener?.("abort", onAbort, { once: true }); + } + } + + removeEventListener(type: string, listener: EventListener) { + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: { type: string }) { + const bucket = this.listeners.get(event.type); + if (!bucket) return; + + for (const listener of bucket) { + listener(); + } + } +} + +export function createEventTarget(): EventTargetLike { + return new SimpleEventTarget(); +} diff --git a/packages/browser-sdk/test/flagCache.test.ts b/packages/browser-sdk/test/flagCache.test.ts index 27116f35..e9fb3d36 100644 --- a/packages/browser-sdk/test/flagCache.test.ts +++ b/packages/browser-sdk/test/flagCache.test.ts @@ -30,9 +30,12 @@ export function newCache(): { return { cache: new FlagCache({ storage: { - get: () => cacheItem[0], - set: (value) => (cacheItem[0] = value), + getItem: async () => cacheItem[0], + setItem: async (_key, value) => { + cacheItem[0] = value; + }, }, + storageKey: "flags-cache", staleTimeMs: TEST_STALE_MS, expireTimeMs: TEST_EXPIRE_MS, }), @@ -48,8 +51,8 @@ describe("cache", () => { test("caches items", async () => { const { cache } = newCache(); - cache.set("key", { flags }); - expect(cache.get("key")).toEqual({ + await cache.set("key", { flags }); + await expect(cache.get("key")).resolves.toEqual({ stale: false, flags, } satisfies CacheResult); @@ -58,28 +61,28 @@ describe("cache", () => { test("sets stale", async () => { const { cache } = newCache(); - cache.set("key", { flags }); + await cache.set("key", { flags }); vitest.advanceTimersByTime(TEST_STALE_MS + 1); - const cacheItem = cache.get("key"); + const cacheItem = await cache.get("key"); expect(cacheItem?.stale).toBe(true); }); test("expires on set", async () => { const { cache, cacheItem } = newCache(); - cache.set("first key", { + await cache.set("first key", { flags, }); expect(cacheItem[0]).not.toBeNull(); vitest.advanceTimersByTime(TEST_EXPIRE_MS + 1); - cache.set("other key", { + await cache.set("other key", { flags, }); - const item = cache.get("key"); + const item = await cache.get("key"); expect(item).toBeUndefined(); expect(cacheItem[0]).not.toContain("first key"); // should have been removed }); diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index d28f6ab1..7c8bfb73 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -14,6 +14,30 @@ Install via npm: npm i @reflag/react-sdk ``` +### React Native (Expo) + +The React SDK works in React Native for flag evaluation and tracking. Web-only UI +features (the toolbar and built-in feedback form) are not supported on React Native. +Use your own UI and call `useSendFeedback` or `client.feedback` instead. + +An Expo example app lives at `packages/react-sdk/dev/expo`. + +AsyncStorage is automatically detected in React Native if you have +`@react-native-async-storage/async-storage` installed. You can also pass it +explicitly if you prefer: + +```tsx +import AsyncStorage from "@react-native-async-storage/async-storage"; + + + {children} +; +``` + ## Get started ### 1. Add the `ReflagProvider` context provider diff --git a/packages/react-sdk/dev/expo/App.tsx b/packages/react-sdk/dev/expo/App.tsx new file mode 100644 index 00000000..1973d4c1 --- /dev/null +++ b/packages/react-sdk/dev/expo/App.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { Button, SafeAreaView, StyleSheet, Text, View } from "react-native"; + +import { + ReflagProvider, + useFlag, + useIsLoading, +} from "@reflag/react-sdk"; + +const publishableKey = + process.env.EXPO_PUBLIC_REFLAG_PUBLISHABLE_KEY ?? ""; +const isConfigured = publishableKey.length > 0; + +function FlagCard() { + const isLoading = useIsLoading(); + const { isEnabled, track } = useFlag("expo-demo"); + + return ( + + expo-demo + + Status: {isLoading ? "loading" : isEnabled ? "enabled" : "disabled"} + +