diff --git a/.circleci/config.yml b/.circleci/config.yml index 6727bcd97d..ce118f8ebd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -72,7 +72,7 @@ jobs: lint-unit-27: <<: *lint-unit docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYLINTRC: .pylintrc PYVERSION: python27 @@ -122,7 +122,7 @@ jobs: build-core-27: <<: *build-core docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYVERSION: python27 @@ -172,7 +172,7 @@ jobs: build-misc-27: <<: *build-misc docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYVERSION: python27 @@ -372,7 +372,7 @@ jobs: test-27: <<: *test docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PERCY_ENABLE: 0 PYVERSION: python27 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe567b9c32..4312e8ca04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. +- [#1254](https://github.com/plotly/dash/pull/1254) Modifies the callback chain implementation and improves performance for apps with a lot of components ### Fixed - [#1255](https://github.com/plotly/dash/pull/1255) Hard hot reload targets only the current window, not the top - so if your app is in an iframe you will only reload the app diff --git a/dash-renderer/@Types/modules.d.ts b/dash-renderer/@Types/modules.d.ts new file mode 100644 index 0000000000..042d739872 --- /dev/null +++ b/dash-renderer/@Types/modules.d.ts @@ -0,0 +1,9 @@ +declare module 'cookie' { + const value: { + parse: (cookie: string) => { + _csrf_token: string + } + }; + + export default value; +} diff --git a/dash-renderer/babel.config.js b/dash-renderer/babel.config.js index aee2bac0c7..455e1966b4 100644 --- a/dash-renderer/babel.config.js +++ b/dash-renderer/babel.config.js @@ -1,11 +1,16 @@ module.exports = { presets: [ + '@babel/preset-typescript', '@babel/preset-env', '@babel/preset-react' ], + plugins: [ + '@babel/plugin-proposal-class-properties', + ], env: { test: { plugins: [ + '@babel/plugin-proposal-class-properties', '@babel/plugin-transform-modules-commonjs' ] } diff --git a/dash-renderer/jest.config.js b/dash-renderer/jest.config.js index bfe097b036..4e05b43997 100644 --- a/dash-renderer/jest.config.js +++ b/dash-renderer/jest.config.js @@ -85,7 +85,7 @@ module.exports = { // notifyMode: "always", // A preset that is used as a base for Jest's configuration - // preset: null, + preset: "ts-jest/presets/js-with-babel", // Run tests from one or more projects // projects: null, diff --git a/dash-renderer/package-lock.json b/dash-renderer/package-lock.json index 1c452c4a1d..58209deaf0 100644 --- a/dash-renderer/package-lock.json +++ b/dash-renderer/package-lock.json @@ -398,6 +398,164 @@ "semver": "^5.5.0" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.2.tgz", + "integrity": "sha512-5C/QhkGFh1vqcziq1vAL6SI9ymzUp8BCYjFpvYVhWP4DlATIb3u5q3iUd35mvlyGs8fO7hckkW7i0tmH+5+bvQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.1", + "@babel/helper-member-expression-to-functions": "^7.10.1", + "@babel/helper-optimise-call-expression": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/helper-replace-supers": "^7.10.1", + "@babel/helper-split-export-declaration": "^7.10.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", + "integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.1" + } + }, + "@babel/generator": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.2.tgz", + "integrity": "sha512-AxfBNHNu99DTMvlUPlt1h2+Hn7knPpH5ayJ8OqDWSeLld+Fi2AYBTC/IejWDM9Edcii4UzZRCsbUt0WlSDsDsA==", + "dev": true, + "requires": { + "@babel/types": "^7.10.2", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.1.tgz", + "integrity": "sha512-fcpumwhs3YyZ/ttd5Rz0xn0TpIwVkN7X0V38B9TWNfVF42KEkhkAAuPCQ3oXmtTRtiPJrmZ0TrfS0GKF0eMaRQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.1", + "@babel/template": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.1.tgz", + "integrity": "sha512-F5qdXkYGOQUb0hpRaPoetF9AnsXknKjWMZ+wmsIRsp5ge5sFh4c3h1eH2pRTTuy9KKAA2+TTYomGXAtEL2fQEw==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.1.tgz", + "integrity": "sha512-u7XLXeM2n50gb6PWJ9hoO5oO7JFPaZtrh35t8RqKLT1jFKj9IWeD1zrcrYp1q1qiZTdEarfDWfTIP8nGsu0h5g==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.1.tgz", + "integrity": "sha512-a0DjNS1prnBsoKx83dP2falChcs7p3i8VMzdrSbfLhuQra/2ENC4sbri34dz/rWmDADsmF1q5GbfaXydh0Jbjg==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz", + "integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.10.1", + "@babel/helper-optimise-call-expression": "^7.10.1", + "@babel/traverse": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz", + "integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/highlight": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.1.tgz", + "integrity": "sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.1", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.2.tgz", + "integrity": "sha512-PApSXlNMJyB4JiGVhCOlzKIif+TKFTvu0aQAhnTvfP/z3vVSN6ZypH5bfUNwFXXjRQtUEBNFd2PtmCmG2Py3qQ==", + "dev": true + }, + "@babel/template": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz", + "integrity": "sha512-OQDg6SqvFSsc9A0ej6SKINWrpJiNonRIniYondK2ViKhB06i3c0s+76XUft71iqBEe9S1OKsHwPAjfHnuvnCig==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.1", + "@babel/parser": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/traverse": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.1.tgz", + "integrity": "sha512-C/cTuXeKt85K+p08jN6vMDz8vSV0vZcI0wmQ36o6mjbuo++kPMdpOYw23W2XH04dbRt9/nMEfA4W3eR21CD+TQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.1", + "@babel/generator": "^7.10.1", + "@babel/helper-function-name": "^7.10.1", + "@babel/helper-split-export-declaration": "^7.10.1", + "@babel/parser": "^7.10.1", + "@babel/types": "^7.10.1", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.2.tgz", + "integrity": "sha512-AD3AwWBSz0AWF0AkCN9VPiWrvldXq+/e3cHa4J89vo4ymjz1XwrBFFVZmkJTsQIPNk+ZVomPSXUJqq8yyjZsng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.1", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-create-regexp-features-plugin": { "version": "7.8.6", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.6.tgz", @@ -1115,6 +1273,12 @@ "@babel/types": "^7.7.0" } }, + "@babel/helper-validator-identifier": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz", + "integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==", + "dev": true + }, "@babel/helper-wrap-function": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz", @@ -1382,6 +1546,24 @@ "@babel/plugin-syntax-async-generators": "^7.8.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.1.tgz", + "integrity": "sha512-sqdGWgoXlnOdgMXU+9MbhzwFRgxVLeiGBqTrnuS7LC2IBU31wSsESbTUreT2O418obpfPdGUR2GbEufZF1bpqw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.8.3.tgz", @@ -1550,6 +1732,23 @@ "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-syntax-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz", + "integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-transform-arrow-functions": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz", @@ -2108,6 +2307,25 @@ "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-transform-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.1.tgz", + "integrity": "sha512-v+QWKlmCnsaimLeqq9vyCsVRMViZG1k2SZTlcZvB+TqyH570Zsij8nvVUZzOASCRiQFUxkLrn9Wg/kH0zgy5OQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/plugin-syntax-typescript": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-transform-unicode-regex": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz", @@ -2238,6 +2456,24 @@ } } }, + "@babel/preset-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz", + "integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/plugin-transform-typescript": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/runtime": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.2.tgz", @@ -3246,6 +3482,16 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dev": true, + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -3301,12 +3547,64 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, "@types/q": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==", "dev": true }, + "@types/ramda": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.6.tgz", + "integrity": "sha512-ephagb0ZIAJSoS5I/qMS4Mqo1b/Nd50pWM+o1QO/dz8NF//GsCGPTLDVRqgXlVncy74KShfHzE5rPZXTeek4PA==", + "dev": true, + "requires": { + "ts-toolbelt": "^6.3.3" + } + }, + "@types/react": { + "version": "16.9.34", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.34.tgz", + "integrity": "sha512-8AJlYMOfPe1KGLKyHpflCg5z46n0b5DbRfqDksxBLBTUpB75ypDBAO9eCUcjNwE6LCUslwTz00yyG/X9gaVtow==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-redux": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.7.tgz", + "integrity": "sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/redux": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz", + "integrity": "sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo=", + "dev": true, + "requires": { + "redux": "*" + } + }, + "@types/redux-actions": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@types/redux-actions/-/redux-actions-2.6.1.tgz", + "integrity": "sha512-zKgK+ATp3sswXs6sOYo1tk8xdXTy4CTaeeYrVQlClCjeOpag5vzPo0ASWiiBJ7vsiQRAdb3VkuFLnDoBimF67g==", + "dev": true + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -4594,6 +4892,15 @@ } } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -4632,6 +4939,12 @@ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, "builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -5599,6 +5912,12 @@ } } }, + "csstype": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.10.tgz", + "integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==", + "dev": true + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -5849,6 +6168,12 @@ "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", "dev": true }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, "diff-sequences": { "version": "25.1.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz", @@ -12058,6 +12383,12 @@ "semver": "^5.6.0" } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "make-plural": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-4.3.0.tgz", @@ -18315,18 +18646,220 @@ "integrity": "sha512-tdzBRDGWcI1OpPVmChbdSKhvSVurznZ8X36AYURAcl+0o2ldlCY2XPzyXNNxwJwwyIU+rIglTCG4kxtNKBQH7Q==", "dev": true }, + "ts-jest": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.0.0.tgz", + "integrity": "sha512-eBpWH65mGgzobuw7UZy+uPP9lwu+tPp60o324ASRX4Ijg8UC5dl2zcge4kkmqr2Zeuk9FwIjvCTOPuNMEyGWWw==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "micromatch": "4.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "18.x" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "ts-loader": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-7.0.2.tgz", + "integrity": "sha512-DwpZFB67RoILQHx42dMjSgv2STpacsQu5X+GD/H9ocd8IhU0m8p3b/ZrIln2KmcucC6xep2PdEMEblpWT71euA==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "enhanced-resolve": "^4.0.0", + "loader-utils": "^1.0.2", + "micromatch": "^4.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "ts-toolbelt": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.9.4.tgz", + "integrity": "sha512-muRZZqfOTOVvLk5cdnp7YWm6xX+kD/WL2cS/L4zximBRcbQSuMoTbQQ2ZZBVMs1gB0EZw1qThP+HrIQB35OmEw==", + "dev": true + }, "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", "dev": true }, + "tslint": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.2.tgz", + "integrity": "sha512-UyNrLdK3E0fQG/xWNqAFAC5ugtFyPO4JJR1KyyfQAyzR8W0fTRrC91A8Wej4BntFzcvETdCSDa/4PnNYJQLYiA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^4.0.1", + "glob": "^7.1.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.3", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.10.0", + "tsutils": "^2.29.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + } + } + }, "tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", "dev": true }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "tty-browserify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", diff --git a/dash-renderer/package.json b/dash-renderer/package.json index 3b383ef7f6..a2c1b48606 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -4,11 +4,13 @@ "description": "render dash components in react", "main": "dash_renderer/dash_renderer.min.js", "scripts": { - "prepublishOnly": "rm -rf lib && babel src --out-dir lib --copy-files", + "prepublishOnly": "rm -rf lib && babel src --extensions=\".ts,.tsx,.js,.jsx\" --out-dir lib --copy-files", "private::format.js-eslint": "eslint --quiet --fix .", "private::format.js-prettier": "prettier --config .prettierrc --write \"src/**/*.js\"", + "private::format.ts": "tslint --fix --project tsconfig.json --config tslint.json", "private::lint.js-eslint": "eslint .", "private::lint.js-prettier": "prettier --config .prettierrc \"src/**/*.js\" --list-different", + "private::lint.ts": "tslint --project tsconfig.json --config tslint.json", "build:js": "webpack --build release", "build:dev": "webpack --build local", "build:local": "renderer build local", @@ -41,10 +43,17 @@ "devDependencies": { "@babel/cli": "^7.8.4", "@babel/core": "^7.8.7", + "@babel/plugin-proposal-class-properties": "^7.10.1", "@babel/plugin-transform-modules-commonjs": "^7.8.3", "@babel/preset-env": "^7.8.7", "@babel/preset-react": "^7.8.3", + "@babel/preset-typescript": "^7.10.1", "@svgr/webpack": "^5.2.0", + "@types/ramda": "^0.27.6", + "@types/react": "^16.9.34", + "@types/react-redux": "^7.1.7", + "@types/redux": "^3.6.0", + "@types/redux-actions": "^2.6.1", "babel-eslint": "^10.1.0", "babel-loader": "^8.0.6", "css-loader": "^3.4.2", @@ -63,6 +72,10 @@ "prettier-eslint-cli": "^5.0.0", "prettier-stylelint": "^0.4.2", "style-loader": "^1.1.3", + "ts-jest": "^26.0.0", + "ts-loader": "^7.0.2", + "tslint": "^6.1.2", + "typescript": "^3.8.3", "webpack": "^4.42.0", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3", diff --git a/dash-renderer/src/APIController.react.js b/dash-renderer/src/APIController.react.js index 23e5f34c01..8479b0fd07 100644 --- a/dash-renderer/src/APIController.react.js +++ b/dash-renderer/src/APIController.react.js @@ -1,6 +1,6 @@ import {connect} from 'react-redux'; import {includes, isEmpty} from 'ramda'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState, createContext} from 'react'; import PropTypes from 'prop-types'; import TreeContainer from './TreeContainer'; import GlobalErrorContainer from './components/error/GlobalErrorContainer.react'; @@ -19,6 +19,9 @@ import {EventEmitter} from './actions/utils'; import {applyPersistence} from './persistence'; import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; +import {getLoadingState, getLoadingHash} from './utils/TreeContainer'; + +export const DashContext = createContext({}); /** * Fire off API calls for initialization @@ -26,6 +29,16 @@ import {STATUS} from './constants/constants'; * @returns {*} component */ const UnconnectedContainer = props => { + const { + appLifecycle, + config, + dependenciesRequest, + error, + layoutRequest, + layout, + loadingMap, + } = props; + const [errorLoading, setErrorLoading] = useState(false); const events = useRef(null); @@ -34,6 +47,18 @@ const UnconnectedContainer = props => { } const renderedTree = useRef(false); + const propsRef = useRef({}); + propsRef.current = props; + + const provider = useRef({ + fn: () => ({ + _dashprivate_config: propsRef.current.config, + _dashprivate_dispatch: propsRef.current.dispatch, + _dashprivate_graphs: propsRef.current.graphs, + _dashprivate_loadingMap: propsRef.current.loadingMap, + }), + }); + useEffect(storeEffect.bind(null, props, events, setErrorLoading)); useEffect(() => { @@ -43,14 +68,6 @@ const UnconnectedContainer = props => { } }); - const { - appLifecycle, - dependenciesRequest, - layoutRequest, - layout, - config, - } = props; - let content; if ( layoutRequest.status && @@ -65,11 +82,24 @@ const UnconnectedContainer = props => { content =
Error loading dependencies
; } else if (appLifecycle === getAppState('HYDRATED')) { renderedTree.current = true; + content = ( - + + + ); } else { content =
Loading...
; @@ -157,6 +187,7 @@ UnconnectedContainer.propTypes = { graphs: PropTypes.object, layoutRequest: PropTypes.object, layout: PropTypes.object, + loadingMap: PropTypes.any, history: PropTypes.any, error: PropTypes.object, config: PropTypes.object, @@ -169,6 +200,7 @@ const Container = connect( dependenciesRequest: state.dependenciesRequest, layoutRequest: state.layoutRequest, layout: state.layout, + loadingMap: state.loadingMap, graphs: state.graphs, history: state.history, error: state.error, diff --git a/dash-renderer/src/AppProvider.react.js b/dash-renderer/src/AppProvider.react.tsx similarity index 82% rename from dash-renderer/src/AppProvider.react.js rename to dash-renderer/src/AppProvider.react.tsx index d44a27eb61..6b534be900 100644 --- a/dash-renderer/src/AppProvider.react.js +++ b/dash-renderer/src/AppProvider.react.tsx @@ -1,14 +1,13 @@ +import PropTypes from 'prop-types'; import React from 'react'; import {Provider} from 'react-redux'; import initializeStore from './store'; import AppContainer from './AppContainer.react'; -import PropTypes from 'prop-types'; - const store = initializeStore(); -const AppProvider = ({hooks}) => { +const AppProvider = ({hooks}: any) => { return ( @@ -19,15 +18,17 @@ const AppProvider = ({hooks}) => { AppProvider.propTypes = { hooks: PropTypes.shape({ request_pre: PropTypes.func, - request_post: PropTypes.func, - }), + request_post: PropTypes.func + }) }; AppProvider.defaultProps = { hooks: { request_pre: null, - request_post: null, - }, + request_post: null + } }; export default AppProvider; + + diff --git a/dash-renderer/src/StoreObserver.ts b/dash-renderer/src/StoreObserver.ts new file mode 100644 index 0000000000..4bc82382f0 --- /dev/null +++ b/dash-renderer/src/StoreObserver.ts @@ -0,0 +1,113 @@ +import { + any, + filter, + forEach, + map, + path +} from 'ramda'; + +import { Store, Unsubscribe } from 'redux'; + +type Observer = (store: TStore) => void; +type UnregisterObserver = () => void; + +interface IStoreObserverState { + inputPaths: string[][]; + lastState: any; + observer: Observer; + triggered: boolean; +} + +export interface IStoreObserverDefinition { + observer: Observer>; + inputs: string[] +} + +export default class StoreObserver { + private _store?: Store; + private _unsubscribe?: Unsubscribe; + + private readonly _observers: IStoreObserverState>[] = []; + + constructor(store?: Store) { + this.__init__(store); + } + + observe = ( + observer: IStoreObserverDefinition | Observer>, + inputs?: string[] + ): UnregisterObserver => { + if (typeof observer === 'function') { + if (!Array.isArray(inputs)) { + throw new Error('inputs must be an array'); + } + + this.add(observer, inputs); + return () => this.remove(observer); + } else { + this.add(observer.observer, observer.inputs); + return () => this.remove(observer.observer); + } + } + + setStore = (store: Store) => { + this.__finalize__(); + this.__init__(store); + } + + private __finalize__ = () => this._unsubscribe?.() + + private __init__ = (store?: Store) => { + this._store = store; + if (store) { + this._unsubscribe = store.subscribe(this.notify); + } + + forEach(o => o.lastState = null, this._observers); + } + + private add = ( + observer: Observer>, + inputs: string[] + ) => this._observers.push({ + inputPaths: map(p => p.split('.'), inputs), + lastState: null, + observer, + triggered: false + }); + + private notify = () => { + const store = this._store; + if (!store) { + return; + } + + const state = store.getState(); + + const triggered = filter( + o => !o.triggered && any( + i => path(i, state) !== path(i, o.lastState), + o.inputPaths + ), + this._observers + ); + + forEach(o => o.triggered = true, triggered); + + forEach( + o => { + o.lastState = store.getState(); + o.observer(store); + o.triggered = false; + }, + triggered + ); + } + + private remove = (observer: Observer>) => this._observers.splice( + this._observers.findIndex( + o => observer === o.observer, + this._observers + ), 1 + ); +} diff --git a/dash-renderer/src/TreeContainer.js b/dash-renderer/src/TreeContainer.js index 3b1c3c13c2..bf317ad17d 100644 --- a/dash-renderer/src/TreeContainer.js +++ b/dash-renderer/src/TreeContainer.js @@ -1,15 +1,12 @@ -import React, {Component} from 'react'; +import React, {Component, memo} from 'react'; import PropTypes from 'prop-types'; import Registry from './registry'; import {propTypeErrorHandler} from './exceptions'; -import {connect} from 'react-redux'; import { addIndex, concat, dissoc, equals, - filter, - has, isEmpty, isNil, keys, @@ -26,46 +23,16 @@ import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; import checkPropTypes from './checkPropTypes'; import {getWatchedKeys, stringifyId} from './actions/dependencies'; - -function validateComponent(componentDefinition) { - if (type(componentDefinition) === 'Array') { - throw new Error( - 'The children property of a component is a list of lists, instead ' + - 'of just a list. ' + - 'Check the component that has the following contents, ' + - 'and remove one of the levels of nesting: \n' + - JSON.stringify(componentDefinition, null, 2) - ); - } - if ( - type(componentDefinition) === 'Object' && - !( - has('namespace', componentDefinition) && - has('type', componentDefinition) && - has('props', componentDefinition) - ) - ) { - throw new Error( - 'An object was provided as `children` instead of a component, ' + - 'string, or number (or list of those). ' + - 'Check the children property that looks something like:\n' + - JSON.stringify(componentDefinition, null, 2) - ); - } -} - -const createContainer = (component, path) => - isSimpleComponent(component) ? ( - component - ) : ( - - ); +import { + getLoadingHash, + getLoadingState, + validateComponent, +} from './utils/TreeContainer'; +import {DashContext} from './APIController.react'; + +const NOT_LOADING = { + is_loading: false, +}; function CheckedComponent(p) { const {element, extraProps, props, children, type} = p; @@ -100,13 +67,51 @@ function createElement(element, props, extraProps, children) { return React.createElement(element, allProps, children); } -class TreeContainer extends Component { +const TreeContainer = memo(props => ( + + {context => ( + + )} + +)); + +class BaseTreeContainer extends Component { constructor(props) { super(props); this.setProps = this.setProps.bind(this); } + createContainer(props, component, path) { + return isSimpleComponent(component) ? ( + component + ) : ( + + ); + } + setProps(newProps) { const { _dashprivate_graphs, @@ -161,17 +166,26 @@ class TreeContainer extends Component { return Array.isArray(components) ? addIndex(map)( (component, i) => - createContainer( + this.createContainer( + this.props, component, concat(path, ['props', 'children', i]) ), components ) - : createContainer(components, concat(path, ['props', 'children'])); + : this.createContainer( + this.props, + components, + concat(path, ['props', 'children']) + ); } getComponent(_dashprivate_layout, children, loading_state, setProps) { - const {_dashprivate_config} = this.props; + const { + _dashprivate_config, + _dashprivate_dispatch, + _dashprivate_error, + } = this.props; if (isEmpty(_dashprivate_layout)) { return null; @@ -192,13 +206,18 @@ class TreeContainer extends Component { // just the id we pass on to the rendered component props.id = stringifyId(props.id); } - const extraProps = {loading_state, setProps}; + const extraProps = { + loading_state: loading_state || NOT_LOADING, + setProps, + }; return ( {_dashprivate_config.props_check ? ( - !isSimpleComponent(child) && !isLoadingComponent(child), - Array.isArray(children) ? children : [children] - ); - - queue.push(...filteredChildren); - } - } - - return ids; -} - -function getLoadingState(layout, pendingCallbacks) { - const ids = isLoadingComponent(layout) - ? getNestedIds(layout) - : layout && layout.props.id && [layout.props.id]; - - let isLoading = false; - let loadingProp; - let loadingComponent; - - if (pendingCallbacks && pendingCallbacks.length && ids && ids.length) { - const idStrs = ids.map(stringifyId); - - pendingCallbacks.forEach(cb => { - const {requestId, requestedOutputs} = cb; - if (requestId === undefined) { - return; - } - - idStrs.forEach(idStr => { - const props = requestedOutputs[idStr]; - if (props) { - isLoading = true; - // TODO: what about multiple loading components / props? - loadingComponent = idStr; - loadingProp = props[0]; - } - }); - }); - } - - // Set loading state - return { - is_loading: isLoading, - prop_name: loadingProp, - component_name: loadingComponent, - }; -} - -export const AugmentedTreeContainer = connect( - state => ({ - graphs: state.graphs, - pendingCallbacks: state.pendingCallbacks, - config: state.config, - }), - dispatch => ({dispatch}), - (stateProps, dispatchProps, ownProps) => ({ - _dashprivate_graphs: stateProps.graphs, - _dashprivate_dispatch: dispatchProps.dispatch, - _dashprivate_layout: ownProps._dashprivate_layout, - _dashprivate_path: ownProps._dashprivate_path, - _dashprivate_loadingState: getLoadingState( - ownProps._dashprivate_layout, - stateProps.pendingCallbacks - ), - _dashprivate_config: stateProps.config, - }) -)(TreeContainer); - -export default AugmentedTreeContainer; +export default TreeContainer; diff --git a/dash-renderer/src/actions/callbacks.ts b/dash-renderer/src/actions/callbacks.ts new file mode 100644 index 0000000000..c660bcb218 --- /dev/null +++ b/dash-renderer/src/actions/callbacks.ts @@ -0,0 +1,422 @@ +import { + concat, + flatten, + keys, + map, + mergeDeepRight, + path, + pick, + pluck, + zip +} from 'ramda'; + +import { STATUS } from '../constants/constants'; +import { CallbackActionType, CallbackAggregateActionType } from '../reducers/callbacks'; +import { CallbackResult, ICallback, IExecutedCallback, IExecutingCallback, ICallbackPayload, IStoredCallback, IBlockedCallback, IPrioritizedCallback } from '../types/callbacks'; +import { isMultiValued, stringifyId, isMultiOutputProp } from './dependencies'; +import { urlBase } from './utils'; +import { getCSRFHeader } from '.'; +import { createAction, Action } from 'redux-actions'; + +export const addBlockedCallbacks = createAction( + CallbackActionType.AddBlocked +); +export const addCompletedCallbacks = createAction( + CallbackAggregateActionType.AddCompleted +); +export const addExecutedCallbacks = createAction( + CallbackActionType.AddExecuted +); +export const addExecutingCallbacks = createAction( + CallbackActionType.AddExecuting +); +export const addPrioritizedCallbacks = createAction( + CallbackActionType.AddPrioritized +); +export const addRequestedCallbacks = createAction( + CallbackActionType.AddRequested +); +export const addStoredCallbacks = createAction( + CallbackActionType.AddStored +); +export const addWatchedCallbacks = createAction(CallbackActionType.AddWatched); +export const removeExecutedCallbacks = createAction( + CallbackActionType.RemoveExecuted +); +export const removeBlockedCallbacks = createAction( + CallbackActionType.RemoveBlocked +); +export const removeExecutingCallbacks = createAction( + CallbackActionType.RemoveExecuting +); +export const removePrioritizedCallbacks = createAction( + CallbackActionType.RemovePrioritized +); +export const removeRequestedCallbacks = createAction( + CallbackActionType.RemoveRequested +); +export const removeStoredCallbacks = createAction( + CallbackActionType.RemoveStored +); +export const removeWatchedCallbacks = createAction( + CallbackActionType.RemoveWatched +); +export const aggregateCallbacks = createAction<( + Action | + Action | + null +)[]>(CallbackAggregateActionType.Aggregate); + +function unwrapIfNotMulti( + paths: any, + idProps: any, + spec: any, + anyVals: any, + depType: any +) { + let msg = ''; + + if (isMultiValued(spec)) { + return [idProps, msg]; + } + + if (idProps.length !== 1) { + if (!idProps.length) { + const isStr = typeof spec.id === 'string'; + msg = + 'A nonexistent object was used in an `' + + depType + + '` of a Dash callback. The id of this object is ' + + (isStr + ? '`' + spec.id + '`' + : JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '')) + + ' and the property is `' + + spec.property + + (isStr + ? '`. The string ids in the current layout are: [' + + keys(paths.strs).join(', ') + + ']' + : '`. The wildcard ids currently available are logged above.'); + } else { + msg = + 'Multiple objects were found for an `' + + depType + + '` of a callback that only takes one value. The id spec is ' + + JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '') + + ' and the property is `' + + spec.property + + '`. The objects we found are: ' + + JSON.stringify(map(pick(['id', 'property']), idProps)); + } + } + return [idProps[0], msg]; +} + +function fillVals( + paths: any, + layout: any, + cb: ICallback, + specs: any, + depType: any, + allowAllMissing: boolean = false +) { + const getter = depType === 'Input' ? cb.getInputs : cb.getState; + const errors: any[] = []; + let emptyMultiValues = 0; + + const inputVals = getter(paths).map((inputList: any, i: number) => { + const [inputs, inputError] = unwrapIfNotMulti( + paths, + inputList.map(({ id, property, path: path_ }: any) => ({ + id, + property, + value: (path(path_, layout) as any).props[property] + })), + specs[i], + cb.anyVals, + depType + ); + if (isMultiValued(specs[i]) && !inputs.length) { + emptyMultiValues++; + } + if (inputError) { + errors.push(inputError); + } + return inputs; + }); + + if (errors.length) { + if ( + allowAllMissing && + errors.length + emptyMultiValues === inputVals.length + ) { + // We have at least one non-multivalued input, but all simple and + // multi-valued inputs are missing. + // (if all inputs are multivalued and all missing we still return + // them as normal, and fire the callback.) + return null; + } + // If we get here we have some missing and some present inputs. + // Or all missing in a context that doesn't allow this. + // That's a real problem, so throw the first message as an error. + refErr(errors, paths); + } + + return inputVals; +} + +function refErr(errors: any, paths: any) { + const err = errors[0]; + if (err.indexOf('logged above') !== -1) { + // Wildcard reference errors mention a list of wildcard specs logged + // TODO: unwrapped list of wildcard ids? + // eslint-disable-next-line no-console + console.error(paths.objs); + } + throw new ReferenceError(err); +} + +const getVals = (input: any) => + Array.isArray(input) ? pluck('value', input) : input.value; + +const zipIfArray = (a: any, b: any) => (Array.isArray(a) ? zip(a, b) : [[a, b]]); + +function handleClientside(clientside_function: any, payload: ICallbackPayload) { + const dc = ((window as any).dash_clientside = (window as any).dash_clientside || {}); + if (!dc.no_update) { + Object.defineProperty(dc, 'no_update', { + value: { description: 'Return to prevent updating an Output.' }, + writable: false + }); + + Object.defineProperty(dc, 'PreventUpdate', { + value: { description: 'Throw to prevent updating all Outputs.' }, + writable: false + }); + } + + const { inputs, outputs, state } = payload; + + let returnValue; + + try { + const { namespace, function_name } = clientside_function; + let args = inputs.map(getVals); + if (state) { + args = concat(args, state.map(getVals)); + } + + // setup callback context + const input_dict = inputsToDict(inputs); + dc.callback_context = {}; + dc.callback_context.triggered = payload.changedPropIds.map(prop_id => ({ + prop_id: prop_id, + value: input_dict[prop_id] + })); + dc.callback_context.inputs_list = inputs; + dc.callback_context.inputs = input_dict; + dc.callback_context.states_list = state; + dc.callback_context.states = inputsToDict(state); + + returnValue = dc[namespace][function_name](...args); + } catch (e) { + if (e === dc.PreventUpdate) { + return {}; + } + throw e; + } finally { + delete dc.callback_context; + } + + if (typeof returnValue?.then === 'function') { + throw new Error( + 'The clientside function returned a Promise. ' + + 'Promises are not supported in Dash clientside ' + + 'right now, but may be in the future.' + ); + } + + const data: any = {}; + zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { + zipIfArray(outi, reti).forEach(([outij, retij]) => { + const { id, property } = outij; + const idStr = stringifyId(id); + const dataForId = (data[idStr] = data[idStr] || {}); + if (retij !== dc.no_update) { + dataForId[property] = retij; + } + }); + }); + return data; +} + +function handleServerside( + hooks: any, + config: any, + payload: any +): Promise { + if (hooks.request_pre !== null) { + hooks.request_pre(payload); + } + + return fetch( + `${urlBase(config)}_dash-update-component`, + mergeDeepRight(config.fetch, { + method: 'POST', + headers: getCSRFHeader() as any, + body: JSON.stringify(payload) + }) + ).then((res: any) => { + const { status } = res; + if (status === STATUS.OK) { + return res.json().then((data: any) => { + const { multi, response } = data; + if (hooks.request_post !== null) { + hooks.request_post(payload, response); + } + + if (multi) { + return response; + } + + const { output } = payload; + const id = output.substr(0, output.lastIndexOf('.')); + return { [id]: response.props }; + }); + } + if (status === STATUS.PREVENT_UPDATE) { + return {}; + } + throw res; + }, () => { + // fetch rejection - this means the request didn't return, + // we don't get here from 400/500 errors, only network + // errors or unresponsive servers. + throw new Error('Callback failed: the server did not respond.'); + }); +} + +function inputsToDict(inputs_list: any) { + // Ported directly from _utils.py, inputs_to_dict + // takes an array of inputs (some inputs may be an array) + // returns an Object (map): + // keys of the form `id.property` or `{"id": 0}.property` + // values contain the property value + if (!inputs_list) { + return {}; + } + const inputs: any = {}; + for (let i = 0; i < inputs_list.length; i++) { + if (Array.isArray(inputs_list[i])) { + const inputsi = inputs_list[i]; + for (let ii = 0; ii < inputsi.length; ii++) { + const id_str = `${stringifyId(inputsi[ii].id)}.${ + inputsi[ii].property + }`; + inputs[id_str] = inputsi[ii].value ?? null; + } + } else { + const id_str = `${stringifyId(inputs_list[i].id)}.${ + inputs_list[i].property + }`; + inputs[id_str] = inputs_list[i].value ?? null; + } + } + return inputs; +} + +export function executeCallback( + cb: IPrioritizedCallback, + config: any, + hooks: any, + paths: any, + layout: any, + { allOutputs }: any +): IExecutingCallback { + const { output, inputs, state, clientside_function } = cb.callback; + + try { + const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); + + /* Prevent callback if there's no inputs */ + if (inVals === null) { + return { + ...cb, + executionPromise: null + }; + } + + const outputs: any[] = []; + const outputErrors: any[] = []; + allOutputs.forEach((out: any, i: number) => { + const [outi, erri] = unwrapIfNotMulti( + paths, + map(pick(['id', 'property']), out), + cb.callback.outputs[i], + cb.anyVals, + 'Output' + ); + outputs.push(outi); + if (erri) { + outputErrors.push(erri); + } + }); + + if (outputErrors.length) { + if (flatten(inVals).length) { + refErr(outputErrors, paths); + } + // This case is all-empty multivalued wildcard inputs, + // which we would normally fire the callback for, except + // some outputs are missing. So instead we treat it like + // regular missing inputs and just silently prevent it. + return { + ...cb, + executionPromise: null + }; + } + + const __promise = new Promise(resolve => { + try { + const payload: ICallbackPayload = { + output, + outputs: isMultiOutputProp(output) ? outputs : outputs[0], + inputs: inVals, + changedPropIds: keys(cb.changedPropIds), + state: cb.callback.state.length ? + fillVals(paths, layout, cb, state, 'State') : + undefined + }; + + if (clientside_function) { + try { + resolve({ data: handleClientside(clientside_function, payload), payload }); + } catch (error) { + resolve({ error, payload }); + } + return null; + } else { + handleServerside(hooks, config, payload) + .then(data => resolve({ data, payload })) + .catch(error => resolve({ error, payload })); + } + } catch (error) { + resolve({ error, payload: null }); + } + }); + + const newCb = { + ...cb, + executionPromise: __promise + }; + + return newCb; + } catch (error) { + return { + ...cb, + executionPromise: { error, payload: null } + }; + } +} diff --git a/dash-renderer/src/actions/dependencies.js b/dash-renderer/src/actions/dependencies.js index add6a20597..1d77086d1d 100644 --- a/dash-renderer/src/actions/dependencies.js +++ b/dash-renderer/src/actions/dependencies.js @@ -5,9 +5,7 @@ import { any, ap, assoc, - clone, difference, - dissoc, equals, evolve, findIndex, @@ -18,24 +16,25 @@ import { isEmpty, keys, map, - mergeDeepRight, mergeRight, - mergeWith, - partition, path, - pickBy, pluck, - propEq, props, startsWith, - unnest, values, zip, zipObj, } from 'ramda'; -const mergeMax = mergeWith(Math.max); - +import { + combineIdAndProp, + getCallbacksByInput, + getPriority, + INDIRECT, + mergeMax, + makeResolvedCallback, + resolveDeps, +} from './dependencies_ts'; import {computePaths, getPath} from './paths'; import {crawlLayout} from './utils'; @@ -91,7 +90,7 @@ function parseMultipleOutputs(outputIdAndProp) { return outputIdAndProp.substr(2, outputIdAndProp.length - 4).split('...'); } -function splitIdAndProp(idAndProp) { +export function splitIdAndProp(idAndProp) { // since wildcard ids can have . in them but props can't, // look for the last . in the string and split there const dotPos = idAndProp.lastIndexOf('.'); @@ -109,9 +108,6 @@ export function parseIfWildcard(idStr) { return isWildcardId(idStr) ? parseWildcardId(idStr) : idStr; } -export const combineIdAndProp = ({id, property}) => - `${stringifyId(id)}.${property}`; - /* * JSON.stringify - for the object form - but ensuring keys are sorted */ @@ -846,7 +842,14 @@ function findWildcardKeys(id) { * Optionally, include another reference set of the same - to ensure the * correct matching of MATCH or ALLSMALLER between input and output items. */ -function idMatch(keys, vals, patternVals, refKeys, refVals, refPatternVals) { +export function idMatch( + keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals +) { for (let i = 0; i < keys.length; i++) { const val = vals[i]; const patternVal = patternVals[i]; @@ -901,74 +904,6 @@ function getAnyVals(patternVals, vals) { return matches.length ? JSON.stringify(matches) : ''; } -function resolveDeps(refKeys, refVals, refPatternVals) { - return paths => ({id: idPattern, property}) => { - if (typeof idPattern === 'string') { - const path = getPath(paths, idPattern); - return path ? [{id: idPattern, property, path}] : []; - } - const keys = Object.keys(idPattern).sort(); - const patternVals = props(keys, idPattern); - const keyStr = keys.join(','); - const keyPaths = paths.objs[keyStr]; - if (!keyPaths) { - return []; - } - const result = []; - keyPaths.forEach(({values: vals, path}) => { - if ( - idMatch( - keys, - vals, - patternVals, - refKeys, - refVals, - refPatternVals - ) - ) { - result.push({id: zipObj(keys, vals), property, path}); - } - }); - return result; - }; -} - -/* - * Create a pending callback object. Includes the original callback definition, - * its resolved ID (including the value of all MATCH wildcards), - * accessors to find all inputs, outputs, and state involved in this - * callback (lazy as not all users will want all of these), - * placeholders for which other callbacks this one is blockedBy or blocking, - * and a boolean for whether it has been dispatched yet. - */ -const makeResolvedCallback = (callback, resolve, anyVals) => ({ - callback, - anyVals, - resolvedId: callback.output + anyVals, - getOutputs: paths => callback.outputs.map(resolve(paths)), - getInputs: paths => callback.inputs.map(resolve(paths)), - getState: paths => callback.state.map(resolve(paths)), - blockedBy: {}, - blocking: {}, - changedPropIds: {}, - initialCall: false, - requestId: 0, - requestedOutputs: {}, -}); - -const DIRECT = 2; -const INDIRECT = 1; - -let nextRequestId = 0; - -/* - * Give a callback a new requestId. - */ -export function setNewRequestId(callback) { - nextRequestId++; - return assoc('requestId', nextRequestId, callback); -} - /* * Does this item (input / output / state) support multiple values? * string IDs do not; wildcard IDs only do if they contain ALL or ALLSMALLER @@ -991,8 +926,6 @@ export function isMultiValued({id}) { * The result is a list of {id (string or object), property (string)} * getInputs: same for inputs * getState: same for state - * blockedBy: an object of {[resolvedId]: 1} blocking this callback - * blocking: an object of {[resolvedId]: 1} this callback is blocking * changedPropIds: an object of {[idAndProp]: v} triggering this callback * v = DIRECT (2): the prop was changed in the front end, so dependent * callbacks *MUST* be executed. @@ -1003,12 +936,6 @@ export function isMultiValued({id}) { * this value on page load or changing part of the layout. * By default this is true for callbacks generated by * getCallbackByOutput, false from getCallbacksByInput. - * requestId: integer: starts at 0. when this callback is dispatched it will - * get a unique requestId, but if it gets added again the requestId will - * be reset to 0, and we'll know to ignore the response of the first - * request. - * requestedOutputs: object of {[idStr]: [props]} listing all the props - * actually requested for update. * } */ function getCallbackByOutput(graphs, paths, id, prop) { @@ -1062,7 +989,7 @@ function addResolvedFromOutputs(callback, outPattern, outs, matches) { }); } -function addAllResolvedFromOutputs(resolve, paths, matches) { +export function addAllResolvedFromOutputs(resolve, paths, matches) { return callback => { const {matchKeys, firstSingleOutput, outputs} = callback; if (matchKeys.length) { @@ -1119,47 +1046,6 @@ function addAllResolvedFromOutputs(resolve, paths, matches) { * (with an MATCH corresponding to the input's ALLSMALLER) will only appear * in one entry. */ -export function getCallbacksByInput(graphs, paths, id, prop, changeType) { - const matches = []; - const idAndProp = combineIdAndProp({id, property: prop}); - - if (typeof id === 'string') { - // standard id version - const callbacks = (graphs.inputMap[id] || {})[prop]; - if (!callbacks) { - return []; - } - - callbacks.forEach( - addAllResolvedFromOutputs(resolveDeps(), paths, matches) - ); - } else { - // wildcard version - const keys = Object.keys(id).sort(); - const vals = props(keys, id); - const keyStr = keys.join(','); - const patterns = (graphs.inputPatterns[keyStr] || {})[prop]; - if (!patterns) { - return []; - } - patterns.forEach(pattern => { - if (idMatch(keys, vals, pattern.values)) { - pattern.callbacks.forEach( - addAllResolvedFromOutputs( - resolveDeps(keys, vals, pattern.values), - paths, - matches - ) - ); - } - }); - } - matches.forEach(match => { - match.changedPropIds[idAndProp] = changeType || DIRECT; - }); - return matches; -} - export function getWatchedKeys(id, newProps, graphs) { if (!(id && graphs && newProps.length)) { return []; @@ -1205,7 +1091,7 @@ export function getWatchedKeys(id, newProps, graphs) { * {callback, resolvedId, getOutputs, getInputs, getState, ...etc} * See getCallbackByOutput for details. */ -export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) { +export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) { const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts; const foundCbIds = {}; const callbacks = []; @@ -1315,245 +1201,11 @@ export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) { } }); - // We still need to follow these forward in order to capture blocks and, - // if based on a partial layout, any knock-on effects in the full layout. - const finalCallbacks = followForward(graphs, paths, callbacks); - - // Exception to the `initialCall` case of callbacks found by output: - // if *every* input to this callback is itself an output of another - // callback earlier in the chain, we remove the `initialCall` flag - // so that if all of those prior callbacks abort all of their outputs, - // this later callback never runs. - // See test inin003 "callback2 is never triggered, even on initial load" - finalCallbacks.forEach(cb => { - if (cb.initialCall && !isEmpty(cb.blockedBy)) { - const inputs = flatten(cb.getInputs(paths)); - cb.initialCall = false; - inputs.forEach(i => { - const propId = combineIdAndProp(i); - if (cb.changedPropIds[propId]) { - cb.changedPropIds[propId] = INDIRECT; - } else { - cb.initialCall = true; - } - }); - } - }); - - return finalCallbacks; -} - -export function removePendingCallback( - pendingCallbacks, - paths, - removeResolvedId, - skippedProps -) { - const finalPendingCallbacks = []; - pendingCallbacks.forEach(pending => { - const {blockedBy, blocking, changedPropIds, resolvedId} = pending; - if (resolvedId !== removeResolvedId) { - finalPendingCallbacks.push( - mergeRight(pending, { - blockedBy: dissoc(removeResolvedId, blockedBy), - blocking: dissoc(removeResolvedId, blocking), - changedPropIds: pickBy( - (v, k) => v === DIRECT || !includes(k, skippedProps), - changedPropIds - ), - }) - ); - } - }); - // If any callback no longer has any changed inputs, it shouldn't fire. - // This will repeat recursively until all unneeded callbacks are pruned - if (skippedProps.length) { - for (let i = 0; i < finalPendingCallbacks.length; i++) { - const cb = finalPendingCallbacks[i]; - if (!cb.initialCall && isEmpty(cb.changedPropIds)) { - return removePendingCallback( - finalPendingCallbacks, - paths, - cb.resolvedId, - flatten(cb.getOutputs(paths)).map(combineIdAndProp) - ); - } - } - } - return finalPendingCallbacks; -} - -/* - * Split the list of pending callbacks into ready (not blocked by any others) - * and blocked. Sort the ready callbacks by how many each is blocking, on the - * theory that the most important ones to dispatch are the ones with the most - * others depending on them. - */ -export function findReadyCallbacks(pendingCallbacks) { - const [readyCallbacks, blockedCallbacks] = partition( - pending => isEmpty(pending.blockedBy) && !pending.requestId, - pendingCallbacks + return map( + cb => ({ + ...cb, + priority: getPriority(graphs, paths, cb), + }), + callbacks ); - readyCallbacks.sort((a, b) => { - return Object.keys(b.blocking).length - Object.keys(a.blocking).length; - }); - - return {readyCallbacks, blockedCallbacks}; -} - -function addBlock(callbacks, blockingId, blockedId) { - callbacks.forEach(({blockedBy, blocking, resolvedId}) => { - if (resolvedId === blockingId || blocking[blockingId]) { - blocking[blockedId] = 1; - } else if (resolvedId === blockedId || blockedBy[blockedId]) { - blockedBy[blockingId] = 1; - } - }); -} - -function collectIds(callbacks) { - const allResolvedIds = {}; - callbacks.forEach(({resolvedId}, i) => { - allResolvedIds[resolvedId] = i; - }); - return allResolvedIds; -} - -/* - * Take a list of callbacks and follow them all forward, ie see if any of their - * outputs are inputs of another callback. Any new callbacks get added to the - * list. All that come after another get marked as blocked by that one, whether - * they were in the initial list or not. - */ -export function followForward(graphs, paths, callbacks_) { - const callbacks = clone(callbacks_); - const allResolvedIds = collectIds(callbacks); - let i; - let callback; - - const followOutput = ({id, property}) => { - const nextCBs = getCallbacksByInput( - graphs, - paths, - id, - property, - INDIRECT - ); - nextCBs.forEach(nextCB => { - let existingIndex = allResolvedIds[nextCB.resolvedId]; - if (existingIndex === undefined) { - existingIndex = callbacks.length; - callbacks.push(nextCB); - allResolvedIds[nextCB.resolvedId] = existingIndex; - } else { - const existingCB = callbacks[existingIndex]; - existingCB.changedPropIds = mergeMax( - existingCB.changedPropIds, - nextCB.changedPropIds - ); - } - addBlock(callbacks, callback.resolvedId, nextCB.resolvedId); - }); - }; - - // Using a for loop instead of forEach because followOutput may extend the - // callbacks array, and we want to continue into these new elements. - for (i = 0; i < callbacks.length; i++) { - callback = callbacks[i]; - const outputs = unnest(callback.getOutputs(paths)); - outputs.forEach(followOutput); - } - return callbacks; -} - -function mergeAllBlockers(cb1, cb2) { - function mergeBlockers(a, b) { - if (cb1[a][cb2.resolvedId] && !cb2[b][cb1.resolvedId]) { - cb2[b][cb1.resolvedId] = cb1[a][cb2.resolvedId]; - cb2[b] = mergeMax(cb1[b], cb2[b]); - cb1[a] = mergeMax(cb2[a], cb1[a]); - } - } - mergeBlockers('blockedBy', 'blocking'); - mergeBlockers('blocking', 'blockedBy'); -} - -/* - * Given two arrays of pending callbacks, merge them into one so that - * each will only fire once, and any extra blockages from combining the lists - * will be accounted for. - */ -export function mergePendingCallbacks(cb1, cb2) { - if (!cb2.length) { - return cb1; - } - if (!cb1.length) { - return cb2; - } - const finalCallbacks = clone(cb1); - const callbacks2 = clone(cb2); - const allResolvedIds = collectIds(finalCallbacks); - - callbacks2.forEach((callback, i) => { - const existingIndex = allResolvedIds[callback.resolvedId]; - if (existingIndex !== undefined) { - finalCallbacks.forEach(finalCb => { - mergeAllBlockers(finalCb, callback); - }); - callbacks2.slice(i + 1).forEach(cb2 => { - mergeAllBlockers(cb2, callback); - }); - finalCallbacks[existingIndex] = mergeDeepRight( - finalCallbacks[existingIndex], - callback - ); - } else { - allResolvedIds[callback.resolvedId] = finalCallbacks.length; - finalCallbacks.push(callback); - } - }); - - return finalCallbacks; -} - -/* - * Remove callbacks whose outputs or changed inputs have been removed - * from the layout - */ -export function pruneRemovedCallbacks(pendingCallbacks, paths) { - const removeIds = []; - let cleanedCallbacks = pendingCallbacks.map(callback => { - const {changedPropIds, getOutputs, resolvedId} = callback; - if (!flatten(getOutputs(paths)).length) { - removeIds.push(resolvedId); - return callback; - } - - let omittedProps = false; - const newChangedProps = pickBy((_, propId) => { - if (getPath(paths, splitIdAndProp(propId).id)) { - return true; - } - omittedProps = true; - return false; - }, changedPropIds); - - return omittedProps - ? assoc('changedPropIds', newChangedProps, callback) - : callback; - }); - - removeIds.forEach(resolvedId => { - const cb = cleanedCallbacks.find(propEq('resolvedId', resolvedId)); - if (cb) { - cleanedCallbacks = removePendingCallback( - pendingCallbacks, - paths, - resolvedId, - flatten(cb.getOutputs(paths)).map(combineIdAndProp) - ); - } - }); - - return cleanedCallbacks; } diff --git a/dash-renderer/src/actions/dependencies_ts.ts b/dash-renderer/src/actions/dependencies_ts.ts new file mode 100644 index 0000000000..840f03d89f --- /dev/null +++ b/dash-renderer/src/actions/dependencies_ts.ts @@ -0,0 +1,333 @@ +import { + all, + assoc, + concat, + difference, + filter, + flatten, + forEach, + isEmpty, + keys, + map, + mergeWith, + partition, + pickBy, + props, + reduce, + zipObj +} from 'ramda'; +import { ICallback, ICallbackProperty, ICallbackDefinition, ILayoutCallbackProperty, ICallbackTemplate } from '../types/callbacks'; +import { addAllResolvedFromOutputs, splitIdAndProp, stringifyId, getUnfilteredLayoutCallbacks, isMultiValued, idMatch } from './dependencies'; +import { getPath } from './paths'; + +export const DIRECT = 2; +export const INDIRECT = 1; +export const mergeMax = mergeWith(Math.max); + +export const combineIdAndProp = ({ + id, + property +}: ICallbackProperty) => `${stringifyId(id)}.${property}`; + +export function getCallbacksByInput( + graphs: any, + paths: any, + id: any, + prop: any, + changeType?: any, + withPriority: boolean = true +): ICallback[] { + const matches: ICallback[] = []; + const idAndProp = combineIdAndProp({ id, property: prop }); + + if (typeof id === 'string') { + // standard id version + const callbacks = (graphs.inputMap[id] || {})[prop]; + if (!callbacks) { + return []; + } + + callbacks.forEach( + addAllResolvedFromOutputs(resolveDeps(), paths, matches) + ); + } else { + // wildcard version + const _keys = Object.keys(id).sort(); + const vals = props(_keys, id); + const keyStr = _keys.join(','); + const patterns: any[] = (graphs.inputPatterns[keyStr] || {})[prop]; + if (!patterns) { + return []; + } + patterns.forEach(pattern => { + if (idMatch(_keys, vals, pattern.values)) { + pattern.callbacks.forEach( + addAllResolvedFromOutputs( + resolveDeps(_keys, vals, pattern.values), + paths, + matches + ) + ); + } + }); + } + matches.forEach(match => { + match.changedPropIds[idAndProp] = changeType || DIRECT; + if (withPriority) { + match.priority = getPriority(graphs, paths, match) + } + }); + return matches; +} + +/* + * Builds a tree of all callbacks that can be triggered by the provided callback. + * Uses the number of callbacks at each tree depth and the total depth of the tree + * to create a sortable priority hash. + */ +export function getPriority(graphs: any, paths: any, callback: ICallback): string { + let callbacks: ICallback[] = [callback]; + let touchedOutputs: { [key: string]: boolean } = {}; + let priority: number[] = []; + + while (callbacks.length) { + const outputs = filter( + o => !touchedOutputs[combineIdAndProp(o)], + flatten(map( + cb => flatten(cb.getOutputs(paths)), + callbacks + )) + ); + + touchedOutputs = reduce( + (touched, o) => assoc(combineIdAndProp(o), true, touched), + touchedOutputs, + outputs + ); + + callbacks = flatten(map( + ({ id, property }: any) => getCallbacksByInput( + graphs, + paths, + id, + property, + INDIRECT, + false + ), + outputs + )); + + if (callbacks.length) { + priority.push(callbacks.length); + } + } + + priority.unshift(priority.length); + + return map(i => Math.min(i, 35).toString(36), priority).join(''); +} + +export const getReadyCallbacks = ( + paths: any, + candidates: ICallback[], + callbacks: ICallback[] = candidates +): ICallback[] => { + // Skip if there's no candidates + if (!candidates.length) { + return []; + } + + // Find all outputs of all active callbacks + const outputs = map( + combineIdAndProp, + reduce( + (o, cb) => concat(o, flatten(cb.getOutputs(paths))), + [], + callbacks + ) + ); + + // Make `outputs` hash table for faster access + const outputsMap: { [key: string]: boolean } = {}; + forEach(output => outputsMap[output] = true, outputs); + + // Find `requested` callbacks that do not depend on a outstanding output (as either input or state) + return filter( + cb => all( + cbp => !outputsMap[combineIdAndProp(cbp)], + flatten(cb.getInputs(paths)) + ), + candidates + ); +} + +export const getLayoutCallbacks = ( + graphs: any, + paths: any, + layout: any, + options: any +): ICallback[] => { + let exclusions: string[] = []; + let callbacks = getUnfilteredLayoutCallbacks( + graphs, + paths, + layout, + options + ); + + /* + Remove from the initial callbacks those that are left with only excluded inputs. + + Exclusion of inputs happens when: + - an input is missing + - an input in the initial callback chain depends only on excluded inputs + + Further execlusion might happen after callbacks return with: + - PreventUpdate + - no_update + */ + while (true) { + // Find callbacks for which all inputs are missing or in the exclusions + const [included, excluded] = partition(({ + callback: { inputs }, + getInputs + }) => all(isMultiValued, inputs) || + !isEmpty(difference( + map(combineIdAndProp, flatten(getInputs(paths))), + exclusions + )), + callbacks + ); + + // If there's no additional exclusions, break loop - callbacks have been cleaned + if (!excluded.length) { + break; + } + + callbacks = included; + + // update exclusions with all additional excluded outputs + exclusions = concat( + exclusions, + map(combineIdAndProp, flatten(map( + ({ getOutputs }) => getOutputs(paths), + excluded + ))) + ); + } + + /* + Return all callbacks with an `executionGroup` to allow group-processing + */ + const executionGroup = Math.random().toString(16); + return map(cb => ({ + ...cb, + executionGroup + }), callbacks); +} + +export const getUniqueIdentifier = ({ + anyVals, + callback: { + inputs, + outputs, + state + } +}: ICallback): string => concat( + map(combineIdAndProp, [ + ...inputs, + ...outputs, + ...state + ]), + Array.isArray(anyVals) ? + anyVals : + anyVals === '' ? [] : [anyVals] + ).join(','); + +export function includeObservers(id: any, properties: any, graphs: any, paths: any): ICallback[] { + return flatten(map( + propName => getCallbacksByInput(graphs, paths, id, propName), + keys(properties) + )); +} + +/* + * Create a pending callback object. Includes the original callback definition, + * its resolved ID (including the value of all MATCH wildcards), + * accessors to find all inputs, outputs, and state involved in this + * callback (lazy as not all users will want all of these). + */ +export const makeResolvedCallback = ( + callback: ICallbackDefinition, + resolve: (_: any) => (_: ICallbackProperty) => ILayoutCallbackProperty[], + anyVals: any[] | string +): ICallbackTemplate => ({ + callback, + anyVals, + resolvedId: callback.output + anyVals, + getOutputs: paths => callback.outputs.map(resolve(paths)), + getInputs: paths => callback.inputs.map(resolve(paths)), + getState: paths => callback.state.map(resolve(paths)), + changedPropIds: {}, + initialCall: false +}); + +export function pruneCallbacks(callbacks: T[], paths: any): { + added: T[], + removed: T[] +} { + const [, removed] = partition( + ({ getOutputs, callback: { outputs } }) => flatten(getOutputs(paths)).length === outputs.length, + callbacks + ); + + const [, modified] = partition( + ({ getOutputs }) => !flatten(getOutputs(paths)).length, + removed + ); + + const added = map( + cb => assoc('changedPropIds', pickBy( + (_, propId) => getPath(paths, splitIdAndProp(propId).id), + cb.changedPropIds + ), cb), + modified + ); + + return { + added, + removed + }; +} + +export function resolveDeps(refKeys?: any, refVals?: any, refPatternVals?: string) { + return (paths: any) => ({ id: idPattern, property }: ICallbackProperty) => { + if (typeof idPattern === 'string') { + const path = getPath(paths, idPattern); + return path ? [{ id: idPattern, property, path }] : []; + } + const _keys = Object.keys(idPattern).sort(); + const patternVals = props(_keys, idPattern); + const keyStr = _keys.join(','); + const keyPaths = paths.objs[keyStr]; + if (!keyPaths) { + return []; + } + const result: ILayoutCallbackProperty[] = []; + keyPaths.forEach(({ values: vals, path }: any) => { + if ( + idMatch( + _keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals + ) + ) { + result.push({ id: zipObj(_keys, vals), property, path }); + } + }); + return result; + }; +} diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index a48c81a687..7d165f41a6 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -1,59 +1,22 @@ -import { - concat, - flatten, - has, - isEmpty, - keys, - map, - mergeDeepRight, - once, - path, - pick, - pickBy, - pluck, - propEq, - type, - uniq, - without, - zip, -} from 'ramda'; +import {once} from 'ramda'; import {createAction} from 'redux-actions'; +import {addRequestedCallbacks} from './callbacks'; import {getAppState} from '../reducers/constants'; import {getAction} from './constants'; import cookie from 'cookie'; -import {urlBase} from './utils'; -import { - combineIdAndProp, - findReadyCallbacks, - followForward, - getCallbacksByInput, - getCallbacksInLayout, - isMultiOutputProp, - isMultiValued, - mergePendingCallbacks, - removePendingCallback, - parseIfWildcard, - pruneRemovedCallbacks, - setNewRequestId, - stringifyId, - validateCallbacksToLayout, -} from './dependencies'; -import {computePaths, getPath} from './paths'; -import {STATUS} from '../constants/constants'; -import {applyPersistence, prunePersistence} from '../persistence'; +import {validateCallbacksToLayout} from './dependencies'; +import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; +import {getPath} from './paths'; -import isAppReady from './isAppReady'; - -export const updateProps = createAction(getAction('ON_PROP_CHANGE')); -export const setPendingCallbacks = createAction('SET_PENDING_CALLBACKS'); -export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); -export const setGraphs = createAction(getAction('SET_GRAPHS')); -export const setPaths = createAction(getAction('SET_PATHS')); +export const onError = createAction(getAction('ON_ERROR')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); export const setConfig = createAction(getAction('SET_CONFIG')); +export const setGraphs = createAction(getAction('SET_GRAPHS')); export const setHooks = createAction(getAction('SET_HOOKS')); export const setLayout = createAction(getAction('SET_LAYOUT')); -export const onError = createAction(getAction('ON_ERROR')); +export const setPaths = createAction(getAction('SET_PATHS')); +export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); +export const updateProps = createAction(getAction('ON_PROP_CHANGE')); export const dispatchError = dispatch => (message, lines) => dispatch( @@ -103,10 +66,13 @@ function triggerDefaultState(dispatch, getState) { ); } - const initialCallbacks = getCallbacksInLayout(graphs, paths, layout, { - outputsOnly: true, - }); - dispatch(startCallbacks(initialCallbacks)); + dispatch( + addRequestedCallbacks( + getLayoutCallbacks(graphs, paths, layout, { + outputsOnly: true, + }) + ) + ); } export const redo = moveHistory('REDO'); @@ -135,606 +101,15 @@ function moveHistory(changeType) { }; } -function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { - let msg = ''; - - if (isMultiValued(spec)) { - return [idProps, msg]; - } - - if (idProps.length !== 1) { - if (!idProps.length) { - const isStr = typeof spec.id === 'string'; - msg = - 'A nonexistent object was used in an `' + - depType + - '` of a Dash callback. The id of this object is ' + - (isStr - ? '`' + spec.id + '`' - : JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '')) + - ' and the property is `' + - spec.property + - (isStr - ? '`. The string ids in the current layout are: [' + - keys(paths.strs).join(', ') + - ']' - : '`. The wildcard ids currently available are logged above.'); - } else { - msg = - 'Multiple objects were found for an `' + - depType + - '` of a callback that only takes one value. The id spec is ' + - JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '') + - ' and the property is `' + - spec.property + - '`. The objects we found are: ' + - JSON.stringify(map(pick(['id', 'property']), idProps)); - } - } - return [idProps[0], msg]; -} - -function startCallbacks(callbacks) { - return async function(dispatch, getState) { - return await fireReadyCallbacks(dispatch, getState, callbacks); - }; -} - -async function fireReadyCallbacks(dispatch, getState, callbacks) { - const {readyCallbacks, blockedCallbacks} = findReadyCallbacks(callbacks); - const {config, hooks, layout, paths} = getState(); - - // We want to calculate all the outputs only once, but we need them - // for pendingCallbacks which we're going to dispatch prior to - // initiating the queue. So first loop over readyCallbacks to - // generate the output lists, then dispatch pendingCallbacks, - // then loop again to fire off the requests. - const outputStash = {}; - const requestedCallbacks = readyCallbacks.map(cb => { - const cbOut = setNewRequestId(cb); - - const {requestId, getOutputs} = cbOut; - const allOutputs = getOutputs(paths); - const flatOutputs = flatten(allOutputs); - const allPropIds = []; - - const reqOut = {}; - flatOutputs.forEach(({id, property}) => { - const idStr = stringifyId(id); - const idOut = (reqOut[idStr] = reqOut[idStr] || []); - idOut.push(property); - allPropIds.push(combineIdAndProp({id: idStr, property})); - }); - cbOut.requestedOutputs = reqOut; - - outputStash[requestId] = {allOutputs, allPropIds}; - - return cbOut; - }); - - const allCallbacks = concat(requestedCallbacks, blockedCallbacks); - dispatch(setPendingCallbacks(allCallbacks)); - - const ids = requestedCallbacks.map(cb => [ - cb.getInputs(paths), - cb.getState(paths), - ]); - await isAppReady(layout, paths, uniq(pluck('id', flatten(ids)))); - - function fireNext() { - return fireReadyCallbacks( - dispatch, - getState, - getState().pendingCallbacks - ); - } - - let hasClientSide = false; - - const queue = requestedCallbacks.map(cb => { - const {output, inputs, state, clientside_function} = cb.callback; - const {requestId, resolvedId} = cb; - const {allOutputs, allPropIds} = outputStash[requestId]; - - let payload; - try { - const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); - - const preventCallback = () => { - removeCallbackFromPending(); - // no server call here; for performance purposes pretend this is - // a clientside callback and defer fireNext for the end - // of the currently-ready callbacks. - hasClientSide = true; - return null; - }; - - if (inVals === null) { - return preventCallback(); - } - - const outputs = []; - const outputErrors = []; - allOutputs.forEach((out, i) => { - const [outi, erri] = unwrapIfNotMulti( - paths, - map(pick(['id', 'property']), out), - cb.callback.outputs[i], - cb.anyVals, - 'Output' - ); - outputs.push(outi); - if (erri) { - outputErrors.push(erri); - } - }); - if (outputErrors.length) { - if (flatten(inVals).length) { - refErr(outputErrors, paths); - } - // This case is all-empty multivalued wildcard inputs, - // which we would normally fire the callback for, except - // some outputs are missing. So instead we treat it like - // regular missing inputs and just silently prevent it. - return preventCallback(); - } - - payload = { - output, - outputs: isMultiOutputProp(output) ? outputs : outputs[0], - inputs: inVals, - changedPropIds: keys(cb.changedPropIds), - }; - if (cb.callback.state.length) { - payload.state = fillVals(paths, layout, cb, state, 'State'); - } - } catch (e) { - handleError(e); - return fireNext(); - } - - function updatePending(pendingCallbacks, skippedProps) { - const newPending = removePendingCallback( - pendingCallbacks, - getState().paths, - resolvedId, - skippedProps - ); - dispatch(setPendingCallbacks(newPending)); - } - - function handleData(data) { - let {pendingCallbacks} = getState(); - if (!requestIsActive(pendingCallbacks, resolvedId, requestId)) { - return; - } - const updated = []; - Object.entries(data).forEach(([id, props]) => { - const parsedId = parseIfWildcard(id); - - const {layout: oldLayout, paths: oldPaths} = getState(); - - const appliedProps = doUpdateProps( - dispatch, - getState, - parsedId, - props - ); - if (appliedProps) { - // doUpdateProps can cause new callbacks to be added - // via derived props - update pendingCallbacks - // But we may also need to merge in other callbacks that - // we found in an earlier interation of the data loop. - const statePendingCallbacks = getState().pendingCallbacks; - if (statePendingCallbacks !== pendingCallbacks) { - pendingCallbacks = mergePendingCallbacks( - pendingCallbacks, - statePendingCallbacks - ); - } - - Object.keys(appliedProps).forEach(property => { - updated.push(combineIdAndProp({id, property})); - }); - - if (has('children', appliedProps)) { - const oldChildren = path( - concat(getPath(oldPaths, parsedId), [ - 'props', - 'children', - ]), - oldLayout - ); - // If components changed, need to update paths, - // check if all pending callbacks are still - // valid, and add all callbacks associated with - // new components, either as inputs or outputs, - // or components removed from ALL/ALLSMALLER inputs - pendingCallbacks = updateChildPaths( - dispatch, - getState, - pendingCallbacks, - parsedId, - appliedProps.children, - oldChildren - ); - } - - // persistence edge case: if you explicitly update the - // persistence key, other props may change that require us - // to fire additional callbacks - const addedProps = pickBy( - (v, k) => !(k in props), - appliedProps - ); - if (!isEmpty(addedProps)) { - const {graphs, paths} = getState(); - pendingCallbacks = includeObservers( - id, - addedProps, - graphs, - paths, - pendingCallbacks - ); - } - } - }); - updatePending(pendingCallbacks, without(updated, allPropIds)); - } - - function removeCallbackFromPending() { - const {pendingCallbacks} = getState(); - if (requestIsActive(pendingCallbacks, resolvedId, requestId)) { - // Skip all prop updates from this callback, and remove - // it from the pending list so callbacks it was blocking - // that have other changed inputs will still fire. - updatePending(pendingCallbacks, allPropIds); - } - } - - function handleError(err) { - removeCallbackFromPending(); - const outputs = payload - ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') - : output; - let message = `Callback error updating ${outputs}`; - if (clientside_function) { - const {namespace: ns, function_name: fn} = clientside_function; - message += ` via clientside function ${ns}.${fn}`; - } - handleAsyncError(err, message, dispatch); - } - - if (clientside_function) { - try { - handleData(handleClientside(clientside_function, payload)); - } catch (err) { - handleError(err); - } - hasClientSide = true; - return null; - } - - return handleServerside(config, payload, hooks) - .then(handleData) - .catch(handleError) - .then(fireNext); - }); - const done = Promise.all(queue); - return hasClientSide ? fireNext().then(done) : done; -} - -function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { - const getter = depType === 'Input' ? cb.getInputs : cb.getState; - const errors = []; - let emptyMultiValues = 0; - - const inputVals = getter(paths).map((inputList, i) => { - const [inputs, inputError] = unwrapIfNotMulti( - paths, - inputList.map(({id, property, path: path_}) => ({ - id, - property, - value: path(path_, layout).props[property], - })), - specs[i], - cb.anyVals, - depType - ); - if (isMultiValued(specs[i]) && !inputs.length) { - emptyMultiValues++; - } - if (inputError) { - errors.push(inputError); - } - return inputs; - }); - - if (errors.length) { - if ( - allowAllMissing && - errors.length + emptyMultiValues === inputVals.length - ) { - // We have at least one non-multivalued input, but all simple and - // multi-valued inputs are missing. - // (if all inputs are multivalued and all missing we still return - // them as normal, and fire the callback.) - return null; - } - // If we get here we have some missing and some present inputs. - // Or all missing in a context that doesn't allow this. - // That's a real problem, so throw the first message as an error. - refErr(errors, paths); - } - - return inputVals; -} - -function refErr(errors, paths) { - const err = errors[0]; - if (err.indexOf('logged above') !== -1) { - // Wildcard reference errors mention a list of wildcard specs logged - // TODO: unwrapped list of wildcard ids? - // eslint-disable-next-line no-console - console.error(paths.objs); - } - throw new ReferenceError(err); -} - -function handleServerside(config, payload, hooks) { - if (hooks.request_pre !== null) { - hooks.request_pre(payload); - } - - return fetch( - `${urlBase(config)}_dash-update-component`, - mergeDeepRight(config.fetch, { - method: 'POST', - headers: getCSRFHeader(), - body: JSON.stringify(payload), - }) - ).then( - res => { - const {status} = res; - if (status === STATUS.OK) { - return res.json().then(data => { - const {multi, response} = data; - if (hooks.request_post !== null) { - hooks.request_post(payload, response); - } - - if (multi) { - return response; - } - - const {output} = payload; - const id = output.substr(0, output.lastIndexOf('.')); - return {[id]: response.props}; - }); - } - if (status === STATUS.PREVENT_UPDATE) { - return {}; - } - throw res; - }, - () => { - // fetch rejection - this means the request didn't return, - // we don't get here from 400/500 errors, only network - // errors or unresponsive servers. - throw new Error('Callback failed: the server did not respond.'); - } - ); -} - -const getVals = input => - Array.isArray(input) ? pluck('value', input) : input.value; - -const zipIfArray = (a, b) => (Array.isArray(a) ? zip(a, b) : [[a, b]]); - -function inputsToDict(inputs_list) { - // Ported directly from _utils.py, inputs_to_dict - // takes an array of inputs (some inputs may be an array) - // returns an Object (map): - // keys of the form `id.property` or `{"id": 0}.property` - // values contain the property value - if (!inputs_list) { - return {}; - } - const inputs = {}; - for (let i = 0; i < inputs_list.length; i++) { - if (Array.isArray(inputs_list[i])) { - const inputsi = inputs_list[i]; - for (let ii = 0; ii < inputsi.length; ii++) { - const id_str = `${stringifyId(inputsi[ii].id)}.${ - inputsi[ii].property - }`; - inputs[id_str] = inputsi[ii].value ?? null; - } - } else { - const id_str = `${stringifyId(inputs_list[i].id)}.${ - inputs_list[i].property - }`; - inputs[id_str] = inputs_list[i].value ?? null; - } - } - return inputs; -} - -function handleClientside(clientside_function, payload) { - const dc = (window.dash_clientside = window.dash_clientside || {}); - if (!dc.no_update) { - Object.defineProperty(dc, 'no_update', { - value: {description: 'Return to prevent updating an Output.'}, - writable: false, - }); - - Object.defineProperty(dc, 'PreventUpdate', { - value: {description: 'Throw to prevent updating all Outputs.'}, - writable: false, - }); - } - - const {inputs, outputs, state} = payload; - - let returnValue; - - try { - // setup callback context - const input_dict = inputsToDict(inputs); - dc.callback_context = {}; - dc.callback_context.triggered = payload.changedPropIds.map(prop_id => ({ - prop_id: prop_id, - value: input_dict[prop_id], - })); - dc.callback_context.inputs_list = inputs; - dc.callback_context.inputs = input_dict; - dc.callback_context.states_list = state; - dc.callback_context.states = inputsToDict(state); - - const {namespace, function_name} = clientside_function; - let args = inputs.map(getVals); - if (state) { - args = concat(args, state.map(getVals)); - } - returnValue = dc[namespace][function_name](...args); - - delete dc.callback_context; - } catch (e) { - if (e === dc.PreventUpdate) { - return {}; - } - throw e; - } - - if (type(returnValue) === 'Promise') { - throw new Error( - 'The clientside function returned a Promise. ' + - 'Promises are not supported in Dash clientside ' + - 'right now, but may be in the future.' - ); - } - - const data = {}; - zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { - zipIfArray(outi, reti).forEach(([outij, retij]) => { - const {id, property} = outij; - const idStr = stringifyId(id); - const dataForId = (data[idStr] = data[idStr] || {}); - if (retij !== dc.no_update) { - dataForId[property] = retij; - } - }); - }); - return data; -} - -function requestIsActive(pendingCallbacks, resolvedId, requestId) { - const thisCallback = pendingCallbacks.find( - propEq('resolvedId', resolvedId) - ); - // could be inactivated if it was requested again, in which case it could - // potentially even have finished and been removed from the list - return thisCallback && thisCallback.requestId === requestId; -} - -function doUpdateProps(dispatch, getState, id, updatedProps) { - const {layout, paths} = getState(); - const itempath = getPath(paths, id); - if (!itempath) { - return false; - } - - // This is a callback-generated update. - // Check if this invalidates existing persisted prop values, - // or if persistence changed, whether this updates other props. - const updatedProps2 = prunePersistence( - path(itempath, layout), - updatedProps, - dispatch - ); - - // In case the update contains whole components, see if any of - // those components have props to update to persist user edits. - const {props} = applyPersistence({props: updatedProps2}, dispatch); - - dispatch( - updateProps({ - itempath, - props, - source: 'response', - }) - ); - - return props; -} - -function updateChildPaths( - dispatch, - getState, - pendingCallbacks, - id, - children, - oldChildren -) { - const {paths: oldPaths, graphs} = getState(); - const childrenPath = concat(getPath(oldPaths, id), ['props', 'children']); - const paths = computePaths(children, childrenPath, oldPaths); - dispatch(setPaths(paths)); - - const cleanedCallbacks = pruneRemovedCallbacks(pendingCallbacks, paths); - - const newCallbacks = getCallbacksInLayout(graphs, paths, children, { - chunkPath: childrenPath, - }); - - // Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger - // even due to the deletion of components - const deletedComponentCallbacks = getCallbacksInLayout( - graphs, - oldPaths, - oldChildren, - {removedArrayInputsOnly: true, newPaths: paths, chunkPath: childrenPath} - ); - - const allNewCallbacks = mergePendingCallbacks( - newCallbacks, - deletedComponentCallbacks - ); - return mergePendingCallbacks(cleanedCallbacks, allNewCallbacks); -} - export function notifyObservers({id, props}) { return async function(dispatch, getState) { - const {graphs, paths, pendingCallbacks} = getState(); - const finalCallbacks = includeObservers( - id, - props, - graphs, - paths, - pendingCallbacks + const {graphs, paths} = getState(); + dispatch( + addRequestedCallbacks(includeObservers(id, props, graphs, paths)) ); - dispatch(startCallbacks(finalCallbacks)); }; } -function includeObservers(id, props, graphs, paths, pendingCallbacks) { - const changedProps = keys(props); - let finalCallbacks = pendingCallbacks; - - changedProps.forEach(propName => { - const newCBs = getCallbacksByInput(graphs, paths, id, propName); - if (newCBs.length) { - finalCallbacks = mergePendingCallbacks( - finalCallbacks, - followForward(graphs, paths, newCBs) - ); - } - }); - return finalCallbacks; -} - export function handleAsyncError(err, message, dispatch) { // Handle html error responses if (err && typeof err.text === 'function') { diff --git a/dash-renderer/src/actions/isLoading.ts b/dash-renderer/src/actions/isLoading.ts new file mode 100644 index 0000000000..a501211317 --- /dev/null +++ b/dash-renderer/src/actions/isLoading.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions'; + +import { IsLoadingActionType, IsLoadingState } from '../reducers/isLoading'; + +export const setIsLoading = createAction(IsLoadingActionType.Set); diff --git a/dash-renderer/src/actions/loadingMap.ts b/dash-renderer/src/actions/loadingMap.ts new file mode 100644 index 0000000000..2e4834dbd8 --- /dev/null +++ b/dash-renderer/src/actions/loadingMap.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions'; + +import { LoadingMapActionType, LoadingMapState } from '../reducers/loadingMap'; + +export const setLoadingMap = createAction(LoadingMapActionType.Set); diff --git a/dash-renderer/src/checkPropTypes.js b/dash-renderer/src/checkPropTypes.js index 04f4d7b821..18dc8b5615 100644 --- a/dash-renderer/src/checkPropTypes.js +++ b/dash-renderer/src/checkPropTypes.js @@ -21,7 +21,7 @@ export default function checkPropTypes( values, location, componentName, - getStack + getStack = null ) { const errors = []; for (const typeSpecName in typeSpecs) { diff --git a/dash-renderer/src/components/core/DocumentTitle.react.js b/dash-renderer/src/components/core/DocumentTitle.react.js index 46eba06bfc..e192f32ce1 100644 --- a/dash-renderer/src/components/core/DocumentTitle.react.js +++ b/dash-renderer/src/components/core/DocumentTitle.react.js @@ -11,7 +11,7 @@ class DocumentTitle extends Component { } UNSAFE_componentWillReceiveProps(props) { - if (props.pendingCallbacks.length) { + if (props.isLoading) { document.title = 'Updating...'; } else { document.title = this.state.initialTitle; @@ -28,9 +28,9 @@ class DocumentTitle extends Component { } DocumentTitle.propTypes = { - pendingCallbacks: PropTypes.array.isRequired, + isLoading: PropTypes.bool.isRequired, }; export default connect(state => ({ - pendingCallbacks: state.pendingCallbacks, + isLoading: state.isLoading, }))(DocumentTitle); diff --git a/dash-renderer/src/components/core/Loading.react.js b/dash-renderer/src/components/core/Loading.react.js index 999684a8dc..b4eb2793f5 100644 --- a/dash-renderer/src/components/core/Loading.react.js +++ b/dash-renderer/src/components/core/Loading.react.js @@ -3,16 +3,16 @@ import React from 'react'; import PropTypes from 'prop-types'; function Loading(props) { - if (props.pendingCallbacks.length) { + if (props.isLoading) { return
; } return null; } Loading.propTypes = { - pendingCallbacks: PropTypes.array.isRequired, + isLoading: PropTypes.bool.isRequired, }; export default connect(state => ({ - pendingCallbacks: state.pendingCallbacks, + isLoading: state.isLoading, }))(Loading); diff --git a/dash-renderer/src/components/error/ComponentErrorBoundary.react.js b/dash-renderer/src/components/error/ComponentErrorBoundary.react.js index 5440cae0eb..f62b63cbe6 100644 --- a/dash-renderer/src/components/error/ComponentErrorBoundary.react.js +++ b/dash-renderer/src/components/error/ComponentErrorBoundary.react.js @@ -1,10 +1,8 @@ -import {connect} from 'react-redux'; import {Component} from 'react'; import PropTypes from 'prop-types'; -import Radium from 'radium'; import {onError, revert} from '../../actions'; -class UnconnectedComponentErrorBoundary extends Component { +class ComponentErrorBoundary extends Component { constructor(props) { super(props); this.state = { @@ -51,20 +49,11 @@ class UnconnectedComponentErrorBoundary extends Component { } } -UnconnectedComponentErrorBoundary.propTypes = { +ComponentErrorBoundary.propTypes = { children: PropTypes.object, componentId: PropTypes.string, error: PropTypes.object, dispatch: PropTypes.func, }; -const ComponentErrorBoundary = connect( - state => ({ - error: state.error, - }), - dispatch => { - return {dispatch}; - } -)(Radium(UnconnectedComponentErrorBoundary)); - export default ComponentErrorBoundary; diff --git a/dash-renderer/src/observers/executedCallbacks.ts b/dash-renderer/src/observers/executedCallbacks.ts new file mode 100644 index 0000000000..90e3787dd7 --- /dev/null +++ b/dash-renderer/src/observers/executedCallbacks.ts @@ -0,0 +1,237 @@ +import { + concat, + flatten, + isEmpty, + isNil, + map, + path, + forEach, + keys, + has, + pickBy, + toPairs +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + addRequestedCallbacks, + removeExecutedCallbacks, + addCompletedCallbacks, + addStoredCallbacks +} from '../actions/callbacks'; + +import { parseIfWildcard } from '../actions/dependencies'; + +import { + combineIdAndProp, + getCallbacksByInput, + getLayoutCallbacks, + includeObservers +} from '../actions/dependencies_ts'; + +import { + ICallback, + IStoredCallback +} from '../types/callbacks'; + +import { updateProps, setPaths, handleAsyncError } from '../actions'; +import { getPath, computePaths } from '../actions/paths'; + +import { + applyPersistence, + prunePersistence +} from '../persistence'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executed + } + } = getState(); + + function applyProps(id: any, updatedProps: any) { + const { layout, paths } = getState(); + const itempath = getPath(paths, id); + if (!itempath) { + return false; + } + + // This is a callback-generated update. + // Check if this invalidates existing persisted prop values, + // or if persistence changed, whether this updates other props. + updatedProps = prunePersistence( + path(itempath, layout), + updatedProps, + dispatch + ); + + // In case the update contains whole components, see if any of + // those components have props to update to persist user edits. + const { props } = applyPersistence({ props: updatedProps }, dispatch); + + dispatch( + updateProps({ + itempath, + props, + source: 'response' + }) + ); + + return props; + } + + let requestedCallbacks: ICallback[] = []; + let storedCallbacks: IStoredCallback[] = []; + + forEach(cb => { + const predecessors = concat( + cb.predecessors ?? [], + [cb.callback] + ); + + const { + callback: { + clientside_function, + output + }, + executionResult + } = cb; + + if (isNil(executionResult)) { + return; + } + + const { data, error, payload } = executionResult; + + if (data !== undefined) { + forEach(([id, props]: [any, { [key: string]: any }]) => { + const parsedId = parseIfWildcard(id); + const { graphs, layout: oldLayout, paths: oldPaths } = getState(); + + // Components will trigger callbacks on their own as required (eg. derived) + const appliedProps = applyProps(parsedId, props); + + // Add callbacks for modified inputs + requestedCallbacks = concat( + requestedCallbacks, + flatten(map( + prop => getCallbacksByInput(graphs, oldPaths, parsedId, prop, true), + keys(props) + )).map(rcb => ({ + ...rcb, + predecessors + })) + ); + + // New layout - trigger callbacks for that explicitly + if (has('children', appliedProps)) { + const { children } = appliedProps; + + const oldChildrenPath: string[] = concat(getPath(oldPaths, parsedId) as string[], ['props', 'children']); + const oldChildren = path(oldChildrenPath, oldLayout); + + const paths = computePaths(children, oldChildrenPath, oldPaths); + dispatch(setPaths(paths)); + + // Get callbacks for new layout (w/ execution group) + requestedCallbacks = concat( + requestedCallbacks, + getLayoutCallbacks(graphs, paths, children, { + chunkPath: oldChildrenPath + }).map(rcb => ({ + ...rcb, + predecessors + })) + ); + + // Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger + // even due to the deletion of components + requestedCallbacks = concat( + requestedCallbacks, + getLayoutCallbacks(graphs, oldPaths, oldChildren, { + removedArrayInputsOnly: true, newPaths: paths, chunkPath: oldChildrenPath + }).map(rcb => ({ + ...rcb, + predecessors + })) + ); + } + + // persistence edge case: if you explicitly update the + // persistence key, other props may change that require us + // to fire additional callbacks + const addedProps = pickBy( + (_, k) => !(k in props), + appliedProps + ); + if (!isEmpty(addedProps)) { + const { graphs: currentGraphs, paths } = getState(); + + requestedCallbacks = concat( + requestedCallbacks, + includeObservers(id, addedProps, currentGraphs, paths).map(rcb => ({ + ...rcb, + predecessors + })) + ); + } + }, Object.entries(data)); + + // Add information about potentially updated outputs vs. updated outputs, + // this will be used to drop callbacks from execution groups when no output + // matching the downstream callback's inputs were modified + storedCallbacks.push({ + ...cb, + executionMeta: { + allProps: map(combineIdAndProp, flatten(cb.getOutputs(getState().paths))), + updatedProps: flatten(map( + ([id, value]) => map( + property => combineIdAndProp({ id, property }), + keys(value) + ), + toPairs(data) + )) + } + }); + } + + if (error !== undefined) { + const outputs = payload + ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') + : output; + let message = `Callback error updating ${outputs}`; + if (clientside_function) { + const { namespace: ns, function_name: fn } = clientside_function; + message += ` via clientside function ${ns}.${fn}`; + } + + handleAsyncError(error, message, dispatch); + + storedCallbacks.push({ + ...cb, + executionMeta: { + allProps: map(combineIdAndProp, flatten(cb.getOutputs(getState().paths))), + updatedProps: [] + } + }); + } + }, executed); + + dispatch(aggregateCallbacks([ + executed.length ? removeExecutedCallbacks(executed) : null, + executed.length ? addCompletedCallbacks(executed.length) : null, + storedCallbacks.length ? addStoredCallbacks(storedCallbacks) : null, + requestedCallbacks.length ? addRequestedCallbacks(requestedCallbacks) : null + ])); + }, + inputs: ['callbacks.executed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/executingCallbacks.ts b/dash-renderer/src/observers/executingCallbacks.ts new file mode 100644 index 0000000000..ffc92141e4 --- /dev/null +++ b/dash-renderer/src/observers/executingCallbacks.ts @@ -0,0 +1,63 @@ +import { + assoc, + find, + forEach, + partition +} from 'ramda'; + +import { + addExecutedCallbacks, + addWatchedCallbacks, + aggregateCallbacks, + removeExecutingCallbacks, + removeWatchedCallbacks +} from '../actions/callbacks'; + +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executing + } + } = getState(); + + const [deferred, skippedOrReady] = partition(cb => cb.executionPromise instanceof Promise, executing); + + dispatch(aggregateCallbacks([ + executing.length ? removeExecutingCallbacks(executing) : null, + deferred.length ? addWatchedCallbacks(deferred) : null, + skippedOrReady.length ? addExecutedCallbacks(skippedOrReady.map(cb => assoc('executionResult', cb.executionPromise as any, cb))) : null + ])); + + forEach(async cb => { + const result = await cb.executionPromise; + + const { callbacks: { watched } } = getState(); + + // Check if it's been removed from the `watched` list since - on callback completion, another callback may be cancelled + // Find the callback instance or one that matches its promise (eg. could have been pruned) + const currentCb = find(_cb => _cb === cb || _cb.executionPromise === cb.executionPromise, watched); + if (!currentCb) { + return; + } + + // Otherwise move to `executed` and remove from `watched` + dispatch(aggregateCallbacks([ + removeWatchedCallbacks([currentCb]), + addExecutedCallbacks([{ + ...currentCb, + executionResult: result + }]) + ])); + }, deferred); + }, + inputs: ['callbacks.executing'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/isLoading.ts b/dash-renderer/src/observers/isLoading.ts new file mode 100644 index 0000000000..fc625d45a9 --- /dev/null +++ b/dash-renderer/src/observers/isLoading.ts @@ -0,0 +1,28 @@ +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; +import { getPendingCallbacks } from '../utils/callbacks'; +import { setIsLoading } from '../actions/isLoading'; + + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks, + isLoading + } = getState(); + + const pendingCallbacks = getPendingCallbacks(callbacks); + + const next = Boolean(pendingCallbacks.length); + + if (isLoading !== next) { + dispatch(setIsLoading(next)); + } + }, + inputs: ['callbacks'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/loadingMap.ts b/dash-renderer/src/observers/loadingMap.ts new file mode 100644 index 0000000000..8242143083 --- /dev/null +++ b/dash-renderer/src/observers/loadingMap.ts @@ -0,0 +1,83 @@ +import { + equals, + flatten, + forEach, + isEmpty, + map, + reduce +} from 'ramda'; + +import { setLoadingMap } from '../actions/loadingMap'; +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; +import { ILayoutCallbackProperty } from '../types/callbacks'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executing, + watched, + executed + }, + loadingMap, + paths + } = getState(); + + /* + Get the path of all components impacted by callbacks + with states: executing, watched, executed. + + For each path, keep track of all (id,prop) tuples that + are impacted for this node and nested nodes. + */ + + const loadingPaths: ILayoutCallbackProperty[] = flatten(map( + cb => cb.getOutputs(paths), + [...executing, ...watched, ...executed] + )); + + const nextMap: any = isEmpty(loadingPaths) ? + null : + reduce( + (res, path) => { + let target = res; + const idprop = { + id: path.id, + property: path.property + }; + + // Assign all affected props for this path and nested paths + target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; + target.__dashprivate__idprops__.push(idprop); + + forEach(p => { + target = (target[p] = + target[p] ?? + p === 'children' ? [] : {} + ) + + target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; + target.__dashprivate__idprops__.push(idprop); + }, path.path); + + // Assign one affected prop for this path + target.__dashprivate__idprop__ = target.__dashprivate__idprop__ || idprop; + + return res; + }, + {} as any, + loadingPaths + ); + + if (!equals(nextMap, loadingMap)) { + dispatch(setLoadingMap(nextMap)); + } + }, + inputs: ['callbacks.executing', 'callbacks.watched', 'callbacks.executed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/prioritizedCallbacks.ts b/dash-renderer/src/observers/prioritizedCallbacks.ts new file mode 100644 index 0000000000..0b6efeb2ba --- /dev/null +++ b/dash-renderer/src/observers/prioritizedCallbacks.ts @@ -0,0 +1,143 @@ +import { + find, + flatten, + forEach, + map, + partition, + pluck, + sort, + uniq +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + addBlockedCallbacks, + addExecutingCallbacks, + aggregateCallbacks, + executeCallback, + removeBlockedCallbacks, + removePrioritizedCallbacks +} from '../actions/callbacks'; + +import { stringifyId } from '../actions/dependencies'; + +import { + combineIdAndProp +} from '../actions/dependencies_ts'; + +import isAppReady from '../actions/isAppReady'; + +import { + IBlockedCallback, + ICallback, + ILayoutCallbackProperty, + IPrioritizedCallback +} from '../types/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const sortPriority = (c1: ICallback, c2: ICallback): number => { + return (c1.priority ?? '') > (c2.priority ?? '') ? -1 : 1; +} + +const getStash = (cb: IPrioritizedCallback, paths: any): { + allOutputs: ILayoutCallbackProperty[][], + allPropIds: any[] +} => { + const { getOutputs } = cb; + const allOutputs = getOutputs(paths); + const flatOutputs: any[] = flatten(allOutputs); + const allPropIds: any[] = []; + + const reqOut: any = {}; + flatOutputs.forEach(({ id, property }) => { + const idStr = stringifyId(id); + const idOut = (reqOut[idStr] = reqOut[idStr] || []); + idOut.push(property); + allPropIds.push(combineIdAndProp({ id: idStr, property })); + }); + + return { allOutputs, allPropIds }; +} + +const getIds = (cb: ICallback, paths: any) => uniq(pluck('id', [ + ...flatten(cb.getInputs(paths)), + ...flatten(cb.getState(paths)) +])); + +const observer: IStoreObserverDefinition = { + observer: async ({ + dispatch, + getState + }) => { + const { callbacks: { executing, watched }, config, hooks, layout, paths } = getState(); + let { callbacks: { prioritized } } = getState(); + + const available = Math.max( + 0, + 12 - executing.length - watched.length + ); + + // Order prioritized callbacks based on depth and breadth of callback chain + prioritized = sort(sortPriority, prioritized); + + // Divide between sync and async + const [syncCallbacks, asyncCallbacks] = partition(cb => isAppReady( + layout, + paths, + getIds(cb, paths) + ) === true, prioritized); + + const pickedSyncCallbacks = syncCallbacks.slice(0, available); + const pickedAsyncCallbacks = asyncCallbacks.slice(0, available - pickedSyncCallbacks.length); + + if (pickedSyncCallbacks.length) { + dispatch(aggregateCallbacks([ + removePrioritizedCallbacks(pickedSyncCallbacks), + addExecutingCallbacks(map( + cb => executeCallback(cb, config, hooks, paths, layout, getStash(cb, paths)), + pickedSyncCallbacks + )) + ])); + } + + if (pickedAsyncCallbacks.length) { + const deffered = map( + cb => ({ + ...cb, + ...getStash(cb, paths), + isReady: isAppReady(layout, paths, getIds(cb, paths)) + }), + pickedAsyncCallbacks + ); + + dispatch(aggregateCallbacks([ + removePrioritizedCallbacks(pickedAsyncCallbacks), + addBlockedCallbacks(deffered) + ])); + + forEach(async cb => { + await cb.isReady; + + const { callbacks: { blocked } } = getState(); + + // Check if it's been removed from the `blocked` list since - on callback completion, another callback may be cancelled + // Find the callback instance or one that matches its promise (eg. could have been pruned) + const currentCb = find(_cb => _cb === cb || _cb.isReady === cb.isReady, blocked); + if (!currentCb) { + return; + } + + const executingCallback = executeCallback(cb, config, hooks, paths, layout, cb); + + dispatch(aggregateCallbacks([ + removeBlockedCallbacks([cb]), + addExecutingCallbacks([executingCallback]) + ])); + }, deffered); + } + }, + inputs: ['callbacks.prioritized', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/requestedCallbacks.ts b/dash-renderer/src/observers/requestedCallbacks.ts new file mode 100644 index 0000000000..1ca30a3ba1 --- /dev/null +++ b/dash-renderer/src/observers/requestedCallbacks.ts @@ -0,0 +1,348 @@ +import { + all, + concat, + difference, + filter, + flatten, + groupBy, + includes, + intersection, + isEmpty, + isNil, + map, + values +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + removeRequestedCallbacks, + removePrioritizedCallbacks, + removeExecutingCallbacks, + removeWatchedCallbacks, + addRequestedCallbacks, + addPrioritizedCallbacks, + addExecutingCallbacks, + addWatchedCallbacks, + removeBlockedCallbacks, + addBlockedCallbacks +} from '../actions/callbacks'; + +import { isMultiValued } from '../actions/dependencies'; + +import { + combineIdAndProp, + getReadyCallbacks, + getUniqueIdentifier, + pruneCallbacks +} from '../actions/dependencies_ts'; + +import { + ICallback, + IExecutingCallback, + IStoredCallback, + IBlockedCallback +} from '../types/callbacks'; + +import { getPendingCallbacks } from '../utils/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { callbacks, callbacks: { prioritized, blocked, executing, watched, stored }, paths } = getState(); + let { callbacks: { requested } } = getState(); + + const pendingCallbacks = getPendingCallbacks(callbacks); + + /* + 0. Prune circular callbacks that have completed the loop + - cb.callback included in cb.predecessors + */ + const rCirculars = filter( + cb => includes(cb.callback, cb.predecessors ?? []), + requested + ); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + circulars will be removed for real + */ + requested = difference(requested, rCirculars); + + /* + 1. Remove duplicated `requested` callbacks - give precedence to newer callbacks over older ones + */ + + /* + Extract all but the first callback from each IOS-key group + these callbacks are duplicates. + */ + const rDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + requested + ) + ) + )); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + duplicates will be removed for real + */ + requested = difference(requested, rDuplicates); + + /* + 2. Remove duplicated `prioritized`, `executing` and `watching` callbacks + */ + + /* + Extract all but the first callback from each IOS-key group + these callbacks are `prioritized` and duplicates. + */ + const pDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(prioritized, requested) + ) + ) + )); + + const bDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(blocked, requested) + ) + ) + )) as IBlockedCallback[]; + + const eDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(executing, requested) + ) + ) + )) as IExecutingCallback[]; + + const wDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(watched, requested) + ) + ) + )) as IExecutingCallback[]; + + /* + 3. Modify or remove callbacks that are outputing to non-existing layout `id`. + */ + + const { added: rAdded, removed: rRemoved } = pruneCallbacks(requested, paths); + const { added: pAdded, removed: pRemoved } = pruneCallbacks(prioritized, paths); + const { added: bAdded, removed: bRemoved } = pruneCallbacks(blocked, paths); + const { added: eAdded, removed: eRemoved } = pruneCallbacks(executing, paths); + const { added: wAdded, removed: wRemoved } = pruneCallbacks(watched, paths); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = concat( + difference( + requested, + rRemoved + ), + rAdded + ); + + /* + 4. Find `requested` callbacks that do not depend on a outstanding output (as either input or state) + */ + let readyCallbacks = getReadyCallbacks(paths, requested, pendingCallbacks); + + let oldBlocked: ICallback[] = []; + let newBlocked: ICallback[] = []; + + /** + * If there is : + * - no ready callbacks + * - at least one requested callback + * - no additional pending callbacks + * + * can assume: + * - the requested callbacks are part of a circular dependency loop + * + * then recursively: + * - assume the first callback in the list is ready (the entry point for the loop) + * - check what callbacks are blocked / ready with the assumption + * - update the missing predecessors based on assumptions + * - continue until there are no remaining candidates + * + */ + if ( + !readyCallbacks.length && + requested.length && + requested.length === pendingCallbacks.length + ) { + let candidates = requested.slice(0); + + while (candidates.length) { + // Assume 1st callback is ready and + // update candidates / readyCallbacks accordingly + const readyCallback = candidates[0]; + + readyCallbacks.push(readyCallback); + candidates = candidates.slice(1); + + // Remaining candidates are not blocked by current assumptions + candidates = getReadyCallbacks(paths, candidates, readyCallbacks); + + // Blocked requests need to make sure they have the callback as a predecessor + const blockedByAssumptions = difference(candidates, candidates); + + const modified = filter( + cb => !cb.predecessors || !includes(readyCallback.callback, cb.predecessors), + blockedByAssumptions + ); + + oldBlocked = concat(oldBlocked, modified); + newBlocked = concat(newBlocked, modified.map(cb => ({ + ...cb, + predecessors: concat(cb.predecessors ?? [], [readyCallback.callback]) + }))); + } + } + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = concat( + difference( + requested, + oldBlocked + ), + newBlocked + ); + + /* + 5. Prune callbacks that became irrelevant in their `executionGroup` + */ + + // Group by executionGroup, drop non-executionGroup callbacks + // those were not triggered by layout changes and don't have "strong" interdependency for + // callback chain completion + const pendingGroups = groupBy( + cb => cb.executionGroup as any, + filter(cb => !isNil(cb.executionGroup), stored) + ); + + const dropped: ICallback[] = filter(cb => { + // If there is no `stored` callback for the group, no outputs were dropped -> `cb` is kept + if (!cb.executionGroup || !pendingGroups[cb.executionGroup] || !pendingGroups[cb.executionGroup].length) { + return false; + } + + // Get all intputs for `cb` + const inputs = map(combineIdAndProp, flatten(cb.getInputs(paths))); + + // Get all the potentially updated props for the group so far + const allProps = flatten(map( + gcb => gcb.executionMeta.allProps, + pendingGroups[cb.executionGroup] + )); + + // Get all the updated props for the group so far + const updated = flatten(map( + gcb => gcb.executionMeta.updatedProps, + pendingGroups[cb.executionGroup] + )); + + // If there's no overlap between the updated props and the inputs, + // + there's no props that aren't covered by the potentially updated props, + // and not all inputs are multi valued + // -> drop `cb` + const res = + isEmpty(intersection( + inputs, + updated + )) && + isEmpty(difference( + inputs, + allProps + )) + && !all( + isMultiValued, + cb.callback.inputs + ); + + return res; + }, + readyCallbacks + ); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = difference( + requested, + dropped + ); + + readyCallbacks = difference( + readyCallbacks, + dropped + ); + + dispatch(aggregateCallbacks([ + // Clean up duplicated callbacks + rDuplicates.length ? removeRequestedCallbacks(rDuplicates) : null, + pDuplicates.length ? removePrioritizedCallbacks(pDuplicates) : null, + bDuplicates.length ? removeBlockedCallbacks(bDuplicates) : null, + eDuplicates.length ? removeExecutingCallbacks(eDuplicates) : null, + wDuplicates.length ? removeWatchedCallbacks(wDuplicates) : null, + // Prune callbacks + rRemoved.length ? removeRequestedCallbacks(rRemoved) : null, + rAdded.length ? addRequestedCallbacks(rAdded) : null, + pRemoved.length ? removePrioritizedCallbacks(pRemoved) : null, + pAdded.length ? addPrioritizedCallbacks(pAdded) : null, + bRemoved.length ? removeBlockedCallbacks(bRemoved) : null, + bAdded.length ? addBlockedCallbacks(bAdded) : null, + eRemoved.length ? removeExecutingCallbacks(eRemoved) : null, + eAdded.length ? addExecutingCallbacks(eAdded) : null, + wRemoved.length ? removeWatchedCallbacks(wRemoved) : null, + wAdded.length ? addWatchedCallbacks(wAdded) : null, + // Prune circular callbacks + rCirculars.length ? removeRequestedCallbacks(rCirculars) : null, + // Prune circular assumptions + oldBlocked.length ? removeRequestedCallbacks(oldBlocked) : null, + newBlocked.length ? addRequestedCallbacks(newBlocked) : null, + // Drop non-triggered initial callbacks + dropped.length ? removeRequestedCallbacks(dropped) : null, + // Promote callbacks + readyCallbacks.length ? removeRequestedCallbacks(readyCallbacks) : null, + readyCallbacks.length ? addPrioritizedCallbacks(readyCallbacks) : null + ])); + }, + inputs: ['callbacks.requested', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/storedCallbacks.ts b/dash-renderer/src/observers/storedCallbacks.ts new file mode 100644 index 0000000000..83b19518b9 --- /dev/null +++ b/dash-renderer/src/observers/storedCallbacks.ts @@ -0,0 +1,69 @@ +import { + concat, + filter, + groupBy, + isNil, + partition, + reduce, + toPairs +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + removeStoredCallbacks +} from '../actions/callbacks'; + +import { + ICallback, + IStoredCallback +} from '../types/callbacks'; + +import { getPendingCallbacks } from '../utils/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { callbacks } = getState(); + const pendingCallbacks = getPendingCallbacks(callbacks); + + let { callbacks: { stored } } = getState(); + + const [nullGroupCallbacks, groupCallbacks] = partition( + cb => isNil(cb.executionGroup), + stored + ); + + const executionGroups = groupBy( + cb => cb.executionGroup as any, + groupCallbacks + ) + + const pendingGroups = groupBy( + cb => cb.executionGroup as any, + filter(cb => !isNil(cb.executionGroup), pendingCallbacks) + ); + + let dropped = reduce((res, [ + executionGroup, + executionGroupCallbacks + ]) => !pendingGroups[executionGroup] ? + concat(res, executionGroupCallbacks) : + res, + [] as IStoredCallback[], + toPairs(executionGroups) + ); + + dispatch(aggregateCallbacks([ + nullGroupCallbacks.length ? removeStoredCallbacks(nullGroupCallbacks) : null, + dropped.length ? removeStoredCallbacks(dropped) : null + ])); + }, + inputs: ['callbacks.stored', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/reducers/callbacks.ts b/dash-renderer/src/reducers/callbacks.ts new file mode 100644 index 0000000000..81d24e65ab --- /dev/null +++ b/dash-renderer/src/reducers/callbacks.ts @@ -0,0 +1,154 @@ +import { + concat, + difference, + reduce +} from 'ramda'; + +import { + ICallback, + IExecutedCallback, + IExecutingCallback, + IStoredCallback, + IPrioritizedCallback, + IBlockedCallback, + IWatchedCallback +} from '../types/callbacks'; + +export enum CallbackActionType { + AddBlocked = 'Callbacks.AddBlocked', + AddExecuted = 'Callbacks.AddExecuted', + AddExecuting = 'Callbacks.AddExecuting', + AddPrioritized = 'Callbacks.AddPrioritized', + AddRequested = 'Callbacks.AddRequested', + AddStored = 'Callbacks.AddStored', + AddWatched = 'Callbacks.AddWatched', + RemoveBlocked = 'Callbacks.RemoveBlocked', + RemoveExecuted = 'Callbacks.RemoveExecuted', + RemoveExecuting = 'Callbacks.RemoveExecuting', + RemovePrioritized = 'Callbacks.ReomvePrioritized', + RemoveRequested = 'Callbacks.RemoveRequested', + RemoveStored = 'Callbacks.RemoveStored', + RemoveWatched = 'Callbacks.RemoveWatched' +} + +export enum CallbackAggregateActionType { + AddCompleted = 'Callbacks.Completed', + Aggregate = 'Callbacks.Aggregate' +} + +export interface IAggregateAction { + type: CallbackAggregateActionType.Aggregate, + payload: (ICallbackAction | ICompletedAction | null)[] +} + +export interface ICallbackAction { + type: CallbackActionType; + payload: ICallback[]; +} + +export interface ICompletedAction { + type: CallbackAggregateActionType.AddCompleted, + payload: number +} + +type CallbackAction = + IAggregateAction | + ICallbackAction | + ICompletedAction; + +export interface ICallbacksState { + requested: ICallback[]; + prioritized: IPrioritizedCallback[]; + blocked: IBlockedCallback[]; + executing: IExecutingCallback[]; + watched: IWatchedCallback[]; + executed: IExecutedCallback[]; + stored: IStoredCallback[]; + completed: number; +} + +const DEFAULT_STATE: ICallbacksState = { + blocked: [], + executed: [], + executing: [], + prioritized: [], + requested: [], + stored: [], + watched: [], + completed: 0 +}; + +const transforms: { + [key: string]: (a1: ICallback[], a2: ICallback[]) => ICallback[] +} = { + [CallbackActionType.AddBlocked]: concat, + [CallbackActionType.AddExecuted]: concat, + [CallbackActionType.AddExecuting]: concat, + [CallbackActionType.AddPrioritized]: concat, + [CallbackActionType.AddRequested]: concat, + [CallbackActionType.AddStored]: concat, + [CallbackActionType.AddWatched]: concat, + [CallbackActionType.RemoveBlocked]: difference, + [CallbackActionType.RemoveExecuted]: difference, + [CallbackActionType.RemoveExecuting]: difference, + [CallbackActionType.RemovePrioritized]: difference, + [CallbackActionType.RemoveRequested]: difference, + [CallbackActionType.RemoveStored]: difference, + [CallbackActionType.RemoveWatched]: difference +}; + +const fields: { + [key: string]: keyof Omit +} = { + [CallbackActionType.AddBlocked]: 'blocked', + [CallbackActionType.AddExecuted]: 'executed', + [CallbackActionType.AddExecuting]: 'executing', + [CallbackActionType.AddPrioritized]: 'prioritized', + [CallbackActionType.AddRequested]: 'requested', + [CallbackActionType.AddStored]: 'stored', + [CallbackActionType.AddWatched]: 'watched', + [CallbackActionType.RemoveBlocked]: 'blocked', + [CallbackActionType.RemoveExecuted]: 'executed', + [CallbackActionType.RemoveExecuting]: 'executing', + [CallbackActionType.RemovePrioritized]: 'prioritized', + [CallbackActionType.RemoveRequested]: 'requested', + [CallbackActionType.RemoveStored]: 'stored', + [CallbackActionType.RemoveWatched]: 'watched' +} + +const mutateCompleted = ( + state: ICallbacksState, + action: ICompletedAction +) => ({ ...state, completed: state.completed + action.payload }); + +const mutateCallbacks = ( + state: ICallbacksState, + action: ICallbackAction +) => { + const transform = transforms[action.type]; + const field = fields[action.type]; + + return (!transform || !field || action.payload.length === 0) ? + state : { + ...state, + [field]: transform(state[field], action.payload) + }; +} + + + +export default ( + state: ICallbacksState = DEFAULT_STATE, + action: CallbackAction +) => reduce((s, a) => { + if (a === null) { + return s; + } else if (a.type === CallbackAggregateActionType.AddCompleted) { + return mutateCompleted(s, a); + } else { + return mutateCallbacks(s, a); + } +}, state, action.type === CallbackAggregateActionType.Aggregate ? + action.payload : + [action] +); diff --git a/dash-renderer/src/reducers/isLoading.ts b/dash-renderer/src/reducers/isLoading.ts new file mode 100644 index 0000000000..0a25260551 --- /dev/null +++ b/dash-renderer/src/reducers/isLoading.ts @@ -0,0 +1,22 @@ +export enum IsLoadingActionType { + Set = 'IsLoading.Set' +} + +export interface ILoadingMapAction { + type: IsLoadingActionType.Set; + payload: any; +} + +type IsLoadingState = boolean; +export { + IsLoadingState +}; + +const DEFAULT_STATE: IsLoadingState = true; + +export default ( + state: IsLoadingState = DEFAULT_STATE, + action: ILoadingMapAction +) => action.type === IsLoadingActionType.Set ? + action.payload : + state; diff --git a/dash-renderer/src/reducers/loadingMap.ts b/dash-renderer/src/reducers/loadingMap.ts new file mode 100644 index 0000000000..1fb31a208d --- /dev/null +++ b/dash-renderer/src/reducers/loadingMap.ts @@ -0,0 +1,22 @@ +export enum LoadingMapActionType { + Set = 'LoadingMap.Set' +} + +export interface ILoadingMapAction { + type: LoadingMapActionType.Set; + payload: any; +} + +type LoadingMapState = any; +export { + LoadingMapState +}; + +const DEFAULT_STATE: LoadingMapState = {}; + +export default ( + state: LoadingMapState = DEFAULT_STATE, + action: ILoadingMapAction +) => action.type === LoadingMapActionType.Set ? + action.payload : + state; diff --git a/dash-renderer/src/reducers/pendingCallbacks.js b/dash-renderer/src/reducers/pendingCallbacks.js deleted file mode 100644 index 70a2cd3f86..0000000000 --- a/dash-renderer/src/reducers/pendingCallbacks.js +++ /dev/null @@ -1,11 +0,0 @@ -const pendingCallbacks = (state = [], action) => { - switch (action.type) { - case 'SET_PENDING_CALLBACKS': - return action.payload; - - default: - return state; - } -}; - -export default pendingCallbacks; diff --git a/dash-renderer/src/reducers/reducer.js b/dash-renderer/src/reducers/reducer.js index ffdb8794fa..d238b75225 100644 --- a/dash-renderer/src/reducers/reducer.js +++ b/dash-renderer/src/reducers/reducer.js @@ -1,18 +1,20 @@ import {forEach, isEmpty, keys, path} from 'ramda'; import {combineReducers} from 'redux'; -import {getCallbacksByInput} from '../actions/dependencies'; +import {getCallbacksByInput} from '../actions/dependencies_ts'; -import layout from './layout'; -import graphs from './dependencyGraph'; -import paths from './paths'; -import pendingCallbacks from './pendingCallbacks'; +import createApiReducer from './api'; import appLifecycle from './appLifecycle'; -import history from './history'; +import callbacks from './callbacks'; +import config from './config'; +import graphs from './dependencyGraph'; import error from './error'; +import history from './history'; import hooks from './hooks'; -import createApiReducer from './api'; -import config from './config'; +import isLoading from './isLoading'; +import layout from './layout'; +import loadingMap from './loadingMap'; +import paths from './paths'; export const apiRequests = [ 'dependenciesRequest', @@ -24,14 +26,16 @@ export const apiRequests = [ function mainReducer() { const parts = { appLifecycle, - layout, - graphs, - paths, - pendingCallbacks, + callbacks, config, - history, error, + graphs, + history, hooks, + isLoading, + layout, + loadingMap, + paths, }; forEach(r => { parts[r] = createApiReducer(r); diff --git a/dash-renderer/src/store.js b/dash-renderer/src/store.js deleted file mode 100644 index fc9fa7f465..0000000000 --- a/dash-renderer/src/store.js +++ /dev/null @@ -1,52 +0,0 @@ -import {createStore, applyMiddleware} from 'redux'; -import thunk from 'redux-thunk'; -import {createReducer} from './reducers/reducer'; - -let store; - -/** - * Initialize a Redux store with thunk, plus logging (only in development mode) middleware - * - * @param {bool} reset: discard any previous store - * - * @returns {Store} - * An initialized redux store with middleware and possible hot reloading of reducers - */ -const initializeStore = reset => { - if (store && !reset) { - return store; - } - - const reducer = createReducer(); - - // eslint-disable-next-line no-process-env - if (process.env.NODE_ENV === 'production') { - store = createStore(reducer, applyMiddleware(thunk)); - } else { - // only attach logger to middleware in non-production mode - const reduxDTEC = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; - if (reduxDTEC) { - store = createStore(reducer, reduxDTEC(applyMiddleware(thunk))); - } else { - store = createStore(reducer, applyMiddleware(thunk)); - } - } - - if (!reset) { - // TODO - Protect this under a debug mode? - window.store = store; - } - - if (module.hot) { - // Enable hot module replacement for reducers - module.hot.accept('./reducers/reducer', () => { - const nextRootReducer = require('./reducers/reducer').createReducer(); - - store.replaceReducer(nextRootReducer); - }); - } - - return store; -}; - -export default initializeStore; diff --git a/dash-renderer/src/store.ts b/dash-renderer/src/store.ts new file mode 100644 index 0000000000..3b26f75a92 --- /dev/null +++ b/dash-renderer/src/store.ts @@ -0,0 +1,96 @@ +import { once } from 'ramda'; +import { createStore, applyMiddleware, Store, Observer } from 'redux'; +import thunk from 'redux-thunk'; +import {createReducer} from './reducers/reducer'; +import StoreObserver from './StoreObserver'; +import { ICallbacksState } from './reducers/callbacks'; +import { LoadingMapState } from './reducers/loadingMap'; +import { IsLoadingState } from './reducers/isLoading'; + +import executedCallbacks from './observers/executedCallbacks'; +import executingCallbacks from './observers/executingCallbacks'; +import isLoading from './observers/isLoading' +import loadingMap from './observers/loadingMap'; +import prioritizedCallbacks from './observers/prioritizedCallbacks'; +import requestedCallbacks from './observers/requestedCallbacks'; +import storedCallbacks from './observers/storedCallbacks'; + +export interface IStoreObserver { + observer: Observer>; + inputs: string[]; +} + +export interface IStoreState { + callbacks: ICallbacksState; + isLoading: IsLoadingState; + loadingMap: LoadingMapState; + [key: string]: any; +} + +let store: Store; +const storeObserver = new StoreObserver(); + +const setObservers = once(() => { + const observe = storeObserver.observe; + + observe(isLoading); + observe(loadingMap); + observe(requestedCallbacks); + observe(prioritizedCallbacks); + observe(executingCallbacks); + observe(executedCallbacks); + observe(storedCallbacks); +}); + +function createAppStore(reducer: any, middleware: any) { + store = createStore(reducer, middleware); + storeObserver.setStore(store); + setObservers(); +} + +/** + * Initialize a Redux store with thunk, plus logging (only in development mode) middleware + * + * @param {bool} reset: discard any previous store + * + * @returns {Store} + * An initialized redux store with middleware and possible hot reloading of reducers + */ +const initializeStore = (reset?: boolean): Store => { + if (store && !reset) { + return store; + } + + const reducer = createReducer(); + + // eslint-disable-next-line no-process-env + if (process.env.NODE_ENV === 'production') { + createAppStore(reducer, applyMiddleware(thunk)); + } else { + // only attach logger to middleware in non-production mode + const reduxDTEC = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; + if (reduxDTEC) { + createAppStore(reducer, reduxDTEC(applyMiddleware(thunk))); + } else { + createAppStore(reducer, applyMiddleware(thunk)); + } + } + + if (!reset) { + // TODO - Protect this under a debug mode? + (window as any).store = store; + } + + if ((module as any).hot) { + // Enable hot module replacement for reducers + (module as any).hot.accept('./reducers/reducer', () => { + const nextRootReducer = require('./reducers/reducer').createReducer(); + + store.replaceReducer(nextRootReducer); + }); + } + + return store; +}; + +export default initializeStore; diff --git a/dash-renderer/src/types/callbacks.ts b/dash-renderer/src/types/callbacks.ts new file mode 100644 index 0000000000..14882607d5 --- /dev/null +++ b/dash-renderer/src/types/callbacks.ts @@ -0,0 +1,85 @@ +type CallbackId = string | { [key: string]: any } + +export interface ICallbackDefinition { + clientside_function?: { + namespace: string; + function_name: string; + }; + input: string; + inputs: ICallbackProperty[]; + output: string; + outputs: ICallbackProperty[]; + prevent_initial_call: boolean; + state: ICallbackProperty[]; +} + +export interface ICallbackProperty { + id: CallbackId; + property: string; +} + +export interface ILayoutCallbackProperty extends ICallbackProperty { + path: (string | number)[]; +} + +export interface ICallbackTemplate { + anyVals: any[] | string; + callback: ICallbackDefinition; + changedPropIds: any; + executionGroup?: string; + initialCall: boolean; + getInputs: (paths: any) => ILayoutCallbackProperty[][]; + getOutputs: (paths: any) => ILayoutCallbackProperty[][]; + getState: (paths: any) => ILayoutCallbackProperty[][]; + resolvedId: any; +} + +export interface ICallback extends ICallbackTemplate { + predecessors?: ICallbackDefinition[]; + priority?: string; +} + +// tslint:disable-next-line:no-empty-interface +export interface IPrioritizedCallback extends ICallback { + +} + +export interface IBlockedCallback extends IPrioritizedCallback { + allOutputs: ILayoutCallbackProperty[][]; + allPropIds: any[]; + isReady: Promise | true; +} + +export interface IExecutingCallback extends IPrioritizedCallback { + executionPromise: Promise | CallbackResult | null; +} + +// tslint:disable-next-line:no-empty-interface +export interface IWatchedCallback extends IExecutingCallback { + +} + +export interface IExecutedCallback extends IWatchedCallback { + executionResult: CallbackResult | null; +} + +export interface IStoredCallback extends IExecutedCallback { + executionMeta: { + allProps: string[]; + updatedProps: string[]; + } +} + +export interface ICallbackPayload { + changedPropIds: any[]; + inputs: any[]; + output: string; + outputs: any[]; + state?: any[] | null; +} + +export type CallbackResult = { + data?: any; + error?: Error; + payload: ICallbackPayload | null; +} diff --git a/dash-renderer/src/utils/TreeContainer.ts b/dash-renderer/src/utils/TreeContainer.ts new file mode 100644 index 0000000000..cf9df689c1 --- /dev/null +++ b/dash-renderer/src/utils/TreeContainer.ts @@ -0,0 +1,78 @@ +import { path, type, has } from 'ramda'; + +import Registry from '../registry'; +import { stringifyId } from '../actions/dependencies'; + +function isLoadingComponent(layout: any) { + validateComponent(layout); + return (Registry.resolve(layout) as any)._dashprivate_isLoadingComponent; +} + +const NULL_LOADING_STATE = false; + +export function getLoadingState(componentLayout: any, componentPath: any, loadingMap: any) { + if (!loadingMap) { + return NULL_LOADING_STATE; + } + + const loadingFragment: any = path(componentPath, loadingMap); + // Component and children are not loading if there's no loading fragment + // for the component's path in the layout. + if (!loadingFragment) { + return NULL_LOADING_STATE; + } + + const idprop: any = loadingFragment.__dashprivate__idprop__; + if (idprop) { + return { + is_loading: true, + prop_name: idprop.property, + component_name: stringifyId(idprop.id) + }; + } + + const idprops: any = loadingFragment.__dashprivate__idprops__?.[0]; + if (idprops && isLoadingComponent(componentLayout)) { + return { + is_loading: true, + prop_name: idprops.property, + component_name: stringifyId(idprops.id) + }; + } + + return NULL_LOADING_STATE; +} + +export const getLoadingHash = ( + componentPath: any, + loadingMap: any +) => ( + ((loadingMap && (path(componentPath, loadingMap) as any)?.__dashprivate__idprops__) ?? []) as any[] +).map(({ id, property }) => `${id}.${property}`).join(','); + +export function validateComponent(componentDefinition: any) { + if (type(componentDefinition) === 'Array') { + throw new Error( + 'The children property of a component is a list of lists, instead ' + + 'of just a list. ' + + 'Check the component that has the following contents, ' + + 'and remove one of the levels of nesting: \n' + + JSON.stringify(componentDefinition, null, 2) + ); + } + if ( + type(componentDefinition) === 'Object' && + !( + has('namespace', componentDefinition) && + has('type', componentDefinition) && + has('props', componentDefinition) + ) + ) { + throw new Error( + 'An object was provided as `children` instead of a component, ' + + 'string, or number (or list of those). ' + + 'Check the children property that looks something like:\n' + + JSON.stringify(componentDefinition, null, 2) + ); + } +} diff --git a/dash-renderer/src/utils/callbacks.ts b/dash-renderer/src/utils/callbacks.ts new file mode 100644 index 0000000000..14befb6e7c --- /dev/null +++ b/dash-renderer/src/utils/callbacks.ts @@ -0,0 +1,8 @@ +import { omit, values } from 'ramda'; + +import { ICallbacksState } from '../reducers/callbacks'; +import { ICallback } from '../types/callbacks'; + +export const getPendingCallbacks = (state: ICallbacksState) => Array().concat( + ...values(omit(['stored', 'completed'], state)) +); diff --git a/dash-renderer/tsconfig.json b/dash-renderer/tsconfig.json new file mode 100644 index 0000000000..ca13e15e1c --- /dev/null +++ b/dash-renderer/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "jsx": "react", + "lib": ["esnext", "dom", "es2018.promise"], + "module": "esnext", + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "sourceMap": false, + "strict": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "esnext", + "traceResolution": false + }, + "include": [ + "src/*" + ] +} \ No newline at end of file diff --git a/dash-renderer/tslint.json b/dash-renderer/tslint.json new file mode 100644 index 0000000000..600dce2dfc --- /dev/null +++ b/dash-renderer/tslint.json @@ -0,0 +1,57 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "linterOptions": { + "exclude": [ + ".config/**", + "cypress/**", + "inst/**", + "node_modules/**", + "@Types/**", + "venv/**", + "**/*.js" + ] + }, + "rules": { + "array-type": false, + "arrow-parens": [true, "ban-single-arg-parens"], + "ban-types": false, + "eofline": true, + "max-classes-per-file": false, + "max-line-length": false, + "member-access": false, + "member-ordering": false, + "no-conditional-assignment": false, + "no-console": false, + "no-empty": false, + "no-unused-expression": [true, "allow-new"], + "object-literal-key-quotes": [true, "as-needed"], + "object-literal-sort-keys": false, + "object-literal-shorthand": false, + "one-line": [true, + "check-catch", + "check-finally", + "check-else", + "check-whitespace" + ], + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "ordered-imports": false, + "prefer-const": false, + "prefer-for-of": false, + "quotemark": [true, "single"], + "space-before-function-paren": [false, "always"], + "trailing-comma": [true, { + "singleline": "never", + "multiline": "never" + }], + "unified-signatures": false, + "variable-name": false + }, + "rulesDirectory": [] +} diff --git a/dash-renderer/webpack.config.js b/dash-renderer/webpack.config.js index 7df554cd08..ae4ac37a5e 100644 --- a/dash-renderer/webpack.config.js +++ b/dash-renderer/webpack.config.js @@ -15,6 +15,11 @@ const defaults = { loader: 'babel-loader', }, }, + { + test: /\.ts(x?)$/, + exclude: /node_modules/, + use: ['babel-loader', 'ts-loader'], + }, { test: /\.css$/, use: ['style-loader', 'css-loader'], @@ -24,6 +29,9 @@ const defaults = { use: ['@svgr/webpack'], } ] + }, + resolve: { + extensions: ['.js', '.ts', '.tsx'] } }; diff --git a/dash/dash.py b/dash/dash.py index 577ad51607..75c7ba6436 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1605,8 +1605,12 @@ def verify_url_part(served_part, url_part, part_name): display_url = (protocol, host, ":{}".format(port), path) self.logger.info("Dash is running on %s://%s%s%s\n", *display_url) - self.logger.info(" Warning: This is a development server. Do not use app.run_server") - self.logger.info(" in production, use a production WSGI server like gunicorn instead.\n") + self.logger.info( + " Warning: This is a development server. Do not use app.run_server" + ) + self.logger.info( + " in production, use a production WSGI server like gunicorn instead.\n" + ) if not os.environ.get("FLASK_ENV"): os.environ["FLASK_ENV"] = "development" diff --git a/dash/testing/dash_page.py b/dash/testing/dash_page.py index 63b30d407a..caedb9b5e7 100644 --- a/dash/testing/dash_page.py +++ b/dash/testing/dash_page.py @@ -36,13 +36,33 @@ def redux_state_paths(self): def redux_state_rqs(self): return self.driver.execute_script( """ - return window.store.getState().pendingCallbacks.map(function(cb) { - var out = {}; - for (var key in cb) { - if (typeof cb[key] !== 'function') { out[key] = cb[key]; } - } - return out; - }) + + // Check for legacy `pendingCallbacks` store prop (compatibility for Dash matrix testing) + var pendingCallbacks = window.store.getState().pendingCallbacks; + if (pendingCallbacks) { + return pendingCallbacks.map(function(cb) { + var out = {}; + for (var key in cb) { + if (typeof cb[key] !== 'function') { out[key] = cb[key]; } + } + return out; + }); + } + + // Otherwise, use the new `callbacks` store prop + var callbacksState = Object.assign({}, window.store.getState().callbacks); + delete callbacksState.stored; + delete callbacksState.completed; + + return Array.prototype.concat.apply([], Object.values(callbacksState)); + """ + ) + + @property + def redux_state_is_loading(self): + return self.driver.execute_script( + """ + return window.store.getState().isLoading; """ ) @@ -51,7 +71,7 @@ def window_store(self): return self.driver.execute_script("return window.store") def _wait_for_callbacks(self): - return not self.window_store or self.redux_state_rqs == [] + return not self.window_store or self.redux_state_rqs def get_local_storage(self, store_id="local"): return self.driver.execute_script( diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 3d84d7a07f..f7f37c2f47 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -41,7 +41,7 @@ def update_output(value): assert call_count.value == 2 + len("hello world"), "initial count + each key stroke" - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading assert dash_duo.get_logs() == [] @@ -133,7 +133,7 @@ def update_input(value): "#sub-output-1", pad_input.attrs["value"] + "deadbeef" ) - assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty" + assert not dash_duo.redux_state_is_loading, "loadingMap is empty" dash_duo.percy_snapshot(name="callback-generating-function-2") assert dash_duo.get_logs() == [], "console is clean" diff --git a/tests/integration/callbacks/test_callback_context.py b/tests/integration/callbacks/test_callback_context.py index bddca9c6de..f4f4552c4c 100644 --- a/tests/integration/callbacks/test_callback_context.py +++ b/tests/integration/callbacks/test_callback_context.py @@ -17,7 +17,7 @@ def test_cbcx001_modified_response(dash_duo): @app.callback(Output("output", "children"), [Input("input", "value")]) def update_output(value): - callback_context.response.set_cookie("dash cookie", value + " - cookie") + callback_context.response.set_cookie("dash_cookie", value + " - cookie") return value + " - output" dash_duo.start_server(app) @@ -27,7 +27,7 @@ def update_output(value): input1.send_keys("cd") dash_duo.wait_for_text_to_equal("#output", "abcd - output") - cookie = dash_duo.driver.get_cookie("dash cookie") + cookie = dash_duo.driver.get_cookie("dash_cookie") # cookie gets json encoded assert cookie["value"] == '"abcd - cookie"' diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py index 80656d5b5f..163aa01e7e 100644 --- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py +++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py @@ -176,7 +176,7 @@ def check_chapter(chapter): TIMEOUT, ) - assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty" + assert not dash_duo.redux_state_is_loading, "loadingMap is empty" def check_call_counts(chapters, count): for chapter in chapters: diff --git a/tests/integration/callbacks/test_missing_inputs.py b/tests/integration/callbacks/test_missing_inputs.py index 2cfffb8ecc..8ee1df8bb2 100644 --- a/tests/integration/callbacks/test_missing_inputs.py +++ b/tests/integration/callbacks/test_missing_inputs.py @@ -9,7 +9,7 @@ def wait_for_queue(dash_duo): # mostly for cases where no callbacks should fire: # just wait until we have the button and the queue is empty dash_duo.wait_for_text_to_equal("#btn", "click") - wait.until(lambda: dash_duo.redux_state_rqs == [], 3) + wait.until(lambda: not dash_duo.redux_state_is_loading, 3) def test_cbmi001_all_missing_inputs(dash_duo): diff --git a/tests/integration/callbacks/test_multiple_callbacks.py b/tests/integration/callbacks/test_multiple_callbacks.py index d9b13503a0..8c081d2cad 100644 --- a/tests/integration/callbacks/test_multiple_callbacks.py +++ b/tests/integration/callbacks/test_multiple_callbacks.py @@ -32,7 +32,7 @@ def update_output(n_clicks): assert call_count.value == 4, "get called 4 times" assert dash_duo.find_element("#output").text == "3", "clicked button 3 times" - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading dash_duo.percy_snapshot( name="test_callbacks_called_multiple_times_and_out_of_order" diff --git a/tests/integration/callbacks/test_wildcards.py b/tests/integration/callbacks/test_wildcards.py index 2dbfeb368a..2cd14954a2 100644 --- a/tests/integration/callbacks/test_wildcards.py +++ b/tests/integration/callbacks/test_wildcards.py @@ -6,6 +6,7 @@ import dash_html_components as html import dash_core_components as dcc import dash +from dash.testing import wait from dash.dependencies import Input, Output, State, ALL, ALLSMALLER, MATCH @@ -229,7 +230,17 @@ def assert_item(item, text, done, prefix="", suffix=""): assert_count(0) +fibonacci_count = 0 +fibonacci_sum_count = 0 + + def fibonacci_app(clientside): + global fibonacci_count + global fibonacci_sum_count + + fibonacci_count = 0 + fibonacci_sum_count = 0 + # This app tests 2 things in particular: # - clientside callbacks work the same as server-side # - callbacks using ALLSMALLER as an input to MATCH of the exact same id/prop @@ -275,12 +286,20 @@ def items(n): Output({"i": MATCH}, "children"), [Input({"i": ALLSMALLER}, "children")] ) def sequence(prev): + global fibonacci_count + fibonacci_count = fibonacci_count + 1 + print(fibonacci_count) + if len(prev) < 2: return len(prev) return int(prev[-1] or 0) + int(prev[-2] or 0) @app.callback(Output("sum", "children"), [Input({"i": ALL}, "children")]) def show_sum(seq): + global fibonacci_sum_count + fibonacci_sum_count = fibonacci_sum_count + 1 + print("fibonacci_sum_count: ", fibonacci_sum_count) + return "{} elements, sum: {}".format( len(seq), sum(int(v or 0) for v in seq) ) @@ -454,3 +473,46 @@ def update_output_on_page_pattern(value): trigger_text = 'triggered is Truthy with prop_ids {"index":1,"type":"input"}.value' dash_duo.wait_for_text_to_equal("#output-outer", trigger_text) dash_duo.wait_for_text_to_equal("#output-inner", trigger_text) + + +def test_cbwc005_callbacks_count(dash_duo): + global fibonacci_count + global fibonacci_sum_count + + app = fibonacci_app(False) + dash_duo.start_server(app) + + wait.until(lambda: fibonacci_count == 4, 3) # initial + wait.until(lambda: fibonacci_sum_count == 2, 3) # initial + triggered + + dash_duo.find_element("#n").send_keys(Keys.UP) # 5 + wait.until(lambda: fibonacci_count == 9, 3) + wait.until(lambda: fibonacci_sum_count == 3, 3) + + dash_duo.find_element("#n").send_keys(Keys.UP) # 6 + wait.until(lambda: fibonacci_count == 15, 3) + wait.until(lambda: fibonacci_sum_count == 4, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 5 + wait.until(lambda: fibonacci_count == 20, 3) + wait.until(lambda: fibonacci_sum_count == 5, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 4 + wait.until(lambda: fibonacci_count == 24, 3) + wait.until(lambda: fibonacci_sum_count == 6, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 3 + wait.until(lambda: fibonacci_count == 27, 3) + wait.until(lambda: fibonacci_sum_count == 7, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 2 + wait.until(lambda: fibonacci_count == 29, 3) + wait.until(lambda: fibonacci_sum_count == 8, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 1 + wait.until(lambda: fibonacci_count == 30, 3) + wait.until(lambda: fibonacci_sum_count == 9, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 0 + wait.until(lambda: fibonacci_count == 30, 3) + wait.until(lambda: fibonacci_sum_count == 10, 3) diff --git a/tests/integration/renderer/test_dependencies.py b/tests/integration/renderer/test_dependencies.py index 6213d71f7b..b29413d1d6 100644 --- a/tests/integration/renderer/test_dependencies.py +++ b/tests/integration/renderer/test_dependencies.py @@ -40,6 +40,6 @@ def update_output_2(value): assert output_1_call_count.value == 2 and output_2_call_count.value == 0 - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading assert dash_duo.get_logs() == [] diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py index 3ec1ce40ba..e46314bc51 100644 --- a/tests/integration/renderer/test_due_diligence.py +++ b/tests/integration/renderer/test_due_diligence.py @@ -95,7 +95,7 @@ def test_rddd001_initial_state(dash_duo): ) }, "paths should reflect to the component hierarchy" - assert dash_duo.redux_state_rqs == [], "no callback => no pendingCallbacks" + assert not dash_duo.redux_state_is_loading, "no callback => no pendingCallbacks" dash_duo.percy_snapshot(name="layout") assert dash_duo.get_logs() == [], "console has no errors" diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py index ed3bc7a180..1a79090323 100644 --- a/tests/integration/test_render.py +++ b/tests/integration/test_render.py @@ -490,10 +490,13 @@ def update_output(n_clicks): self.assertEqual(call_count.value, 3) self.wait_for_text_to_equal("#output1", "2") self.wait_for_text_to_equal("#output2", "3") - pending_count = self.driver.execute_script( - "return window.store.getState().pendingCallbacks.length" + ready = self.driver.execute_script( + """ + return !window.store.getState().isLoading; + """ ) - self.assertEqual(pending_count, 0) + + assert ready def test_callbacks_with_shared_grandparent(self): app = Dash()