Files
flipper/desktop/app/src/utils/__tests__/sideEffect.node.tsx
Michel Weststrate 7a40d3f0a3 Introduce sideEffects for safer management of store side effects
Summary:
This diff introducing the sideEffect abstractions, that creates a fundamental solution for the problem in D20619226: If a subscription to the store errors, the entire store can't be updated anymore, bringing the entire app to a halt.

Applying this abstraction will be done in a next diff.

To essential idea of sideEffect is that it fixes a few problems:

1. I 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.

Note that if some subscription _must_ be handled synchronously, than that logic should be in a reducer, not in a subscription or side effect. Luckily we don't have any examples of that in our code base.

This abstraction might actually be intesting to be shared wider for fun and profit.

Reviewed By: passy

Differential Revision: D20625872

fbshipit-source-id: adaf8356950594d50e6a99a17a862f757c3777db
2020-03-27 04:42:12 -07:00

224 lines
5.3 KiB
TypeScript

/**
* 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<State, Action>;
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']);
});
});