diff --git a/CHANGES.txt b/CHANGES.txt index de66386d..e985763c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.6.0 (September 19, 2025) + - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. + 2.5.0 (September 10, 2025) - Added `factory.getRolloutPlan()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage. - Added `initialRolloutPlan` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan. diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index 545c532f..3341871d 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -10,6 +10,10 @@ jest.mock('../../inMemory/InMemoryStorageCS', () => { import { IStorageFactoryParams } from '../../types'; import { assertStorageInterface } from '../../__tests__/testUtils'; import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { createMemoryStorage } from './wrapper.mock'; +import * as storageAdapter from '../storageAdapter'; + +const storageAdapterSpy = jest.spyOn(storageAdapter, 'storageAdapter'); // Test target import { InLocalStorage } from '../index'; @@ -23,7 +27,7 @@ describe('IN LOCAL STORAGE', () => { fakeInMemoryStorageFactory.mockClear(); }); - test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => { + test('fallback to InMemoryStorage if LocalStorage API is not available or the provided storage wrapper is invalid', () => { // Delete global localStorage property const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage'); Object.defineProperty(global, 'localStorage', {}); @@ -40,7 +44,7 @@ describe('IN LOCAL STORAGE', () => { expect(storage).toBe(fakeInMemoryStorage); // Provided storage is valid - storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } }); + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); storage = storageFactory(internalSdkParams); expect(storage).not.toBe(fakeInMemoryStorage); @@ -48,14 +52,38 @@ describe('IN LOCAL STORAGE', () => { Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); }); - test('calls its own storage factory if LocalStorage API is available', () => { + test('calls InLocalStorage if LocalStorage API is available', () => { const storageFactory = InLocalStorage({ prefix: 'prefix' }); const storage = storageFactory(internalSdkParams); assertStorageInterface(storage); // the instance must implement the storage interface expect(fakeInMemoryStorageFactory).not.toBeCalled(); // doesn't call InMemoryStorage factory + }); + + test('calls InLocalStorage if the provided storage wrapper is valid', () => { + storageAdapterSpy.mockClear(); + + // Web Storages should not use the storageAdapter + let storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: localStorage }); + let storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: sessionStorage }); + storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + // Non Web Storages should use the storageAdapter + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); + storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).toBeCalled(); }); }); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 46e824b8..fa19081e 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -4,7 +4,7 @@ import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable, isValidStorageWrapper } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable, isValidStorageWrapper, isWebStorage } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -21,7 +21,11 @@ import { storageAdapter } from './storageAdapter'; function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { if (wrapper) { - if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper); + if (isValidStorageWrapper(wrapper)) { + return isWebStorage(wrapper) ? + wrapper as StorageAdapter: // localStorage and sessionStorage don't need adapter + storageAdapter(log, prefix, wrapper); + } log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); } diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index 9f5fef87..beb55c24 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -17,3 +17,15 @@ export function isValidStorageWrapper(wrapper: any): boolean { typeof wrapper.getItem === 'function' && typeof wrapper.removeItem === 'function'; } + +export function isWebStorage(wrapper: any): boolean { + if (typeof wrapper.length === 'number') { + try { + wrapper.key(0); + return true; + } catch (e) { + return false; + } + } + return false; +}