diff options
Diffstat (limited to 'devtools/client/shared/redux')
16 files changed, 672 insertions, 0 deletions
diff --git a/devtools/client/shared/redux/create-store.js b/devtools/client/shared/redux/create-store.js new file mode 100644 index 000000000..baacf428e --- /dev/null +++ b/devtools/client/shared/redux/create-store.js @@ -0,0 +1,51 @@ +/* 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"; + +const { createStore, applyMiddleware } = require("devtools/client/shared/vendor/redux"); +const { thunk } = require("./middleware/thunk"); +const { waitUntilService } = require("./middleware/wait-service"); +const { task } = require("./middleware/task"); +const { log } = require("./middleware/log"); +const { promise } = require("./middleware/promise"); +const { history } = require("./middleware/history"); + +/** + * This creates a dispatcher with all the standard middleware in place + * that all code requires. It can also be optionally configured in + * various ways, such as logging and recording. + * + * @param {object} opts: + * - log: log all dispatched actions to console + * - history: an array to store every action in. Should only be + * used in tests. + * - middleware: array of middleware to be included in the redux store + */ +module.exports = (opts = {}) => { + const middleware = [ + task, + thunk, + promise, + + // Order is important: services must go last as they always + // operate on "already transformed" actions. Actions going through + // them shouldn't have any special fields like promises, they + // should just be normal JSON objects. + waitUntilService + ]; + + if (opts.history) { + middleware.push(history(opts.history)); + } + + if (opts.middleware) { + opts.middleware.forEach(fn => middleware.push(fn)); + } + + if (opts.log) { + middleware.push(log); + } + + return applyMiddleware(...middleware)(createStore); +}; diff --git a/devtools/client/shared/redux/middleware/history.js b/devtools/client/shared/redux/middleware/history.js new file mode 100644 index 000000000..dba88c045 --- /dev/null +++ b/devtools/client/shared/redux/middleware/history.js @@ -0,0 +1,23 @@ +/* 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"; + +const flags = require("devtools/shared/flags"); + +/** + * A middleware that stores every action coming through the store in the passed + * in logging object. Should only be used for tests, as it collects all + * action information, which will cause memory bloat. + */ +exports.history = (log = []) => ({ dispatch, getState }) => { + if (!flags.testing) { + console.warn("Using history middleware stores all actions in state for " + + "testing and devtools is not currently running in test " + + "mode. Be sure this is intentional."); + } + return next => action => { + log.push(action); + next(action); + }; +}; diff --git a/devtools/client/shared/redux/middleware/log.js b/devtools/client/shared/redux/middleware/log.js new file mode 100644 index 000000000..f812f793b --- /dev/null +++ b/devtools/client/shared/redux/middleware/log.js @@ -0,0 +1,17 @@ +/* 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"; + +/** + * A middleware that logs all actions coming through the system + * to the console. + */ +function log({ dispatch, getState }) { + return next => action => { + console.log("[DISPATCH]", JSON.stringify(action, null, 2)); + next(action); + }; +} + +exports.log = log; diff --git a/devtools/client/shared/redux/middleware/moz.build b/devtools/client/shared/redux/middleware/moz.build new file mode 100644 index 000000000..a25bfd518 --- /dev/null +++ b/devtools/client/shared/redux/middleware/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'history.js', + 'log.js', + 'promise.js', + 'task.js', + 'thunk.js', + 'wait-service.js', +) + +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini'] diff --git a/devtools/client/shared/redux/middleware/promise.js b/devtools/client/shared/redux/middleware/promise.js new file mode 100644 index 000000000..237e41eef --- /dev/null +++ b/devtools/client/shared/redux/middleware/promise.js @@ -0,0 +1,54 @@ +/* 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"; + +const uuidgen = require("sdk/util/uuid").uuid; +const defer = require("devtools/shared/defer"); +const { + entries, toObject, executeSoon +} = require("devtools/shared/DevToolsUtils"); +const PROMISE = exports.PROMISE = "@@dispatch/promise"; + +function promiseMiddleware({ dispatch, getState }) { + return next => action => { + if (!(PROMISE in action)) { + return next(action); + } + + const promiseInst = action[PROMISE]; + const seqId = uuidgen().toString(); + + // Create a new action that doesn't have the promise field and has + // the `seqId` field that represents the sequence id + action = Object.assign( + toObject(entries(action).filter(pair => pair[0] !== PROMISE)), { seqId } + ); + + dispatch(Object.assign({}, action, { status: "start" })); + + // Return the promise so action creators can still compose if they + // want to. + const deferred = defer(); + promiseInst.then(value => { + executeSoon(() => { + dispatch(Object.assign({}, action, { + status: "done", + value: value + })); + deferred.resolve(value); + }); + }, error => { + executeSoon(() => { + dispatch(Object.assign({}, action, { + status: "error", + error: error.message || error + })); + deferred.reject(error); + }); + }); + return deferred.promise; + }; +} + +exports.promise = promiseMiddleware; diff --git a/devtools/client/shared/redux/middleware/task.js b/devtools/client/shared/redux/middleware/task.js new file mode 100644 index 000000000..c1dd262ee --- /dev/null +++ b/devtools/client/shared/redux/middleware/task.js @@ -0,0 +1,42 @@ +/* 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"; + +const { Task } = require("devtools/shared/task"); +const { executeSoon, isGenerator, reportException } = require("devtools/shared/DevToolsUtils"); +const ERROR_TYPE = exports.ERROR_TYPE = "@@redux/middleware/task#error"; + +/** + * A middleware that allows generator thunks (functions) and promise + * to be dispatched. If it's a generator, it is called with `dispatch` + * and `getState`, allowing the action to create multiple actions (most likely + * asynchronously) and yield on each. If called with a promise, calls `dispatch` + * on the results. + */ + +function task({ dispatch, getState }) { + return next => action => { + if (isGenerator(action)) { + return Task.spawn(action.bind(null, dispatch, getState)) + .then(null, handleError.bind(null, dispatch)); + } + + /* + if (isPromise(action)) { + return action.then(dispatch, handleError.bind(null, dispatch)); + } + */ + + return next(action); + }; +} + +function handleError(dispatch, error) { + executeSoon(() => { + reportException(ERROR_TYPE, error); + dispatch({ type: ERROR_TYPE, error }); + }); +} + +exports.task = task; diff --git a/devtools/client/shared/redux/middleware/test/.eslintrc.js b/devtools/client/shared/redux/middleware/test/.eslintrc.js new file mode 100644 index 000000000..0d12cd9a3 --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/.eslintrc.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + "extends": "../../../../../.eslintrc.mochitests.js", + "globals": { + "run_test": true, + "run_next_test": true, + "equal": true, + "do_print": true, + "waitUntilState": true + }, + "rules": { + // Stop giving errors for run_test + "camelcase": "off" + } +}; diff --git a/devtools/client/shared/redux/middleware/test/head.js b/devtools/client/shared/redux/middleware/test/head.js new file mode 100644 index 000000000..1e5cbff7a --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/head.js @@ -0,0 +1,27 @@ +/* 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/. */ + +/* exported waitUntilState */ + +"use strict"; + +const { require } = Components.utils.import("resource://devtools/shared/Loader.jsm", {}); +const flags = require("devtools/shared/flags"); + +flags.testing = true; + +function waitUntilState(store, predicate) { + return new Promise(resolve => { + let unsubscribe = store.subscribe(check); + function check() { + if (predicate(store.getState())) { + unsubscribe(); + resolve(); + } + } + + // Fire the check immediately incase the action has already occurred + check(); + }); +} diff --git a/devtools/client/shared/redux/middleware/test/test_middleware-task-01.js b/devtools/client/shared/redux/middleware/test/test_middleware-task-01.js new file mode 100644 index 000000000..be94560cb --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/test_middleware-task-01.js @@ -0,0 +1,56 @@ +/* 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"; + +const { createStore, applyMiddleware } = require("devtools/client/shared/vendor/redux"); +const { task } = require("devtools/client/shared/redux/middleware/task"); + +/** + * Tests that task middleware allows dispatching generators, promises and objects + * that return actions; + */ + +function run_test() { + run_next_test(); +} + +add_task(function* () { + let store = applyMiddleware(task)(createStore)(reducer); + + store.dispatch(fetch1("generator")); + yield waitUntilState(store, () => store.getState().length === 1); + equal(store.getState()[0].data, "generator", + "task middleware async dispatches an action via generator"); + + store.dispatch(fetch2("sync")); + yield waitUntilState(store, () => store.getState().length === 2); + equal(store.getState()[1].data, "sync", + "task middleware sync dispatches an action via sync"); +}); + +function fetch1(data) { + return function* (dispatch, getState) { + equal(getState().length, 0, "`getState` is accessible in a generator action"); + let moreData = yield new Promise(resolve => resolve(data)); + // Ensure it handles more than one yield + moreData = yield new Promise(resolve => resolve(data)); + dispatch({ type: "fetch1", data: moreData }); + }; +} + +function fetch2(data) { + return { + type: "fetch2", + data + }; +} + +function reducer(state = [], action) { + do_print("Action called: " + action.type); + if (["fetch1", "fetch2"].includes(action.type)) { + state.push(action); + } + return [...state]; +} diff --git a/devtools/client/shared/redux/middleware/test/test_middleware-task-02.js b/devtools/client/shared/redux/middleware/test/test_middleware-task-02.js new file mode 100644 index 000000000..7e2a88d2c --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/test_middleware-task-02.js @@ -0,0 +1,67 @@ +/* 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"; + +/** + * Tests that task middleware allows dispatching generators that dispatch + * additional sync and async actions. + */ + +const { createStore, applyMiddleware } = require("devtools/client/shared/vendor/redux"); +const { task } = require("devtools/client/shared/redux/middleware/task"); + +function run_test() { + run_next_test(); +} + +add_task(function* () { + let store = applyMiddleware(task)(createStore)(reducer); + + store.dispatch(comboAction()); + yield waitUntilState(store, () => store.getState().length === 3); + + equal(store.getState()[0].type, "fetchAsync-start", + "Async dispatched actions in a generator task are fired"); + equal(store.getState()[1].type, "fetchAsync-end", + "Async dispatched actions in a generator task are fired"); + equal(store.getState()[2].type, "fetchSync", + "Return values of yielded sync dispatched actions are correct"); + equal(store.getState()[3].type, "fetch-done", + "Return values of yielded async dispatched actions are correct"); + equal(store.getState()[3].data.sync.data, "sync", + "Return values of dispatched sync values are correct"); + equal(store.getState()[3].data.async, "async", + "Return values of dispatched async values are correct"); +}); + +function comboAction() { + return function* (dispatch, getState) { + let data = {}; + data.async = yield dispatch(fetchAsync("async")); + data.sync = yield dispatch(fetchSync("sync")); + dispatch({ type: "fetch-done", data }); + }; +} + +function fetchSync(data) { + return { type: "fetchSync", data }; +} + +function fetchAsync(data) { + return function* (dispatch) { + dispatch({ type: "fetchAsync-start" }); + let val = yield new Promise(resolve => resolve(data)); + dispatch({ type: "fetchAsync-end" }); + return val; + }; +} + +function reducer(state = [], action) { + do_print("Action called: " + action.type); + if (/fetch/.test(action.type)) { + state.push(action); + } + return [...state]; +} diff --git a/devtools/client/shared/redux/middleware/test/test_middleware-task-03.js b/devtools/client/shared/redux/middleware/test/test_middleware-task-03.js new file mode 100644 index 000000000..7dc0e5c9d --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/test_middleware-task-03.js @@ -0,0 +1,42 @@ +/* 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"; + +const { createStore, applyMiddleware } = require("devtools/client/shared/vendor/redux"); +const { task, ERROR_TYPE } = require("devtools/client/shared/redux/middleware/task"); + +/** + * Tests that the middleware handles errors thrown in tasks, and rejected promises. + */ + +function run_test() { + run_next_test(); +} + +add_task(function* () { + let store = applyMiddleware(task)(createStore)(reducer); + + store.dispatch(generatorError()); + yield waitUntilState(store, () => store.getState().length === 1); + equal(store.getState()[0].type, ERROR_TYPE, + "generator errors dispatch ERROR_TYPE actions"); + equal(store.getState()[0].error, "task-middleware-error-generator", + "generator errors dispatch ERROR_TYPE actions with error"); +}); + +function generatorError() { + return function* (dispatch, getState) { + let error = "task-middleware-error-generator"; + throw error; + }; +} + +function reducer(state = [], action) { + do_print("Action called: " + action.type); + if (action.type === ERROR_TYPE) { + state.push(action); + } + return [...state]; +} diff --git a/devtools/client/shared/redux/middleware/test/xpcshell.ini b/devtools/client/shared/redux/middleware/test/xpcshell.ini new file mode 100644 index 000000000..3836ed1fd --- /dev/null +++ b/devtools/client/shared/redux/middleware/test/xpcshell.ini @@ -0,0 +1,10 @@ +[DEFAULT] +tags = devtools +head = head.js +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_middleware-task-01.js] +[test_middleware-task-02.js] +[test_middleware-task-03.js] diff --git a/devtools/client/shared/redux/middleware/thunk.js b/devtools/client/shared/redux/middleware/thunk.js new file mode 100644 index 000000000..8f564a033 --- /dev/null +++ b/devtools/client/shared/redux/middleware/thunk.js @@ -0,0 +1,19 @@ +/* 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"; + +/** + * A middleware that allows thunks (functions) to be dispatched. + * If it's a thunk, it is called with `dispatch` and `getState`, + * allowing the action to create multiple actions (most likely + * asynchronously). + */ +function thunk({ dispatch, getState }) { + return next => action => { + return (typeof action === "function") + ? action(dispatch, getState) + : next(action); + }; +} +exports.thunk = thunk; diff --git a/devtools/client/shared/redux/middleware/wait-service.js b/devtools/client/shared/redux/middleware/wait-service.js new file mode 100644 index 000000000..93878a312 --- /dev/null +++ b/devtools/client/shared/redux/middleware/wait-service.js @@ -0,0 +1,64 @@ +/* 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"; + +/** + * A middleware which acts like a service, because it is stateful + * and "long-running" in the background. It provides the ability + * for actions to install a function to be run once when a specific + * condition is met by an action coming through the system. Think of + * it as a thunk that blocks until the condition is met. Example: + * + * ```js + * const services = { WAIT_UNTIL: require('wait-service').NAME }; + * + * { type: services.WAIT_UNTIL, + * predicate: action => action.type === constants.ADD_ITEM, + * run: (dispatch, getState, action) => { + * // Do anything here. You only need to accept the arguments + * // if you need them. `action` is the action that satisfied + * // the predicate. + * } + * } + * ``` + */ +const NAME = exports.NAME = "@@service/waitUntil"; + +function waitUntilService({ dispatch, getState }) { + let pending = []; + + function checkPending(action) { + let readyRequests = []; + let stillPending = []; + + // Find the pending requests whose predicates are satisfied with + // this action. Wait to run the requests until after we update the + // pending queue because the request handler may synchronously + // dispatch again and run this service (that use case is + // completely valid). + for (let request of pending) { + if (request.predicate(action)) { + readyRequests.push(request); + } else { + stillPending.push(request); + } + } + + pending = stillPending; + for (let request of readyRequests) { + request.run(dispatch, getState, action); + } + } + + return next => action => { + if (action.type === NAME) { + pending.push(action); + return null; + } + let result = next(action); + checkPending(action); + return result; + }; +} +exports.waitUntilService = waitUntilService; diff --git a/devtools/client/shared/redux/moz.build b/devtools/client/shared/redux/moz.build new file mode 100644 index 000000000..02b1f6bd6 --- /dev/null +++ b/devtools/client/shared/redux/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + 'middleware', +] + +DevToolsModules( + 'create-store.js', + 'non-react-subscriber.js', +) diff --git a/devtools/client/shared/redux/non-react-subscriber.js b/devtools/client/shared/redux/non-react-subscriber.js new file mode 100644 index 000000000..0bb3f0b8f --- /dev/null +++ b/devtools/client/shared/redux/non-react-subscriber.js @@ -0,0 +1,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 +}; |