diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx index d69e068bdea3a..7598141bdea65 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx @@ -19,12 +19,13 @@ import { appStoreFactory } from './store'; export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) { coreStart.http.get('/api/endpoint/hello-world'); - const store = appStoreFactory(coreStart); + const [store, stopSagas] = appStoreFactory(coreStart); ReactDOM.render(, element); return () => { ReactDOM.unmountComponentAtNode(element); + stopSagas(); }; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts index 0387eac0e7c7f..91841f75c24fe 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts @@ -3,18 +3,21 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { createSagaMiddleware, SagaContext } from './index'; -import { applyMiddleware, createStore, Reducer } from 'redux'; + +import { createSagaMiddleware, SagaContext, SagaMiddleware } from './index'; +import { applyMiddleware, createStore, Reducer, Store } from 'redux'; describe('saga', () => { const INCREMENT_COUNTER = 'INCREMENT'; const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER'; const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR'; - const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)); + const sleep = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)); + let store: Store; let reducerA: Reducer; let sideAffect: (a: unknown, s: unknown) => void; let sagaExe: (sagaContext: SagaContext) => Promise; + let sagaExeReduxMiddleware: SagaMiddleware; beforeEach(() => { reducerA = jest.fn((prevState = { count: 0 }, { type }) => { @@ -47,53 +50,63 @@ describe('saga', () => { } } }); + + sagaExeReduxMiddleware = createSagaMiddleware(sagaExe); + store = createStore(reducerA, applyMiddleware(sagaExeReduxMiddleware)); }); - test('it returns Redux Middleware from createSagaMiddleware()', () => { - const sagaMiddleware = createSagaMiddleware(async () => {}); - expect(sagaMiddleware).toBeInstanceOf(Function); + afterEach(() => { + sagaExeReduxMiddleware.stop(); }); + test('it does nothing if saga is not started', () => { - const store = createStore(reducerA, applyMiddleware(createSagaMiddleware(sagaExe))); - expect(store.getState().count).toEqual(0); - expect(reducerA).toHaveBeenCalled(); - expect(sagaExe).toHaveBeenCalled(); - expect(sideAffect).not.toHaveBeenCalled(); - expect(store.getState()).toEqual({ count: 0 }); + expect(sagaExe).not.toHaveBeenCalled(); }); - test('it updates store once running', async () => { - const sagaMiddleware = createSagaMiddleware(sagaExe); - const store = createStore(reducerA, applyMiddleware(sagaMiddleware)); + test('it can dispatch store actions once running', async () => { + sagaExeReduxMiddleware.start(); expect(store.getState()).toEqual({ count: 0 }); expect(sagaExe).toHaveBeenCalled(); store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); expect(store.getState()).toEqual({ count: 0 }); - await sleep(100); + await sleep(); expect(sideAffect).toHaveBeenCalled(); expect(store.getState()).toEqual({ count: 1 }); }); - test('it stops processing if break out of loop', async () => { - const sagaMiddleware = createSagaMiddleware(sagaExe); - const store = createStore(reducerA, applyMiddleware(sagaMiddleware)); + test('it stops processing if break out of loop', async () => { + sagaExeReduxMiddleware.start(); store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(100); + await sleep(); expect(store.getState()).toEqual({ count: 1 }); expect(sideAffect).toHaveBeenCalledTimes(2); store.dispatch({ type: STOP_SAGA_PROCESSING }); - await sleep(100); + await sleep(); + + store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); + await sleep(); + + expect(store.getState()).toEqual({ count: 1 }); + expect(sideAffect).toHaveBeenCalledTimes(2); + }); + + test('it stops saga middleware when stop() is called', async () => { + sagaExeReduxMiddleware.start(); + store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); + await sleep(); expect(store.getState()).toEqual({ count: 1 }); expect(sideAffect).toHaveBeenCalledTimes(2); + sagaExeReduxMiddleware.stop(); + store.dispatch({ type: DELAYED_INCREMENT_COUNTER }); - await sleep(100); + await sleep(); expect(store.getState()).toEqual({ count: 1 }); expect(sideAffect).toHaveBeenCalledTimes(2); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts index b93360ec6b5aa..bca6aa6563fe5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts @@ -35,7 +35,20 @@ export interface SagaContext { dispatch: Dispatch; } +export interface SagaMiddleware extends Middleware { + /** + * Start the saga. Should be called after the `store` has been created + */ + start: () => void; + + /** + * Stop the saga by exiting the internal generator `for await...of` loop. + */ + stop: () => void; +} + const noop = () => {}; +const STOP = Symbol('STOP'); /** * Creates Saga Middleware for use with Redux. @@ -43,7 +56,7 @@ const noop = () => {}; * @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against * the return value of the `actionsAndState()` method provided by the `SagaContext`. * - * @return {Middleware} + * @return {SagaMiddleware} * * @example * @@ -64,22 +77,31 @@ const noop = () => {}; * //.... * const store = createStore(reducers, [ endpointsSagaMiddleware ]); */ -export function createSagaMiddleware(saga: Saga): Middleware { +export function createSagaMiddleware(saga: Saga): SagaMiddleware { const iteratorInstances = new Set(); let runSaga: () => void = noop; + let stopSaga: () => void = noop; + let runningPromise: Promise; async function* getActionsAndStateIterator(): StoreActionsAndState { const instance: IteratorInstance = { queue: [], nextResolve: null }; iteratorInstances.add(instance); + try { while (true) { - yield await nextActionAndState(); + const actionAndState = await Promise.race([nextActionAndState(), runningPromise]); + + if (actionAndState === STOP) { + break; + } + + yield actionAndState as QueuedAction; } } finally { // If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await` // then this `finally` block will run and unregister this instance and reset `runSaga` iteratorInstances.delete(instance); - runSaga = noop; + runSaga = stopSaga = noop; } function nextActionAndState() { @@ -109,7 +131,6 @@ export function createSagaMiddleware(saga: Saga): Middleware { actionsAndState: getActionsAndStateIterator, dispatch, }); - runSaga(); } return (next: Dispatch) => (action: AnyAction) => { // Call the next dispatch method in the middleware chain. @@ -125,5 +146,14 @@ export function createSagaMiddleware(saga: Saga): Middleware { }; } + middleware.start = () => { + runningPromise = new Promise(resolve => (stopSaga = () => resolve(STOP))); + runSaga(); + }; + + middleware.stop = () => { + stopSaga(); + }; + return middleware; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts index 92bf3b7fd92dd..6bf946873e179 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts @@ -24,6 +24,7 @@ describe('endpoint list saga', () => { let fakeHttpServices: jest.Mocked; let store: Store; let dispatch: Dispatch; + let stopSagas: () => void; // TODO: consolidate the below ++ helpers in `index.test.ts` into a `test_helpers.ts`?? const generateEndpoint = (): EndpointData => { @@ -89,13 +90,19 @@ describe('endpoint list saga', () => { beforeEach(() => { fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); fakeHttpServices = fakeCoreStart.http as jest.Mocked; - store = createStore( - endpointListReducer, - applyMiddleware(createSagaMiddleware(endpointListSagaFactory())) - ); + + const sagaMiddleware = createSagaMiddleware(endpointListSagaFactory()); + store = createStore(endpointListReducer, applyMiddleware(sagaMiddleware)); + + sagaMiddleware.start(); + stopSagas = sagaMiddleware.stop; dispatch = store.dispatch; }); + afterEach(() => { + stopSagas(); + }); + test('it handles `userEnteredEndpointListPage`', async () => { const apiResponse = getEndpointListApiResponse(); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts index d0dc002031ce2..bfa1385b9f0ac 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createStore, compose, applyMiddleware } from 'redux'; +import { createStore, compose, applyMiddleware, Store } from 'redux'; import { CoreStart } from 'kibana/public'; import { appSagaFactory } from './saga'; import { appReducer } from './reducer'; @@ -15,10 +15,13 @@ const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMP ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' }) : compose; -export const appStoreFactory = (coreStart: CoreStart) => { +export const appStoreFactory = (coreStart: CoreStart): [Store, () => void] => { + const sagaReduxMiddleware = appSagaFactory(coreStart); const store = createStore( appReducer, - composeWithReduxDevTools(applyMiddleware(appSagaFactory(coreStart))) + composeWithReduxDevTools(applyMiddleware(sagaReduxMiddleware)) ); - return store; + + sagaReduxMiddleware.start(); + return [store, sagaReduxMiddleware.stop]; };