Skip to content

Commit 7146207

Browse files
authored
feat(events): Use new event dispatcher API (#103)
* feat: Set event dispatcher instance * feat(events): Use new event dispatcher API * fix imports * point to main of common lib * bump common lib version
1 parent ba443c2 commit 7146207

12 files changed

+550
-153
lines changed

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const jestConfig = {
55
'^src/(.*)': ['<rootDir>/src/$1'],
66
'^test/(.*)': ['<rootDir>/test/$1'],
77
'@eppo(.*)': '<rootDir>/node_modules/@eppo/$1',
8+
'^uuid$': '<rootDir>/node_modules/uuid/dist/index.js',
89
},
910
testRegex: '.*\\..*spec\\.ts$',
1011
transform: {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"webpack-cli": "^4.10.0"
6060
},
6161
"dependencies": {
62-
"@eppo/js-client-sdk-common": "4.3.0"
62+
"@eppo/js-client-sdk-common": "^4.5.0"
6363
},
6464
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
6565
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import BrowserNetworkStatusListener from './browser-network-status-listener';
2+
3+
describe('BrowserNetworkStatusListener', () => {
4+
let originalNavigator: Navigator;
5+
let originalWindow: Window;
6+
7+
beforeEach(() => {
8+
// Save original references
9+
originalNavigator = global.navigator;
10+
originalWindow = global.window;
11+
12+
// Mock `navigator.onLine`
13+
Object.defineProperty(global, 'navigator', {
14+
value: { onLine: true },
15+
writable: true,
16+
});
17+
18+
const listeners: Map<string, (offline: boolean) => void> = new Map();
19+
Object.defineProperty(global, 'window', {
20+
value: {
21+
addEventListener: (evt: string, fn: () => void) => {
22+
listeners.set(evt, fn);
23+
},
24+
removeEventListener: () => null,
25+
dispatchEvent: (event: Event) => {
26+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
27+
const listener = listeners.get(event.type)!;
28+
listener(event.type === 'offline');
29+
},
30+
},
31+
writable: true,
32+
});
33+
});
34+
35+
afterEach(() => {
36+
// Restore original references
37+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
38+
// @ts-ignore test code
39+
// noinspection JSConstantReassignment
40+
global.navigator = originalNavigator;
41+
// noinspection JSConstantReassignment
42+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
43+
// @ts-ignore test code
44+
// noinspection JSConstantReassignment
45+
global.window = originalWindow;
46+
47+
jest.clearAllMocks();
48+
});
49+
50+
test('throws an error if instantiated outside a browser environment', () => {
51+
Object.defineProperty(global, 'window', { value: undefined });
52+
53+
expect(() => new BrowserNetworkStatusListener()).toThrow(
54+
'BrowserNetworkStatusListener can only be used in a browser environment',
55+
);
56+
});
57+
58+
test('correctly initializes offline state based on navigator.onLine', () => {
59+
// Online state
60+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
61+
// @ts-ignore test code
62+
// noinspection JSConstantReassignment
63+
navigator.onLine = true;
64+
const listener = new BrowserNetworkStatusListener();
65+
expect(listener.isOffline()).toBe(false);
66+
67+
// Offline state
68+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
69+
// @ts-ignore
70+
// noinspection JSConstantReassignment
71+
navigator.onLine = false;
72+
const offlineListener = new BrowserNetworkStatusListener();
73+
expect(offlineListener.isOffline()).toBe(true);
74+
});
75+
76+
test('notifies listeners when offline event is triggered', async () => {
77+
const listener = new BrowserNetworkStatusListener();
78+
const mockCallback = jest.fn();
79+
80+
listener.onNetworkStatusChange(mockCallback);
81+
82+
// Simulate offline event
83+
const offlineEvent = new Event('offline');
84+
window.dispatchEvent(offlineEvent);
85+
await new Promise((resolve) => setTimeout(resolve, 200));
86+
expect(mockCallback).toHaveBeenCalledWith(true);
87+
});
88+
89+
test('notifies listeners when online event is triggered', async () => {
90+
const listener = new BrowserNetworkStatusListener();
91+
const mockCallback = jest.fn();
92+
93+
listener.onNetworkStatusChange(mockCallback);
94+
95+
// Simulate online event
96+
const onlineEvent = new Event('online');
97+
window.dispatchEvent(onlineEvent);
98+
await new Promise((resolve) => setTimeout(resolve, 200));
99+
expect(mockCallback).toHaveBeenCalledWith(false);
100+
});
101+
102+
test('removes listeners and does not notify them after removal', () => {
103+
const listener = new BrowserNetworkStatusListener();
104+
const mockCallback = jest.fn();
105+
106+
listener.onNetworkStatusChange(mockCallback);
107+
listener.removeNetworkStatusChange(mockCallback);
108+
109+
// Simulate offline event
110+
const offlineEvent = new Event('offline');
111+
window.dispatchEvent(offlineEvent);
112+
113+
expect(mockCallback).not.toHaveBeenCalled();
114+
});
115+
116+
test('debounces notifications for rapid online/offline changes', () => {
117+
jest.useFakeTimers();
118+
const listener = new BrowserNetworkStatusListener();
119+
const mockCallback = jest.fn();
120+
121+
listener.onNetworkStatusChange(mockCallback);
122+
123+
// Simulate rapid online/offline changes
124+
const offlineEvent = new Event('offline');
125+
const onlineEvent = new Event('online');
126+
window.dispatchEvent(offlineEvent);
127+
window.dispatchEvent(onlineEvent);
128+
129+
// Fast-forward time by less than debounce duration
130+
jest.advanceTimersByTime(100);
131+
132+
expect(mockCallback).not.toHaveBeenCalled();
133+
134+
// Fast-forward time past debounce duration
135+
jest.advanceTimersByTime(200);
136+
137+
expect(mockCallback).toHaveBeenCalledWith(false); // Online state
138+
jest.useRealTimers();
139+
});
140+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NetworkStatusListener } from '@eppo/js-client-sdk-common';
2+
3+
const debounceDurationMs = 200;
4+
5+
/** A NetworkStatusListener that listens for online/offline events in the browser. */
6+
export default class BrowserNetworkStatusListener implements NetworkStatusListener {
7+
private readonly listeners: ((isOffline: boolean) => void)[] = [];
8+
private _isOffline: boolean;
9+
private debounceTimer: NodeJS.Timeout | null = null;
10+
11+
constructor() {
12+
if (typeof window === 'undefined') {
13+
throw new Error('BrowserNetworkStatusListener can only be used in a browser environment');
14+
}
15+
// guard against navigator API not being available (oder browsers)
16+
// noinspection SuspiciousTypeOfGuard
17+
this._isOffline = typeof navigator.onLine === 'boolean' ? !navigator.onLine : false;
18+
window.addEventListener('offline', () => this.notifyListeners(true));
19+
window.addEventListener('online', () => this.notifyListeners(false));
20+
}
21+
22+
isOffline(): boolean {
23+
return this._isOffline;
24+
}
25+
26+
onNetworkStatusChange(callback: (isOffline: boolean) => void): void {
27+
this.listeners.push(callback);
28+
}
29+
30+
removeNetworkStatusChange(callback: (isOffline: boolean) => void): void {
31+
const index = this.listeners.indexOf(callback);
32+
if (index !== -1) {
33+
this.listeners.splice(index, 1);
34+
}
35+
}
36+
37+
private notifyListeners(isOffline: boolean): void {
38+
if (this.debounceTimer) {
39+
clearTimeout(this.debounceTimer);
40+
}
41+
this.debounceTimer = setTimeout(() => {
42+
this._isOffline = isOffline;
43+
[...this.listeners].forEach((listener) => listener(isOffline));
44+
}, debounceDurationMs);
45+
}
46+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import LocalStorageBackedNamedEventQueue from './local-storage-backed-named-event-queue';
6+
7+
describe('LocalStorageBackedNamedEventQueue', () => {
8+
const queueName = 'testQueue';
9+
let queue: LocalStorageBackedNamedEventQueue<string>;
10+
11+
beforeEach(() => {
12+
localStorage.clear();
13+
queue = new LocalStorageBackedNamedEventQueue(queueName);
14+
});
15+
16+
it('should initialize with an empty queue', () => {
17+
expect(queue.length).toBe(0);
18+
});
19+
20+
it('should persist and retrieve events correctly via push and iterator', () => {
21+
queue.push('event1');
22+
queue.push('event2');
23+
24+
expect(queue.length).toBe(2);
25+
26+
const events = Array.from(queue);
27+
expect(events).toEqual(['event1', 'event2']);
28+
});
29+
30+
it('should persist and retrieve events correctly via push and shift', () => {
31+
queue.push('event1');
32+
queue.push('event2');
33+
34+
const firstEvent = queue.shift();
35+
expect(firstEvent).toBe('event1');
36+
expect(queue.length).toBe(1);
37+
38+
const secondEvent = queue.shift();
39+
expect(secondEvent).toBe('event2');
40+
expect(queue.length).toBe(0);
41+
});
42+
43+
it('should remove events from localStorage after shift', () => {
44+
queue.push('event1');
45+
const eventKey = Object.keys(localStorage).find(
46+
(key) => key.includes(queueName) && localStorage.getItem(key)?.includes('event1'),
47+
);
48+
49+
expect(eventKey).toBeDefined();
50+
queue.shift();
51+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
52+
expect(localStorage.getItem(eventKey!)).toBeNull();
53+
});
54+
55+
it('should reconstruct the queue from localStorage', () => {
56+
queue.push('event1');
57+
queue.push('event2');
58+
59+
const newQueueInstance = new LocalStorageBackedNamedEventQueue<string>(queueName);
60+
expect(newQueueInstance.length).toBe(2);
61+
62+
const events = Array.from(newQueueInstance);
63+
expect(events).toEqual(['event1', 'event2']);
64+
});
65+
66+
it('should handle empty shift gracefully', () => {
67+
expect(queue.shift()).toBeUndefined();
68+
});
69+
70+
it('should not fail if localStorage state is corrupted', () => {
71+
localStorage.setItem(`eventQueue:${queueName}`, '{ corrupted state }');
72+
73+
const newQueueInstance = new LocalStorageBackedNamedEventQueue<string>(queueName);
74+
expect(newQueueInstance.length).toBe(0);
75+
});
76+
77+
it('should handle events with the same content correctly using consistent hashing', () => {
78+
queue.push('event1');
79+
queue.push('event1'); // Push the same event content twice
80+
81+
expect(queue.length).toBe(2);
82+
83+
const events = Array.from(queue);
84+
expect(events).toEqual(['event1', 'event1']);
85+
});
86+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { applicationLogger } from '@eppo/js-client-sdk-common';
2+
import NamedEventQueue from '@eppo/js-client-sdk-common/dist/events/named-event-queue';
3+
4+
import { takeWhile } from '../util';
5+
6+
/** A localStorage-backed NamedEventQueue. */
7+
export default class LocalStorageBackedNamedEventQueue<T> implements NamedEventQueue<T> {
8+
private readonly localStorageKey: string;
9+
private eventKeys: string[] = [];
10+
11+
constructor(public readonly name: string) {
12+
this.localStorageKey = `eventQueue:${this.name}`;
13+
this.loadStateFromLocalStorage();
14+
}
15+
16+
splice(count: number): T[] {
17+
const arr = Array.from({ length: count }, () => this.shift());
18+
return takeWhile(arr, (item) => item !== undefined) as T[];
19+
}
20+
21+
isEmpty(): boolean {
22+
return this.length === 0;
23+
}
24+
25+
get length(): number {
26+
return this.eventKeys.length;
27+
}
28+
29+
push(event: T): void {
30+
const eventKey = this.generateEventKey(event);
31+
const serializedEvent = JSON.stringify(event);
32+
localStorage.setItem(eventKey, serializedEvent);
33+
this.eventKeys.push(eventKey);
34+
this.saveStateToLocalStorage();
35+
}
36+
37+
*[Symbol.iterator](): IterableIterator<T> {
38+
for (const key of this.eventKeys) {
39+
const eventData = localStorage.getItem(key);
40+
if (eventData) {
41+
yield JSON.parse(eventData);
42+
}
43+
}
44+
}
45+
46+
shift(): T | undefined {
47+
if (this.eventKeys.length === 0) {
48+
return undefined;
49+
}
50+
const eventKey = this.eventKeys.shift()!;
51+
const eventData = localStorage.getItem(eventKey);
52+
if (eventData) {
53+
localStorage.removeItem(eventKey);
54+
this.saveStateToLocalStorage();
55+
return JSON.parse(eventData);
56+
}
57+
return undefined;
58+
}
59+
60+
private loadStateFromLocalStorage(): void {
61+
const serializedState = localStorage.getItem(this.localStorageKey);
62+
if (serializedState) {
63+
try {
64+
this.eventKeys = JSON.parse(serializedState);
65+
} catch {
66+
applicationLogger.error(
67+
`Failed to parse event queue ${this.name} state. Initializing empty queue.`,
68+
);
69+
this.eventKeys = [];
70+
}
71+
}
72+
}
73+
74+
private saveStateToLocalStorage(): void {
75+
const serializedState = JSON.stringify(this.eventKeys);
76+
localStorage.setItem(this.localStorageKey, serializedState);
77+
}
78+
79+
private generateEventKey(event: T): string {
80+
const hash = this.hashEvent(event);
81+
return `eventQueue:${this.name}:${hash}`;
82+
}
83+
84+
private hashEvent(event: T): string {
85+
const serializedEvent = JSON.stringify(event);
86+
let hash = 0;
87+
for (let i = 0; i < serializedEvent.length; i++) {
88+
hash = (hash << 5) - hash + serializedEvent.charCodeAt(i);
89+
hash |= 0; // Convert to 32bit integer
90+
}
91+
return hash.toString(36);
92+
}
93+
}

0 commit comments

Comments
 (0)