summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/redux/non-react-subscriber.js
blob: 0bb3f0b8f95de8b14c14406ca17460c04e059af1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

/**
 * This file defines functions to add the ability for redux reducers
 * to broadcast specific state changes to a non-React UI. You should
 * *never* use this for new code that uses React, as it violates the
 * core principals of a functional UI. This should only be used when
 * migrating old code to redux, because it allows you to use redux
 * with event-listening UI elements. The typical way to set all of
 * this up is this:
 *
 *  const emitter = makeEmitter();
 *  let store = createStore(combineEmittingReducers(
 *    reducers,
 *    emitter.emit
 *  ));
 *  store = enhanceStoreWithEmitter(store, emitter);
 *
 * Now reducers will receive a 3rd argument, `emit`, for emitting
 * events, and the store has an `on` function for listening to them.
 * For example, a reducer can now do this:
 *
 * function update(state = initialState, action, emitChange) {
 *   if (action.type === constants.ADD_BREAKPOINT) {
 *     const id = action.breakpoint.id;
 *     emitChange('add-breakpoint', action.breakpoint);
 *     return state.merge({ [id]: action.breakpoint });
 *   }
 *   return state;
 * }
 *
 * `emitChange` is *not* synchronous, the state changes will be
 * broadcasted *after* all reducers are run and the state has been
 * updated.
 *
 * Now, a non-React widget can do this:
 *
 * store.on('add-breakpoint', breakpoint => { ... });
 */

const { combineReducers } = require("devtools/client/shared/vendor/redux");

/**
 * Make an emitter that is meant to be used in redux reducers. This
 * does not run listeners immediately when an event is emitted; it
 * waits until all reducers have run and the store has updated the
 * state, and then fires any enqueued events. Events *are* fired
 * synchronously, but just later in the process.
 *
 * This is important because you never want the UI to be updating in
 * the middle of a reducing process. Reducers will fire these events
 * in the middle of generating new state, but the new state is *not*
 * available from the store yet. So if the UI executes in the middle
 * of the reducing process and calls `getState()` to get something
 * from the state, it will get stale state.
 *
 * We want the reducing and the UI updating phases to execute
 * atomically and independent from each other.
 *
 * @param {Function} stillAliveFunc
 *        A function that indicates the app is still active. If this
 *        returns false, changes will stop being broadcasted.
 */
function makeStateBroadcaster(stillAliveFunc) {
  const listeners = {};
  let enqueuedChanges = [];

  return {
    onChange: (name, cb) => {
      if (!listeners[name]) {
        listeners[name] = [];
      }
      listeners[name].push(cb);
    },

    offChange: (name, cb) => {
      listeners[name] = listeners[name].filter(listener => listener !== cb);
    },

    emitChange: (name, payload) => {
      enqueuedChanges.push([name, payload]);
    },

    subscribeToStore: store => {
      store.subscribe(() => {
        if (stillAliveFunc()) {
          enqueuedChanges.forEach(([name, payload]) => {
            if (listeners[name]) {
              listeners[name].forEach(listener => {
                listener(payload);
              });
            }
          });
          enqueuedChanges = [];
        }
      });
    }
  };
}

/**
 * Make a store fire any enqueued events whenever the state changes,
 * and add an `on` function to allow users to listen for specific
 * events.
 *
 * @param {Object} store
 * @param {Object} broadcaster
 * @return {Object}
 */
function enhanceStoreWithBroadcaster(store, broadcaster) {
  broadcaster.subscribeToStore(store);
  store.onChange = broadcaster.onChange;
  store.offChange = broadcaster.offChange;
  return store;
}

/**
 * Function that takes a hash of reducers, like `combineReducers`, and
 * an `emitChange` function and returns a function to be used as a
 * reducer for a Redux store. This allows all reducers defined here to
 * receive a third argument, the `emitChange` function, for
 * event-based subscriptions from within reducers.
 *
 * @param {Object} reducers
 * @param {Function} emitChange
 * @return {Function}
 */
function combineBroadcastingReducers(reducers, emitChange) {
  // Wrap each reducer with a wrapper function that calls
  // the reducer with a third argument, an `emitChange` function.
  // Use this rather than a new custom top level reducer that would ultimately
  // have to replicate redux's `combineReducers` so we only pass in correct
  // state, the error checking, and other edge cases.
  function wrapReduce(newReducers, key) {
    newReducers[key] = (state, action) => {
      return reducers[key](state, action, emitChange);
    };
    return newReducers;
  }

  return combineReducers(
    Object.keys(reducers).reduce(wrapReduce, Object.create(null))
  );
}

module.exports = {
  makeStateBroadcaster,
  enhanceStoreWithBroadcaster,
  combineBroadcastingReducers
};