From d03ea42fa329b218a8b541a54dec91035e9fddad Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 3 Mar 2026 13:53:42 +0100 Subject: [PATCH 1/3] fix(browser-sdk): avoid localStorage crash during SSR --- packages/browser-sdk/src/flag/flags.ts | 4 +-- packages/browser-sdk/src/storage.ts | 22 +++++++++++++ packages/browser-sdk/test/client.test.ts | 21 +++++++++++- packages/browser-sdk/test/storage.test.ts | 39 +++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/browser-sdk/test/storage.test.ts diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 408d3adc..1a776f6a 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -5,7 +5,7 @@ import { ReflagContext } from "../context"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; -import { getLocalStorageAdapter, StorageAdapter } from "../storage"; +import { getDefaultStorageAdapter, StorageAdapter } from "../storage"; import { createAbortController } from "../utils/abortController"; import { createEventTarget } from "../utils/eventTarget"; @@ -235,7 +235,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.storage = (cache ? undefined : storage) ?? getDefaultStorageAdapter(); this.cache = cache ?? this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs); diff --git a/packages/browser-sdk/src/storage.ts b/packages/browser-sdk/src/storage.ts index ffe5ab08..5b003300 100644 --- a/packages/browser-sdk/src/storage.ts +++ b/packages/browser-sdk/src/storage.ts @@ -4,6 +4,20 @@ export type StorageAdapter = { removeItem?(key: string): Promise; }; +export function createMemoryStorageAdapter(): StorageAdapter { + const memoryStorage = new Map(); + + return { + getItem: async (key) => memoryStorage.get(key) ?? null, + setItem: async (key, value) => { + memoryStorage.set(key, value); + }, + removeItem: async (key) => { + memoryStorage.delete(key); + }, + }; +} + export function getLocalStorageAdapter(): StorageAdapter { if ( typeof localStorage === "undefined" || @@ -24,3 +38,11 @@ export function getLocalStorageAdapter(): StorageAdapter { }, }; } + +export function getDefaultStorageAdapter(): StorageAdapter { + try { + return getLocalStorageAdapter(); + } catch { + return createMemoryStorageAdapter(); + } +} diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 48e2b215..d42d4408 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ReflagClient } from "../src/client"; import { FlagsClient } from "../src/flag/flags"; @@ -23,6 +23,25 @@ describe("ReflagClient", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("storage runtime compatibility", () => { + it("can be constructed without localStorage", () => { + vi.stubGlobal("localStorage", undefined); + + expect( + () => + new ReflagClient({ + publishableKey: "test-key", + user: { id: "user1" }, + company: { id: "company1" }, + }), + ).not.toThrow(); + }); + }); + describe("updateUser", () => { it("should update the user context", async () => { // and send new user data and trigger flag update diff --git a/packages/browser-sdk/test/storage.test.ts b/packages/browser-sdk/test/storage.test.ts new file mode 100644 index 00000000..2542daa5 --- /dev/null +++ b/packages/browser-sdk/test/storage.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createMemoryStorageAdapter, + getDefaultStorageAdapter, + getLocalStorageAdapter, +} from "../src/storage"; + +describe("storage adapters", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("memory adapter stores and retrieves values", async () => { + const adapter = createMemoryStorageAdapter(); + + expect(await adapter.getItem("key")).toBeNull(); + await adapter.setItem("key", "value"); + expect(await adapter.getItem("key")).toBe("value"); + + await adapter.removeItem?.("key"); + expect(await adapter.getItem("key")).toBeNull(); + }); + + it("localStorage adapter throws when localStorage is unavailable", () => { + vi.stubGlobal("localStorage", undefined); + expect(() => getLocalStorageAdapter()).toThrowError( + "localStorage is not available. Provide a custom storage adapter.", + ); + }); + + it("default adapter falls back when localStorage is unavailable", async () => { + vi.stubGlobal("localStorage", undefined); + const adapter = getDefaultStorageAdapter(); + + await adapter.setItem("fallback", "ok"); + expect(await adapter.getItem("fallback")).toBe("ok"); + }); +}); From b024bcb83e0c73c5b2766affcd8739219be0a378 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 3 Mar 2026 14:02:15 +0100 Subject: [PATCH 2/3] fix(browser-sdk): use noop storage fallback on server --- packages/browser-sdk/src/storage.ts | 23 +++++++------------ packages/browser-sdk/test/client.test.ts | 15 ------------ packages/browser-sdk/test/storage.test.ts | 28 ++++++++++++++--------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/packages/browser-sdk/src/storage.ts b/packages/browser-sdk/src/storage.ts index 5b003300..b031736b 100644 --- a/packages/browser-sdk/src/storage.ts +++ b/packages/browser-sdk/src/storage.ts @@ -1,20 +1,16 @@ +import { IS_SERVER } from "./config"; + export type StorageAdapter = { getItem(key: string): Promise; setItem(key: string, value: string): Promise; removeItem?(key: string): Promise; }; -export function createMemoryStorageAdapter(): StorageAdapter { - const memoryStorage = new Map(); - +export function createNoopStorageAdapter(): StorageAdapter { return { - getItem: async (key) => memoryStorage.get(key) ?? null, - setItem: async (key, value) => { - memoryStorage.set(key, value); - }, - removeItem: async (key) => { - memoryStorage.delete(key); - }, + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, }; } @@ -40,9 +36,6 @@ export function getLocalStorageAdapter(): StorageAdapter { } export function getDefaultStorageAdapter(): StorageAdapter { - try { - return getLocalStorageAdapter(); - } catch { - return createMemoryStorageAdapter(); - } + if (IS_SERVER) return createNoopStorageAdapter(); + return getLocalStorageAdapter(); } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index d42d4408..bf1fe228 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -27,21 +27,6 @@ describe("ReflagClient", () => { vi.unstubAllGlobals(); }); - describe("storage runtime compatibility", () => { - it("can be constructed without localStorage", () => { - vi.stubGlobal("localStorage", undefined); - - expect( - () => - new ReflagClient({ - publishableKey: "test-key", - user: { id: "user1" }, - company: { id: "company1" }, - }), - ).not.toThrow(); - }); - }); - describe("updateUser", () => { it("should update the user context", async () => { // and send new user data and trigger flag update diff --git a/packages/browser-sdk/test/storage.test.ts b/packages/browser-sdk/test/storage.test.ts index 2542daa5..ffcc5fab 100644 --- a/packages/browser-sdk/test/storage.test.ts +++ b/packages/browser-sdk/test/storage.test.ts @@ -1,39 +1,45 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createMemoryStorageAdapter, - getDefaultStorageAdapter, - getLocalStorageAdapter, -} from "../src/storage"; +async function loadStorageModule() { + vi.resetModules(); + return import("../src/storage"); +} describe("storage adapters", () => { afterEach(() => { vi.unstubAllGlobals(); }); - it("memory adapter stores and retrieves values", async () => { - const adapter = createMemoryStorageAdapter(); + it("noop adapter ignores writes", async () => { + const { createNoopStorageAdapter } = await loadStorageModule(); + const adapter = createNoopStorageAdapter(); expect(await adapter.getItem("key")).toBeNull(); await adapter.setItem("key", "value"); - expect(await adapter.getItem("key")).toBe("value"); + expect(await adapter.getItem("key")).toBeNull(); await adapter.removeItem?.("key"); expect(await adapter.getItem("key")).toBeNull(); }); - it("localStorage adapter throws when localStorage is unavailable", () => { + it("localStorage adapter throws when localStorage is unavailable", async () => { + const { getLocalStorageAdapter } = await loadStorageModule(); vi.stubGlobal("localStorage", undefined); expect(() => getLocalStorageAdapter()).toThrowError( "localStorage is not available. Provide a custom storage adapter.", ); }); - it("default adapter falls back when localStorage is unavailable", async () => { + it("default adapter falls back to noop on server runtimes", async () => { + vi.resetModules(); + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); vi.stubGlobal("localStorage", undefined); + + const { getDefaultStorageAdapter } = await import("../src/storage"); const adapter = getDefaultStorageAdapter(); await adapter.setItem("fallback", "ok"); - expect(await adapter.getItem("fallback")).toBe("ok"); + expect(await adapter.getItem("fallback")).toBeNull(); }); }); From eb6e1c849c1760a1ba241e45c81a420ce7c18dd9 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 3 Mar 2026 14:15:22 +0100 Subject: [PATCH 3/3] chore: bump sdk patch versions for storage fallback fix --- packages/browser-sdk/package.json | 2 +- packages/react-native-sdk/package.json | 4 ++-- packages/react-sdk/package.json | 4 ++-- yarn.lock | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index c9c13870..61577760 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/browser-sdk", - "version": "1.4.2", + "version": "1.4.3", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 543c2032..85f1e2b8 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-native-sdk", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "repository": { "type": "git", @@ -32,7 +32,7 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", - "@reflag/react-sdk": "1.4.2" + "@reflag/react-sdk": "1.4.3" }, "peerDependencies": { "react": "*", diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 5ff56280..803e2555 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-sdk", - "version": "1.4.2", + "version": "1.4.3", "license": "MIT", "repository": { "type": "git", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.2" + "@reflag/browser-sdk": "1.4.3" }, "peerDependencies": { "react": "*", diff --git a/yarn.lock b/yarn.lock index ee752c53..f19683f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5036,7 +5036,7 @@ __metadata: languageName: node linkType: hard -"@reflag/browser-sdk@npm:1.4.2, @reflag/browser-sdk@workspace:packages/browser-sdk": +"@reflag/browser-sdk@npm:1.4.3, @reflag/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@reflag/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -5223,7 +5223,7 @@ __metadata: dependencies: "@react-native-async-storage/async-storage": "npm:^2.2.0" "@reflag/eslint-config": "npm:^0.0.2" - "@reflag/react-sdk": "npm:1.4.2" + "@reflag/react-sdk": "npm:1.4.3" "@reflag/tsconfig": "npm:^0.0.2" "@types/react": "npm:^19.0.12" eslint: "npm:^9.21.0" @@ -5235,11 +5235,11 @@ __metadata: languageName: unknown linkType: soft -"@reflag/react-sdk@npm:1.4.2, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": +"@reflag/react-sdk@npm:1.4.3, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": version: 0.0.0-use.local resolution: "@reflag/react-sdk@workspace:packages/react-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.4.2" + "@reflag/browser-sdk": "npm:1.4.3" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@testing-library/react": "npm:^15.0.7"