Skip to content

Commit 11bd171

Browse files
raju-opticlaude
andauthored
[FSSDK-13343] Skip the default browser event store when localStorage is unavailable (#1184)
## Summary - **localStorage check:** new `isLocalStorageAvailable()` in `lib/utils/env/browser.ts`. It returns `false` when reading the `localStorage` global throws (a `ReferenceError` where it isn't defined, such as SSR, or a `SecurityError` where storage is blocked, such as disabled cookies or sandboxed iframes) or when the global is `null`. - **Browser batch event processor:** `createBatchEventProcessor` only builds its default `LocalStorageCache`-backed `EventStore` when localStorage is available. Otherwise it runs without an event store and adds a Warn startup log: `localStorage is not available, events will not be persisted.` A user-provided `eventStore` is used exactly as before, whether or not localStorage is available. - **Startup logs from platform factories:** `getBatchEventProcessor` accepts a `startupLogs` option and puts those logs before its own, without changing the caller's array. The option is on the internal `BatchEventProcessorFactoryOptions` only; the public `BatchEventProcessorOptions` is unchanged. Before this change, the browser factory always created a `LocalStorageCache` event store. Where localStorage can't be accessed, every store operation threw. With no event store, events are still batched and dispatched; failed events just aren't persisted for retry across page loads. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5a388f5 commit 11bd171

7 files changed

Lines changed: 242 additions & 16 deletions

File tree

‎lib/event_processor/event_processor_factory.browser.spec.ts‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ vi.mock('../utils/cache/local_storage_cache.browser', () => {
5555
return { LocalStorageCache: vi.fn() };
5656
});
5757

58+
vi.mock('../utils/env/browser', () => {
59+
return {
60+
isLocalStorageAvailable: vi.fn().mockReturnValue(true),
61+
__platforms: ['browser'],
62+
};
63+
});
64+
5865
vi.mock('./event_store', () => {
5966
return {
6067
DEFAULT_MAX_EVENTS_IN_STORE: 500,
@@ -88,11 +95,15 @@ vi.mock('../utils/cache/store', () => {
8895

8996

9097
import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
91-
import { SyncPrefixStore } from '../utils/cache/store';
98+
import { Store, SyncPrefixStore } from '../utils/cache/store';
99+
import { EventWithId } from './batch_event_processor';
100+
import { isLocalStorageAvailable } from '../utils/env/browser';
92101
import { default as browserDefaultEventDispatcher, default as defaultEventDispatcher } from './event_dispatcher/default_dispatcher.browser';
93102
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
94-
import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor } from './event_processor_factory';
103+
import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor, getPrefixEventStore } from './event_processor_factory';
95104
import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor_factory.browser';
105+
import { LogLevel } from '../logging/logger';
106+
import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message';
96107
import { EventStore } from './event_store';
97108

98109
describe('createForwardingEventProcessor', () => {
@@ -126,12 +137,71 @@ describe('createBatchEventProcessor', () => {
126137
const MockLocalStorageCache = vi.mocked(LocalStorageCache);
127138
const MockSyncPrefixStore = vi.mocked(SyncPrefixStore);
128139
const MockEventStore = vi.mocked(EventStore);
140+
const mockIsLocalStorageAvailable = vi.mocked(isLocalStorageAvailable);
141+
const mockGetPrefixEventStore = vi.mocked(getPrefixEventStore);
129142

130143
beforeEach(() => {
131144
mockGetOpaqueBatchEventProcessor.mockClear();
132145
MockLocalStorageCache.mockClear();
133146
MockSyncPrefixStore.mockClear();
134147
MockEventStore.mockClear();
148+
mockIsLocalStorageAvailable.mockClear();
149+
mockIsLocalStorageAvailable.mockReturnValue(true);
150+
mockGetPrefixEventStore.mockClear();
151+
});
152+
153+
it('does not use any eventStore and adds a warning startup log if no eventStore is provided and localStorage is not available', () => {
154+
mockIsLocalStorageAvailable.mockReturnValue(false);
155+
156+
const processor = createBatchEventProcessor({ storeTtl: 60_000 });
157+
158+
expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
159+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(undefined);
160+
expect(MockEventStore).not.toHaveBeenCalled();
161+
expect(MockLocalStorageCache).not.toHaveBeenCalled();
162+
163+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([{
164+
level: LogLevel.Warn,
165+
message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE,
166+
params: [],
167+
}]);
168+
});
169+
170+
it('does not add any startup log if localStorage is available', () => {
171+
mockIsLocalStorageAvailable.mockReturnValue(true);
172+
173+
createBatchEventProcessor({});
174+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]);
175+
});
176+
177+
it('does not add any startup log if an eventStore is provided and localStorage is not available', () => {
178+
mockIsLocalStorageAvailable.mockReturnValue(false);
179+
180+
createBatchEventProcessor({ eventStore: { operation: 'sync' } as Store<string> });
181+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]);
182+
});
183+
184+
it('uses the provided eventStore wrapped with getPrefixEventStore regardless of localStorage availability', () => {
185+
const providedStore = { operation: 'sync' } as Store<string>;
186+
const prefixStore = {} as Store<EventWithId>;
187+
mockGetPrefixEventStore.mockReturnValue(prefixStore);
188+
189+
mockIsLocalStorageAvailable.mockReturnValue(true);
190+
const processor1 = createBatchEventProcessor({ eventStore: providedStore });
191+
192+
expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true);
193+
expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(1, providedStore);
194+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(prefixStore);
195+
196+
mockIsLocalStorageAvailable.mockReturnValue(false);
197+
const processor2 = createBatchEventProcessor({ eventStore: providedStore });
198+
199+
expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true);
200+
expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(2, providedStore);
201+
expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].eventStore).toBe(prefixStore);
202+
203+
expect(MockEventStore).not.toHaveBeenCalled();
204+
expect(MockLocalStorageCache).not.toHaveBeenCalled();
135205
});
136206

137207
it('uses an EventStore instance with AsyncStorageCache and correct options if no eventStore is provided', () => {

‎lib/event_processor/event_processor_factory.browser.ts‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,28 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message';
17+
import { LogLevel } from '../logging/logger';
18+
import { Platform } from '../platform_support';
19+
import { StartupLog } from '../service';
20+
import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
21+
import { Store } from '../utils/cache/store';
22+
import { isLocalStorageAvailable } from '../utils/env/browser';
23+
import { EventWithId } from './batch_event_processor';
24+
import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
1625
import { EventDispatcher } from './event_dispatcher/event_dispatcher';
17-
import { EventProcessor } from './event_processor';
26+
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
1827
import {
19-
getOpaqueBatchEventProcessor,
2028
BatchEventProcessorOptions,
21-
OpaqueEventProcessor,
22-
wrapEventProcessor,
29+
FAILED_EVENT_RETRY_INTERVAL,
2330
getForwardingEventProcessor,
31+
getOpaqueBatchEventProcessor,
2432
getPrefixEventStore,
33+
OpaqueEventProcessor,
34+
wrapEventProcessor,
2535
} from './event_processor_factory';
26-
import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
27-
import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser';
28-
import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser';
29-
import { FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
3036
import { DEFAULT_MAX_EVENTS_IN_STORE, EventStore } from './event_store';
31-
import { Platform } from '../platform_support';
37+
3238
export const DEFAULT_EVENT_BATCH_SIZE = 10;
3339
export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000;
3440
export const EVENT_MAX_RETRIES_BROWSER = 2;
@@ -39,15 +45,33 @@ export const createForwardingEventProcessor = (
3945
return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher));
4046
};
4147

42-
export const createBatchEventProcessor = (
43-
options: BatchEventProcessorOptions = {}
44-
): OpaqueEventProcessor => {
45-
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : new EventStore({
48+
const getDefaultEventStore = (
49+
options: BatchEventProcessorOptions,
50+
startupLogs: StartupLog[],
51+
): Store<EventWithId> | undefined => {
52+
if (!isLocalStorageAvailable()) {
53+
startupLogs.push({
54+
level: LogLevel.Warn,
55+
message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE,
56+
params: [],
57+
});
58+
return undefined;
59+
}
60+
61+
return new EventStore({
4662
store: new LocalStorageCache(),
4763
maxSize: options.batchSize ? Math.max(options.batchSize * 2, DEFAULT_MAX_EVENTS_IN_STORE)
4864
: DEFAULT_MAX_EVENTS_IN_STORE,
4965
ttl: options.storeTtl,
5066
});
67+
};
68+
69+
export const createBatchEventProcessor = (
70+
options: BatchEventProcessorOptions = {}
71+
): OpaqueEventProcessor => {
72+
const startupLogs: StartupLog[] = [];
73+
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) :
74+
getDefaultEventStore(options, startupLogs);
5175

5276
return getOpaqueBatchEventProcessor({
5377
eventDispatcher: options.eventDispatcher || defaultEventDispatcher,
@@ -63,6 +87,7 @@ export const createBatchEventProcessor = (
6387
failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL,
6488
eventStore,
6589
storeTtl: options.storeTtl,
90+
startupLogs,
6691
});
6792
};
6893

‎lib/event_processor/event_processor_factory.spec.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,36 @@ describe('getBatchEventProcessor', () => {
352352
}]));
353353
});
354354

355+
it('includes the provided startupLogs along with its own startup logs', () => {
356+
const providedLog = {
357+
level: LogLevel.Warn,
358+
message: 'provided startup log %s',
359+
params: ['param'],
360+
};
361+
362+
const options = {
363+
eventDispatcher: getMockEventDispatcher(),
364+
defaultBatchSize: 77,
365+
defaultFlushInterval: 12345,
366+
startupLogs: [providedLog],
367+
};
368+
369+
const processor = getBatchEventProcessor(options);
370+
371+
expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true);
372+
373+
const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs;
374+
expect(startupLogs).toEqual(expect.arrayContaining([
375+
providedLog,
376+
{
377+
level: LogLevel.Warn,
378+
message: 'Invalid batchSize %s, defaulting to %s',
379+
params: [undefined, 77],
380+
},
381+
]));
382+
expect(options.startupLogs).toEqual([providedLog]);
383+
});
384+
355385
it('does not use a failedEventRepeater if failedEventRetryInterval is not provided', () => {
356386
const options = {
357387
eventDispatcher: getMockEventDispatcher(),

‎lib/event_processor/event_processor_factory.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type BatchEventProcessorFactoryOptions = Omit<BatchEventProcessorOptions,
7777
minBackoff?: number;
7878
maxBackoff?: number;
7979
};
80+
startupLogs?: StartupLog[];
8081
}
8182

8283
export const validateEventDispatcher = (eventDispatcher: EventDispatcher): void => {
@@ -109,7 +110,7 @@ export const getBatchEventProcessor = (
109110
}
110111
} : undefined;
111112

112-
const startupLogs: StartupLog[] = [];
113+
const startupLogs: StartupLog[] = [...(options.startupLogs ?? [])];
113114

114115
const { defaultFlushInterval, defaultBatchSize } = options;
115116

‎lib/message/error_message.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export const PROMISE_NOT_ALLOWED = "Promise value is not allowed in sync operati
102102
export const SERVICE_NOT_RUNNING = "%s not running";
103103
export const EVENT_STORE_FULL = 'Event store is full. Not saving event with id %d.';
104104
export const LOCAL_HOLDOUT_MISSING_INCLUDED_RULES = 'Local holdout "%s" is missing or has empty "includedRules"; skipping.';
105+
export const EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE =
106+
'localStorage is not available, events will not be persisted.';
105107

106108
export const messages: string[] = [];
107109

‎lib/utils/env/browser.spec.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { it, describe, expect, beforeEach, afterEach } from 'vitest';
18+
import { isLocalStorageAvailable } from './browser';
19+
20+
describe('isLocalStorageAvailable', () => {
21+
let originalDescriptor: PropertyDescriptor | undefined;
22+
23+
const mockLocalStorageGetter = (get: () => unknown) => {
24+
Object.defineProperty(globalThis, 'localStorage', { get, configurable: true });
25+
};
26+
27+
beforeEach(() => {
28+
originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
29+
});
30+
31+
afterEach(() => {
32+
if (originalDescriptor) {
33+
Object.defineProperty(globalThis, 'localStorage', originalDescriptor);
34+
} else {
35+
Reflect.deleteProperty(globalThis, 'localStorage');
36+
}
37+
});
38+
39+
it('should return true when localStorage global can be accessed', () => {
40+
mockLocalStorageGetter(() => ({}));
41+
42+
expect(isLocalStorageAvailable()).toBe(true);
43+
});
44+
45+
it('should return false when localStorage is null', () => {
46+
mockLocalStorageGetter(() => null);
47+
48+
expect(isLocalStorageAvailable()).toBe(false);
49+
});
50+
51+
it('should return false when localStorage is not defined', () => {
52+
mockLocalStorageGetter(() => {
53+
throw new ReferenceError('localStorage is not defined');
54+
});
55+
56+
expect(isLocalStorageAvailable()).toBe(false);
57+
});
58+
59+
it('should return false when accessing localStorage throws a SecurityError', () => {
60+
mockLocalStorageGetter(() => {
61+
throw new DOMException('The operation is insecure.', 'SecurityError');
62+
});
63+
64+
expect(isLocalStorageAvailable()).toBe(false);
65+
});
66+
});

‎lib/utils/env/browser.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Platform } from '../../platform_support';
18+
19+
/**
20+
* Checks whether localStorage exists and is accessible. Referencing it throws a
21+
* ReferenceError where it is not defined (e.g. SSR) and a SecurityError where
22+
* storage is blocked (e.g. cookies disabled, sandboxed iframes).
23+
*/
24+
export const isLocalStorageAvailable = (): boolean => {
25+
try {
26+
return !!localStorage;
27+
} catch {
28+
return false;
29+
}
30+
};
31+
32+
export const __platforms: Platform[] = ['browser'];

0 commit comments

Comments
 (0)