diff --git a/desktop/app/src/utils/__tests__/sideEffect.node.tsx b/desktop/app/src/utils/__tests__/sideEffect.node.tsx new file mode 100644 index 000000000..185950551 --- /dev/null +++ b/desktop/app/src/utils/__tests__/sideEffect.node.tsx @@ -0,0 +1,223 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {sideEffect} from '../sideEffect'; +import {createStore, Store} from 'redux'; +import produce from 'immer'; +import {sleep} from '../promiseTimeout'; + +const initialState = { + counter: {count: 0}, + somethingUnrelated: false, +}; + +type State = typeof initialState; +type Action = {type: 'inc'} | {type: 'unrelated'}; + +function reducer(state: State, action: Action): State { + return produce(state, (draft) => { + if (action.type === 'inc') { + draft.counter.count++; + } + if (action.type === 'unrelated') { + draft.somethingUnrelated = !draft.somethingUnrelated; + } + }); +} + +describe('sideeffect', () => { + let store: Store; + let events: string[]; + let unsubscribe: undefined | (() => void) = undefined; + let warn: jest.Mock; + let error: jest.Mock; + const origWarning = console.warn; + const origError = console.error; + + beforeEach(() => { + // @ts-ignore + store = createStore(reducer, initialState); + events = []; + warn = console.warn = jest.fn(); + error = console.error = jest.fn(); + }); + + afterEach(() => { + unsubscribe?.(); + }); + + afterEach(() => { + console.warn = origWarning; + console.error = origError; + }); + + test('can run a basic effect', async () => { + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 1}, + (s) => s, + (s, passedStore) => { + expect(passedStore).toBe(store); + events.push(`counter: ${s.counter.count}`); + }, + ); + + store.dispatch({type: 'inc'}); + store.dispatch({type: 'inc'}); + expect(events.length).toBe(0); + + // arrive as a single effect + await sleep(10); + expect(events).toEqual(['counter: 2']); + + // no more events arrive after unsubscribe + unsubscribe(); + store.dispatch({type: 'inc'}); + await sleep(10); + expect(events).toEqual(['counter: 2']); + expect(warn).not.toBeCalled(); + expect(error).not.toBeCalled(); + }); + + test('respects selector', async () => { + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 1}, + (s) => s.counter.count, + (count) => { + events.push(`counter: ${count}`); + }, + ); + + store.dispatch({type: 'unrelated'}); + expect(events.length).toBe(0); + + // unrelated event doesn't trigger + await sleep(10); + expect(events.length).toBe(0); + + // counter increment does + store.dispatch({type: 'inc'}); + + await sleep(10); + expect(events).toEqual(['counter: 1']); + expect(warn).not.toBeCalled(); + expect(error).not.toBeCalled(); + }); + + test('respects shallow equal selector', async () => { + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 1}, + (s) => ({number: s.counter.count}), + ({number}) => { + events.push(`counter: ${number}`); + }, + ); + + store.dispatch({type: 'unrelated'}); + expect(events.length).toBe(0); + + // unrelated event doesn't trigger + await sleep(10); + expect(events.length).toBe(0); + + // counter increment does + store.dispatch({type: 'inc'}); + + await sleep(10); + expect(events).toEqual(['counter: 1']); + expect(warn).not.toBeCalled(); + expect(error).not.toBeCalled(); + }); + + test('handles errors', async () => { + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 1}, + (s) => s, + () => { + throw new Error('oops'); + }, + ); + + expect(() => { + store.dispatch({type: 'inc'}); + }).not.toThrow(); + + await sleep(10); + expect(error.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Error while running side effect 'test': Error: oops", + [Error: oops], + ], + ] + `); + }); + + test('warns about long running effects', async () => { + let done = false; + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 10}, + (s) => s, + () => { + const end = Date.now() + 100; + while (Date.now() < end) { + // block + } + done = true; + }, + ); + + store.dispatch({type: 'inc'}); + await sleep(200); + expect(done).toBe(true); + expect(warn.mock.calls[0][0]).toContain("Side effect 'test' took"); + }); + + test('throttles correctly', async () => { + unsubscribe = sideEffect( + store, + {name: 'test', throttleMs: 100}, + (s) => s.counter.count, + (number) => { + events.push(`counter: ${number}`); + }, + ); + + store.dispatch({type: 'inc'}); + await sleep(10); + expect(events).toEqual(['counter: 1']); + + // no new tick in the next 100 ms + await sleep(30); + store.dispatch({type: 'inc'}); + + await sleep(30); + store.dispatch({type: 'inc'}); + + expect(events).toEqual(['counter: 1']); + await sleep(100); + expect(events).toEqual(['counter: 1', 'counter: 3']); + + // long time now effect, it will fire right away again + await sleep(200); + + // ..but firing an event that doesn't match the selector doesn't reset the timer + store.dispatch({type: 'unrelated'}); + await sleep(10); + + store.dispatch({type: 'inc'}); + store.dispatch({type: 'inc'}); + await sleep(10); + expect(events).toEqual(['counter: 1', 'counter: 3', 'counter: 5']); + }); +}); diff --git a/desktop/app/src/utils/sideEffect.tsx b/desktop/app/src/utils/sideEffect.tsx new file mode 100644 index 000000000..b760c6eb2 --- /dev/null +++ b/desktop/app/src/utils/sideEffect.tsx @@ -0,0 +1,89 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {Store as ReduxStore} from 'redux'; +import {shallowEqual} from 'react-redux'; + +/** + * Registers a sideEffect for the given redux store. Use this utility rather than subscribing to the Redux store directly, which fixes a few problems: + * 1. It decouples and throttles the effect so that no arbitrary expensive burden is added to every store update. + * 2. It makes sure that a crashing side effect doesn't crash the entire store update. + * 3. It helps with tracing and monitoring perf problems. + * 4. It puts the side effect behind a selector so that the side effect is only triggered if a relevant part of the store changes, like we do for components. + * + * @param store + * @param options + * @param selector + * @param effect + */ +export function sideEffect< + Store extends ReduxStore, + V, + State = Store extends ReduxStore ? S : never +>( + store: Store, + options: {name: string; throttleMs: number}, + selector: (state: State) => V, + effect: (selectedState: V, store: Store) => void, +): () => void { + let scheduled = false; + let lastRun = 0; + let lastSelectedValue: V = selector(store.getState()); + let timeout: NodeJS.Timeout; + + function run() { + scheduled = false; + const start = performance.now(); + try { + // Future idea: support effects that return promises as well + lastSelectedValue = selector(store.getState()); + effect(lastSelectedValue, store); + } catch (e) { + console.error( + `Error while running side effect '${options.name}': ${e}`, + e, + ); + } + lastRun = performance.now(); + const duration = lastRun - start; + if (duration > 15 && duration > options.throttleMs / 10) { + console.warn( + `Side effect '${ + options.name + }' took ${duration}ms, which exceeded its budget of ${Math.floor( + options.throttleMs / 10, + )}ms. Please make the effect faster or increase the throttle time.`, + ); + } + } + + const unsubscribe = store.subscribe(() => { + if (scheduled) { + return; + } + const newValue = selector(store.getState()); + if ( + newValue === lastSelectedValue || + shallowEqual(newValue, lastSelectedValue) + ) { + return; // no new value, no need to schedule + } + scheduled = true; + timeout = setTimeout( + run, + // Run ASAP (but async) or, if we recently did run, delay until at least 'throttle' time has expired + Math.max(1, lastRun + options.throttleMs - performance.now()), + ); + }); + + return () => { + clearTimeout(timeout); + unsubscribe(); + }; +}