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 (