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..4b7a432ca1 --- /dev/null +++ b/@plotly/webpack-dash-dynamic-import/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plotly/webpack-dash-dynamic-import", + "version": "1.1.1", + "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..b02a7edc70 --- /dev/null +++ b/@plotly/webpack-dash-dynamic-import/src/index.js @@ -0,0 +1,76 @@ +const fs = require('fs'); + +function getFingerprint() { + const package = fs.readFileSync('./package.json'); + const packageJson = JSON.parse(package); + + const timestamp = Math.round(Date.now() / 1000); + const version = packageJson.version.replace(/[.]/g, '_'); + + return `"v${version}m${timestamp}"`; +} + +const resolveImportSource = () => `\ +const getCurrentScript = 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]; + } + + return script; +}; + +const isLocalScript = function(script) { + return /\\\/_dash-component-suites\\\//.test(script.src); +}; + +Object.defineProperty(__webpack_require__, 'p', { + get: (function () { + let script = getCurrentScript(); + + var url = script.src.split('/').slice(0, -1).join('/') + '/'; + + return function() { + return url; + }; + })() +}); + +const __jsonpScriptSrc__ = jsonpScriptSrc; +jsonpScriptSrc = function(chunkId) { + let script = getCurrentScript(); + let isLocal = isLocalScript(script); + + let src = __jsonpScriptSrc__(chunkId); + + if(!isLocal) { + return src; + } + + const srcFragments = src.split('/'); + const fileFragments = srcFragments.slice(-1)[0].split('.'); + + fileFragments.splice(1, 0, ${getFingerprint()}); + srcFragments.splice(-1, 1, fileFragments.join('.')) + + return srcFragments.join('/'); +}; +` + +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 9dc3c1a607..c53116aa7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [1.5.0] - 2019-10-29 +### Added +- [#964](https://github.com/plotly/dash/pull/964) Adds support for preventing updates in clientside functions. + - Reject all updates with `throw window.dash_clientside.PreventUpdate;` + - Reject a single output by returning `window.dash_clientside.no_update` +- [#899](https://github.com/plotly/dash/pull/899) Add support for async dependencies and components +- [#973](https://github.com/plotly/dash/pull/973) Adds support for resource caching and adds a fallback caching mechanism through etag + +### Fixed +- [#974](https://github.com/plotly/dash/pull/974) Fix and improve a percy snapshot behavior issue we found in dash-docs testing. It adds a flag `wait_for_callbacks` to ensure that, in the context of a dash app testing, the percy snapshot action will happen only after all callbacks get fired. + ## [1.4.1] - 2019-10-17 ### Fixed - [#969](https://github.com/plotly/dash/pull/969) Fix warnings emitted by react devtools coming from our own devtools components. 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.json b/dash-renderer/package.json index 72501f331b..00566d01cc 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "1.1.2", + "version": "1.2.0", "description": "render dash components in react", "main": "dash_renderer/dash_renderer.min.js", "scripts": { @@ -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 0d16bdfffa..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. @@ -565,6 +573,27 @@ function updateOutput( // Clientside hook if (clientside_function) { let returnValue; + + /* + * Create the dash_clientside namespace if it doesn't exist and inject + * no_update and PreventUpdate. + */ + if (!window.dash_clientside) { + window.dash_clientside = {}; + } + + if (!window.dash_clientside.no_update) { + Object.defineProperty(window.dash_clientside, 'no_update', { + value: {description: 'Return to prevent updating an Output.'}, + writable: false, + }); + + Object.defineProperty(window.dash_clientside, 'PreventUpdate', { + value: {description: 'Throw to prevent updating all Outputs.'}, + writable: false, + }); + } + try { returnValue = window.dash_clientside[clientside_function.namespace][ clientside_function.function_name @@ -573,6 +602,14 @@ function updateOutput( ...(has('state', payload) ? pluck('value', payload.state) : []) ); } catch (e) { + /* + * Prevent all updates. + */ + if (e === window.dash_clientside.PreventUpdate) { + updateRequestQueue(true, STATUS.PREVENT_UPDATE); + return; + } + /* eslint-disable no-console */ console.error( `The following error occurred while executing ${clientside_function.namespace}.${clientside_function.function_name} ` + @@ -618,6 +655,13 @@ function updateOutput( */ updateRequestQueue(false, STATUS.OK); + /* + * Prevent update. + */ + if (outputValue === window.dash_clientside.no_update) { + return; + } + // Update the layout with the new result const appliedProps = doUpdateProps(outputId, updatedProps); @@ -906,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/_utils.py b/dash/_utils.py index da0a79da1b..4816864385 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -128,7 +128,8 @@ def create_callback_id(output): def run_command_with_process(cmd): - proc = subprocess.Popen(shlex.split(cmd, posix=sys.platform != "win32")) + is_win = sys.platform == "win32" + proc = subprocess.Popen(shlex.split(cmd, posix=is_win), shell=is_win) proc.wait() if proc.poll() is None: logger.warning("🚨 trying to terminate subprocess in safe way") diff --git a/dash/dash.py b/dash/dash.py index d210d5c59e..ddb64bec80 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -24,6 +24,7 @@ import dash_renderer from .dependencies import Input, Output, State +from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css from .development.base_component import Component, ComponentRegistry from . import exceptions @@ -212,6 +213,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 +229,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 +295,7 @@ def __init__( "name", "assets_folder", "assets_url_path", + "eager_loading", "url_base_pathname", "routes_pathname_prefix", "requests_pathname_prefix", @@ -314,7 +322,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) @@ -534,12 +542,14 @@ def _relative_url_path(relative_package_path="", namespace=""): modified = int(os.stat(module_path).st_mtime) - return "{}_dash-component-suites/{}/{}?v={}&m={}".format( + return "{}_dash-component-suites/{}/{}".format( self.config.requests_pathname_prefix, namespace, - relative_package_path, - importlib.import_module(namespace).__version__, - modified, + build_fingerprint( + relative_package_path, + importlib.import_module(namespace).__version__, + modified, + ), ) srcs = [] @@ -612,7 +622,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 +667,9 @@ def _generate_meta_html(self): tags = [] if not has_ie_compat: - tags.append('') + tags.append( + '' + ) if not has_charset: tags.append('') @@ -665,6 +679,10 @@ def _generate_meta_html(self): # Serve the JS bundles for each package def serve_component_suites(self, package_name, path_in_package_dist): + path_in_package_dist, has_fingerprint = check_fingerprint( + path_in_package_dist + ) + if package_name not in self.registered_paths: raise exceptions.DependencyException( "Error loading dependency.\n" @@ -700,11 +718,28 @@ def serve_component_suites(self, package_name, path_in_package_dist): package.__path__, ) - return flask.Response( + response = flask.Response( pkgutil.get_data(package_name, path_in_package_dist), mimetype=mimetype, ) + if has_fingerprint: + # Fingerprinted resources are good forever (1 year) + # No need for ETag as the fingerprint changes with each build + response.cache_control.max_age = 31536000 # 1 year + else: + # Non-fingerprinted resources are given an ETag that + # will be used / check on future requests + response.add_etag() + tag = response.get_etag()[0] + + request_etag = flask.request.headers.get('If-None-Match') + + if '"{}"'.format(tag) == request_etag: + response = flask.Response(None, status=304) + + return response + def index(self, *args, **kwargs): # pylint: disable=unused-argument scripts = self._generate_scripts_html() css = self._generate_css_dist_html() @@ -931,7 +966,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 +1108,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 +1494,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/dependencies.py b/dash/dependencies.py index 9ba9a9fbff..314cf7db75 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -35,6 +35,11 @@ class State(DashDependency): # pylint: disable=too-few-public-methods class ClientsideFunction: # pylint: disable=too-few-public-methods def __init__(self, namespace=None, function_name=None): + + if namespace in ['PreventUpdate', 'no_update']: + raise ValueError('"{}" is a forbidden namespace in' + ' dash_clientside.'.format(namespace)) + self.namespace = namespace self.function_name = function_name 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/fingerprint.py b/dash/fingerprint.py new file mode 100644 index 0000000000..01db7af5aa --- /dev/null +++ b/dash/fingerprint.py @@ -0,0 +1,31 @@ +import re + +build_regex = re.compile(r'^(?P[\w@~-]+)(?P.*)$') + +check_regex = re.compile( + r'^(?P.*)[.]v[\w-]+m[0-9a-fA-F]+(?P(?:(?:(? 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..44eeb07959 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") @@ -253,7 +255,7 @@ def start(self, app, start_timeout=2, cwd=None): """Start the server with subprocess and Rscript.""" # app is a R string chunk - if (os.path.isfile(app) and os.path.exists(app)): + if os.path.isfile(app) and os.path.exists(app): # app is already a file in a dir - use that as cwd if not cwd: cwd = os.path.dirname(app) @@ -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/dash/testing/browser.py b/dash/testing/browser.py index 61c76c222f..73772ab167 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -100,7 +100,7 @@ def __exit__(self, exc_type, exc_val, traceback): except percy.errors.Error: logger.exception("percy runner failed to finalize properly") - def percy_snapshot(self, name=""): + def percy_snapshot(self, name="", wait_for_callbacks=False): """percy_snapshot - visual test api shortcut to `percy_runner.snapshot`. It also combines the snapshot `name` with the python version. """ @@ -108,6 +108,8 @@ def percy_snapshot(self, name=""): name, sys.version_info.major, sys.version_info.minor ) logger.info("taking snapshot name => %s", snapshot_name) + if wait_for_callbacks: + until(self._wait_for_callbacks, timeout=10) self.percy_runner.snapshot(name=snapshot_name) def take_snapshot(self, name): @@ -445,7 +447,7 @@ def zoom_in_graph_by_ratio( elem_or_selector, start_fraction=0.5, zoom_box_fraction=0.2, - compare=True + compare=True, ): """Zoom out a graph with a zoom box fraction of component dimension default start at middle with a rectangle of 1/5 of the dimension use @@ -498,14 +500,18 @@ def reset_log_timestamp(self): if entries: self._last_ts = entries[-1]["timestamp"] - def visit_and_snapshot(self, resource_path, hook_id, assert_check=True): + def visit_and_snapshot( + self, resource_path, hook_id, wait_for_callbacks=True, assert_check=True + ): try: path = resource_path.lstrip("/") if path != resource_path: logger.warning("we stripped the left '/' in resource_path") self.driver.get("{}/{}".format(self.server_url.rstrip("/"), path)) + + # wait for the hook_id to present and all callbacks get fired self.wait_for_element_by_id(hook_id) - self.percy_snapshot(path) + self.percy_snapshot(path, wait_for_callbacks=wait_for_callbacks) if assert_check: assert not self.driver.find_elements_by_css_selector( "div.dash-debug-alert" diff --git a/dash/testing/dash_page.py b/dash/testing/dash_page.py index 62b3b95e8b..c7db11b69d 100644 --- a/dash/testing/dash_page.py +++ b/dash/testing/dash_page.py @@ -36,6 +36,17 @@ def redux_state_rqs(self): "return window.store.getState().requestQueue" ) + @property + def window_store(self): + return self.driver.execute_script("return window.store") + + def _wait_for_callbacks(self): + if self.window_store: + return self.redux_state_rqs and all( + (_.get("responseTime") for _ in self.redux_state_rqs) + ) + return True + def get_local_storage(self, store_id="local"): return self.driver.execute_script( "return JSON.parse(window.localStorage.getItem('{}'));".format( diff --git a/dash/version.py b/dash/version.py index 8e3c933cd0..77f1c8e63c 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = '1.4.1' +__version__ = '1.5.0' diff --git a/requires-install.txt b/requires-install.txt index 50485284bb..346a7c4f34 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -1,8 +1,8 @@ Flask>=1.0.2 flask-compress plotly -dash_renderer==1.1.2 -dash-core-components==1.3.1 +dash_renderer==1.2.0 +dash-core-components==1.4.0 dash-html-components==1.0.1 -dash-table==4.4.1 +dash-table==4.5.0 future \ No newline at end of file diff --git a/setup.py b/setup.py index a009111194..61ccb578e0 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def read_req_file(req_type): setup( name="dash", version=main_ns["__version__"], - author="chris p", + author="Chris Parmer", author_email="chris@plot.ly", packages=find_packages(exclude=["tests*"]), include_package_data=True, diff --git a/tests/integration/clientside/assets/clientside.js b/tests/integration/clientside/assets/clientside.js index 2a02278c0b..067d7b4a1f 100644 --- a/tests/integration/clientside/assets/clientside.js +++ b/tests/integration/clientside/assets/clientside.js @@ -22,6 +22,20 @@ window.dash_clientside.clientside = { return parseInt(value, 10) + 1; }, + add1_prevent_at_11: function (value1, value2) { + if (parseInt(value1, 10) === 11) { + throw window.dash_clientside.PreventUpdate; + } + return parseInt(value2, 10) + 1; + }, + + add1_no_update_at_11: function (value1, value2, value3) { + if (parseInt(value1, 10) === 11) { + return [window.dash_clientside.no_update, parseInt(value3, 10) + 1]; + } + return [parseInt(value2, 10) + 1, parseInt(value3, 10) + 1]; + }, + add_to_four_outputs: function(value) { return [ parseInt(value) + 1, diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index c83a5ef5b9..83d8057ecf 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -4,7 +4,7 @@ import dash_html_components as html import dash_core_components as dcc from dash import Dash -from dash.dependencies import Input, Output, ClientsideFunction +from dash.dependencies import Input, Output, State, ClientsideFunction def test_clsd001_simple_clientside_serverside_callback(dash_duo): @@ -261,3 +261,86 @@ def test_clsd005_clientside_fails_when_returning_a_promise(dash_duo): dash_duo.wait_for_text_to_equal("#input", "hello") dash_duo.wait_for_text_to_equal("#side-effect", "side effect") dash_duo.wait_for_text_to_equal("#output", "output") + + +def test_clsd006_PreventUpdate(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + dcc.Input(id="first", value=1), + dcc.Input(id="second", value=1), + dcc.Input(id="third", value=1) + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_prevent_at_11"), + Output("second", "value"), + [Input("first", "value")], + [State("second", "value")] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_prevent_at_11"), + Output("third", "value"), + [Input("second", "value")], + [State("third", "value")] + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#first", '1') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '11') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '111') + dash_duo.wait_for_text_to_equal("#second", '3') + dash_duo.wait_for_text_to_equal("#third", '3') + + +def test_clsd006_no_update(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + dcc.Input(id="first", value=1), + dcc.Input(id="second", value=1), + dcc.Input(id="third", value=1) + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_no_update_at_11"), + [Output("second", "value"), + Output("third", "value")], + [Input("first", "value")], + [State("second", "value"), + State("third", "value")] + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#first", '1') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '11') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '3') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '111') + dash_duo.wait_for_text_to_equal("#second", '3') + dash_duo.wait_for_text_to_equal("#third", '4') 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) diff --git a/tests/unit/test_fingerprint.py b/tests/unit/test_fingerprint.py new file mode 100644 index 0000000000..913fb09d0f --- /dev/null +++ b/tests/unit/test_fingerprint.py @@ -0,0 +1,56 @@ + +from dash.fingerprint import build_fingerprint, check_fingerprint + +version = 1 +hash_value = 1 + +valid_resources = [ + {'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1m1.8.6.min.js'}, + {'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1', 'hash': '1234567890abcdef' }, + {'path': 'react@16.8.6.min.js', 'fingerprint': 'react@16.v1_1_1-alpha_1m1234567890abcdef.8.6.min.js', 'version': '1.1.1-alpha.1', 'hash': '1234567890abcdef' }, + {'path': 'dash.plotly.js', 'fingerprint': 'dash.v1m1.plotly.js'}, + {'path': 'dash.plotly.j_s', 'fingerprint': 'dash.v1m1.plotly.j_s'}, + {'path': 'dash.plotly.css', 'fingerprint': 'dash.v1m1.plotly.css'}, + {'path': 'dash.plotly.xxx.yyy.zzz', 'fingerprint': 'dash.v1m1.plotly.xxx.yyy.zzz'}, + {'path': 'dash~plotly.js', 'fingerprint': 'dash~plotly.v1m1.js'} +] + +valid_fingerprints = [ + 'react@16.v1_1_2m1571771240.8.6.min.js', + 'dash.plotly.v1_1_1m1234567890.js', + 'dash.plotly.v1_1_1m1234567890.j_s', + 'dash.plotly.v1_1_1m1234567890.css', + 'dash.plotly.v1_1_1m1234567890.xxx.yyy.zzz', + 'dash.plotly.v1_1_1-alpha1m1234567890.js', + 'dash.plotly.v1_1_1-alpha_3m1234567890.js', + 'dash.plotly.v1_1_1m1234567890123.js', + 'dash.plotly.v1_1_1m4bc3.js', + 'dash~plotly.v1m1.js' +] + +invalid_fingerprints = [ + 'dash.plotly.v1_1_1m1234567890..js', + 'dash.plotly.v1_1_1m1234567890.', + 'dash.plotly.v1_1_1m1234567890..', + 'dash.plotly.v1_1_1m1234567890.js.', + 'dash.plotly.v1_1_1m1234567890.j-s' +] + +def test_fingerprint(): + for resource in valid_resources: + # The fingerprint matches expectations + fingerprint = build_fingerprint(resource.get('path'), resource.get('version', version), resource.get('hash', hash_value)) + assert fingerprint == resource.get('fingerprint') + + (original_path, has_fingerprint) = check_fingerprint(fingerprint) + # The inverse operation returns that the fingerprint was valid and the original path + assert has_fingerprint + assert original_path == resource.get('path') + + for resource in valid_fingerprints: + (_, has_fingerprint) = check_fingerprint(resource) + assert has_fingerprint + + for resource in invalid_fingerprints: + (_, has_fingerprint) = check_fingerprint(resource) + assert not has_fingerprint diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index 448c5e030e..3d3727b485 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -37,7 +37,7 @@ def test_external(mocker): mocker.patch("dash_core_components._js_dist") mocker.patch("dash_html_components._js_dist") dcc._js_dist = _monkey_patched_js_dist # noqa: W0212 - dcc.__version__ = 1 + dcc.__version__ = "1.0.0" app = dash.Dash( __name__, assets_folder="tests/assets", assets_ignore="load_after.+.js" @@ -66,7 +66,7 @@ def test_internal(mocker): mocker.patch("dash_core_components._js_dist") mocker.patch("dash_html_components._js_dist") dcc._js_dist = _monkey_patched_js_dist # noqa: W0212, - dcc.__version__ = 1 + dcc.__version__ = "1.0.0" app = dash.Dash( __name__, assets_folder="tests/assets", assets_ignore="load_after.+.js" @@ -83,10 +83,10 @@ def test_internal(mocker): assert resource == [ "/_dash-component-suites/" - "dash_core_components/external_javascript.js?v=1&m=1", + "dash_core_components/external_javascript.v1_0_0m1.js", "/_dash-component-suites/" - "dash_core_components/external_css.css?v=1&m=1", - "/_dash-component-suites/" "dash_core_components/fake_dcc.js?v=1&m=1", + "dash_core_components/external_css.v1_0_0m1.css", + "/_dash-component-suites/" "dash_core_components/fake_dcc.v1_0_0m1.js", ] assert (