From 92d71600ff594476b84d595814aefe819310c1ec Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Wed, 7 Feb 2024 15:58:35 -0800 Subject: [PATCH] fix: preserve pending events when initialization doesn't complete (offline) --- .../AnalyticsReactNativeExample/.detoxrc.js | 93 ------- .../e2e/jest.config.js | 13 - .../e2e/main.e2e.js | 229 ------------------ .../e2e/matchers.js | 34 --- .../e2e/mockServer.js | 56 ----- .../jest.config.js | 3 - .../src/__tests__/methods/process.test.ts | 8 +- packages/core/src/analytics.ts | 10 +- .../storage/__tests__/sovranStorage.test.ts | 31 ++- packages/core/src/storage/sovranStorage.ts | 57 ++++- packages/core/src/storage/types.ts | 6 + .../core/src/test-helpers/mockSegmentStore.ts | 31 +++ packages/sovran/src/store.ts | 6 +- 13 files changed, 127 insertions(+), 450 deletions(-) delete mode 100644 examples/AnalyticsReactNativeExample/.detoxrc.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/jest.config.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/main.e2e.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/matchers.js delete mode 100644 examples/AnalyticsReactNativeExample/e2e/mockServer.js delete mode 100644 examples/AnalyticsReactNativeExample/jest.config.js diff --git a/examples/AnalyticsReactNativeExample/.detoxrc.js b/examples/AnalyticsReactNativeExample/.detoxrc.js deleted file mode 100644 index a93d376b5..000000000 --- a/examples/AnalyticsReactNativeExample/.detoxrc.js +++ /dev/null @@ -1,93 +0,0 @@ -/** @type {Detox.DetoxConfig} */ -module.exports = { - testRunner: { - args: { - '$0': 'jest', - config: 'e2e/jest.config.js' - }, - jest: { - setupTimeout: 120000 - } - }, - behavior: { - init: { - reinstallApp: true, - exposeGlobals: false - }, - launchApp: "auto", - cleanup: { - shutdownDevice: false - } - }, - apps: { - 'ios.debug': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/AnalyticsReactNativeExample.app', - build: 'xcodebuild -workspace ios/AnalyticsReactNativeExample.xcworkspace -scheme AnalyticsReactNativeExample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' - }, - 'ios.release': { - type: 'ios.app', - binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/AnalyticsReactNativeExample.app', - build: 'xcodebuild -workspace ios/AnalyticsReactNativeExample.xcworkspace -scheme AnalyticsReactNativeExample -configuration Release -sdk iphonesimulator -derivedDataPath ios/build' - }, - 'android.debug': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', - build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug', - reversePorts: [ - 8081 - ] - }, - 'android.release': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', - build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release' - } - }, - devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 14' - } - }, - attached: { - type: 'android.attached', - device: { - adbName: '.*' - } - }, - emulator: { - type: 'android.emulator', - device: { - avdName: 'Pixel_API_21_AOSP' - } - } - }, - configurations: { - 'ios.sim.debug': { - device: 'simulator', - app: 'ios.debug' - }, - 'ios.sim.release': { - device: 'simulator', - app: 'ios.release' - }, - 'android.att.debug': { - device: 'attached', - app: 'android.debug' - }, - 'android.att.release': { - device: 'attached', - app: 'android.release' - }, - 'android.emu.debug': { - device: 'emulator', - app: 'android.debug' - }, - 'android.emu.release': { - device: 'emulator', - app: 'android.release' - } - } -}; diff --git a/examples/AnalyticsReactNativeExample/e2e/jest.config.js b/examples/AnalyticsReactNativeExample/e2e/jest.config.js deleted file mode 100644 index 8340b08d5..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/jest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - maxWorkers: 1, - testTimeout: 240000, - rootDir: '..', - testMatch: ['/e2e/**/*.e2e.js'], - verbose: true, - reporters: ['detox/runners/jest/reporter'], - globalSetup: 'detox/runners/jest/globalSetup', - globalTeardown: 'detox/runners/jest/globalTeardown', - testEnvironment: 'detox/runners/jest/testEnvironment', -}; - diff --git a/examples/AnalyticsReactNativeExample/e2e/main.e2e.js b/examples/AnalyticsReactNativeExample/e2e/main.e2e.js deleted file mode 100644 index 9069366fc..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/main.e2e.js +++ /dev/null @@ -1,229 +0,0 @@ -const { element, by, device } = require('detox'); - -import { startServer, stopServer } from './mockServer'; -import { setupMatchers } from './matchers'; -import { retry } from 'ts-retry-promise' - -const launchApp = async ( - launchArgs = { - newInstance: true, - launchArgs: { - detoxPrintBusyIdleResources: 'YES', - }, - } -) => { - await retry( - async () => { - try { - await device.launchApp(launchArgs) - } catch (error) { - error.message = `Failed to launch app with error: ${error.message}` - throw error - } - }, - { retries: 5, delay: 10 * 1000, timeout: 30 * 10000 } - ) -} - -const reloadReactNative = async () => { - await retry( - async () => { - try { - await device.reloadReactNative() - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to reload react native with error', error) - await launchApp() - } - }, - { retries: 5, delay: 10 * 1000, timeout: 30 * 10000 } - ) -} - -describe('#mainTest', () => { - const mockServerListener = jest.fn(); - - const trackButton = element(by.id('BUTTON_TRACK')); - const screenButton = element(by.id('BUTTON_SCREEN')); - const identifyButton = element(by.id('BUTTON_IDENTIFY')); - const groupButton = element(by.id('BUTTON_GROUP')); - const aliasButton = element(by.id('BUTTON_ALIAS')); - const resetButton = element(by.id('BUTTON_RESET')); - const flushButton = element(by.id('BUTTON_FLUSH')); - - beforeAll(async () => { - await startServer(mockServerListener); - await launchApp(); - setupMatchers(); - }); - - const clearLifecycleEvents = async () => { - await flushButton.tap(); - - mockServerListener.mockClear(); - expect(mockServerListener).not.toHaveBeenCalled(); - }; - - beforeEach(async () => { - mockServerListener.mockReset(); - await reloadReactNative(); - }); - - afterAll(async () => { - await stopServer(); - }); - - it('checks that lifecycle methods are triggered', async () => { - await flushButton.tap(); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Opened', - }); - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Installed', - }); - }); - - it('checks that track & screen methods are logged', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await screenButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(2); - expect(events).toHaveEventWith({ type: 'track', event: 'Track pressed' }); - expect(events).toHaveEventWith({ type: 'screen', name: 'Home Screen' }); - }); - - it('checks the identify method', async () => { - await clearLifecycleEvents(); - - await identifyButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ - type: 'identify', - userId: 'user_2', - }); - }); - - it('checks the group method', async () => { - await clearLifecycleEvents(); - - await groupButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ type: 'group', groupId: 'best-group' }); - }); - - it('checks the alias method', async () => { - await clearLifecycleEvents(); - - await aliasButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - expect(events).toHaveLength(1); - expect(events).toHaveEventWith({ type: 'alias', userId: 'new-id' }); - }); - - it('reset the client and checks the user id', async () => { - await clearLifecycleEvents(); - - await identifyButton.tap(); - await trackButton.tap(); - await resetButton.tap(); - await screenButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - const screenEvent = events.find((item) => item.type === 'screen'); - - expect(events).toHaveLength(3); - expect(events).toHaveEventWith({ type: 'identify', userId: 'user_2' }); - expect(events).toHaveEventWith({ type: 'track', userId: 'user_2' }); - expect(screenEvent.userId).toBeUndefined(); - }); - - it('checks that the context is set properly', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const request = mockServerListener.mock.calls[0][0]; - const context = request.batch[0].context; - - expect(request.batch).toHaveLength(1); - expect(context.app.name).toBe('AnalyticsReactNativeExample'); - expect(context.app.version).toBe('1.0'); - expect(context.library.name).toBe('@segment/analytics-react-native'); - expect(context.locale).toBe('en-US'); - // This test only works in iOS for now - if (device.getPlatform() === 'ios') { - expect(context.network.wifi).toBe(true); - } - }); - - it('checks that persistence is working', async () => { - await clearLifecycleEvents(); - - await trackButton.tap(); - await identifyButton.tap(); - - await device.sendToHome(); - await device.launchApp({ newInstance: true }); - - await flushButton.tap(); - - expect(mockServerListener).toHaveBeenCalledTimes(1); - - const events = mockServerListener.mock.calls[0][0].batch; - - const platform = device.getPlatform(); - - expect(events).toHaveLength(platform === 'android' ? 4 : 3); // Track + Identify + App Launch (+ Backgrounded on Android) - expect(events).toHaveEventWith({ type: 'identify', userId: 'user_2' }); - expect(events).toHaveEventWith({ type: 'track', userId: 'user_2' }); - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Opened', - }); - // Android only - // RN in Android immediately halts JS execution when leaving the app and sends the BG event (in iOS it happens after a short while when the OS decides to) - // Hence in Android the event list will contain this extra event - if (platform === 'android') { - expect(events).toHaveEventWith({ - type: 'track', - event: 'Application Backgrounded', - }); - } - }); -}); diff --git a/examples/AnalyticsReactNativeExample/e2e/matchers.js b/examples/AnalyticsReactNativeExample/e2e/matchers.js deleted file mode 100644 index a922ff138..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/matchers.js +++ /dev/null @@ -1,34 +0,0 @@ -/* globals expect */ - -export const setupMatchers = () => { - expect.extend({ - toHaveEvent(events, eventType) { - return { - message: () => `Expect events to contain a ${eventType} event`, - pass: events.some((item) => item.type === eventType), - }; - }, - - toHaveEventWith(events, eventAtts) { - const hasEvent = events.some((item) => { - let isValid = true; - for (const [key, value] of Object.entries(eventAtts)) { - if (!(key in item)) { - isValid = false; - } else if (key in item && item[key] !== value) { - isValid = false; - } - } - return isValid; - }); - - return { - message: () => - `Expect events to contain an object with attributes: ${JSON.stringify( - eventAtts - )}`, - pass: hasEvent, - }; - }, - }); -}; diff --git a/examples/AnalyticsReactNativeExample/e2e/mockServer.js b/examples/AnalyticsReactNativeExample/e2e/mockServer.js deleted file mode 100644 index c38d644bc..000000000 --- a/examples/AnalyticsReactNativeExample/e2e/mockServer.js +++ /dev/null @@ -1,56 +0,0 @@ -const express = require('express'); -const bodyParser = require('body-parser'); - -const port = 9091; - -let server; - -export const startServer = async (mockServerListener) => { - if (server) { - throw new Error('Server is already running'); - } - - return new Promise((resolve) => { - const app = express(); - - app.use(bodyParser.json()); - - // Handles batch events - app.post('/events', (req, res) => { - console.log(`➡️ Received request`); - const body = req.body; - mockServerListener(body); - - res.status(200).send({ mockSuccess: true }); - }); - - // Handles settings calls - app.get('/settings/:writeKey/*', (req, res) => { - console.log(`➡️ Replying with Settings`); - res.status(200).send({ - integrations: { - 'Segment.io': {}, - }, - }); - }); - - server = app.listen(port, () => { - console.log(`🚀 Started mock server on port ${port}`); - resolve(); - }); - }); -}; - -export const stopServer = async () => { - return new Promise((resolve, reject) => { - if (server) { - server.close(() => { - console.log('✋ Mock server has stopped'); - server = undefined; - resolve(); - }); - } else { - reject('⚠️ Mock server is not running'); - } - }); -}; diff --git a/examples/AnalyticsReactNativeExample/jest.config.js b/examples/AnalyticsReactNativeExample/jest.config.js deleted file mode 100644 index 8eb675e9b..000000000 --- a/examples/AnalyticsReactNativeExample/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - preset: 'react-native', -}; diff --git a/packages/core/src/__tests__/methods/process.test.ts b/packages/core/src/__tests__/methods/process.test.ts index ba99ca2e2..bebece19e 100644 --- a/packages/core/src/__tests__/methods/process.test.ts +++ b/packages/core/src/__tests__/methods/process.test.ts @@ -35,7 +35,7 @@ describe('process', () => { jest.clearAllMocks(); }); - it('stamps basic data: timestamp and messageId for events when not ready', async () => { + it('stamps basic data: timestamp and messageId for pending events when not ready', async () => { const client = new SegmentClient(clientArgs); jest.spyOn(client.isReady, 'value', 'get').mockReturnValue(false); // @ts-ignore @@ -54,7 +54,7 @@ describe('process', () => { // While not ready only timestamp and messageId should be defined // @ts-ignore - const pendingEvents = client.pendingEvents; + const pendingEvents = client.store.pendingEvents.get(); expect(pendingEvents.length).toBe(1); const pendingEvent = pendingEvents[0]; expect(pendingEvent).toMatchObject(expectedEvent); @@ -76,7 +76,7 @@ describe('process', () => { }; // @ts-ignore - expect(client.pendingEvents.length).toBe(0); + expect(client.store.pendingEvents.get().length).toBe(0); expect(timeline.process).toHaveBeenCalledWith( expect.objectContaining(expectedEvent) @@ -105,7 +105,7 @@ describe('process', () => { } as SegmentEvent; // @ts-ignore - const pendingEvents = client.pendingEvents; + const pendingEvents = client.store.pendingEvents.get(); expect(pendingEvents.length).toBe(0); expect(timeline.process).toHaveBeenCalledWith( diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 3355e4193..d7f3c77aa 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -86,8 +86,6 @@ export class SegmentClient { private timeline: Timeline; - private pendingEvents: SegmentEvent[] = []; - private pluginsToAdd: Plugin[] = []; private flushPolicyExecuter!: FlushPolicyExecuter; @@ -431,7 +429,7 @@ export class SegmentClient { if (this.isReady.value) { return this.startTimelineProcessing(event); } else { - this.pendingEvents.push(event); + this.store.pendingEvents.add(event); return event; } } @@ -497,10 +495,12 @@ export class SegmentClient { } // Send all events in the queue - for (const e of this.pendingEvents) { + const pending = await this.store.pendingEvents.get(true); + for (const e of pending) { await this.startTimelineProcessing(e); + await this.store.pendingEvents.remove(e); } - this.pendingEvents = []; + // this.store.pendingEvents.set([]); } async flush(): Promise { diff --git a/packages/core/src/storage/__tests__/sovranStorage.test.ts b/packages/core/src/storage/__tests__/sovranStorage.test.ts index d49bba29f..babbd1fd0 100644 --- a/packages/core/src/storage/__tests__/sovranStorage.test.ts +++ b/packages/core/src/storage/__tests__/sovranStorage.test.ts @@ -4,15 +4,15 @@ import { createCallbackManager as mockCreateCallbackManager } from '../../test-h import { SovranStorage } from '../sovranStorage'; import type { Persistor } from '@segment/sovran-react-native'; -import type { Context, DeepPartial } from '../../types'; +import { EventType, type Context, type DeepPartial, SegmentEvent } from '../../types'; jest.mock('@segment/sovran-react-native', () => ({ registerBridgeStore: jest.fn(), createStore: (initialState: T) => { const callbackManager = mockCreateCallbackManager(); - let store = { - ...initialState, - }; + let store: T = Array.isArray(initialState) ? + [...initialState] as T: + {...initialState} as T; return { subscribe: jest @@ -30,7 +30,9 @@ jest.mock('@segment/sovran-react-native', () => ({ return store; } ), - getState: jest.fn().mockImplementation(() => ({ ...store })), + getState: jest.fn().mockImplementation(() => { + return Array.isArray(store) ? [...store] : { ...store } + }), }; }, })); @@ -125,4 +127,23 @@ describe('sovranStorage', () => { }); await commonAssertions(sovran); }); + + it('adds/removes pending events', async () => { + const sovran = new SovranStorage({ storeId: 'test' }); + console.log(sovran.pendingEvents.get()); + + // expect(sovran.pendingEvents.get().length).toBe(0); + + let event: SegmentEvent = { + messageId: '1', + type: EventType.TrackEvent, + event: "Track" + }; + await sovran.pendingEvents.add(event); + + expect(sovran.pendingEvents.get().length).toBe(1); + + await sovran.pendingEvents.remove(event); + expect(sovran.pendingEvents.get().length).toBe(0); + }) }); diff --git a/packages/core/src/storage/sovranStorage.ts b/packages/core/src/storage/sovranStorage.ts index 032f75138..15472a41f 100644 --- a/packages/core/src/storage/sovranStorage.ts +++ b/packages/core/src/storage/sovranStorage.ts @@ -28,21 +28,19 @@ import type { Settable, Dictionary, ReadinessStore, + Queue, } from './types'; type Data = { - events: SegmentEvent[]; - eventsToRetry: SegmentEvent[]; context: DeepPartial; settings: SegmentAPIIntegrations; consentSettings: SegmentAPIConsentSettings | undefined; userInfo: UserInfoState; filters: DestinationFilters; + pendingEvents: SegmentEvent[]; }; const INITIAL_VALUES: Data = { - events: [], - eventsToRetry: [], context: {}, settings: {}, consentSettings: undefined, @@ -52,6 +50,7 @@ const INITIAL_VALUES: Data = { userId: undefined, traits: undefined, }, + pendingEvents: [] }; const isEverythingReady = (state: ReadinessStore) => @@ -115,7 +114,7 @@ const addAnonymousId = }; function createStoreGetter< - U extends Record, + U extends object, Z extends keyof U | undefined = undefined, V = undefined, >(store: Store, key?: Z): getStateFunc { @@ -151,6 +150,8 @@ export class SovranStorage implements Storage { private userInfoStore: Store<{ userInfo: UserInfoState }>; private deepLinkStore: Store = deepLinkStore; private filtersStore: Store; + private pendingStore: Store; + readonly isReady: Watchable; @@ -172,6 +173,10 @@ export class SovranStorage implements Storage { readonly deepLinkData: Watchable; + readonly pendingEvents: Watchable & + Settable & + Queue; + constructor(config: StorageConfig) { this.storeId = config.storeId; this.storePersistor = config.storePersistor; @@ -181,6 +186,7 @@ export class SovranStorage implements Storage { hasRestoredSettings: false, hasRestoredUserInfo: false, hasRestoredFilters: false, + hasRestoredPendingEvents: false }); const markAsReadyGenerator = (key: keyof ReadinessStore) => () => { @@ -385,6 +391,47 @@ export class SovranStorage implements Storage { }, }; + // Pending Events + this.pendingStore = createStore( + INITIAL_VALUES.pendingEvents, + { + persist: { + storeId: `${this.storeId}-pendingEvents`, + persistor: this.storePersistor, + saveDelay: this.storePersistorSaveDelay, + onInitialized: markAsReadyGenerator('hasRestoredPendingEvents') + } + } + ) + + this.pendingEvents = { + get: createStoreGetter(this.pendingStore), + onChange: (callback: (value: SegmentEvent[]) => void) => + this.pendingStore.subscribe((store) => callback(store)), + set: async (value) => { + return await this.pendingStore.dispatch((state) => { + let newState: SegmentEvent[]; + if (value instanceof Function) { + newState = value(state); + } else { + newState = [...value]; + } + return newState + }) + }, + add: (event: SegmentEvent) => { + return this.pendingStore.dispatch((events) => ([ + ...events, + event + ])) + }, + remove: (event: SegmentEvent) => { + return this.pendingStore.dispatch((events) => + events.filter((e) => e.messageId != event.messageId) + ) + } + } + registerBridgeStore({ store: this.userInfoStore, actions: { diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts index a1baee174..cc7edbfe2 100644 --- a/packages/core/src/storage/types.ts +++ b/packages/core/src/storage/types.ts @@ -7,6 +7,7 @@ import type { IntegrationSettings, RoutingRule, SegmentAPIIntegrations, + SegmentEvent, UserInfoState, } from '../types'; @@ -57,6 +58,7 @@ export interface ReadinessStore { hasRestoredSettings: boolean; hasRestoredUserInfo: boolean; hasRestoredFilters: boolean; + hasRestoredPendingEvents: boolean; } /** @@ -82,6 +84,10 @@ export interface Storage { readonly userInfo: Watchable & Settable; readonly deepLinkData: Watchable; + + readonly pendingEvents: Watchable & + Settable & + Queue; } export type DeepLinkData = { referring_application: string; diff --git a/packages/core/src/test-helpers/mockSegmentStore.ts b/packages/core/src/test-helpers/mockSegmentStore.ts index 4504d42fc..84e78bd21 100644 --- a/packages/core/src/test-helpers/mockSegmentStore.ts +++ b/packages/core/src/test-helpers/mockSegmentStore.ts @@ -2,6 +2,7 @@ import { SEGMENT_DESTINATION_KEY } from '../plugins/SegmentDestination'; import type { DeepLinkData, Dictionary, + Queue, Settable, Storage, Watchable, @@ -14,6 +15,7 @@ import type { RoutingRule, SegmentAPIConsentSettings, SegmentAPIIntegrations, + SegmentEvent, UserInfoState, } from '../types'; import { createCallbackManager } from './utils'; @@ -27,6 +29,7 @@ export type StoreData = { filters: DestinationFilters; userInfo: UserInfoState; deepLinkData: DeepLinkData; + pendingEvents: SegmentEvent[]; }; const INITIAL_VALUES: StoreData = { @@ -46,6 +49,7 @@ const INITIAL_VALUES: StoreData = { referring_application: '', url: '', }, + pendingEvents: [] }; export function createMockStoreGetter(fn: () => T) { @@ -80,6 +84,7 @@ export class MockSegmentStore implements Storage { filters: createCallbackManager(), userInfo: createCallbackManager(), deepLinkData: createCallbackManager(), + pendingEvents: createCallbackManager(), }; readonly isReady = { @@ -188,4 +193,30 @@ export class MockSegmentStore implements Storage { onChange: (callback: (value: DeepLinkData) => void) => this.callbacks.deepLinkData.register(callback), }; + + readonly pendingEvents: Watchable & Settable & Queue = { + get: createMockStoreGetter(() => { + return this.data.pendingEvents + }), + set: (value) => { + this.data.pendingEvents = + value instanceof Function + ? value(this.data.pendingEvents ?? []) + : [ ...value ]; + this.callbacks.pendingEvents.run(this.data.pendingEvents) + return this.data.pendingEvents + }, + add: (value: SegmentEvent) => { + this.data.pendingEvents.push(value); + this.callbacks.pendingEvents.run(this.data.pendingEvents); + return Promise.resolve(this.data.pendingEvents) + }, + remove: (value: SegmentEvent) => { + this.data.pendingEvents = this.data.pendingEvents.filter((e) => e.messageId != value.messageId) + this.callbacks.pendingEvents.run(this.data.pendingEvents); + return Promise.resolve(this.data.pendingEvents) + }, + onChange: (callback: (value: SegmentEvent[]) => void) => + this.callbacks.pendingEvents.register(callback), + } } diff --git a/packages/sovran/src/store.ts b/packages/sovran/src/store.ts index 53399bc31..4ac5d4723 100644 --- a/packages/sovran/src/store.ts +++ b/packages/sovran/src/store.ts @@ -102,7 +102,7 @@ export const createStore = ( initialState: T, config?: StoreConfig ): Store => { - let state = initialState; + let state: T = Array.isArray(initialState) ? [...initialState] as T : {...initialState} as T; const queue: { call: Action; finally?: (newState: T) => void }[] = []; const isPersisted = config?.persist !== undefined; let saveTimeout: ReturnType | undefined; @@ -163,7 +163,7 @@ export const createStore = ( function getState(safe: true): Promise; function getState(safe?: boolean): T | Promise { if (safe !== true) { - return { ...state }; + return Array.isArray(state) ? [...state] as T: { ...state }; } return new Promise((resolve) => { queue.push({ @@ -192,7 +192,7 @@ export const createStore = ( const action = queue.shift(); try { if (action !== undefined) { - const newState = await action.call(state); + const newState = await action.call(state as T); if (newState !== state) { state = newState; // TODO: Debounce notifications