diff --git a/packages/core/src/__tests__/analytics.test.ts b/packages/core/src/__tests__/analytics.test.ts index 0fd4377a6..ad2ac0353 100644 --- a/packages/core/src/__tests__/analytics.test.ts +++ b/packages/core/src/__tests__/analytics.test.ts @@ -1,17 +1,23 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import type { AppStateStatus } from 'react-native'; +import * as ReactNative from 'react-native'; +import { EventType, IdentifyEventType } from '..'; import { SegmentClient } from '../analytics'; +import * as checkInstalledVersion from '../internal/checkInstalledVersion'; +import * as flushRetry from '../internal/flushRetry'; +import * as handleAppStateChange from '../internal/handleAppStateChange'; +import * as trackDeepLinks from '../internal/trackDeepLinks'; import { Logger } from '../logger'; -import * as ReactNative from 'react-native'; import * as alias from '../methods/alias'; +import * as flush from '../methods/flush'; import * as group from '../methods/group'; import * as identify from '../methods/identify'; import * as screen from '../methods/screen'; import * as track from '../methods/track'; -import * as flush from '../methods/flush'; -import * as flushRetry from '../internal/flushRetry'; -import * as checkInstalledVersion from '../internal/checkInstalledVersion'; -import * as handleAppStateChange from '../internal/handleAppStateChange'; -import * as trackDeepLinks from '../internal/trackDeepLinks'; -import type { AppStateStatus } from 'react-native'; +import { actions, Store } from '../store'; +import mainSlice from '../store/main'; +import systemSlice from '../store/system'; +import userInfo from '../store/userInfo'; import { getMockStore } from './__helpers__/mockStore'; jest.mock('redux-persist', () => { @@ -109,7 +115,8 @@ describe('SegmentClient initialise', () => { segmentClient.setupStoreSubscribe(); - expect(clientArgs.store.subscribe).toHaveBeenCalledTimes(1); + // Each watcher generates a subscription so we just check that it has subscribed at least once + expect(clientArgs.store.subscribe).toHaveBeenCalled(); }); }); @@ -147,7 +154,8 @@ describe('SegmentClient initialise', () => { const segmentClient = new SegmentClient(clientArgs); // @ts-ignore actual value is irrelevant segmentClient.interval = 'INTERVAL'; - segmentClient.unsubscribe = jest.fn(); + const unsubscribe = jest.fn(); + segmentClient.watchers = [unsubscribe]; // @ts-ignore actual value is irrelevant segmentClient.refreshTimeout = 'TIMEOUT'; segmentClient.appStateSubscription = { @@ -158,7 +166,7 @@ describe('SegmentClient initialise', () => { expect(segmentClient.destroyed).toBe(true); expect(clearInterval).toHaveBeenCalledTimes(1); expect(clearInterval).toHaveBeenCalledWith('INTERVAL'); - expect(segmentClient.unsubscribe).toHaveBeenCalledTimes(1); + expect(unsubscribe).toHaveBeenCalledTimes(1); expect(clearTimeout).toHaveBeenCalledTimes(1); expect(clearTimeout).toHaveBeenCalledWith('TIMEOUT'); expect(segmentClient.appStateSubscription.remove).toHaveBeenCalledTimes( @@ -349,8 +357,29 @@ describe('SegmentClient #onUpdateStore', () => { actions: {}, }; + const sampleEvent: IdentifyEventType = { + userId: 'user-123', + anonymousId: 'eWpqvL-EHSHLWoiwagN-T', + type: EventType.IdentifyEvent, + integrations: {}, + timestamp: '2000-01-01T00:00:00.000Z', + traits: { + foo: 'bar', + }, + messageId: 'iDMkR2-I7c2_LCsPPlvwH', + }; + + const rootReducer = combineReducers({ + main: mainSlice.reducer, + system: systemSlice.reducer, + userInfo: userInfo.reducer, + }); + let mockStore = configureStore({ reducer: rootReducer }) as Store; + beforeEach(() => { jest.useFakeTimers(); + // Reset the Redux store to a clean state + mockStore = configureStore({ reducer: rootReducer }) as Store; }); afterEach(() => { @@ -358,143 +387,68 @@ describe('SegmentClient #onUpdateStore', () => { jest.clearAllMocks(); }); - it('calls flush when there are unsent events', () => { + /** + * Creates a client wired up with store subscriptions and flush mocks for testing automatic flushes + */ + const setupClient = (flushAt: number): SegmentClient => { const args = { ...clientArgs, config: { ...clientArgs.config, - flushAt: 1, - }, - store: { - ...clientArgs.store, - getState: jest.fn().mockReturnValue({ - main: { - events: [{ messageId: '1' }], - eventsToRetry: [], - }, - system: { - settings: {}, - }, - }), + flushAt, }, + store: mockStore, + actions: actions, }; const client = new SegmentClient(args); + // It is important to setup the flush spy before setting up the subscriptions so that it tracks the calls in the closure jest.spyOn(client, 'flush').mockResolvedValueOnce(); - client.onUpdateStore(); + jest.spyOn(client, 'flushRetry').mockResolvedValueOnce(); + client.setupStoreSubscribe(); + return client; + }; + it('calls flush when there are unsent events', () => { + const client = setupClient(1); + mockStore.dispatch(mainSlice.actions.addEvent({ event: sampleEvent })); expect(client.flush).toHaveBeenCalledTimes(1); }); it('does not flush when number of events does not exceed the flush threshold', () => { - const args = { - ...clientArgs, - config: { - ...clientArgs.config, - flushAt: 2, - }, - store: { - ...clientArgs.store, - getState: jest.fn().mockReturnValue({ - main: { - events: [{ messageId: '1' }], - eventsToRetry: [], - }, - system: { - settings: {}, - }, - }), - }, - }; - const client = new SegmentClient(args); - jest.spyOn(client, 'flush').mockResolvedValueOnce(); - client.onUpdateStore(); - + const client = setupClient(2); + mockStore.dispatch(mainSlice.actions.addEvent({ event: sampleEvent })); expect(client.flush).not.toHaveBeenCalled(); }); it('does not call flush when there are no events to send', () => { - const args = { - ...clientArgs, - config: { - ...clientArgs.config, - flushAt: 1, - }, - store: { - ...clientArgs.store, - getState: jest.fn().mockReturnValue({ - main: { - events: [], - eventsToRetry: [], - }, - system: { - settings: {}, - }, - }), - }, - }; - const client = new SegmentClient(args); - jest.spyOn(client, 'flush').mockResolvedValueOnce(); - jest.spyOn(client, 'flushRetry').mockResolvedValueOnce(); - client.onUpdateStore(); - + const client = setupClient(1); expect(client.flush).not.toHaveBeenCalled(); expect(client.flushRetry).not.toHaveBeenCalled(); }); it('flushes retry queue when it is non-empty', () => { - const args = { - ...clientArgs, - config: { - ...clientArgs.config, - flushAt: 2, - }, - store: { - ...clientArgs.store, - getState: jest.fn().mockReturnValue({ - main: { - events: [], - eventsToRetry: [{ messageId: '1' }], - }, - system: { - settings: {}, - }, - }), - }, - }; - const client = new SegmentClient(args); - jest.spyOn(client, 'flush').mockResolvedValueOnce(); - client.onUpdateStore(); + const client = setupClient(2); - expect(setTimeout).toHaveBeenLastCalledWith( - expect.any(Function), - args.config.retryInterval! * 1000 + mockStore.dispatch( + mainSlice.actions.addEventsToRetry({ + events: [sampleEvent], + config: { ...clientArgs.config }, + }) ); - expect(client.refreshTimeout).not.toBeNull(); + + expect(client.flushRetry).toHaveBeenCalledTimes(1); }); it('does not flush the retry queue when the refreshTimeout is not null', () => { - const args = { - ...clientArgs, - config: { - ...clientArgs.config, - flushAt: 2, - }, - store: { - ...clientArgs.store, - getState: jest.fn().mockReturnValue({ - main: { - events: [], - eventsToRetry: [{ messageId: '1' }], - }, - system: { - settings: {}, - }, - }), - }, - }; - const client = new SegmentClient(args); + const client = setupClient(2); client.refreshTimeout = jest.fn() as any; - client.onUpdateStore(); + + mockStore.dispatch( + mainSlice.actions.addEventsToRetry({ + events: [sampleEvent], + config: { ...clientArgs.config }, + }) + ); expect(setTimeout).not.toHaveBeenCalled(); }); @@ -579,8 +533,10 @@ describe('SegmentClient #flushRetry', () => { it('calls the screen method', async () => { const flushRetrySpy = jest.spyOn(flushRetry, 'default').mockResolvedValue(); const client = new SegmentClient(clientArgs); + client.setupStoreSubscribe(); await client.flushRetry(); + jest.runAllTimers(); expect(flushRetrySpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index f704d161b..13f4faea3 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -1,3 +1,18 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; + +import { actions, getStoreWatcher, initializeStore, Store } from '../store'; +import { + default as mainSlice, + initialState as mainInitialState, +} from '../store/main'; +import { + default as systemSlice, + initialState as systemInitialState, +} from '../store/system'; +import { + default as userInfo, + initialState as userInfoInitialState, +} from '../store/userInfo'; import { Context, EventType, @@ -5,10 +20,6 @@ import { ScreenEventType, TrackEventType, } from '../types'; -import { initializeStore, actions } from '../store'; -import { initialState as mainInitialState } from '../store/main'; -import { initialState as systemInitialState } from '../store/system'; -import { initialState as userInfoInitialState } from '../store/userInfo'; const initialState = { main: mainInitialState, @@ -406,4 +417,47 @@ describe('#initializeStore', () => { }); }); }); + + describe('getStoreWatcher', () => { + const event = { + userId: 'user-123', + anonymousId: 'eWpqvL-EHSHLWoiwagN-T', + type: EventType.IdentifyEvent, + integrations: {}, + timestamp: '2000-01-01T00:00:00.000Z', + traits: { + foo: 'bar', + }, + messageId: 'iDMkR2-I7c2_LCsPPlvwH', + } as IdentifyEventType; + + const rootReducer = combineReducers({ + main: mainSlice.reducer, + system: systemSlice.reducer, + userInfo: userInfo.reducer, + }); + let mockStore = configureStore({ reducer: rootReducer }) as Store; + + beforeEach(() => { + jest.useFakeTimers(); + // Reset the Redux store to a clean state + mockStore = configureStore({ reducer: rootReducer }) as Store; + }); + + it('subscribes to changes in the selected objects', () => { + const subscription = jest.fn(); + const watcher = getStoreWatcher(mockStore); + watcher((state) => state.main.events, subscription); + mockStore.dispatch(mainSlice.actions.addEvent({ event })); + expect(subscription).toHaveBeenCalledTimes(1); + }); + + it('no trigger for changes in non-selected objects', () => { + const subscription = jest.fn(); + const watcher = getStoreWatcher(mockStore); + watcher((state) => state.main.eventsToRetry, subscription); + mockStore.dispatch(mainSlice.actions.addEvent({ event })); + expect(subscription).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 4e8a19631..c40b7bb44 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -1,33 +1,39 @@ -import { AppState, AppStateStatus } from 'react-native'; -import type { - Config, - JsonMap, - GroupTraits, - UserTraits, - SegmentEvent, - Store, - SegmentAPISettings, -} from './types'; -import type { Logger } from './logger'; import type { Unsubscribe } from '@reduxjs/toolkit'; +import { AppState, AppStateStatus } from 'react-native'; import type { Persistor } from 'redux-persist'; -import track from './methods/track'; -import screen from './methods/screen'; -import identify from './methods/identify'; -import flush from './methods/flush'; -import group from './methods/group'; -import alias from './methods/alias'; + +import { applyRawEventData } from './events'; import checkInstalledVersion from './internal/checkInstalledVersion'; -import handleAppStateChange from './internal/handleAppStateChange'; import flushRetry from './internal/flushRetry'; import getSettings from './internal/getSettings'; +import handleAppStateChange from './internal/handleAppStateChange'; import trackDeepLinks from './internal/trackDeepLinks'; -import { Timeline } from './timeline'; -import { SegmentDestination } from './plugins/SegmentDestination'; -import { InjectContext } from './plugins/Context'; +import type { Logger } from './logger'; +import alias from './methods/alias'; +import flush from './methods/flush'; +import group from './methods/group'; +import identify from './methods/identify'; +import screen from './methods/screen'; +import track from './methods/track'; import type { DestinationPlugin, PlatformPlugin, Plugin } from './plugin'; -import type { actions as ReduxActions } from './store'; -import { applyRawEventData } from './events'; +import { InjectContext } from './plugins/Context'; +import { SegmentDestination } from './plugins/SegmentDestination'; +import { + actions as ReduxActions, + getEvents, + getEventsToRetry, + getStoreWatcher, + Store, +} from './store'; +import { Timeline } from './timeline'; +import type { + Config, + GroupTraits, + JsonMap, + SegmentAPISettings, + SegmentEvent, + UserTraits, +} from './types'; export class SegmentClient { // the config parameters for the client - a merge of user provided and default options @@ -55,19 +61,32 @@ export class SegmentClient { logger: Logger; // timeout for refreshing the failed events queue - refreshTimeout: NodeJS.Timeout | null = null; + refreshTimeout: ReturnType | null = null; // internal time to know when to flush, ticks every second - interval: NodeJS.Timeout | null = null; + interval: ReturnType | null = null; - // unsubscribe for the redux store - unsubscribe: Unsubscribe | null = null; + // unsubscribe watchers for the redux store + watchers: Unsubscribe[] = []; // whether the user has called cleanup destroyed: boolean = false; + // has a pending upload to respond + isPendingUpload: boolean = false; + + // has a pending upload of the events to retry upload + isPendingRetryUpload: boolean = false; + + isAddingPlugins: boolean = false; + timeline: Timeline; + /** + * Watches changes to redux store + */ + watch: ReturnType; + // mechanism to prevent adding plugins before we are fully initalised private isReady = false; private pluginsToAdd: Plugin[] = []; @@ -116,6 +135,8 @@ export class SegmentClient { this.persistor = persistor; this.timeline = new Timeline(); + this.watch = getStoreWatcher(this.store); + // Get everything running this.platformStartup(); } @@ -142,6 +163,21 @@ export class SegmentClient { await getSettings.bind(this)(); } + /** + * Clears all subscriptions to the redux store + */ + private unsubscribeWatchers() { + if (this.watchers.length > 0) { + for (const unsubscribe of this.watchers) { + try { + unsubscribe(); + } catch (e) { + this.logger.error(e); + } + } + } + } + /** * There is no garbage collection in JS, which means that any listeners, timeouts and subscriptions * would run until the application closes @@ -157,9 +193,7 @@ export class SegmentClient { clearInterval(this.interval); } - if (this.unsubscribe) { - this.unsubscribe(); - } + this.unsubscribeWatchers(); if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); @@ -193,10 +227,24 @@ export class SegmentClient { } setupStoreSubscribe() { - if (this.unsubscribe) { - this.unsubscribe(); - } - this.unsubscribe = this.store.subscribe(() => this.onUpdateStore()); + this.unsubscribeWatchers(); + this.watchers.push(this.store.subscribe(() => this.onUpdateStore())); + + this.watchers.push( + this.watch(getEvents, (events: SegmentEvent[]) => { + if (events.length >= this.config.flushAt!) { + this.flush(); + } + }) + ); + + this.watchers.push( + this.watch(getEventsToRetry, (events: SegmentEvent[]) => { + if (events.length >= 0) { + this.flushRetry(); + } + }) + ); } setupLifecycleEvents() { @@ -292,56 +340,59 @@ export class SegmentClient { } onUpdateStore() { - const { main } = this.store.getState(); - - if (this.pluginsToAdd.length > 0) { - // start by adding the plugins - this.pluginsToAdd.forEach((plugin) => { - this.addPlugin(plugin); - }); - - // filter to see if we need to register any - const destPlugins = this.pluginsToAdd.filter( - this.isNonSegmentDestinationPlugin - ); - - // now that they're all added, clear the cache - // this prevents this block running for every update - this.pluginsToAdd = []; + if (this.pluginsToAdd.length > 0 && !this.isAddingPlugins) { + this.isAddingPlugins = true; + try { + // start by adding the plugins + this.pluginsToAdd.forEach((plugin) => { + this.addPlugin(plugin); + }); - // if we do have destPlugins, bulk-register them with the system - // this isn't done as part of addPlugin to avoid dispatching an update as part of an update - // which can lead to an infinite loop - // this is safe to fire & forget here as we've cleared pluginsToAdd - if (destPlugins.length > 0) { - this.store.dispatch( - this.actions.system.addIntegrations( - (destPlugins as DestinationPlugin[]).map(({ key }) => ({ key })) - ) + // filter to see if we need to register any + const destPlugins = this.pluginsToAdd.filter( + this.isNonSegmentDestinationPlugin ); - } - - // finally set the flag which means plugins will be added + registered immediately in future - this.isReady = true; - } - const numEvents = main.events.length; - if (numEvents >= this.config.flushAt!) { - this.flush(); - } - - const numEventsToRetry = main.eventsToRetry.length; - if (numEventsToRetry && this.refreshTimeout === null) { - const retryIntervalMs = this.config.retryInterval! * 1000; - this.refreshTimeout = setTimeout( - () => this.flushRetry(), - retryIntervalMs - ) as any; + // now that they're all added, clear the cache + // this prevents this block running for every update + this.pluginsToAdd = []; + + // if we do have destPlugins, bulk-register them with the system + // this isn't done as part of addPlugin to avoid dispatching an update as part of an update + // which can lead to an infinite loop + // this is safe to fire & forget here as we've cleared pluginsToAdd + if (destPlugins.length > 0) { + this.store.dispatch( + this.actions.system.addIntegrations( + (destPlugins as DestinationPlugin[]).map(({ key }) => ({ key })) + ) + ); + } + + // finally set the flag which means plugins will be added + registered immediately in future + this.isReady = true; + } finally { + this.isAddingPlugins = false; + } } } async flushRetry() { - await flushRetry.bind(this)(); + if (this.refreshTimeout === null) { + const retryIntervalMs = this.config.retryInterval! * 1000; + this.refreshTimeout = setTimeout(() => { + (async () => { + if (!this.isPendingRetryUpload) { + this.isPendingRetryUpload = true; + try { + await flushRetry.bind(this)(); + } finally { + this.isPendingRetryUpload = false; + } + } + })(); + }, retryIntervalMs); + } } private tick() { @@ -353,7 +404,14 @@ export class SegmentClient { } async flush() { - await flush.bind(this)(); + if (!this.isPendingUpload) { + this.isPendingUpload = true; + try { + await flush.bind(this)(); + } finally { + this.isPendingUpload = false; + } + } } screen(name: string, options?: JsonMap) { diff --git a/packages/core/src/client.tsx b/packages/core/src/client.tsx index bca981022..8e06224ac 100644 --- a/packages/core/src/client.tsx +++ b/packages/core/src/client.tsx @@ -1,23 +1,13 @@ import React, { createContext, useContext } from 'react'; +import { PersistGate } from 'redux-persist/integration/react'; + +import { defaultConfig } from './constants'; import type { Config, ClientMethods } from './types'; import { createLogger } from './logger'; import { initializeStore } from './store'; -import { PersistGate } from 'redux-persist/integration/react'; import { SegmentClient } from './analytics'; import { actions } from './store'; -export const defaultConfig: Config = { - writeKey: '', - flushAt: 20, - flushInterval: 30, - retryInterval: 60, - maxBatchSize: 1000, - maxEventsToRetry: 1000, - trackDeepLinks: false, - trackAppLifecycleEvents: false, - autoAddSegmentDestination: true, -}; - const doClientSetup = async (client: SegmentClient) => { // make sure the persisted store is fetched await client.bootstrapStore(); diff --git a/packages/core/src/constants.e2e.mock.js b/packages/core/src/constants.e2e.mock.js deleted file mode 100644 index ad9eea1c0..000000000 --- a/packages/core/src/constants.e2e.mock.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Platform } from 'react-native'; - -export const batchApi = Platform.select({ - ios: 'http://localhost:9091', - android: 'http://10.0.2.2:9091', -}); diff --git a/packages/core/src/constants.e2e.mock.ts b/packages/core/src/constants.e2e.mock.ts new file mode 100644 index 000000000..25664ba0b --- /dev/null +++ b/packages/core/src/constants.e2e.mock.ts @@ -0,0 +1,21 @@ +import { Platform } from 'react-native'; +import type { Config } from '.'; + +export const batchApi = Platform.select({ + ios: 'http://localhost:9091', + android: 'http://10.0.2.2:9091', +}); + +export const defaultApiHost = 'api.segment.io/v1'; + +export const defaultConfig: Config = { + writeKey: '', + flushAt: 20, + flushInterval: 30, + retryInterval: 60, + maxBatchSize: 1000, + maxEventsToRetry: 1000, + trackDeepLinks: false, + trackAppLifecycleEvents: false, + autoAddSegmentDestination: true, +}; diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6c057ae9f..b55b98012 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,2 +1,16 @@ +import type { Config } from './types'; + export const batchApi = 'https://api.segment.io/v1/batch'; export const defaultApiHost = 'api.segment.io/v1'; + +export const defaultConfig: Config = { + writeKey: '', + flushAt: 20, + flushInterval: 30, + retryInterval: 60, + maxBatchSize: 1000, + maxEventsToRetry: 1000, + trackDeepLinks: false, + trackAppLifecycleEvents: false, + autoAddSegmentDestination: true, +}; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 93f5e9c9f..f053d9ad8 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,5 +1,6 @@ import { getUUID } from './uuid'; +import type { Store } from './store'; import { GroupEventType, GroupTraits, @@ -11,7 +12,6 @@ import { AliasEventType, EventType, SegmentEvent, - Store, } from './types'; export const createTrackEvent = ({ diff --git a/packages/core/src/methods/flush.ts b/packages/core/src/methods/flush.ts index 136ea89bd..bc8524a2b 100644 --- a/packages/core/src/methods/flush.ts +++ b/packages/core/src/methods/flush.ts @@ -9,7 +9,7 @@ export default async function flush(this: SegmentClientContext) { this.secondsElapsed = 0; const state = this.store.getState(); - if (state.main.events.length) { + if (state.main.events.length > 0) { getPluginsWithFlush(this.timeline).forEach((plugin) => plugin.flush()); } } diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index 65d3c0565..fa13fc1c9 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -42,3 +42,9 @@ export const initializeStore = (segmentKey: string) => { return { store, persistor }; }; + +export type Store = ReturnType['store']; +export type RootState = ReturnType; + +export * from './selectors'; +export * from './watcher'; diff --git a/packages/core/src/store/main.ts b/packages/core/src/store/main.ts index b18bef418..190de3557 100644 --- a/packages/core/src/store/main.ts +++ b/packages/core/src/store/main.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import type { SegmentEvent, Context, Config, PartialContext } from '../types'; +import type { Config, Context, PartialContext, SegmentEvent } from '../types'; type MainState = { events: SegmentEvent[]; diff --git a/packages/core/src/store/selectors.ts b/packages/core/src/store/selectors.ts new file mode 100644 index 000000000..636e299b2 --- /dev/null +++ b/packages/core/src/store/selectors.ts @@ -0,0 +1,12 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '.'; + +export const getEvents = createSelector( + (state: RootState) => state.main.events, + (events) => events +); + +export const getEventsToRetry = createSelector( + (state: RootState) => state.main.eventsToRetry, + (events) => events +); diff --git a/packages/core/src/store/system.ts b/packages/core/src/store/system.ts index dfc9beaa1..ee702fed1 100644 --- a/packages/core/src/store/system.ts +++ b/packages/core/src/store/system.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { defaultConfig } from '../constants'; import type { SegmentAPISettings, Config, Integrations } from '../types'; -import { defaultConfig } from '../client'; type SystemState = { configuration: Config; diff --git a/packages/core/src/store/watcher.ts b/packages/core/src/store/watcher.ts new file mode 100644 index 000000000..e94d38442 --- /dev/null +++ b/packages/core/src/store/watcher.ts @@ -0,0 +1,24 @@ +import type { Store } from '.'; + +/** + * Creates a watcher that subscribes to the store and tracks + * changes to a selector return + * @param store Store to subscribe to + * @returns a function to subscribe actions for + */ +export const getStoreWatcher = (store: Store) => { + return ( + selector: (state: ReturnType) => T, + onChange: (value: T) => void + ) => { + let currentVal: T = selector(store.getState()); + const unsubscribe = store.subscribe(() => { + const newVal: T = selector(store.getState()); + if (newVal !== currentVal) { + currentVal = newVal; + onChange(newVal); + } + }); + return unsubscribe; + }; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 41bce19a2..f74290ea0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,7 +1,3 @@ -import type { initializeStore } from './store'; - -export type Store = ReturnType['store']; - export type JsonValue = | boolean | number