From 625d02644f8a38a004dd110afb3631fb0e5ee0c9 Mon Sep 17 00:00:00 2001 From: Greg Beaver Date: Tue, 6 Nov 2018 15:10:39 -0600 Subject: [PATCH] Use React.createContext() by @cellog (#1000) * alternate 6.x implementation * use canary values in store instead of setting a prop on the store * remove createProvider (missed this one before) * fix renderCountProp, add a test also clean up unused canary values * remove errant console.log * expose context consumer and provider This will allow third party apps to use these in their code * changes requested by @timdorr * export Context instead of just Consumer/Provider * fix error messages for removed functionality * minor displayName change * keep prop-types in production minified UMD build when the time is right, one need only change the BABEL_ENV for build:umd:min back to "rollup-production" * performance optimizations: HEADS UP API change too * React.forwardRef is VERY slow, on the order of 2x slower in our benchmark. So we only enable it if withRef="forwardRef" folks using withRef=true will get an error telling them to update and not rely on getWrappedInstance() but just to use the ref directly * renderCountProp is removed, as this is natively supported in React dev tools now * all usages of shallowEquals are removed for pure components, it was unnecessary. * instead of allowing passing in a custom Context consumer in props, it is now required to be passed in via connect options at declaration time. * small optimizations/refactors * fix storeKey error, allow unstable_observedBits * update hoist-non-react-statics * cosmetics * Replace `withRef="fowardRef"` option with `forwardRef=true` * Less package-lock.json noise * Bump React dep to 16.6, and remove shallow-equals * Switch context variable and prop naming, and use the whole object * Update Provider implementation and use storeState field * Rework Provider tests * Rework connect tests, combine warnings, and remove observedBits Ported connect test handling from 995 Deduped the "custom store context" messages Removed use of observedBits for now Commented out derivedProps propTypes that caused test failures * Rework connect component based on review notes Removed use of PureWrapper Used React.memo() on the wrapped component Renamed and extracted makeDerivedPropsSelector Added makeChildElementSelector Simplified render props callback Simplified forwardRef handling * Fix tests around custom context * Fix custom context as a prop usage * Fix lint warnings * Run through Prettier. Update lockfile. --- .travis.yml | 4 - package-lock.json | 160 ++--- package.json | 9 +- src/components/Context.js | 5 + src/components/Provider.js | 114 ++-- src/components/connectAdvanced.js | 328 +++++------ src/index.js | 5 +- src/utils/PropTypes.js | 14 - src/utils/Subscription.js | 87 --- test/components/Provider.spec.js | 331 +++++------ test/components/connect.spec.js | 937 ++++++++++++++++++------------ 11 files changed, 1033 insertions(+), 961 deletions(-) create mode 100644 src/components/Context.js delete mode 100644 src/utils/PropTypes.js delete mode 100644 src/utils/Subscription.js 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/) }) }) })