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/.prettierignore b/.prettierignore index 2376f8a4..2d3d01a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ **/gen **/node_modules **/dist +**/.expo diff --git a/package.json b/package.json index a4ba6605..d947068f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "workspaces": [ "packages/*", - "packages/react-sdk/dev/*", + "packages/react-native-sdk/dev/*", "packages/openfeature-browser-provider/example" ], "scripts": { @@ -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..31d769d5 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.native.json && tsc --project tsconfig.build.json && vite build", "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" @@ -63,6 +65,7 @@ "typescript": "^5.7.3", "vite": "^5.3.5", "vite-plugin-dts": "^4.0.0-beta.1", + "vite-plugin-static-copy": "^1.0.6", "vitest": "^2.0.4" } } diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 08d79122..84cd6667 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -26,6 +26,7 @@ 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; @@ -297,6 +298,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 +373,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 +450,7 @@ export class ReflagClient { timeoutMs: opts.timeoutMs, fallbackFlags: opts.fallbackFlags, offline: this.config.offline, + storage: opts.storage, }, ); @@ -869,6 +879,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..a489f39e --- /dev/null +++ b/packages/browser-sdk/src/feedback/ui/index.native.ts @@ -0,0 +1,10 @@ +import { OpenFeedbackFormOptions } from "./types"; + +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..efcf4eb4 100644 --- a/packages/browser-sdk/src/flag/flagCache.ts +++ b/packages/browser-sdk/src/flag/flagCache.ts @@ -1,9 +1,8 @@ +import { StorageAdapter } from "../storage"; + import { RawFlags } from "./flags"; -interface StorageItem { - get(): string | null; - set(value: string): void; -} +const DEFAULT_STORAGE_KEY = "__reflag_fetched_flags"; interface cacheEntry { expireAt: number; @@ -52,7 +51,8 @@ export interface CacheResult { } export class FlagCache { - private storage: StorageItem; + private storage: StorageAdapter; + private readonly storageKey: string; private readonly staleTimeMs: number; private readonly expireTimeMs: number; @@ -61,16 +61,17 @@ export class FlagCache { staleTimeMs, expireTimeMs, }: { - storage: StorageItem; + storage: StorageAdapter; staleTimeMs: number; expireTimeMs: number; }) { this.storage = storage; + this.storageKey = DEFAULT_STORAGE_KEY; this.staleTimeMs = staleTimeMs; this.expireTimeMs = expireTimeMs; } - set( + async set( key: string, { flags, @@ -81,7 +82,7 @@ export class FlagCache { let cacheData: CacheData = {}; try { - const cachedResponseRaw = this.storage.get(); + const cachedResponseRaw = await this.storage.getItem(this.storageKey); if (cachedResponseRaw) { cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {}; } @@ -99,14 +100,14 @@ export class FlagCache { Object.entries(cacheData).filter(([_k, v]) => v.expireAt > Date.now()), ); - this.storage.set(JSON.stringify(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(); + 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..6573df6c 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 { getLocalStorageAdapter, StorageAdapter } from "../storage"; +import { createEventTarget } from "../utils/eventTarget"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache"; @@ -174,8 +176,9 @@ export interface CheckEvent { missingContextFields?: string[]; } -const localStorageFetchedFlagsKey = `__reflag_fetched_flags`; const storageOverridesKey = `__reflag_overrides`; +const REFRESH_LIMIT_COUNT = 10; +const REFRESH_LIMIT_WINDOW_MS = 5 * 60 * 1000; export type FlagOverrides = Record; @@ -184,6 +187,7 @@ type FlagsClientOptions = Partial & { fallbackFlags?: Record | string[]; cache?: FlagCache; rateLimiter?: RateLimiter; + storage?: StorageAdapter; }; /** @@ -201,10 +205,12 @@ export class FlagsClient { private flagOverrides: FlagOverrides = {}; private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; + private storage: StorageAdapter; + private refreshEvents: number[] = []; private config: Config = DEFAULT_FLAGS_CONFIG; - private eventTarget = new EventTarget(); + private eventTarget = createEventTarget(); private abortController: AbortController = new AbortController(); constructor( @@ -216,6 +222,7 @@ export class FlagsClient { cache, rateLimiter, fallbackFlags, + storage, ...config }: FlagsClientOptions = {}, ) { @@ -227,6 +234,7 @@ export class FlagsClient { this.logger = loggerWithPrefix(logger, "[Flags]"); this.rateLimiter = rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger); + this.storage = (cache ? undefined : storage) ?? getLocalStorageAdapter(); this.cache = cache ?? this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs); @@ -236,8 +244,6 @@ export class FlagsClient { this.bootstrapped = true; this.setFetchedFlags(bootstrappedFlags, false); } - - this.flagOverrides = this.getOverridesCache(); } async initialize() { @@ -247,6 +253,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 +311,8 @@ export class FlagsClient { } else { this.flagOverrides[key] = isEnabled; } - this.setOverridesCache(this.flagOverrides); + // TODO(next major): make this async and await storage persistence. + void this.setOverridesCache(this.flagOverrides); this.updateFlags(); } @@ -399,30 +411,54 @@ 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; + } + + // rate limit refreshes to prevent accidental abuse + const now = Date.now(); + this.refreshEvents = this.refreshEvents.filter( + (timestamp) => now - timestamp < REFRESH_LIMIT_WINDOW_MS, + ); + if (this.refreshEvents.length >= REFRESH_LIMIT_COUNT) { + this.logger.warn("refresh rate limit exceeded"); + return; + } + this.refreshEvents.push(now); + + const flags = await this.fetchFlags(); + if (flags) { + this.setFetchedFlags(flags); + } + return flags; + } + + private async setOverridesCache(overrides: FlagOverrides) { try { - localStorage.setItem(storageOverridesKey, JSON.stringify(overrides)); + 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); + 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 +469,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 +478,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 +498,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 +539,12 @@ 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, staleTimeMs, expireTimeMs, }); diff --git a/packages/browser-sdk/src/index.native.js b/packages/browser-sdk/src/index.native.js new file mode 100644 index 00000000..20393eba --- /dev/null +++ b/packages/browser-sdk/src/index.native.js @@ -0,0 +1,3 @@ +"use strict"; + +module.exports = require("./reflag-browser-sdk.umd.js"); 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..ffe5ab08 --- /dev/null +++ b/packages/browser-sdk/src/storage.ts @@ -0,0 +1,26 @@ +export type StorageAdapter = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem?(key: string): Promise; +}; + +export function getLocalStorageAdapter(): StorageAdapter { + if ( + typeof localStorage === "undefined" || + !("setItem" in localStorage) || + !("removeItem" in localStorage) + ) { + throw new Error( + "localStorage is not available. Provide a custom storage adapter.", + ); + } + return { + getItem: async (key) => localStorage.getItem(key), + setItem: async (key, value) => { + localStorage.setItem(key, value); + }, + removeItem: async (key) => { + localStorage.removeItem(key); + }, + }; +} 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..916ae1ad 100644 --- a/packages/browser-sdk/test/flagCache.test.ts +++ b/packages/browser-sdk/test/flagCache.test.ts @@ -30,8 +30,10 @@ 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; + }, }, staleTimeMs: TEST_STALE_MS, expireTimeMs: TEST_EXPIRE_MS, @@ -48,8 +50,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 +60,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/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index fcc2474e..b531f353 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -5,7 +5,7 @@ import { FLAGS_EXPIRE_MS } from "../src/config"; import { FlagsClient, RawFlag } from "../src/flag/flags"; import { HttpClient } from "../src/httpClient"; -import { flagsResult } from "./mocks/handlers"; +import { flagResponse, flagsResult } from "./mocks/handlers"; import { newCache, TEST_STALE_MS } from "./flagCache.test"; import { testLogger } from "./testLogger"; @@ -26,6 +26,12 @@ function flagsClientFactory() { vi.spyOn(httpClient, "get"); vi.spyOn(httpClient, "post"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(flagResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); return { cache, diff --git a/packages/browser-sdk/tsconfig.native.json b/packages/browser-sdk/tsconfig.native.json new file mode 100644 index 00000000..d1d46f3c --- /dev/null +++ b/packages/browser-sdk/tsconfig.native.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2020"], + "types": ["react"], + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "src/**/*.native.ts", + "src/**/*.native.tsx", + "src/**/*.native.js", + "src/**/*.native.jsx", + "src/**/*.d.ts" + ] +} diff --git a/packages/browser-sdk/vite.config.mjs b/packages/browser-sdk/vite.config.mjs index f6c651ef..8b30a6a5 100644 --- a/packages/browser-sdk/vite.config.mjs +++ b/packages/browser-sdk/vite.config.mjs @@ -1,6 +1,7 @@ import { resolve } from "path"; import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; +import { viteStaticCopy } from "vite-plugin-static-copy"; import { defaultExclude } from "vitest/config"; export default defineConfig({ @@ -11,7 +12,17 @@ export default defineConfig({ setupFiles: ["./vitest.setup.ts"], exclude: [...defaultExclude, "test/e2e/**"], }, - plugins: [dts({ insertTypesEntry: true })], + plugins: [ + dts({ insertTypesEntry: true }), + viteStaticCopy({ + targets: [ + { + src: "src/index.native.js", + dest: ".", + }, + ], + }), + ], build: { exclude: ["**/node_modules/**", "test/e2e/**"], sourcemap: true, diff --git a/packages/react-native-sdk/README.md b/packages/react-native-sdk/README.md new file mode 100644 index 00000000..d3f1f65b --- /dev/null +++ b/packages/react-native-sdk/README.md @@ -0,0 +1,29 @@ +# Reflag React Native SDK + +A thin React Native wrapper around `@reflag/react-sdk`. + +For more usage details, see the React SDK README in `packages/react-sdk/README.md`. + +An Expo example app lives at `packages/react-native-sdk/dev/expo`. + +## Install + +```shell +npm i @reflag/react-native-sdk +``` + +## Usage + +```tsx +import { ReflagProvider, useFlag } from "@reflag/react-native-sdk"; + + + +; +``` diff --git a/packages/react-native-sdk/dev/expo/App.tsx b/packages/react-native-sdk/dev/expo/App.tsx new file mode 100644 index 00000000..a29f70ad --- /dev/null +++ b/packages/react-native-sdk/dev/expo/App.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Button, StyleSheet, Text, View } from "react-native"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; + +import { + ReflagProvider, + useClient, + useFlag, + useIsLoading, +} from "@reflag/react-native-sdk"; + +const publishableKey = process.env.EXPO_PUBLIC_REFLAG_PUBLISHABLE_KEY ?? ""; +const isConfigured = publishableKey.length > 0; + +function FlagCard() { + const client = useClient(); + const isLoading = useIsLoading(); + const { isEnabled, track } = useFlag("expo-demo"); + + return ( + + expo-demo + + Status: {isLoading ? "loading" : isEnabled ? "enabled" : "disabled"} + + +