diff --git a/.travis.yml b/.travis.yml index 46436afda..e4df5ceca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,6 @@ node_js: - "10" env: matrix: - - REACT=0.14 - - REACT=15 - - REACT=16.2 - - REACT=16.3 - REACT=16.4 - REACT=16.5 - REACT=16.6 diff --git a/package-lock.json b/package-lock.json index eae14f19d..f5f825a12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1336,7 +1336,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -1345,9 +1345,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "js-tokens": { @@ -2696,7 +2696,8 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", + "dev": true }, "asn1": { "version": "0.2.3", @@ -3927,6 +3928,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "dev": true, "requires": { "iconv-lite": "~0.4.13" } @@ -4374,7 +4376,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } } } @@ -4463,6 +4465,7 @@ "version": "0.8.16", "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz", "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=", + "dev": true, "requires": { "core-js": "^1.0.0", "isomorphic-fetch": "^2.1.1", @@ -4476,7 +4479,8 @@ "core-js": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", - "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=", + "dev": true } } }, @@ -5417,7 +5421,8 @@ "iconv-lite": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.18.tgz", - "integrity": "sha512-sr1ZQph3UwHTR0XftSbK85OvBbxe/abLGzEnPENCQwmHf7sck8Oyu4ob3LgBxWWxRoM+QszeUyl7jbqapu2TqA==" + "integrity": "sha512-sr1ZQph3UwHTR0XftSbK85OvBbxe/abLGzEnPENCQwmHf7sck8Oyu4ob3LgBxWWxRoM+QszeUyl7jbqapu2TqA==", + "dev": true }, "ignore": { "version": "3.3.7", @@ -5809,7 +5814,8 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true }, "is-symbol": { "version": "1.0.1", @@ -5860,6 +5866,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "dev": true, "requires": { "node-fetch": "^1.0.1", "whatwg-fetch": ">=0.10.0" @@ -5944,7 +5951,7 @@ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", "dev": true, "requires": { - "has-flag": "1.0.0" + "has-flag": "^1.0.0" } } } @@ -6010,7 +6017,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6019,9 +6026,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "jest-cli": { @@ -6074,7 +6081,7 @@ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", "dev": true, "requires": { - "ansi-regex": "3.0.0" + "ansi-regex": "^3.0.0" } }, "supports-color": { @@ -6131,7 +6138,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "babel-code-frame": { @@ -6205,9 +6212,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "debug": { @@ -6276,7 +6283,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6285,9 +6292,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -6440,7 +6447,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6449,9 +6456,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -6550,7 +6557,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6559,9 +6566,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -6644,7 +6651,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6653,9 +6660,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -6752,7 +6759,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -6761,9 +6768,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "supports-color": { @@ -6861,7 +6868,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "babel-code-frame": { @@ -6935,9 +6942,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "debug": { @@ -7008,7 +7015,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -7017,9 +7024,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -7126,7 +7133,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "requires": { - "color-convert": "1.9.1" + "color-convert": "^1.9.0" } }, "chalk": { @@ -7135,9 +7142,9 @@ "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "dev": true, "requires": { - "ansi-styles": "3.2.1", - "escape-string-regexp": "1.0.5", - "supports-color": "5.5.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" } }, "pretty-format": { @@ -7738,6 +7745,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "dev": true, "requires": { "encoding": "^0.1.11", "is-stream": "^1.0.1" @@ -8245,6 +8253,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, "requires": { "asap": "~2.0.3" } @@ -8260,11 +8269,10 @@ } }, "prop-types": { - "version": "15.6.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.1.tgz", - "integrity": "sha512-4ec7bY1Y66LymSUOH/zARVYObB23AT2h8cf6e/O6ZALB/N0sqZFEx7rq6EYPX2MkOdKORuooI/H5k9TlR4q7kQ==", + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.3.1", "object-assign": "^4.1.1" } @@ -8399,11 +8407,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.0.tgz", "integrity": "sha512-q8U7k0Fi7oxF1HvQgyBjPwDXeMplEsArnKt2iYhuIF86+GBbgLHdAmokL3XUFjTd7Q363OSNG55FOGUdONVn1g==" }, - "react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, "react-testing-library": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-testing-library/-/react-testing-library-5.0.0.tgz", @@ -9365,7 +9368,8 @@ "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true }, "shebang-command": { "version": "1.2.0", @@ -9839,11 +9843,11 @@ "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "dev": true, "requires": { - "graceful-fs": "4.1.11", - "parse-json": "2.2.0", - "pify": "2.3.0", - "pinkie-promise": "2.0.1", - "strip-bom": "2.0.0" + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" } }, "path-type": { @@ -9852,9 +9856,9 @@ "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", "dev": true, "requires": { - "graceful-fs": "4.1.11", - "pify": "2.3.0", - "pinkie-promise": "2.0.1" + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" } }, "read-pkg": { @@ -9863,9 +9867,9 @@ "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", "dev": true, "requires": { - "load-json-file": "1.1.0", - "normalize-package-data": "2.4.0", - "path-type": "1.1.0" + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" } }, "read-pkg-up": { @@ -9874,8 +9878,8 @@ "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", "dev": true, "requires": { - "find-up": "1.1.2", - "read-pkg": "1.1.0" + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" } }, "strip-bom": { @@ -9884,7 +9888,7 @@ "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", "dev": true, "requires": { - "is-utf8": "0.2.1" + "is-utf8": "^0.2.0" } } } @@ -10036,7 +10040,8 @@ "ua-parser-js": { "version": "0.7.17", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz", - "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g==" + "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g==", + "dev": true }, "uglify-js": { "version": "3.4.9", @@ -10327,7 +10332,8 @@ "whatwg-fetch": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", - "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" + "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=", + "dev": true }, "whatwg-mimetype": { "version": "2.2.0", diff --git a/package.json b/package.json index ec759fdc6..fbd0d7020 100644 --- a/package.json +++ b/package.json @@ -40,17 +40,16 @@ "coverage": "codecov" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0-0", + "react": "^16.6.0-0", "redux": "^2.0.0 || ^3.0.0 || ^4.0.0-0" }, "dependencies": { "@babel/runtime": "^7.1.2", - "hoist-non-react-statics": "^3.0.0", + "hoist-non-react-statics": "^3.0.1", "invariant": "^2.2.4", "loose-envify": "^1.1.0", - "prop-types": "^15.6.1", - "react-is": "^16.6.0", - "react-lifecycles-compat": "^3.0.0" + "prop-types": "^15.6.2", + "react-is": "^16.6.0" }, "devDependencies": { "@babel/cli": "^7.1.2", diff --git a/src/components/Context.js b/src/components/Context.js new file mode 100644 index 000000000..d1169aa8b --- /dev/null +++ b/src/components/Context.js @@ -0,0 +1,5 @@ +import React from 'react' + +export const ReactReduxContext = React.createContext(null) + +export default ReactReduxContext diff --git a/src/components/Provider.js b/src/components/Provider.js index f78e25e65..03c562645 100644 --- a/src/components/Provider.js +++ b/src/components/Provider.js @@ -1,60 +1,84 @@ -import { Component, Children } from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' -import { storeShape, subscriptionShape } from '../utils/PropTypes' -import warning from '../utils/warning' +import { ReactReduxContext } from './Context' -let didWarnAboutReceivingStore = false -function warnAboutReceivingStore() { - if (didWarnAboutReceivingStore) { - return +class Provider extends Component { + constructor(props) { + super(props) + + const { store } = props + + this.state = { + storeState: store.getState(), + store + } } - didWarnAboutReceivingStore = true - - warning( - ' does not support changing `store` on the fly. ' + - 'It is most likely that you see this error because you updated to ' + - 'Redux 2.x and React Redux 2.x which no longer hot reload reducers ' + - 'automatically. See https://github.com/reduxjs/react-redux/releases/' + - 'tag/v2.0.0 for the migration instructions.' - ) -} -export function createProvider(storeKey = 'store') { - const subscriptionKey = `${storeKey}Subscription` + componentDidMount() { + this._isMounted = true + this.subscribe() + } - class Provider extends Component { - getChildContext() { - return { [storeKey]: this[storeKey], [subscriptionKey]: null } - } + componentWillUnmount() { + if (this.unsubscribe) this.unsubscribe() - constructor(props, context) { - super(props, context) - this[storeKey] = props.store; - } + this._isMounted = false + } - render() { - return Children.only(this.props.children) - } + componentDidUpdate(prevProps) { + if (this.props.store !== prevProps.store) { + if (this.unsubscribe) this.unsubscribe() + + this.subscribe() } + } - if (process.env.NODE_ENV !== 'production') { - Provider.prototype.componentWillReceiveProps = function (nextProps) { - if (this[storeKey] !== nextProps.store) { - warnAboutReceivingStore() - } + subscribe() { + const { store } = this.props + + this.unsubscribe = store.subscribe(() => { + const newStoreState = store.getState() + + if (!this._isMounted) { + return } - } - Provider.propTypes = { - store: storeShape.isRequired, - children: PropTypes.element.isRequired, - } - Provider.childContextTypes = { - [storeKey]: storeShape.isRequired, - [subscriptionKey]: subscriptionShape, + this.setState(providerState => { + // If the value is the same, skip the unnecessary state update. + if (providerState.storeState === newStoreState) { + return null + } + + return { storeState: newStoreState } + }) + }) + + // Actions might have been dispatched between render and mount - handle those + const postMountStoreState = store.getState() + if (postMountStoreState !== this.state.storeState) { + this.setState({ storeState: postMountStoreState }) } + } + + render() { + const Context = this.props.context || ReactReduxContext + + return ( + + {this.props.children} + + ) + } +} - return Provider +Provider.propTypes = { + store: PropTypes.shape({ + subscribe: PropTypes.func.isRequired, + dispatch: PropTypes.func.isRequired, + getState: PropTypes.func.isRequired + }), + context: PropTypes.object, + children: PropTypes.any } -export default createProvider() +export default Provider diff --git a/src/components/connectAdvanced.js b/src/components/connectAdvanced.js index ca945d069..eec203d2c 100644 --- a/src/components/connectAdvanced.js +++ b/src/components/connectAdvanced.js @@ -1,34 +1,9 @@ import hoistStatics from 'hoist-non-react-statics' import invariant from 'invariant' -import { Component, createElement } from 'react' +import React, { Component, PureComponent } from 'react' import { isValidElementType } from 'react-is' -import Subscription from '../utils/Subscription' -import { storeShape, subscriptionShape } from '../utils/PropTypes' - -let hotReloadingVersion = 0 -const dummyState = {} -function noop() {} -function makeSelectorStateful(sourceSelector, store) { - // wrap the selector in an object that tracks its results between runs. - const selector = { - run: function runComponentSelector(props) { - try { - const nextProps = sourceSelector(store.getState(), props) - if (nextProps !== selector.props || selector.error) { - selector.shouldComponentUpdate = true - selector.props = nextProps - selector.error = null - } - } catch (error) { - selector.shouldComponentUpdate = true - selector.error = error - } - } - } - - return selector -} +import { ReactReduxContext } from './Context' export default function connectAdvanced( /* @@ -59,33 +34,52 @@ export default function connectAdvanced( // probably overridden by wrapper functions such as connect() methodName = 'connectAdvanced', - // if defined, the name of the property passed to the wrapped element indicating the number of + // REMOVED: if defined, the name of the property passed to the wrapped element indicating the number of // calls to render. useful for watching in react devtools for unnecessary re-renders. renderCountProp = undefined, // determines whether this HOC subscribes to store changes shouldHandleStateChanges = true, - // the key of props/context to get the store + // REMOVED: the key of props/context to get the store storeKey = 'store', - // if true, the wrapped element is exposed by this HOC via the getWrappedInstance() function. + // REMOVED: expose the wrapped component via refs withRef = false, + // use React's forwardRef to expose a ref of the wrapped component + forwardRef = false, + + // the context consumer to use + context = ReactReduxContext, + // additional options are passed through to the selectorFactory ...connectOptions } = {} ) { - const subscriptionKey = storeKey + 'Subscription' - const version = hotReloadingVersion++ - - const contextTypes = { - [storeKey]: storeShape, - [subscriptionKey]: subscriptionShape, - } - const childContextTypes = { - [subscriptionKey]: subscriptionShape, - } + invariant( + renderCountProp === undefined, + `renderCountProp is removed. render counting is built into the latest React dev tools profiling extension` + ) + + invariant( + !withRef, + 'withRef is removed. To access the wrapped instance, use a ref on the connected component' + ) + + const customStoreWarningMessage = + 'To use a custom Redux store for specific components, create a custom React context with ' + + "React.createContext(), and pass the context object to React-Redux's Provider and specific components" + + ' like: . ' + + 'You may also pass a {context : MyContext} option to connect' + + invariant( + storeKey === 'store', + 'storeKey has been removed and does not do anything. ' + + customStoreWarningMessage + ) + + const Context = context return function wrapWithConnect(WrappedComponent) { if (process.env.NODE_ENV !== 'production') { @@ -96,9 +90,8 @@ export default function connectAdvanced( ); } - const wrappedComponentName = WrappedComponent.displayName - || WrappedComponent.name - || 'Component' + const wrappedComponentName = + WrappedComponent.displayName || WrappedComponent.name || 'Component' const displayName = getDisplayName(wrappedComponentName) @@ -109,194 +102,139 @@ export default function connectAdvanced( renderCountProp, shouldHandleStateChanges, storeKey, - withRef, displayName, wrappedComponentName, WrappedComponent } - // TODO Actually fix our use of componentWillReceiveProps - /* eslint-disable react/no-deprecated */ + const { pure } = connectOptions - class Connect extends Component { - constructor(props, context) { - super(props, context) + let OuterBaseComponent = Component + let FinalWrappedComponent = WrappedComponent - this.version = version - this.state = {} - this.renderCount = 0 - this.store = props[storeKey] || context[storeKey] - this.propsMode = Boolean(props[storeKey]) - this.setWrappedInstance = this.setWrappedInstance.bind(this) + if (pure) { + OuterBaseComponent = PureComponent + } - invariant(this.store, - `Could not find "${storeKey}" in either the context or props of ` + - `"${displayName}". Either wrap the root component in a , ` + - `or explicitly pass "${storeKey}" as a prop to "${displayName}".` - ) + function makeDerivedPropsSelector() { + let lastProps + let lastState + let lastDerivedProps + let lastStore + let sourceSelector - this.initSelector() - this.initSubscription() - } + return function selectDerivedProps(state, props, store) { + if (pure && lastProps === props && lastState === state) { + return lastDerivedProps + } - getChildContext() { - // If this component received store from props, its subscription should be transparent - // to any descendants receiving store+subscription from context; it passes along - // subscription passed to it. Otherwise, it shadows the parent subscription, which allows - // Connect to control ordering of notifications to flow top-down. - const subscription = this.propsMode ? null : this.subscription - return { [subscriptionKey]: subscription || this.context[subscriptionKey] } - } + if (store !== lastStore) { + lastStore = store + sourceSelector = selectorFactory( + store.dispatch, + selectorFactoryOptions + ) + } - componentDidMount() { - if (!shouldHandleStateChanges) return - - // componentWillMount fires during server side rendering, but componentDidMount and - // componentWillUnmount do not. Because of this, trySubscribe happens during ...didMount. - // Otherwise, unsubscription would never take place during SSR, causing a memory leak. - // To handle the case where a child component may have triggered a state change by - // dispatching an action in its componentWillMount, we have to re-run the select and maybe - // re-render. - this.subscription.trySubscribe() - this.selector.run(this.props) - if (this.selector.shouldComponentUpdate) this.forceUpdate() - } + lastProps = props + lastState = state - componentWillReceiveProps(nextProps) { - this.selector.run(nextProps) - } + const nextProps = sourceSelector(state, props) - shouldComponentUpdate() { - return this.selector.shouldComponentUpdate - } + if (lastDerivedProps === nextProps) { + return lastDerivedProps + } - componentWillUnmount() { - if (this.subscription) this.subscription.tryUnsubscribe() - this.subscription = null - this.notifyNestedSubs = noop - this.store = null - this.selector.run = noop - this.selector.shouldComponentUpdate = false + lastDerivedProps = nextProps + return lastDerivedProps } + } - getWrappedInstance() { - invariant(withRef, - `To access the wrapped instance, you need to specify ` + - `{ withRef: true } in the options argument of the ${methodName}() call.` - ) - return this.wrappedInstance - } + function makeChildElementSelector() { + let lastChildProps, lastForwardRef, lastChildElement - setWrappedInstance(ref) { - this.wrappedInstance = ref - } + return function selectChildElement(childProps, forwardRef) { + if (childProps !== lastChildProps || forwardRef !== lastForwardRef) { + lastChildProps = childProps + lastForwardRef = forwardRef + lastChildElement = ( + + ) + } - initSelector() { - const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions) - this.selector = makeSelectorStateful(sourceSelector, this.store) - this.selector.run(this.props) + return lastChildElement } + } - initSubscription() { - if (!shouldHandleStateChanges) return - - // parentSub's source should match where store came from: props vs. context. A component - // connected to the store via props shouldn't use subscription from context, or vice versa. - const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey] - this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this)) - - // `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in - // the middle of the notification loop, where `this.subscription` will then be null. An - // extra null check every change can be avoided by copying the method onto `this` and then - // replacing it with a no-op on unmount. This can probably be avoided if Subscription's - // listeners logic is changed to not call listeners that have been unsubscribed in the - // middle of the notification loop. - this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription) - } + class Connect extends OuterBaseComponent { + constructor(props) { + super(props) + invariant( + forwardRef ? !props.wrapperProps[storeKey] : !props[storeKey], + 'Passing redux store in props has been removed and does not do anything. ' + + customStoreWarningMessage + ) + this.selectDerivedProps = makeDerivedPropsSelector() + this.selectChildElement = makeChildElementSelector() + this.renderWrappedComponent = this.renderWrappedComponent.bind(this) + } + + renderWrappedComponent(value) { + invariant( + value, + `Could not find "store" in the context of ` + + `"${displayName}". Either wrap the root component in a , ` + + `or pass a custom React context provider to and the corresponding ` + + `React context consumer to ${displayName} in connect options.` + ) + const { storeState, store } = value - onStateChange() { - this.selector.run(this.props) + let wrapperProps = this.props + let forwardedRef - if (!this.selector.shouldComponentUpdate) { - this.notifyNestedSubs() - } else { - this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate - this.setState(dummyState) + if (forwardRef) { + wrapperProps = this.props.wrapperProps + forwardedRef = this.props.forwardedRef } - } - notifyNestedSubsOnComponentDidUpdate() { - // `componentDidUpdate` is conditionally implemented when `onStateChange` determines it - // needs to notify nested subs. Once called, it unimplements itself until further state - // changes occur. Doing it this way vs having a permanent `componentDidUpdate` that does - // a boolean check every time avoids an extra method call most of the time, resulting - // in some perf boost. - this.componentDidUpdate = undefined - this.notifyNestedSubs() - } + let derivedProps = this.selectDerivedProps( + storeState, + wrapperProps, + store + ) - isSubscribed() { - return Boolean(this.subscription) && this.subscription.isSubscribed() - } + if (pure) { + return this.selectChildElement(derivedProps, forwardedRef) + } - addExtraProps(props) { - if (!withRef && !renderCountProp && !(this.propsMode && this.subscription)) return props - // make a shallow copy so that fields added don't leak to the original selector. - // this is especially important for 'ref' since that's a reference back to the component - // instance. a singleton memoized selector would then be holding a reference to the - // instance, preventing the instance from being garbage collected, and that would be bad - const withExtras = { ...props } - if (withRef) withExtras.ref = this.setWrappedInstance - if (renderCountProp) withExtras[renderCountProp] = this.renderCount++ - if (this.propsMode && this.subscription) withExtras[subscriptionKey] = this.subscription - return withExtras + return } render() { - const selector = this.selector - selector.shouldComponentUpdate = false + const ContextToUse = this.props.context || Context - if (selector.error) { - throw selector.error - } else { - return createElement(WrappedComponent, this.addExtraProps(selector.props)) - } + return ( + + {this.renderWrappedComponent} + + ) } } - /* eslint-enable react/no-deprecated */ - Connect.WrappedComponent = WrappedComponent Connect.displayName = displayName - Connect.childContextTypes = childContextTypes - Connect.contextTypes = contextTypes - Connect.propTypes = contextTypes - if (process.env.NODE_ENV !== 'production') { - Connect.prototype.componentWillUpdate = function componentWillUpdate() { - // We are hot reloading! - if (this.version !== version) { - this.version = version - this.initSelector() - - // If any connected descendants don't hot reload (and resubscribe in the process), their - // listeners will be lost when we unsubscribe. Unfortunately, by copying over all - // listeners, this does mean that the old versions of connected descendants will still be - // notified of state changes; however, their onStateChange function is a no-op so this - // isn't a huge deal. - let oldListeners = []; - - if (this.subscription) { - oldListeners = this.subscription.listeners.get() - this.subscription.tryUnsubscribe() - } - this.initSubscription() - if (shouldHandleStateChanges) { - this.subscription.trySubscribe() - oldListeners.forEach(listener => this.subscription.listeners.subscribe(listener)) - } - } - } + if (forwardRef) { + const forwarded = React.forwardRef(function forwardConnectRef( + props, + ref + ) { + return + }) + + forwarded.displayName = displayName + forwarded.WrappedComponent = WrappedComponent + return hoistStatics(forwarded, WrappedComponent) } return hoistStatics(Connect, WrappedComponent) diff --git a/src/index.js b/src/index.js index 2e0c09220..22b1bd9e5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ -import Provider, { createProvider } from './components/Provider' +import Provider from './components/Provider' import connectAdvanced from './components/connectAdvanced' +import { ReactReduxContext } from './components/Context' import connect from './connect/connect' -export { Provider, createProvider, connectAdvanced, connect } +export { Provider, connectAdvanced, ReactReduxContext, connect } diff --git a/src/utils/PropTypes.js b/src/utils/PropTypes.js deleted file mode 100644 index 725b02012..000000000 --- a/src/utils/PropTypes.js +++ /dev/null @@ -1,14 +0,0 @@ -import PropTypes from 'prop-types' - -export const subscriptionShape = PropTypes.shape({ - trySubscribe: PropTypes.func.isRequired, - tryUnsubscribe: PropTypes.func.isRequired, - notifyNestedSubs: PropTypes.func.isRequired, - isSubscribed: PropTypes.func.isRequired, -}) - -export const storeShape = PropTypes.shape({ - subscribe: PropTypes.func.isRequired, - dispatch: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired -}) diff --git a/src/utils/Subscription.js b/src/utils/Subscription.js deleted file mode 100644 index db8146799..000000000 --- a/src/utils/Subscription.js +++ /dev/null @@ -1,87 +0,0 @@ -// encapsulates the subscription logic for connecting a component to the redux store, as -// well as nesting subscriptions of descendant components, so that we can ensure the -// ancestor components re-render before descendants - -const CLEARED = null -const nullListeners = { notify() {} } - -function createListenerCollection() { - // the current/next pattern is copied from redux's createStore code. - // TODO: refactor+expose that code to be reusable here? - let current = [] - let next = [] - - return { - clear() { - next = CLEARED - current = CLEARED - }, - - notify() { - const listeners = current = next - for (let i = 0; i < listeners.length; i++) { - listeners[i]() - } - }, - - get() { - return next - }, - - subscribe(listener) { - let isSubscribed = true - if (next === current) next = current.slice() - next.push(listener) - - return function unsubscribe() { - if (!isSubscribed || current === CLEARED) return - isSubscribed = false - - if (next === current) next = current.slice() - next.splice(next.indexOf(listener), 1) - } - } - } -} - -export default class Subscription { - constructor(store, parentSub, onStateChange) { - this.store = store - this.parentSub = parentSub - this.onStateChange = onStateChange - this.unsubscribe = null - this.listeners = nullListeners - } - - addNestedSub(listener) { - this.trySubscribe() - return this.listeners.subscribe(listener) - } - - notifyNestedSubs() { - this.listeners.notify() - } - - isSubscribed() { - return Boolean(this.unsubscribe) - } - - trySubscribe() { - if (!this.unsubscribe) { - this.unsubscribe = this.parentSub - ? this.parentSub.addNestedSub(this.onStateChange) - : this.store.subscribe(this.onStateChange) - - this.listeners = createListenerCollection() - } - } - - tryUnsubscribe() { - if (this.unsubscribe) { - this.unsubscribe() - this.unsubscribe = null - this.listeners.clear() - this.listeners = nullListeners - } - } -} diff --git a/test/components/Provider.spec.js b/test/components/Provider.spec.js index 9ada2ac0d..ddb02637f 100644 --- a/test/components/Provider.spec.js +++ b/test/components/Provider.spec.js @@ -1,47 +1,39 @@ /*eslint-disable react/prop-types*/ import React, { Component } from 'react' -import PropTypes from 'prop-types' -import semver from 'semver' +import ReactDOM from 'react-dom' import { createStore } from 'redux' -import { Provider, createProvider, connect } from '../../src/index.js' +import { Provider, connect } from '../../src/index.js' +import { ReactReduxContext } from '../../src/components/Context' import * as rtl from 'react-testing-library' import 'jest-dom/extend-expect' -const createExampleTextReducer = () => (state = "example text") => state; +const createExampleTextReducer = () => (state = 'example text') => state describe('React', () => { describe('Provider', () => { afterEach(() => rtl.cleanup()) + const createChild = (storeKey = 'store') => { class Child extends Component { render() { - const store = this.context[storeKey]; - - let text = ''; - - if(store) { - text = store.getState().toString() - } - return ( -
- {storeKey} - {text} -
+ + {({ storeState }) => { + return ( +
{`${storeKey} - ${storeState}`}
+ ) + }} +
) } } - - Child.contextTypes = { - [storeKey]: PropTypes.object.isRequired - } - - return Child + return Child } - const Child = createChild(); + const Child = createChild() - it('should enforce a single child', () => { + it('should not enforce a single child', () => { const store = createStore(() => ({})) // Ignore propTypes warnings @@ -50,47 +42,31 @@ describe('React', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - try { - expect(() => rtl.render( + expect(() => + rtl.render(
- )).not.toThrow() + ) + ).not.toThrow() - if (semver.lt(React.version, '15.0.0')) { - expect(() => rtl.render( - - - )).toThrow(/children with exactly one child/) - } else { - expect(() => rtl.render( - - - )).toThrow(/a single React element child/) - } + expect(() => rtl.render()).not.toThrow( + /children with exactly one child/ + ) - if (semver.lt(React.version, '15.0.0')) { - expect(() => rtl.render( - -
-
- - )).toThrow(/children with exactly one child/) - } else { - expect(() => rtl.render( - -
-
- - )).toThrow(/a single React element child/) - } - } finally { - Provider.propTypes = propTypes - spy.mockRestore() - } + expect(() => + rtl.render( + +
+
+ + ) + ).not.toThrow(/a single React element child/) + spy.mockRestore() + Provider.propTypes = propTypes }) - it('should add the store to the child context', () => { + it('should add the store state to context', () => { const store = createStore(createExampleTextReducer()) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) @@ -101,31 +77,16 @@ describe('React', () => { ) expect(spy).toHaveBeenCalledTimes(0) spy.mockRestore() - - expect(tester.getByTestId('store')).toHaveTextContent('store - example text') - }) - - it('should add the store to the child context using a custom store key', () => { - const store = createStore(createExampleTextReducer()) - const CustomProvider = createProvider('customStoreKey'); - const CustomChild = createChild('customStoreKey'); - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const tester = rtl.render( - - - - ) - expect(spy).toHaveBeenCalledTimes(0) - spy.mockRestore() - - expect(tester.getByTestId('store')).toHaveTextContent('customStoreKey - example text') + expect(tester.getByTestId('store')).toHaveTextContent( + 'store - example text' + ) }) - it('should warn once when receiving a new store in props', () => { + it('accepts new store in props', () => { const store1 = createStore((state = 10) => state + 1) const store2 = createStore((state = 10) => state * 2) - const store3 = createStore((state = 10) => state * state) + const store3 = createStore((state = 10) => state * state + 1) let externalSetState class ProviderContainer extends Component { @@ -134,6 +95,7 @@ describe('React', () => { this.state = { store: store1 } externalSetState = this.setState.bind(this) } + render() { return ( @@ -145,132 +107,173 @@ describe('React', () => { const tester = rtl.render() expect(tester.getByTestId('store')).toHaveTextContent('store - 11') + store1.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 12') - let spy = jest.spyOn(console, 'error').mockImplementation(() => {}) externalSetState({ store: store2 }) - - expect(tester.getByTestId('store')).toHaveTextContent('store - 11') - expect(spy).toHaveBeenCalledTimes(1) - expect(spy.mock.calls[0][0]).toBe( - ' does not support changing `store` on the fly. ' + - 'It is most likely that you see this error because you updated to ' + - 'Redux 2.x and React Redux 2.x which no longer hot reload reducers ' + - 'automatically. See https://github.com/reduxjs/react-redux/releases/' + - 'tag/v2.0.0 for the migration instructions.' - ) - spy.mockRestore() - - spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + expect(tester.getByTestId('store')).toHaveTextContent('store - 20') + store1.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 20') + store2.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 40') + externalSetState({ store: store3 }) - - expect(tester.getByTestId('store')).toHaveTextContent('store - 11') - expect(spy).toHaveBeenCalledTimes(0) - spy.mockRestore() + expect(tester.getByTestId('store')).toHaveTextContent('store - 101') + store1.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 101') + store2.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 101') + store3.dispatch({ type: 'hi' }) + expect(tester.getByTestId('store')).toHaveTextContent('store - 10202') }) it('should handle subscriptions correctly when there is nested Providers', () => { - const reducer = (state = 0, action) => (action.type === 'INC' ? state + 1 : state) + const reducer = (state = 0, action) => + action.type === 'INC' ? state + 1 : state const innerStore = createStore(reducer) const innerMapStateToProps = jest.fn(state => ({ count: state })) @connect(innerMapStateToProps) class Inner extends Component { - render() { return
{this.props.count}
} + render() { + return
{this.props.count}
+ } } const outerStore = createStore(reducer) @connect(state => ({ count: state })) class Outer extends Component { - render() { return } + render() { + return ( + + + + ) + } } - rtl.render() + rtl.render( + + + + ) expect(innerMapStateToProps).toHaveBeenCalledTimes(1) - innerStore.dispatch({ type: 'INC'}) + innerStore.dispatch({ type: 'INC' }) expect(innerMapStateToProps).toHaveBeenCalledTimes(2) }) - }) - it('should pass state consistently to mapState', () => { - function stringBuilder(prev = '', action) { - return action.type === 'APPEND' - ? prev + action.body - : prev - } + it('should pass state consistently to mapState', () => { + function stringBuilder(prev = '', action) { + return action.type === 'APPEND' ? prev + action.body : prev + } - const store = createStore(stringBuilder) + const store = createStore(stringBuilder) - store.dispatch({ type: 'APPEND', body: 'a' }) - let childMapStateInvokes = 0 + store.dispatch({ type: 'APPEND', body: 'a' }) + let childMapStateInvokes = 0 - @connect(state => ({ state }), null, null, { withRef: true }) - class Container extends Component { - emitChange() { - store.dispatch({ type: 'APPEND', body: 'b' }) - } + @connect(state => ({ state })) + class Container extends Component { + emitChange() { + store.dispatch({ type: 'APPEND', body: 'b' }) + } - render() { - return ( -
- - -
- ) + render() { + return ( +
+ + +
+ ) + } } - } - @connect((state, parentProps) => { - childMapStateInvokes++ - // The state from parent props should always be consistent with the current state - expect(state).toEqual(parentProps.parentState) - return {} - }) - class ChildContainer extends Component { - render() { - return
+ const childCalls = [] + @connect((state, parentProps) => { + childMapStateInvokes++ + childCalls.push([state, parentProps.parentState]) + // The state from parent props should always be consistent with the current state + return {} + }) + class ChildContainer extends Component { + render() { + return
+ } } - } - - const tester = rtl.render( - - - - ) - expect(childMapStateInvokes).toBe(1) + const tester = rtl.render( + + + + ) - // The store state stays consistent when setState calls are batched - store.dispatch({ type: 'APPEND', body: 'c' }) - expect(childMapStateInvokes).toBe(2) + expect(childMapStateInvokes).toBe(1) + + // The store state stays consistent when setState calls are batched + store.dispatch({ type: 'APPEND', body: 'c' }) + expect(childMapStateInvokes).toBe(2) + expect(childCalls).toEqual([['a', 'a'], ['ac', 'ac']]) + + // setState calls DOM handlers are batched + const button = tester.getByText('change') + rtl.fireEvent.click(button) + expect(childMapStateInvokes).toBe(3) + + // Provider uses unstable_batchedUpdates() under the hood + store.dispatch({ type: 'APPEND', body: 'd' }) + expect(childCalls).toEqual([ + ['a', 'a'], + ['ac', 'ac'], // then store update is processed + ['acb', 'acb'], // then store update is processed + ['acbd', 'acbd'] // then store update is processed + ]) + expect(childMapStateInvokes).toBe(4) + }) - // setState calls DOM handlers are batched - const button = tester.getByText('change') - rtl.fireEvent.click(button) - expect(childMapStateInvokes).toBe(3) + it('works in without warnings (React 16.3+)', () => { + if (!React.StrictMode) { + return + } + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const store = createStore(() => ({})) - // Provider uses unstable_batchedUpdates() under the hood - store.dispatch({ type: 'APPEND', body: 'd' }) - expect(childMapStateInvokes).toBe(4) - }) + rtl.render( + + +
+ + + ) + expect(spy).not.toHaveBeenCalled() + }) - it.skip('works in without warnings (React 16.3+)', () => { - if (!React.StrictMode) { - return - } - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - const store = createStore(() => ({})) + it('should unsubscribe before unmounting', () => { + const store = createStore(createExampleTextReducer()) + const subscribe = store.subscribe + + // Keep track of unsubscribe by wrapping subscribe() + const spy = jest.fn(() => ({})) + store.subscribe = listener => { + const unsubscribe = subscribe(listener) + return () => { + spy() + return unsubscribe() + } + } - rtl.render( - + const div = document.createElement('div') + ReactDOM.render(
- - - ) + , + div + ) - expect(spy).not.toHaveBeenCalled() + expect(spy).toHaveBeenCalledTimes(0) + ReactDOM.unmountComponentAtNode(div) + expect(spy).toHaveBeenCalledTimes(1) + }) }) - }) diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js index 4a8c815f5..61bb5d086 100644 --- a/test/components/connect.spec.js +++ b/test/components/connect.spec.js @@ -1,11 +1,11 @@ /*eslint-disable react/prop-types*/ -import React, { Children, Component } from 'react' +import React, { Component } from 'react' import createClass from 'create-react-class' import PropTypes from 'prop-types' import ReactDOM from 'react-dom' import { createStore } from 'redux' -import { createProvider, connect } from '../../src/index.js' +import { Provider as ProviderMock, connect } from '../../src/index.js' import * as rtl from 'react-testing-library' import 'jest-dom/extend-expect' @@ -27,26 +27,15 @@ describe('React', () => { return (
    {Object.keys(this.props).map(prop => ( -
  • {propMapper(this.props[prop])}
  • +
  • + {propMapper(this.props[prop])} +
  • ))}
) } } - class ProviderMock extends Component { - getChildContext() { - return { store: this.props.store } - } - - render() { - return Children.only(this.props.children) - } - } - ProviderMock.childContextTypes = { - store: PropTypes.object.isRequired - } - class ContextBoundStore { constructor(reducer) { this.reducer = reducer @@ -61,7 +50,7 @@ describe('React', () => { subscribe(listener) { this.listeners.push(listener) - return (() => this.listeners.filter(l => l !== listener)) + return () => this.listeners.filter(l => l !== listener) } dispatch(action) { @@ -72,26 +61,25 @@ describe('React', () => { } function stringBuilder(prev = '', action) { - return action.type === 'APPEND' - ? prev + action.body - : prev + return action.type === 'APPEND' ? prev + action.body : prev } function imitateHotReloading(TargetClass, SourceClass, container) { // Crude imitation of hot reloading that does the job - Object.getOwnPropertyNames(SourceClass.prototype).filter(key => - typeof SourceClass.prototype[key] === 'function' - ).forEach(key => { - if (key !== 'render' && key !== 'constructor') { - TargetClass.prototype[key] = SourceClass.prototype[key] - } - }) + Object.getOwnPropertyNames(SourceClass.prototype) + .filter(key => typeof SourceClass.prototype[key] === 'function') + .forEach(key => { + if (key !== 'render' && key !== 'constructor') { + TargetClass.prototype[key] = SourceClass.prototype[key] + } + }) container.forceUpdate() } afterEach(() => rtl.cleanup()) - it('should receive the store in the context', () => { + + it('should receive the store state in the context', () => { const store = createStore(() => ({ hi: 'there' })) @connect(state => state) @@ -101,9 +89,11 @@ describe('React', () => { } } - const tester = rtl.render( + const tester = rtl.render( + - ) + + ) expect(tester.getByTestId('hi')).toHaveTextContent('there') }) @@ -112,16 +102,18 @@ describe('React', () => { if (React.memo) { const store = createStore(() => ({ hi: 'there' })) - const Container = React.memo((props) => ) - const WrappedContainer = connect(state => state)(Container); + const Container = React.memo(props => ) + const WrappedContainer = connect(state => state)(Container) - const tester = rtl.render( + const tester = rtl.render( + - ) + + ) expect(tester.getByTestId('hi')).toHaveTextContent('there') } - }); + }) it('should pass state and props to the given component', () => { const store = createStore(() => ({ @@ -130,7 +122,10 @@ describe('React', () => { hello: 'world' })) - @connect(({ foo, baz }) => ({ foo, baz }), {}) + @connect( + ({ foo, baz }) => ({ foo, baz }), + {} + ) class Container extends Component { render() { return @@ -151,10 +146,10 @@ describe('React', () => { it('should subscribe class components to the store changes', () => { const store = createStore(stringBuilder) - @connect(state => ({ string: state }) ) + @connect(state => ({ string: state })) class Container extends Component { render() { - return + return } } @@ -163,7 +158,6 @@ describe('React', () => { ) - expect(tester.getByTestId('string')).toHaveTextContent('') store.dispatch({ type: 'APPEND', body: 'a' }) expect(tester.getByTestId('string')).toHaveTextContent('a') @@ -174,13 +168,14 @@ describe('React', () => { it('should subscribe pure function components to the store changes', () => { const store = createStore(stringBuilder) - const Container = connect( - state => ({ string: state }) - )(function Container(props) { - return - }) + const Container = connect(state => ({ string: state }))( + function Container(props) { + return + } + ) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const tester = rtl.render( @@ -196,13 +191,13 @@ describe('React', () => { expect(tester.getByTestId('string')).toHaveTextContent('ab') }) - it('should retain the store\'s context', () => { + it("should retain the store's context", () => { const store = new ContextBoundStore(stringBuilder) - let Container = connect( - state => ({ string: state }) - )(function Container(props) { - return + let Container = connect(state => ({ string: state }))(function Container( + props + ) { + return }) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) @@ -222,23 +217,21 @@ describe('React', () => { it('should handle dispatches before componentDidMount', () => { const store = createStore(stringBuilder) - @connect(state => ({ string: state }) ) + @connect(state => ({ string: state })) class Container extends Component { componentDidMount() { store.dispatch({ type: 'APPEND', body: 'a' }) } render() { - return + return } } - const tester = rtl.render( ) - expect(tester.getByTestId('string')).toHaveTextContent('a') }) @@ -250,9 +243,7 @@ describe('React', () => { @connect(state => state) class ConnectContainer extends Component { render() { - return ( - - ) + return } } @@ -276,7 +267,7 @@ describe('React', () => { return ( - + ) } } @@ -293,9 +284,7 @@ describe('React', () => { @connect(state => state) class ConnectContainer extends Component { render() { - return ( - - ) + return } } @@ -308,13 +297,12 @@ describe('React', () => { componentDidMount() { this.bar = 'foo' this.forceUpdate() - this.c.forceUpdate() } render() { return ( - this.c = c} /> + ) } @@ -330,26 +318,25 @@ describe('React', () => { let props = { x: true } let container - @connect(() => ({}), () => ({})) + @connect( + () => ({}), + () => ({}) + ) class ConnectContainer extends Component { render() { - return ( - - ) + return } } class HolderContainer extends Component { render() { - return ( - - ) + return } } const tester = rtl.render( - container = instance} /> + (container = instance)} /> ) @@ -369,35 +356,35 @@ describe('React', () => { @connect(() => ({})) class ConnectContainer extends Component { render() { - return ( - - ) + return } } class HolderContainer extends Component { render() { - return ( - - ) + return } } const tester = rtl.render( - container = instance} /> + (container = instance)} /> ) expect(tester.getAllByTitle('prop').length).toBe(2) - expect(tester.getByTestId('dispatch')).toHaveTextContent('[function dispatch]') + expect(tester.getByTestId('dispatch')).toHaveTextContent( + '[function dispatch]' + ) expect(tester.getByTestId('x')).toHaveTextContent('true') props = {} container.forceUpdate() expect(tester.getAllByTitle('prop').length).toBe(1) - expect(tester.getByTestId('dispatch')).toHaveTextContent('[function dispatch]') + expect(tester.getByTestId('dispatch')).toHaveTextContent( + '[function dispatch]' + ) }) it('should ignore deep mutations in props', () => { @@ -408,9 +395,7 @@ describe('React', () => { @connect(state => state) class ConnectContainer extends Component { render() { - return ( - - ) + return } } @@ -442,7 +427,7 @@ describe('React', () => { } } - const tester = rtl.render() + const tester = rtl.render() expect(tester.getByTestId('foo')).toHaveTextContent('bar') expect(tester.getByTestId('pass')).toHaveTextContent('') }) @@ -462,23 +447,23 @@ describe('React', () => { @connect( state => ({ stateThing: state }), dispatch => ({ - doSomething: (whatever) => dispatch(doSomething(whatever)) + doSomething: whatever => dispatch(doSomething(whatever)) }), (stateProps, actionProps, parentProps) => ({ ...stateProps, ...actionProps, mergedDoSomething: (() => { merged = function mergedDoSomething(thing) { - const seed = stateProps.stateThing === '' ? 'HELLO ' : '' - actionProps.doSomething(seed + thing + parentProps.extra) - } + const seed = stateProps.stateThing === '' ? 'HELLO ' : '' + actionProps.doSomething(seed + thing + parentProps.extra) + } return merged })() }) ) class Container extends Component { render() { - return + return } } @@ -498,7 +483,7 @@ describe('React', () => { } } - const tester = rtl.render() + const tester = rtl.render() expect(tester.getByTestId('stateThing')).toHaveTextContent('') merged('a') @@ -515,7 +500,7 @@ describe('React', () => { foo: 'bar' })) - const exampleActionCreator = () => {}; + const exampleActionCreator = () => {} @connect( state => state, @@ -533,7 +518,9 @@ describe('React', () => { ) - expect(tester.getByTestId('exampleActionCreator')).toHaveTextContent('[function exampleActionCreator]') + expect(tester.getByTestId('exampleActionCreator')).toHaveTextContent( + '[function exampleActionCreator]' + ) expect(tester.getByTestId('foo')).toHaveTextContent('bar') }) @@ -543,14 +530,14 @@ describe('React', () => { let invocationCount = 0 /*eslint-disable no-unused-vars */ - @connect((arg1) => { + @connect(arg1 => { invocationCount++ return {} }) /*eslint-enable no-unused-vars */ class WithoutProps extends Component { render() { - return + return } } @@ -576,7 +563,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) outerComponent.setFoo('BAR') @@ -594,10 +581,9 @@ describe('React', () => { invocationCount++ return {} }) - class WithoutProps extends Component { render() { - return + return } } @@ -623,7 +609,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) outerComponent.setFoo('BAR') @@ -645,7 +631,7 @@ describe('React', () => { }) class WithProps extends Component { render() { - return + return } } @@ -671,7 +657,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) @@ -690,14 +676,17 @@ describe('React', () => { let invocationCount = 0 /*eslint-disable no-unused-vars */ - @connect(null, (arg1) => { - invocationCount++ - return {} - }) + @connect( + null, + arg1 => { + invocationCount++ + return {} + } + ) /*eslint-enable no-unused-vars */ class WithoutProps extends Component { render() { - return + return } } @@ -723,7 +712,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) @@ -738,14 +727,16 @@ describe('React', () => { let invocationCount = 0 - @connect(null, () => { - invocationCount++ - return {} - }) - + @connect( + null, + () => { + invocationCount++ + return {} + } + ) class WithoutProps extends Component { render() { - return + return } } @@ -771,7 +762,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) @@ -787,14 +778,17 @@ describe('React', () => { let propsPassedIn let invocationCount = 0 - @connect(null, (dispatch, props) => { - invocationCount++ - propsPassedIn = props - return {} - }) + @connect( + null, + (dispatch, props) => { + invocationCount++ + propsPassedIn = props + return {} + } + ) class WithProps extends Component { render() { - return + return } } @@ -820,7 +814,7 @@ describe('React', () => { let outerComponent rtl.render( - outerComponent = c} /> + (outerComponent = c)} /> ) @@ -851,7 +845,9 @@ describe('React', () => { ) - expect(tester.getByTestId('dispatch')).toHaveTextContent('[function dispatch]') + expect(tester.getByTestId('dispatch')).toHaveTextContent( + '[function dispatch]' + ) expect(tester.queryByTestId('foo')).toBe(null) expect(tester.getByTestId('pass')).toHaveTextContent('through') } @@ -861,43 +857,6 @@ describe('React', () => { runCheck(false, false, false) }) - it('should unsubscribe before unmounting', () => { - const store = createStore(stringBuilder) - const subscribe = store.subscribe - - // Keep track of unsubscribe by wrapping subscribe() - const spy = jest.fn(() => ({})) - store.subscribe = (listener) => { - const unsubscribe = subscribe(listener) - return () => { - spy() - return unsubscribe() - } - } - - @connect( - state => ({ string: state }), - dispatch => ({ dispatch }) - ) - class Container extends Component { - render() { - return - } - } - - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) - - expect(spy).toHaveBeenCalledTimes(0) - ReactDOM.unmountComponentAtNode(div) - expect(spy).toHaveBeenCalledTimes(1) - }) - it('should not attempt to set state after unmounting', () => { const store = createStore(stringBuilder) let mapStateToPropsCalls = 0 @@ -913,9 +872,7 @@ describe('React', () => { } const div = document.createElement('div') - store.subscribe(() => - ReactDOM.unmountComponentAtNode(div) - ) + store.subscribe(() => ReactDOM.unmountComponentAtNode(div)) ReactDOM.render( @@ -934,7 +891,7 @@ describe('React', () => { it('should not attempt to notify unmounted child of state change', () => { const store = createStore(stringBuilder) - @connect((state) => ({ hide: state === 'AB' })) + @connect(state => ({ hide: state === 'AB' })) class App extends Component { render() { return this.props.hide ? null : @@ -944,21 +901,19 @@ describe('React', () => { @connect(() => ({})) class Container extends Component { render() { - return ( - - ) + return } } - @connect((state) => ({ state })) + @connect(state => ({ state })) class Child extends Component { componentDidMount() { if (this.props.state === 'A') { - store.dispatch({ type: 'APPEND', body: 'B' }); + store.dispatch({ type: 'APPEND', body: 'B' }) } } render() { - return null; + return null } } @@ -991,8 +946,24 @@ describe('React', () => { /* eslint-disable react/jsx-no-bind */ return ( ) @@ -1000,13 +971,10 @@ describe('React', () => { } App = connect(() => ({}))(App) - - let A = () => (

A

) + let A = () =>

A

A = connect(() => ({ calls: ++mapStateToPropsCalls }))(A) - - const B = () => (

B

) - + const B = () =>

B

class RouterMock extends React.Component { constructor(...args) { @@ -1022,26 +990,30 @@ describe('React', () => { getChildComponent(location) { switch (location) { - case 'a': return - case 'b': return - default: throw new Error('Unknown location: ' + location) + case 'a': + return + case 'b': + return + default: + throw new Error('Unknown location: ' + location) } } render() { - return ( - {this.getChildComponent(this.state.location.pathname)} - ) + return ( + + {this.getChildComponent(this.state.location.pathname)} + + ) } } - const div = document.createElement('div') document.body.appendChild(div) ReactDOM.render( - ( + - ), + , div ) @@ -1052,7 +1024,7 @@ describe('React', () => { linkB.click() document.body.removeChild(div) - expect(mapStateToPropsCalls).toBe(3) + expect(mapStateToPropsCalls).toBe(2) expect(spy).toHaveBeenCalledTimes(0) spy.mockRestore() }) @@ -1063,7 +1035,7 @@ describe('React', () => { /*eslint-disable no-unused-vars */ @connect( - (state) => ({ calls: mapStateToPropsCalls++ }), + state => ({ calls: mapStateToPropsCalls++ }), dispatch => ({ dispatch }) ) /*eslint-enable no-unused-vars */ @@ -1097,7 +1069,7 @@ describe('React', () => { const spy = jest.fn(() => ({})) function render({ string }) { spy() - return + return } @connect( @@ -1115,7 +1087,6 @@ describe('React', () => {
) - expect(spy).toHaveBeenCalledTimes(1) expect(tester.getByTestId('string')).toHaveTextContent('') store.dispatch({ type: 'APPEND', body: 'a' }) @@ -1206,26 +1177,32 @@ describe('React', () => { tree.setState({ pass: obj2 }) expect(spy).toHaveBeenCalledTimes(5) expect(tester.getByTestId('string')).toHaveTextContent('a') - expect(tester.getByTestId('pass')).toHaveTextContent('{"prop":"val","val":"otherval"}') + expect(tester.getByTestId('pass')).toHaveTextContent( + '{"prop":"val","val":"otherval"}' + ) obj2.val = 'mutation' tree.setState({ pass: obj2 }) expect(spy).toHaveBeenCalledTimes(5) expect(tester.getByTestId('string')).toHaveTextContent('a') - expect(tester.getByTestId('pass')).toHaveTextContent('{"prop":"val","val":"otherval"}') + expect(tester.getByTestId('pass')).toHaveTextContent( + '{"prop":"val","val":"otherval"}' + ) }) it('should throw an error if a component is not passed to the function returned by connect', () => { - expect(connect()).toThrow( - /You must pass a component to the function/ - ) + expect(connect()).toThrow(/You must pass a component to the function/) }) it('should throw an error if mapState, mapDispatch, or mergeProps returns anything but a plain object', () => { const store = createStore(() => ({})) function makeContainer(mapState, mapDispatch, mergeProps) { - @connect(mapState, mapDispatch, mergeProps) + @connect( + mapState, + mapDispatch, + mergeProps + ) class Container extends Component { render() { return @@ -1234,7 +1211,7 @@ describe('React', () => { return React.createElement(Container) } - function AwesomeMap() { } + function AwesomeMap() {} let spy = jest.spyOn(console, 'error').mockImplementation(() => {}) rtl.render( @@ -1352,67 +1329,54 @@ describe('React', () => { ) spy.mockRestore() }) - - it('should recalculate the state and rebind the actions on hot update', () => { + it.skip('should recalculate the state and rebind the actions on hot update', () => { const store = createStore(() => {}) - @connect( null, () => ({ scooby: 'doo' }) ) class ContainerBefore extends Component { render() { - return ( - - ) + return } } - @connect( () => ({ foo: 'baz' }), () => ({ scooby: 'foo' }) ) class ContainerAfter extends Component { render() { - return ( - - ) + return } } - @connect( () => ({ foo: 'bar' }), () => ({ scooby: 'boo' }) ) class ContainerNext extends Component { render() { - return ( - - ) + return } } - let container const tester = rtl.render( - container = instance} /> + (container = instance)} /> ) expect(tester.queryByTestId('foo')).toBe(null) expect(tester.getByTestId('scooby')).toHaveTextContent('doo') - imitateHotReloading(ContainerBefore, ContainerAfter, container) expect(tester.getByTestId('foo')).toHaveTextContent('baz') expect(tester.getByTestId('scooby')).toHaveTextContent('foo') - imitateHotReloading(ContainerBefore, ContainerNext, container) expect(tester.getByTestId('foo')).toHaveTextContent('bar') expect(tester.getByTestId('scooby')).toHaveTextContent('boo') }) - it('should persist listeners through hot update', () => { - const ACTION_TYPE = "ACTION" - const store = createStore((state = {actions: 0}, action) => { + it.skip('should persist listeners through hot update', () => { + const ACTION_TYPE = 'ACTION' + const store = createStore((state = { actions: 0 }, action) => { switch (action.type) { case ACTION_TYPE: { return { @@ -1424,80 +1388,76 @@ describe('React', () => { } }) - @connect( - (state) => ({ actions: state.actions }) - ) + @connect(state => ({ actions: state.actions })) class Child extends Component { render() { - return + return } } - @connect( - () => ({ scooby: 'doo' }) - ) + @connect(() => ({ scooby: 'doo' })) class ParentBefore extends Component { render() { - return ( - - ) + return } } - @connect( - () => ({ scooby: 'boo' }) - ) + @connect(() => ({ scooby: 'boo' })) class ParentAfter extends Component { render() { - return ( - - ) + return } } let container const tester = rtl.render( - container = instance}/> + (container = instance)} /> ) imitateHotReloading(ParentBefore, ParentAfter, container) - store.dispatch({type: ACTION_TYPE}) + store.dispatch({ type: ACTION_TYPE }) expect(tester.getByTestId('actions')).toHaveTextContent('1') }) it('should set the displayName correctly', () => { - expect(connect(state => state)( - class Foo extends Component { - render() { - return
+ expect( + connect(state => state)( + class Foo extends Component { + render() { + return
+ } } - } - ).displayName).toBe('Connect(Foo)') + ).displayName + ).toBe('Connect(Foo)') - expect(connect(state => state)( - createClass({ - displayName: 'Bar', - render() { - return
- } - }) - ).displayName).toBe('Connect(Bar)') + expect( + connect(state => state)( + createClass({ + displayName: 'Bar', + render() { + return
+ } + }) + ).displayName + ).toBe('Connect(Bar)') - expect(connect(state => state)( - // eslint: In this case, we don't want to specify a displayName because we're testing what - // happens when one isn't defined. - /* eslint-disable react/display-name */ - createClass({ - render() { - return
- } - }) - /* eslint-enable react/display-name */ - ).displayName).toBe('Connect(Component)') + expect( + connect(state => state)( + // eslint: In this case, we don't want to specify a displayName because we're testing what + // happens when one isn't defined. + /* eslint-disable react/display-name */ + createClass({ + render() { + return
+ } + }) + /* eslint-enable react/display-name */ + ).displayName + ).toBe('Connect(Component)') }) it('should expose the wrapped component as WrappedComponent', () => { @@ -1531,35 +1491,82 @@ describe('React', () => { expect(decorated.foo).toBe('bar') }) - it('should use the store from the props instead of from the context if present', () => { + it('should use a custom context provider and consumer if given as an option to connect', () => { class Container extends Component { render() { return } } + const context = React.createContext(null) + let actualState const expectedState = { foos: {} } + const ignoredState = { bars: {} } + + const decorator = connect( + state => { + actualState = state + return {} + }, + undefined, + undefined, + { context } + ) + const Decorated = decorator(Container) + + const store1 = createStore(() => expectedState) + const store2 = createStore(() => ignoredState) + + rtl.render( + + + + + + ) + + expect(actualState).toEqual(expectedState) + }) + + it('should use a custom context provider and consumer if passed as a prop to the component', () => { + class Container extends Component { + render() { + return + } + } + + const context = React.createContext(null) + + let actualState + + const expectedState = { foos: {} } + const ignoredState = { bars: {} } + const decorator = connect(state => { actualState = state return {} }) const Decorated = decorator(Container) - const mockStore = { - dispatch: () => {}, - subscribe: () => {}, - getState: () => expectedState - } - rtl.render() + const store1 = createStore(() => expectedState) + const store2 = createStore(() => ignoredState) + + rtl.render( + + + + + + ) expect(actualState).toEqual(expectedState) }) - it('should throw an error if the store is not in the props or context', () => { + it.skip('should throw an error if the store is not in the props or context', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - + class Container extends Component { render() { return @@ -1569,16 +1576,12 @@ describe('React', () => { const decorator = connect(() => {}) const Decorated = decorator(Container) - expect(() => - rtl.render() - ).toThrow( - /Could not find "store"/ - ) + expect(() => rtl.render()).toThrow(/Could not find "store"/) spy.mockRestore() }) - it('should throw when trying to access the wrapped instance if withRef is not specified', () => { + it.skip('should throw when trying to access the wrapped instance if withRef is not specified', () => { const store = createStore(() => ({})) class Container extends Component { @@ -1592,29 +1595,70 @@ describe('React', () => { class Wrapper extends Component { render() { - return ( - comp && comp.getWrappedInstance()}/> - ) + return comp && comp.getWrappedInstance()} /> } } // TODO Remove this when React is fixed, per https://github.com/facebook/react/issues/11098 const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => + rtl.render( + + + + ) + ).toThrow( + `To access the wrapped instance, you need to specify { withRef: true } in the options argument of the connect() call` + ) + spy.mockRestore() + }) - expect(() => rtl.render( + it('should return the instance of the wrapped component for use in calling child methods', async done => { + const store = createStore(() => ({})) + + const someData = { + some: 'data' + } + + class Container extends Component { + someInstanceMethod() { + return someData + } + + render() { + return + } + } + + const decorator = connect( + state => state, + null, + null, + { forwardRef: true } + ) + const Decorated = decorator(Container) + + const ref = React.createRef() + + class Wrapper extends Component { + render() { + return + } + } + + const tester = rtl.render( - )).toThrow( - `To access the wrapped instance, you need to specify { withRef: true } in the options argument of the connect() call` ) - spy.mockRestore() - + await rtl.waitForElement(() => tester.getByTestId('loaded')) + expect(ref.current.someInstanceMethod()).toBe(someData) + done() }) - it('should return the instance of the wrapped component for use in calling child methods', async (done) => { + it('should return the instance of the wrapped component for use in calling child methods, impure component', async done => { const store = createStore(() => ({})) const someData = { @@ -1631,30 +1675,31 @@ describe('React', () => { } } - const decorator = connect(state => state, null, null, { withRef: true }) + const decorator = connect( + state => state, + undefined, + undefined, + { forwardRef: true, pure: false } + ) const Decorated = decorator(Container) - let ref + const ref = React.createRef() + class Wrapper extends Component { render() { - return ( - { - if (!comp) return - ref = comp.getWrappedInstance() - }}/> - ) + return } } + const tester = rtl.render( ) - await rtl.waitForElement(() => tester.getByTestId('loaded')) - expect(ref.someInstanceMethod()).toBe(someData) + expect(ref.current.someInstanceMethod()).toBe(someData) done() }) @@ -1671,7 +1716,12 @@ describe('React', () => { statefulValue: PropTypes.number } - const decorator = connect(state => state, null, null, { pure: false }) + const decorator = connect( + state => state, + null, + null, + { pure: false } + ) const Decorated = decorator(ImpureComponent) let externalSetState @@ -1750,24 +1800,22 @@ describe('React', () => { } } - const tester = rtl.render( ) - - expect(mapStateSpy).toHaveBeenCalledTimes(2) - expect(mapDispatchSpy).toHaveBeenCalledTimes(2) + expect(mapStateSpy).toHaveBeenCalledTimes(1) + expect(mapDispatchSpy).toHaveBeenCalledTimes(1) expect(tester.getByTestId('statefulValue')).toHaveTextContent('foo') // Impure update storeGetter.storeKey = 'bar' externalSetState({ storeGetter }) - expect(mapStateSpy).toHaveBeenCalledTimes(3) - expect(mapDispatchSpy).toHaveBeenCalledTimes(3) + expect(mapStateSpy).toHaveBeenCalledTimes(2) + expect(mapDispatchSpy).toHaveBeenCalledTimes(2) expect(tester.getByTestId('statefulValue')).toHaveTextContent('bar') }) @@ -1777,9 +1825,8 @@ describe('React', () => { store.dispatch({ type: 'APPEND', body: 'a' }) let childMapStateInvokes = 0 - @connect(state => ({ state }), null, null, { withRef: true }) + @connect(state => ({ state })) class Container extends Component { - emitChange() { store.dispatch({ type: 'APPEND', body: 'b' }) } @@ -1794,15 +1841,17 @@ describe('React', () => { } } + const childCalls = [] @connect((state, parentProps) => { childMapStateInvokes++ + childCalls.push([state, parentProps.parentState]) // The state from parent props should always be consistent with the current state expect(state).toEqual(parentProps.parentState) return {} }) class ChildContainer extends Component { render() { - return + return } } @@ -1813,12 +1862,14 @@ describe('React', () => { ) expect(childMapStateInvokes).toBe(1) + expect(childCalls).toEqual([['a', 'a']]) // The store state stays consistent when setState calls are batched ReactDOM.unstable_batchedUpdates(() => { store.dispatch({ type: 'APPEND', body: 'c' }) }) expect(childMapStateInvokes).toBe(2) + expect(childCalls).toEqual([['a', 'a'], ['ac', 'ac']]) // setState calls DOM handlers are batched const button = tester.getByText('change') @@ -1827,6 +1878,12 @@ describe('React', () => { store.dispatch({ type: 'APPEND', body: 'd' }) expect(childMapStateInvokes).toBe(4) + expect(childCalls).toEqual([ + ['a', 'a'], + ['ac', 'ac'], + ['acb', 'acb'], + ['acbd', 'acbd'] + ]) }) it('should not render the wrapped component when mapState does not produce change', () => { @@ -1887,24 +1944,17 @@ describe('React', () => { expect(renderCalls).toBe(1) expect(mapStateCalls).toBe(1) - const spy = jest.spyOn(Container.prototype, 'setState') - store.dispatch({ type: 'APPEND', body: 'a' }) expect(mapStateCalls).toBe(2) expect(renderCalls).toBe(1) - expect(spy).toHaveBeenCalledTimes(0) store.dispatch({ type: 'APPEND', body: 'a' }) expect(mapStateCalls).toBe(3) expect(renderCalls).toBe(1) - expect(spy).toHaveBeenCalledTimes(0) store.dispatch({ type: 'APPEND', body: 'a' }) expect(mapStateCalls).toBe(4) expect(renderCalls).toBe(2) - expect(spy).toHaveBeenCalledTimes(1) - - spy.mockRestore() }) it('should not swallow errors when bailing out early', () => { @@ -1936,9 +1986,7 @@ describe('React', () => { expect(renderCalls).toBe(1) expect(mapStateCalls).toBe(1) - expect( - () => store.dispatch({ type: 'APPEND', body: 'a' }) - ).toThrow() + expect(() => store.dispatch({ type: 'APPEND', body: 'a' })).toThrow() spy.mockRestore() }) @@ -1957,7 +2005,9 @@ describe('React', () => { } lastProp = props.name lastVal = state.value - return lastResult = { someObject: { prop: props.name, stateVal: state.value } } + return (lastResult = { + someObject: { prop: props.name, stateVal: state.value } + }) } } @@ -1991,12 +2041,12 @@ describe('React', () => { let initialState let initialOwnProps let secondaryOwnProps - const mapStateFactory = function (factoryInitialState) { + const mapStateFactory = function(factoryInitialState) { initialState = factoryInitialState - initialOwnProps = arguments[1]; + initialOwnProps = arguments[1] return (state, props) => { secondaryOwnProps = props - return { } + return {} } } @@ -2019,7 +2069,7 @@ describe('React', () => { expect(initialOwnProps).toBe(undefined) expect(initialState).not.toBe(undefined) expect(secondaryOwnProps).not.toBe(undefined) - expect(secondaryOwnProps.name).toBe("a") + expect(secondaryOwnProps.name).toBe('a') }) it('should allow providing a factory function to mapDispatchToProps', () => { @@ -2035,14 +2085,18 @@ describe('React', () => { return lastResult } lastProp = props.name - return lastResult = { someObject: { dispatchFn: dispatch } } + return (lastResult = { someObject: { dispatchFn: dispatch } }) } } function mergeParentDispatch(stateProps, dispatchProps, parentProps) { return { ...stateProps, ...dispatchProps, name: parentProps.name } } - @connect(null, mapDispatchFactory, mergeParentDispatch) + @connect( + null, + mapDispatchFactory, + mergeParentDispatch + ) class Passthrough extends Component { componentDidUpdate() { updatedCount++ @@ -2087,7 +2141,11 @@ describe('React', () => { let renderCalls = 0 const store = createStore(stringBuilder) - @connect(() => ({ a: ++mapStateCalls }), null, () => ({ changed: false })) + @connect( + () => ({ a: ++mapStateCalls }), + null, + () => ({ changed: false }) + ) class Container extends Component { render() { renderCalls++ @@ -2114,7 +2172,12 @@ describe('React', () => { let store = createStore(() => ({})) let renderCount = 0 - @connect(null, null, () => ({ a: 1 }), { pure: false }) + @connect( + null, + null, + () => ({ a: 1 }), + { pure: false } + ) class Container extends React.Component { render() { ++renderCount @@ -2188,10 +2251,15 @@ describe('React', () => { }) it('should allow custom displayName', () => { - @connect(null, null, null, { getDisplayName: name => `Custom(${name})` }) + @connect( + null, + null, + null, + { getDisplayName: name => `Custom(${name})` } + ) class MyComponent extends React.Component { render() { - return
+ return
} } @@ -2202,7 +2270,12 @@ describe('React', () => { const store = createStore(() => ({})) let renderCount = 0 - @connect(() => ({}), null, null, { pure: false }) + @connect( + () => ({}), + null, + null, + { pure: false } + ) class ImpureComponent extends React.Component { render() { ++renderCount @@ -2241,7 +2314,11 @@ describe('React', () => { it('should throw a helpful error for invalid mapStateToProps arguments', () => { @connect('invalid') - class InvalidMapState extends React.Component { render() { return
} } + class InvalidMapState extends React.Component { + render() { + return
+ } + } const error = renderWithBadConnect(InvalidMapState) expect(error).toContain('string') @@ -2250,8 +2327,15 @@ describe('React', () => { }) it('should throw a helpful error for invalid mapDispatchToProps arguments', () => { - @connect(null, 'invalid') - class InvalidMapDispatch extends React.Component { render() { return
} } + @connect( + null, + 'invalid' + ) + class InvalidMapDispatch extends React.Component { + render() { + return
+ } + } const error = renderWithBadConnect(InvalidMapDispatch) expect(error).toContain('string') @@ -2260,8 +2344,16 @@ describe('React', () => { }) it('should throw a helpful error for invalid mergeProps arguments', () => { - @connect(null, null, 'invalid') - class InvalidMerge extends React.Component { render() { return
} } + @connect( + null, + null, + 'invalid' + ) + class InvalidMerge extends React.Component { + render() { + return
+ } + } const error = renderWithBadConnect(InvalidMerge) expect(error).toContain('string') @@ -2272,22 +2364,40 @@ describe('React', () => { it('should notify nested components through a blocking component', () => { @connect(state => ({ count: state })) class Parent extends Component { - render() { return } + render() { + return ( + + + + ) + } } class BlockUpdates extends Component { - shouldComponentUpdate() { return false; } - render() { return this.props.children; } + shouldComponentUpdate() { + return false + } + render() { + return this.props.children + } } const mapStateToProps = jest.fn(state => ({ count: state })) @connect(mapStateToProps) class Child extends Component { - render() { return
{this.props.count}
} + render() { + return
{this.props.count}
+ } } - const store = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state)) - rtl.render() + const store = createStore( + (state = 0, action) => (action.type === 'INC' ? state + 1 : state) + ) + rtl.render( + + + + ) expect(mapStateToProps).toHaveBeenCalledTimes(1) store.dispatch({ type: 'INC' }) @@ -2295,53 +2405,104 @@ describe('React', () => { }) it('should subscribe properly when a middle connected component does not subscribe', () => { - @connect(state => ({ count: state })) - class A extends React.Component { render() { return }} + class A extends React.Component { + render() { + return + } + } @connect() // no mapStateToProps. therefore it should be transparent for subscriptions - class B extends React.Component { render() { return }} + class B extends React.Component { + render() { + return + } + } @connect((state, props) => { expect(props.count).toBe(state) return { count: state * 10 + props.count } }) - class C extends React.Component { render() { return
{this.props.count}
}} + class C extends React.Component { + render() { + return
{this.props.count}
+ } + } - const store = createStore((state = 0, action) => (action.type === 'INC' ? state += 1 : state)) - rtl.render() + const store = createStore( + (state = 0, action) => (action.type === 'INC' ? (state += 1) : state) + ) + rtl.render( + + + + ) store.dispatch({ type: 'INC' }) }) it('should subscribe properly when a new store is provided via props', () => { - const store1 = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state)) - const store2 = createStore((state = 0, action) => (action.type === 'INC' ? state + 1 : state)) + const store1 = createStore( + (state = 0, action) => (action.type === 'INC' ? state + 1 : state) + ) + const store2 = createStore( + (state = 0, action) => (action.type === 'INC' ? state + 1 : state) + ) + const customContext = React.createContext() - @connect(state => ({ count: state })) + @connect( + state => ({ count: state }), + undefined, + undefined, + { context: customContext } + ) class A extends Component { - render() { return } + render() { + return + } } const mapStateToPropsB = jest.fn(state => ({ count: state })) - @connect(mapStateToPropsB) + @connect( + mapStateToPropsB, + undefined, + undefined, + { context: customContext } + ) class B extends Component { - render() { return } + render() { + return + } } const mapStateToPropsC = jest.fn(state => ({ count: state })) - @connect(mapStateToPropsC) + @connect( + mapStateToPropsC, + undefined, + undefined, + { context: customContext } + ) class C extends Component { - render() { return } + render() { + return + } } const mapStateToPropsD = jest.fn(state => ({ count: state })) @connect(mapStateToPropsD) class D extends Component { - render() { return
{this.props.count}
} + render() { + return
{this.props.count}
+ } } - rtl.render(
) + rtl.render( + + + + + + ) expect(mapStateToPropsB).toHaveBeenCalledTimes(1) expect(mapStateToPropsC).toHaveBeenCalledTimes(1) expect(mapStateToPropsD).toHaveBeenCalledTimes(1) @@ -2357,18 +2518,17 @@ describe('React', () => { expect(mapStateToPropsD).toHaveBeenCalledTimes(2) }) - - it.skip('works in without warnings (React 16.3+)', () => { + it('works in without warnings (React 16.3+)', () => { if (!React.StrictMode) { return } const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) const store = createStore(stringBuilder) - @connect(state => ({ string: state }) ) + @connect(state => ({ string: state })) class Container extends Component { render() { - return + return } } @@ -2383,25 +2543,66 @@ describe('React', () => { expect(spy).not.toHaveBeenCalled() }) - it('should receive the store in the context using a custom store key', () => { - const store = createStore(() => ({})) - const CustomProvider = createProvider('customStoreKey') - const connectOptions = { storeKey: 'customStoreKey' } - - @connect(undefined, undefined, undefined, connectOptions) + it('should error on withRef=true', () => { class Container extends Component { render() { - return + return
hi
} } + expect(() => + connect( + undefined, + undefined, + undefined, + { withRef: true } + )(Container) + ).toThrow(/withRef is removed/) + }) - const tester = rtl.render( - - - - ) + it('should error on receiving a custom store key', () => { + const connectOptions = { storeKey: 'customStoreKey' } + + expect(() => { + @connect( + undefined, + undefined, + undefined, + connectOptions + ) + class Container extends Component { + render() { + return + } + } + new Container() + }).toThrow(/storeKey has been removed/) + }) + + it('should error on custom store', () => { + function Comp() { + return
hi
+ } + const Container = connect()(Comp) + function Oops() { + return + } + expect(() => { + rtl.render() + }).toThrow(/Passing redux store/) + }) - expect(tester.getByTestId('dispatch')).toHaveTextContent('[function dispatch]') + it('should error on renderCount prop if specified in connect options', () => { + function Comp(props) { + return
{props.count}
+ } + expect(() => { + connect( + undefined, + undefined, + undefined, + { renderCountProp: 'count' } + )(Comp) + }).toThrow(/renderCountProp is removed/) }) }) })