From b31c447e1caf4a56c1a46a8dd586c8731735278a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Rivet?= Date: Mon, 21 Oct 2019 12:21:08 -0400 Subject: [PATCH] Add async support --- .circleci/config.yml | 4 +- @plotly/dash-component-plugins/LICENSE | 21 ++++ @plotly/dash-component-plugins/package.json | 16 +++ .../dash-component-plugins/src/asyncImport.js | 31 ++++++ @plotly/dash-component-plugins/src/index.js | 3 + @plotly/webpack-dash-dynamic-import/LICENSE | 21 ++++ .../webpack-dash-dynamic-import/package.json | 16 +++ .../webpack-dash-dynamic-import/src/index.js | 33 ++++++ CHANGELOG.md | 1 + dash-renderer/.babelrc | 6 -- dash-renderer/babel.config.js | 13 +++ dash-renderer/jest.config.js | 6 +- dash-renderer/package-lock.json | 6 ++ dash-renderer/package.json | 4 +- dash-renderer/src/APIController.react.js | 2 + dash-renderer/src/TreeContainer.js | 5 +- dash-renderer/src/actions/constants.js | 1 + dash-renderer/src/actions/index.js | 20 +++- dash-renderer/src/actions/setAppReadyState.js | 77 +++++++++++++ dash-renderer/src/index.js | 3 - dash-renderer/src/isSimpleComponent.js | 5 + dash-renderer/src/reducers/isAppReady.js | 8 ++ dash-renderer/src/reducers/reducer.js | 2 + dash-renderer/tests/notifyObservers.test.js | 66 ++++++++++++ dash-renderer/webpack.config.js | 82 +++++++------- dash/dash.py | 29 +++-- dash/development/base_component.py | 3 +- dash/resources.py | 65 +++++++---- dash/testing/application_runners.py | 8 +- .../integration/devtools/test_props_check.py | 71 ++++++++---- tests/integration/test_scripts.py | 102 ++++++++++++++++++ tests/unit/dash/test_async_resources.py | 54 ++++++++++ 32 files changed, 670 insertions(+), 114 deletions(-) create mode 100644 @plotly/dash-component-plugins/LICENSE create mode 100644 @plotly/dash-component-plugins/package.json create mode 100644 @plotly/dash-component-plugins/src/asyncImport.js create mode 100644 @plotly/dash-component-plugins/src/index.js create mode 100644 @plotly/webpack-dash-dynamic-import/LICENSE create mode 100644 @plotly/webpack-dash-dynamic-import/package.json create mode 100644 @plotly/webpack-dash-dynamic-import/src/index.js delete mode 100644 dash-renderer/.babelrc create mode 100644 dash-renderer/babel.config.js create mode 100644 dash-renderer/src/actions/setAppReadyState.js create mode 100644 dash-renderer/src/isSimpleComponent.js create mode 100644 dash-renderer/src/reducers/isAppReady.js create mode 100644 dash-renderer/tests/notifyObservers.test.js create mode 100644 tests/integration/test_scripts.py create mode 100644 tests/unit/dash/test_async_resources.py diff --git a/.circleci/config.yml b/.circleci/config.yml index b8063c5d4c..17b6c4b7bc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -109,7 +109,7 @@ jobs: command: | . venv/bin/activate && pip install --no-cache-dir --upgrade -e . --progress-bar off && mkdir packages cd dash-renderer && renderer build && python setup.py sdist && mv dist/* ../packages/ && cd .. - git clone --depth 1 https://github.com/plotly/dash-core-components.git + git clone --depth 1 -b exp-dynamic-2 https://github.com/plotly/dash-core-components.git cd dash-core-components && npm install --ignore-scripts && npm run build && python setup.py sdist && mv dist/* ../packages/ && cd .. git clone --depth 1 https://github.com/plotly/dash-renderer-test-components cd dash-renderer-test-components && npm install --ignore-scripts && npm run build:all && python setup.py sdist && mv dist/* ../packages/ && cd .. @@ -159,7 +159,7 @@ jobs: name: ️️🏗️ build misc command: | . venv/bin/activate && pip install --no-cache-dir --upgrade -e . --progress-bar off && mkdir packages - git clone --depth 1 https://github.com/plotly/dash-table.git + git clone --depth 1 -b exp-dynamic https://github.com/plotly/dash-table.git cd dash-table && npm install --ignore-scripts && npm run build && python setup.py sdist && mv dist/* ../packages/ && cd .. git clone --depth 1 https://github.com/plotly/dash-html-components.git cd dash-html-components && npm install --ignore-scripts && npm run build && python setup.py sdist && mv dist/* ../packages/ && cd .. diff --git a/@plotly/dash-component-plugins/LICENSE b/@plotly/dash-component-plugins/LICENSE new file mode 100644 index 0000000000..f8dd245665 --- /dev/null +++ b/@plotly/dash-component-plugins/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Plotly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@plotly/dash-component-plugins/package.json b/@plotly/dash-component-plugins/package.json new file mode 100644 index 0000000000..384d8f3b55 --- /dev/null +++ b/@plotly/dash-component-plugins/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plotly/dash-component-plugins", + "version": "1.0.1", + "description": "Plugins for Dash Components", + "repository": { + "type": "git", + "url": "git@github.com:plotly/dash.git" + }, + "bugs": { + "url": "https://github.com/plotly/dash/issues" + }, + "homepage": "https://github.com/plotly/dash", + "main": "src/index.js", + "author": "Marc-André Rivet", + "license": "MIT" +} \ No newline at end of file diff --git a/@plotly/dash-component-plugins/src/asyncImport.js b/@plotly/dash-component-plugins/src/asyncImport.js new file mode 100644 index 0000000000..6ffd9da754 --- /dev/null +++ b/@plotly/dash-component-plugins/src/asyncImport.js @@ -0,0 +1,31 @@ +import { lazy } from 'react'; + +export const asyncDecorator = (target, promise) => { + let resolve; + const isReady = new Promise(r => { + resolve = r; + }); + + const state = { + isReady, + get: lazy(() => { + return Promise.resolve(promise()).then(res => { + setTimeout(async () => { + await resolve(true); + state.isReady = true; + }, 0); + + return res; + }); + }), + }; + + Object.defineProperty(target, '_dashprivate_isLazyComponentReady', { + get: () => state.isReady, + }); + + return state.get; +}; + +export const isReady = target => target && + target._dashprivate_isLazyComponentReady; diff --git a/@plotly/dash-component-plugins/src/index.js b/@plotly/dash-component-plugins/src/index.js new file mode 100644 index 0000000000..4a670ca381 --- /dev/null +++ b/@plotly/dash-component-plugins/src/index.js @@ -0,0 +1,3 @@ +import { asyncDecorator, isReady } from './dynamicImport'; + +export { asyncDecorator, isReady }; \ No newline at end of file diff --git a/@plotly/webpack-dash-dynamic-import/LICENSE b/@plotly/webpack-dash-dynamic-import/LICENSE new file mode 100644 index 0000000000..f8dd245665 --- /dev/null +++ b/@plotly/webpack-dash-dynamic-import/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Plotly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/@plotly/webpack-dash-dynamic-import/package.json b/@plotly/webpack-dash-dynamic-import/package.json new file mode 100644 index 0000000000..8a0ec41b5f --- /dev/null +++ b/@plotly/webpack-dash-dynamic-import/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plotly/webpack-dash-dynamic-import", + "version": "1.0.0", + "description": "Webpack Plugin for Dynamic Import in Dash", + "repository": { + "type": "git", + "url": "git@github.com:plotly/dash.git" + }, + "bugs": { + "url": "https://github.com/plotly/dash/issues" + }, + "homepage": "https://github.com/plotly/dash", + "main": "src/index.js", + "author": "Marc-André Rivet", + "license": "MIT" +} \ No newline at end of file diff --git a/@plotly/webpack-dash-dynamic-import/src/index.js b/@plotly/webpack-dash-dynamic-import/src/index.js new file mode 100644 index 0000000000..3f930a3298 --- /dev/null +++ b/@plotly/webpack-dash-dynamic-import/src/index.js @@ -0,0 +1,33 @@ +const resolveImportSource = `\ +Object.defineProperty(__webpack_require__, 'p', { + get: (function () { + let script = document.currentScript; + if (!script) { + /* Shim for IE11 and below */ + /* Do not take into account async scripts and inline scripts */ + const scripts = Array.from(document.getElementsByTagName('script')).filter(function(s) { return !s.async && !s.text && !s.textContent; }); + script = scripts.slice(-1)[0]; + } + + var url = script.src.split('/').slice(0, -1).join('/') + '/'; + + return function() { + return url; + }; + })() +});` + +class WebpackDashDynamicImport { + apply(compiler) { + compiler.hooks.compilation.tap('WebpackDashDynamicImport', compilation => { + compilation.mainTemplate.hooks.requireExtensions.tap('WebpackDashDynamicImport > RequireExtensions', (source, chunk, hash) => { + return [ + source, + resolveImportSource + ] + }); + }); + } +} + +module.exports = WebpackDashDynamicImport; diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec6c79825..287cd196a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ updates in clientside functions. - Three new `dash.testing` methods: `clear_local_storage`, `clear_session_storage`, and `clear_storage` (to clear both together) - [#937](https://github.com/plotly/dash/pull/937) `dash.testing` adds two APIs `zoom_in_graph_by_ratio` and `click_at_coord_fractions` about advanced interactions using mouse `ActionChain` - [#938](https://github.com/plotly/dash/issues/938) Add debugging traces to dash backend about serving component suites, to verify the installed packages whenever in doubt. +- [#899](https://github.com/plotly/dash/pull/899) Add support for async dependencies and components ### Fixed - [#944](https://github.com/plotly/dash/pull/944) Fix a bug with persistence being toggled on/off on an existing component. diff --git a/dash-renderer/.babelrc b/dash-renderer/.babelrc deleted file mode 100644 index 1f1b99769d..0000000000 --- a/dash-renderer/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": [["@babel/preset-env", { - "useBuiltIns": "usage", - "corejs": 3 - }], "@babel/preset-react"] -} diff --git a/dash-renderer/babel.config.js b/dash-renderer/babel.config.js new file mode 100644 index 0000000000..0dd785200d --- /dev/null +++ b/dash-renderer/babel.config.js @@ -0,0 +1,13 @@ +module.exports = { + presets: [['@babel/preset-env', { + useBuiltIns: 'usage', + corejs: 3 + }], '@babel/preset-react'], + env: { + test: { + plugins: [ + '@babel/plugin-transform-modules-commonjs' + ] + } + } +}; diff --git a/dash-renderer/jest.config.js b/dash-renderer/jest.config.js index 06090ac509..ffe29ab352 100644 --- a/dash-renderer/jest.config.js +++ b/dash-renderer/jest.config.js @@ -163,9 +163,9 @@ module.exports = { // transform: null, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "/node_modules/" - // ], + transformIgnorePatterns: [ + "/node_modules/(?!@plotly).+\\.js" + ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, diff --git a/dash-renderer/package-lock.json b/dash-renderer/package-lock.json index 5020a82065..5bcf0a2927 100644 --- a/dash-renderer/package-lock.json +++ b/dash-renderer/package-lock.json @@ -1178,6 +1178,12 @@ "fastq": "^1.6.0" } }, + "@plotly/dash-component-plugins": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@plotly/dash-component-plugins/-/dash-component-plugins-1.0.1.tgz", + "integrity": "sha512-z3KTahhLhIw3RZL49OOutyV8ePn2kZLCnMBoWYPy5sr55Yd2ghL7aDDyO1yseintG+RTm72n6UB35G++mvNbpA==", + "dev": true + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz", diff --git a/dash-renderer/package.json b/dash-renderer/package.json index 72501f331b..d452dd782e 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -36,8 +36,10 @@ }, "devDependencies": { "@babel/core": "^7.6.0", + "@babel/plugin-transform-modules-commonjs": "^7.6.0", "@babel/preset-env": "^7.6.0", "@babel/preset-react": "^7.0.0", + "@plotly/dash-component-plugins": "^1.0.1", "@svgr/webpack": "^4.1.0", "babel-eslint": "^10.0.3", "babel-loader": "^8.0.6", @@ -57,9 +59,9 @@ "prettier-stylelint": "^0.4.2", "raw-loader": "^3.1.0", "style-loader": "^1.0.0", - "webpack-dev-server": "^3.1.11", "webpack": "^4.39.3", "webpack-cli": "^3.3.8", + "webpack-dev-server": "^3.1.11", "webpack-serve": "^3.1.1", "whatwg-fetch": "^2.0.2" } diff --git a/dash-renderer/src/APIController.react.js b/dash-renderer/src/APIController.react.js index ae9f84aa93..1d8e7054ed 100644 --- a/dash-renderer/src/APIController.react.js +++ b/dash-renderer/src/APIController.react.js @@ -9,6 +9,7 @@ import { computePaths, hydrateInitialOutputs, setLayout, + setAppIsReady, } from './actions/index'; import {applyPersistence} from './persistence'; import apiThunk from './actions/api'; @@ -54,6 +55,7 @@ class UnconnectedContainer extends Component { dispatch ); dispatch(setLayout(finalLayout)); + dispatch(setAppIsReady()); } else if (isNil(paths)) { dispatch(computePaths({subTree: layout, startingPath: []})); } diff --git a/dash-renderer/src/TreeContainer.js b/dash-renderer/src/TreeContainer.js index 12c78df019..d26ce909c3 100644 --- a/dash-renderer/src/TreeContainer.js +++ b/dash-renderer/src/TreeContainer.js @@ -22,14 +22,11 @@ import { type, } from 'ramda'; import {notifyObservers, updateProps} from './actions'; +import isSimpleComponent from './isSimpleComponent'; import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; import checkPropTypes from 'check-prop-types'; -const SIMPLE_COMPONENT_TYPES = ['String', 'Number', 'Null', 'Boolean']; -const isSimpleComponent = component => - includes(type(component), SIMPLE_COMPONENT_TYPES); - function validateComponent(componentDefinition) { if (type(componentDefinition) === 'Array') { throw new Error( diff --git a/dash-renderer/src/actions/constants.js b/dash-renderer/src/actions/constants.js index 37866de19d..5438c8b4dc 100644 --- a/dash-renderer/src/actions/constants.js +++ b/dash-renderer/src/actions/constants.js @@ -8,6 +8,7 @@ const actionList = { SET_CONFIG: 'SET_CONFIG', ON_ERROR: 'ON_ERROR', SET_HOOKS: 'SET_HOOKS', + SET_APP_READY: 'SET_APP_READY', }; export const getAction = action => { diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 18ce4d4d1d..029220af9c 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -33,17 +33,20 @@ import cookie from 'cookie'; import {uid, urlBase, isMultiOutputProp, parseMultipleOutputs} from '../utils'; import {STATUS} from '../constants/constants'; import {applyPersistence, prunePersistence} from '../persistence'; +import setAppIsReady from './setAppReadyState'; export const updateProps = createAction(getAction('ON_PROP_CHANGE')); export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); export const computeGraphs = createAction(getAction('COMPUTE_GRAPHS')); export const computePaths = createAction(getAction('COMPUTE_PATHS')); -export const setLayout = createAction(getAction('SET_LAYOUT')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); export const setConfig = createAction(getAction('SET_CONFIG')); export const setHooks = createAction(getAction('SET_HOOKS')); +export const setLayout = createAction(getAction('SET_LAYOUT')); export const onError = createAction(getAction('ON_ERROR')); +export {setAppIsReady}; + export function hydrateInitialOutputs() { return function(dispatch, getState) { triggerDefaultState(dispatch, getState); @@ -175,7 +178,7 @@ function reduceInputIds(nodeIds, InputGraph) { /* * Create input-output(s) pairs, * sort by number of outputs, - * and remove redudant inputs (inputs that update the same output) + * and remove redundant inputs (inputs that update the same output) */ const inputOutputPairs = nodeIds.map(nodeId => ({ input: nodeId, @@ -194,7 +197,7 @@ function reduceInputIds(nodeIds, InputGraph) { * trigger components to update multiple times. * * For example, [A, B] => C and [A, D] => E - * The unique inputs might be [A, B, D] but that is redudant. + * The unique inputs might be [A, B, D] but that is redundant. * We only need to update B and D or just A. * * In these cases, we'll supply an additional list of outputs @@ -215,10 +218,15 @@ function reduceInputIds(nodeIds, InputGraph) { } export function notifyObservers(payload) { - return function(dispatch, getState) { + return async function(dispatch, getState) { const {id, props, excludedOutputs} = payload; - const {graphs, requestQueue} = getState(); + const {graphs, isAppReady, requestQueue} = getState(); + + if (isAppReady !== true) { + await isAppReady; + } + const {InputGraph} = graphs; /* * Figure out all of the output id's that depend on this input. @@ -942,6 +950,8 @@ function updateOutput( ); }); } + + dispatch(setAppIsReady()); } }; if (multi) { diff --git a/dash-renderer/src/actions/setAppReadyState.js b/dash-renderer/src/actions/setAppReadyState.js new file mode 100644 index 0000000000..eeccacd8a9 --- /dev/null +++ b/dash-renderer/src/actions/setAppReadyState.js @@ -0,0 +1,77 @@ +import {filter} from 'ramda'; +import {createAction} from 'redux-actions'; + +import isSimpleComponent from '../isSimpleComponent'; +import Registry from './../registry'; +import {getAction} from './constants'; +import {isReady} from '@plotly/dash-component-plugins'; + +const isAppReady = layout => { + const queue = [layout]; + + const res = {}; + + /* Would be much simpler if the Registry was aware of what it contained... */ + while (queue.length) { + const elementLayout = queue.shift(); + if (!elementLayout) { + continue; + } + + const children = elementLayout.props && elementLayout.props.children; + const namespace = elementLayout.namespace; + const type = elementLayout.type; + + res[namespace] = res[namespace] || {}; + res[namespace][type] = type; + + if (children) { + const filteredChildren = filter( + child => !isSimpleComponent(child), + Array.isArray(children) ? children : [children] + ); + + queue.push(...filteredChildren); + } + } + + const promises = []; + Object.entries(res).forEach(([namespace, item]) => { + Object.entries(item).forEach(([type]) => { + const component = Registry.resolve({ + namespace, + type, + }); + + const ready = isReady(component); + + if (ready && typeof ready.then === 'function') { + promises.push(ready); + } + }); + }); + + return promises.length ? Promise.all(promises) : true; +}; + +const setAction = createAction(getAction('SET_APP_READY')); + +export default () => async (dispatch, getState) => { + const ready = isAppReady(getState().layout); + + if (ready === true) { + /* All async is ready */ + dispatch(setAction(true)); + } else { + /* Waiting on async */ + dispatch(setAction(ready)); + await ready; + /** + * All known async is ready. + * + * Callbacks were blocked while waiting, we can safely + * assume that no update to layout happened to invalidate. + */ + dispatch(setAction(true)); + } +}; diff --git a/dash-renderer/src/index.js b/dash-renderer/src/index.js index 763944cf80..7eccf61f7b 100644 --- a/dash-renderer/src/index.js +++ b/dash-renderer/src/index.js @@ -1,6 +1,3 @@ -/* eslint-env browser */ - -'use strict'; import {DashRenderer} from './DashRenderer'; // make DashRenderer globally available diff --git a/dash-renderer/src/isSimpleComponent.js b/dash-renderer/src/isSimpleComponent.js new file mode 100644 index 0000000000..0592db65e1 --- /dev/null +++ b/dash-renderer/src/isSimpleComponent.js @@ -0,0 +1,5 @@ +import {includes, type} from 'ramda'; + +const SIMPLE_COMPONENT_TYPES = ['String', 'Number', 'Null', 'Boolean']; + +export default component => includes(type(component), SIMPLE_COMPONENT_TYPES); diff --git a/dash-renderer/src/reducers/isAppReady.js b/dash-renderer/src/reducers/isAppReady.js new file mode 100644 index 0000000000..2dd86ece71 --- /dev/null +++ b/dash-renderer/src/reducers/isAppReady.js @@ -0,0 +1,8 @@ +import {getAction} from '../actions/constants'; + +export default function config(state = false, action) { + if (action.type === getAction('SET_APP_READY')) { + return action.payload; + } + return state; +} diff --git a/dash-renderer/src/reducers/reducer.js b/dash-renderer/src/reducers/reducer.js index 1123134675..22af71779b 100644 --- a/dash-renderer/src/reducers/reducer.js +++ b/dash-renderer/src/reducers/reducer.js @@ -10,6 +10,7 @@ import { view, } from 'ramda'; import {combineReducers} from 'redux'; +import isAppReady from './isAppReady'; import layout from './layout'; import graphs from './dependencyGraph'; import paths from './paths'; @@ -31,6 +32,7 @@ export const apiRequests = [ function mainReducer() { const parts = { appLifecycle, + isAppReady, layout, graphs, paths, diff --git a/dash-renderer/tests/notifyObservers.test.js b/dash-renderer/tests/notifyObservers.test.js new file mode 100644 index 0000000000..3c023ab006 --- /dev/null +++ b/dash-renderer/tests/notifyObservers.test.js @@ -0,0 +1,66 @@ +import { notifyObservers } from "../src/actions"; + +const WAIT = 1000; + +describe('notifyObservers', () => { + const thunk = notifyObservers({ + id: 'id', + props: {}, + undefined + }); + + it('executes if app is ready', async () => { + let done = false; + thunk( + () => { }, + () => ({ + graphs: { + InputGraph: { + hasNode: () => false, + dependenciesOf: () => [], + dependantsOf: () => [], + overallOrder: () => 0 + } + }, + isAppReady: true, + requestQueue: [] + }) + ).then(() => { done = true; }); + + await new Promise(r => setTimeout(r, 0)); + expect(done).toEqual(true); + }); + + it('waits on app to be ready', async () => { + let resolve; + const isAppReady = new Promise(r => { + resolve = r; + }); + + let done = false; + thunk( + () => { }, + () => ({ + graphs: { + InputGraph: { + hasNode: () => false, + dependenciesOf: () => [], + dependantsOf: () => [], + overallOrder: () => 0 + } + }, + isAppReady, + requestQueue: [] + }) + ).then(() => { done = true; }); + + await new Promise(r => setTimeout(r, WAIT)); + expect(done).toEqual(false); + + resolve(); + + await new Promise(r => setTimeout(r, WAIT)); + expect(done).toEqual(true); + }); + +}); \ No newline at end of file diff --git a/dash-renderer/webpack.config.js b/dash-renderer/webpack.config.js index db71e0cd92..92a6a1e806 100644 --- a/dash-renderer/webpack.config.js +++ b/dash-renderer/webpack.config.js @@ -4,24 +4,7 @@ const path = require('path'); const packagejson = require('./package.json'); const dashLibraryName = packagejson.name.replace(/-/g, '_'); -const defaultOptions = { - mode: 'development', - devtool: 'none', - entry: { - main: ['whatwg-fetch', './src/index.js'], - }, - output: { - path: path.resolve(__dirname, dashLibraryName), - filename: `${dashLibraryName}.dev.js`, - library: dashLibraryName, - libraryTarget: 'window', - }, - externals: { - react: 'React', - 'react-dom': 'ReactDOM', - 'plotly.js': 'Plotly', - 'prop-types': 'PropTypes', - }, +const defaults = { plugins: [], module: { rules: [ @@ -50,35 +33,52 @@ const defaultOptions = { { test: /\.txt$/i, use: 'raw-loader', - }, - ], + } + ] + } +}; + +const rendererOptions = { + mode: 'development', + entry: { + main: ['whatwg-fetch', './src/index.js'], + }, + output: { + path: path.resolve(__dirname, dashLibraryName), + filename: `${dashLibraryName}.dev.js`, + library: dashLibraryName, + libraryTarget: 'window', }, + externals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'plotly.js': 'Plotly', + 'prop-types': 'PropTypes' + }, + ...defaults }; module.exports = (_, argv) => { const devtool = argv.build === 'local' ? 'source-map' : 'none'; return [ - R.mergeDeepLeft({devtool}, defaultOptions), - R.mergeDeepLeft( - { - devtool: devtool, - mode: 'production', - output: { - filename: `${dashLibraryName}.min.js`, - }, - plugins: [ - new webpack.NormalModuleReplacementPlugin( - /(.*)GlobalErrorContainer.react(\.*)/, - function(resource) { - resource.request = resource.request.replace( - /GlobalErrorContainer.react/, - 'GlobalErrorContainerPassthrough.react' - ); - } - ), - ], + R.mergeDeepLeft({ devtool }, rendererOptions), + R.mergeDeepLeft({ + devtool, + mode: 'production', + output: { + filename: `${dashLibraryName}.min.js`, }, - defaultOptions - ), + plugins: [ + new webpack.NormalModuleReplacementPlugin( + /(.*)GlobalErrorContainer.react(\.*)/, + function (resource) { + resource.request = resource.request.replace( + /GlobalErrorContainer.react/, + 'GlobalErrorContainerPassthrough.react' + ); + } + ), + ], + }, rendererOptions) ]; }; diff --git a/dash/dash.py b/dash/dash.py index d210d5c59e..0c402b2108 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -212,6 +212,7 @@ def __init__( assets_url_path="assets", assets_ignore="", assets_external_path=None, + eager_loading=False, include_assets_files=True, url_base_pathname=None, requests_pathname_prefix=None, @@ -227,6 +228,11 @@ def __init__( plugins=None, **obsolete ): + # Apply _force_eager_loading overrides from modules + for module_name in ComponentRegistry.registry: + module = sys.modules[module_name] + eager = getattr(module, '_force_eager_loading', False) + eager_loading = eager_loading or eager for key in obsolete: if key in ["components_cache_max_age", "static_folder"]: @@ -288,6 +294,7 @@ def __init__( "name", "assets_folder", "assets_url_path", + "eager_loading", "url_base_pathname", "routes_pathname_prefix", "requests_pathname_prefix", @@ -314,7 +321,7 @@ def __init__( # static files from the packages self.css = Css(serve_locally) - self.scripts = Scripts(serve_locally) + self.scripts = Scripts(serve_locally, eager_loading) self.registered_paths = collections.defaultdict(set) @@ -612,7 +619,9 @@ def _generate_scripts_html(self): dev = self._dev_tools.serve_dev_bundles srcs = ( self._collect_and_register_resources( - self.scripts._resources._filter_resources(deps, dev_bundles=dev) + self.scripts._resources._filter_resources( + deps, dev_bundles=dev + ) ) + self.config.external_scripts + self._collect_and_register_resources( @@ -655,7 +664,9 @@ def _generate_meta_html(self): tags = [] if not has_ie_compat: - tags.append('') + tags.append( + '' + ) if not has_charset: tags.append('') @@ -931,7 +942,9 @@ def _validate_callback(self, output, inputs, state): {2} """ ).format( - arg_prop, arg_id, component.available_properties + arg_prop, + arg_id, + component.available_properties, ) ) @@ -1071,8 +1084,9 @@ def _raise_invalid( location_header=( "The value in question is located at" if not toplevel - else "The value in question is either the only value returned," - "\nor is in the top level of the returned list," + else "The value in question is either the only value " + "returned,\nor is in the top level of the returned " + "list," ), location=( "\n" @@ -1456,7 +1470,8 @@ def _invalid_resources_handler(err): @staticmethod def _serve_default_favicon(): return flask.Response( - pkgutil.get_data("dash", "favicon.ico"), content_type="image/x-icon" + pkgutil.get_data("dash", "favicon.ico"), + content_type="image/x-icon", ) def get_asset_url(self, path): diff --git a/dash/development/base_component.py b/dash/development/base_component.py index 6cd9d830a5..070c4adc90 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -110,7 +110,8 @@ def to_plotly_json(self): for k in self.__dict__ if any( k.startswith(w) - for w in self._valid_wildcard_attributes # pylint:disable=no-member + # pylint:disable=no-member + for w in self._valid_wildcard_attributes ) } ) diff --git a/dash/resources.py b/dash/resources.py index a1853a51fd..7923edfc50 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -14,24 +14,48 @@ def __init__(self, resource_name): def append_resource(self, resource): self._resources.append(resource) + # pylint: disable=too-many-branches def _filter_resources(self, all_resources, dev_bundles=False): filtered_resources = [] for s in all_resources: filtered_resource = {} if 'dynamic' in s: filtered_resource['dynamic'] = s['dynamic'] + if 'async' in s: + if 'dynamic' in s: + raise exceptions.ResourceException( + "Can't have both 'dynamic' and 'async'. " + "{}".format(json.dumps(filtered_resource)) + ) + + # Async assigns a value dynamically to 'dynamic' + # based on the value of 'async' and config.eager_loading + # + # True -> dynamic if the server is not eager, False otherwise + # 'lazy' -> always dynamic + # 'eager' -> dynamic if server is not eager + # (to prevent ever loading it) + filtered_resource['dynamic'] = ( + not self.config.eager_loading + if s['async'] is True + else ( + s['async'] == 'eager' + and not self.config.eager_loading + ) + or s['async'] == 'lazy' + ) if 'namespace' in s: filtered_resource['namespace'] = s['namespace'] if 'external_url' in s and not self.config.serve_locally: filtered_resource['external_url'] = s['external_url'] elif 'dev_package_path' in s and dev_bundles: - filtered_resource['relative_package_path'] = ( - s['dev_package_path'] - ) + filtered_resource['relative_package_path'] = s[ + 'dev_package_path' + ] elif 'relative_package_path' in s: - filtered_resource['relative_package_path'] = ( - s['relative_package_path'] - ) + filtered_resource['relative_package_path'] = s[ + 'relative_package_path' + ] elif 'absolute_path' in s: filtered_resource['absolute_path'] = s['absolute_path'] elif 'asset_path' in s: @@ -39,13 +63,15 @@ def _filter_resources(self, all_resources, dev_bundles=False): filtered_resource['asset_path'] = s['asset_path'] filtered_resource['ts'] = info.st_mtime elif self.config.serve_locally: - warnings.warn(( - 'You have set your config to `serve_locally=True` but ' - 'A local version of {} is not available.\n' - 'If you added this file with `app.scripts.append_script` ' - 'or `app.css.append_css`, use `external_scripts` ' - 'or `external_stylesheets` instead.\n' - 'See https://dash.plot.ly/external-resources' + warnings.warn( + ( + 'You have set your config to `serve_locally=True` but ' + 'A local version of {} is not available.\n' + 'If you added this file with ' + '`app.scripts.append_script` ' + 'or `app.css.append_css`, use `external_scripts` ' + 'or `external_stylesheets` instead.\n' + 'See https://dash.plot.ly/external-resources' ).format(s['external_url']) ) continue @@ -53,9 +79,7 @@ def _filter_resources(self, all_resources, dev_bundles=False): raise exceptions.ResourceException( '{} does not have a ' 'relative_package_path, absolute_path, or an ' - 'external_url.'.format( - json.dumps(filtered_resource) - ) + 'external_url.'.format(json.dumps(filtered_resource)) ) filtered_resources.append(filtered_resource) @@ -71,14 +95,15 @@ def get_all_resources(self, dev_bundles=False): # pylint: disable=too-few-public-methods class _Config: - def __init__(self, serve_locally): + def __init__(self, serve_locally, eager_loading): + self.eager_loading = eager_loading self.serve_locally = serve_locally class Css: def __init__(self, serve_locally): self._resources = Resources('_css_dist') - self._resources.config = self.config = _Config(serve_locally) + self._resources.config = self.config = _Config(serve_locally, True) def append_css(self, stylesheet): self._resources.append_resource(stylesheet) @@ -88,9 +113,9 @@ def get_all_css(self): class Scripts: - def __init__(self, serve_locally): + def __init__(self, serve_locally, eager): self._resources = Resources('_js_dist') - self._resources.config = self.config = _Config(serve_locally) + self._resources.config = self.config = _Config(serve_locally, eager) def append_script(self, script): self._resources.append_resource(script) diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index fd5e6e75f8..fb2bae68ba 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -210,7 +210,9 @@ def start( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) # wait until server is able to answer http request - wait.until(lambda: self.accessible(self.url), timeout=start_timeout) + wait.until( + lambda: self.accessible(self.url), timeout=start_timeout + ) except (OSError, ValueError): logger.exception("process server has encountered an error") @@ -307,7 +309,9 @@ def start(self, app, start_timeout=2, cwd=None): args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd ) # wait until server is able to answer http request - wait.until(lambda: self.accessible(self.url), timeout=start_timeout) + wait.until( + lambda: self.accessible(self.url), timeout=start_timeout + ) except (OSError, ValueError): logger.exception("process server has encountered an error") diff --git a/tests/integration/devtools/test_props_check.py b/tests/integration/devtools/test_props_check.py index 931d973e35..95d81349ac 100644 --- a/tests/integration/devtools/test_props_check.py +++ b/tests/integration/devtools/test_props_check.py @@ -1,5 +1,6 @@ import dash_core_components as dcc import dash_html_components as html +from dash_table import DataTable import dash from dash.dependencies import Input, Output @@ -8,8 +9,8 @@ "not-boolean": { "fail": True, "name": 'simple "not a boolean" check', - "component": dcc.Graph, - "props": {"animate": 0}, + "component": dcc.Input, + "props": {"debounce": 0}, }, "missing-required-nested-prop": { "fail": True, @@ -47,26 +48,50 @@ "invalid-shape-1": { "fail": True, "name": "invalid key within nested object", - "component": dcc.Graph, - "props": {"config": {"asdf": "that"}}, + "component": DataTable, + "props": {"active_cell": {"asdf": "that"}}, }, "invalid-shape-2": { "fail": True, "name": "nested object with bad value", - "component": dcc.Graph, - "props": {"config": {"edits": {"legendPosition": "asdf"}}}, + "component": DataTable, + "props": { + "columns": [{ + "id": "id", + "name": "name", + "format": { + "locale": "asdf" + } + }] + }, }, "invalid-shape-3": { "fail": True, "name": "invalid oneOf within nested object", - "component": dcc.Graph, - "props": {"config": {"toImageButtonOptions": {"format": "asdf"}}}, + "component": DataTable, + "props": { + "columns": [{ + "id": "id", + "name": "name", + "on_change": { + "action": "asdf" + } + }] + }, }, "invalid-shape-4": { "fail": True, "name": "invalid key within deeply nested object", - "component": dcc.Graph, - "props": {"config": {"toImageButtonOptions": {"asdf": "test"}}}, + "component": DataTable, + "props": { + "columns": [{ + "id": "id", + "name": "name", + "on_change": { + "asdf": "asdf" + } + }] + }, }, "invalid-shape-5": { "fail": True, @@ -88,7 +113,7 @@ "no-properties": { "fail": False, "name": "no properties", - "component": dcc.Graph, + "component": dcc.Input, "props": {}, }, "nested-children": { @@ -112,21 +137,29 @@ "nested-prop-failure": { "fail": True, "name": "nested string instead of number/null", - "component": dcc.Graph, + "component": DataTable, "props": { - "figure": {"data": [{}]}, - "config": { - "toImageButtonOptions": {"width": None, "height": "test"} - }, + "columns": [{ + "id": "id", + "name": "name", + "format": { + "prefix": "asdf" + } + }] }, }, "allow-null": { "fail": False, "name": "nested null", - "component": dcc.Graph, + "component": DataTable, "props": { - "figure": {"data": [{}]}, - "config": {"toImageButtonOptions": {"width": None, "height": None}}, + "columns": [{ + "id": "id", + "name": "name", + "format": { + "prefix": None + } + }] }, }, "allow-null-2": { diff --git a/tests/integration/test_scripts.py b/tests/integration/test_scripts.py new file mode 100644 index 0000000000..6f86afc1d4 --- /dev/null +++ b/tests/integration/test_scripts.py @@ -0,0 +1,102 @@ +from multiprocessing import Value +import datetime +import time +import pytest + +from bs4 import BeautifulSoup +from selenium.webdriver.common.keys import Keys + +import dash_dangerously_set_inner_html +import dash_flow_example + +import dash_html_components as html +import dash_core_components as dcc + +from dash import Dash, callback_context, no_update + +from dash.dependencies import Input, Output, State +from dash.exceptions import ( + PreventUpdate, + DuplicateCallbackOutput, + CallbackException, + MissingCallbackContextException, + InvalidCallbackReturnValue, + IncorrectTypeException, + NonExistentIdException, +) +from dash.testing.wait import until +from selenium.webdriver.common.by import By + + +def findSyncPlotlyJs(scripts): + for script in scripts: + if "dash_core_components/plotly-" in script.get_attribute('src'): + return script + + +def findAsyncPlotlyJs(scripts): + for script in scripts: + if "dash_core_components/async~plotlyjs" in script.get_attribute( + 'src' + ): + return script + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_scripts(dash_duo, is_eager): + app = Dash(__name__, eager_loading=is_eager) + app.layout = html.Div( + [dcc.Graph(id="output", figure={"data": [{"y": [3, 1, 2]}]})] + ) + + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + ) + + # Give time for the async dependency to be requested (if any) + time.sleep(2) + + scripts = dash_duo.driver.find_elements(By.CSS_SELECTOR, "script") + + assert (findSyncPlotlyJs(scripts) is None) is not is_eager + assert (findAsyncPlotlyJs(scripts) is None) is is_eager + + +def test_scripts_on_request(dash_duo): + app = Dash(__name__, eager_loading=False) + app.layout = html.Div(id="div", children=[html.Button(id="btn")]) + + @app.callback(Output("div", "children"), [Input("btn", "n_clicks")]) + def load_chart(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return dcc.Graph(id="output", figure={"data": [{"y": [3, 1, 2]}]}) + + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + ) + + # Give time for the async dependency to be requested (if any) + time.sleep(2) + + scripts = dash_duo.driver.find_elements(By.CSS_SELECTOR, "script") + assert findSyncPlotlyJs(scripts) is None + assert findAsyncPlotlyJs(scripts) is None + + dash_duo.find_element("#btn").click() + + # Give time for the async dependency to be requested (if any) + time.sleep(2) + + scripts = dash_duo.driver.find_elements(By.CSS_SELECTOR, "script") + assert findSyncPlotlyJs(scripts) is None + assert findAsyncPlotlyJs(scripts) is not None diff --git a/tests/unit/dash/test_async_resources.py b/tests/unit/dash/test_async_resources.py new file mode 100644 index 0000000000..f42fe136fd --- /dev/null +++ b/tests/unit/dash/test_async_resources.py @@ -0,0 +1,54 @@ +from dash.resources import Resources, Scripts + + +class obj(object): + def __init__(self, dict): + self.__dict__ = dict + + +def test_resources_eager(): + + resource = Resources("js_test") + resource.config = obj({"eager_loading": True, "serve_locally": False}) + + filtered = resource._filter_resources( + [ + {"async": "eager", "external_url": "a.js"}, + {"async": "lazy", "external_url": "b.js"}, + {"async": True, "external_url": "c.js"}, + ], + False, + ) + + assert len(filtered) == 3 + assert filtered[0].get("external_url") == "a.js" + assert filtered[0].get("dynamic") is False # include (eager when eager) + assert filtered[1].get("external_url") == "b.js" + assert ( + filtered[1].get("dynamic") is True + ) # exclude (lazy when eager -> closest to exclude) + assert filtered[2].get("external_url") == "c.js" + assert filtered[2].get("dynamic") is False # include (always matches settings) + + +def test_resources_lazy(): + + resource = Resources("js_test") + resource.config = obj({"eager_loading": False, "serve_locally": False}) + + filtered = resource._filter_resources( + [ + {"async": "eager", "external_url": "a.js"}, + {"async": "lazy", "external_url": "b.js"}, + {"async": True, "external_url": "c.js"}, + ], + False, + ) + + assert len(filtered) == 3 + assert filtered[0].get("external_url") == "a.js" + assert filtered[0].get("dynamic") is True # exclude (no eager when lazy) + assert filtered[1].get("external_url") == "b.js" + assert filtered[1].get("dynamic") is True # exclude (lazy when lazy) + assert filtered[2].get("external_url") == "c.js" + assert filtered[2].get("dynamic") is True # exclude (always matches settings)