diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index b64c53d2..3b90c8d9 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -18,42 +18,127 @@ npm install --save react ### Usage -This package exports a symbol `withTracker`, which you can use to wrap your components with data returned from Tracker reactive functions. +This package provides two ways to use Tracker reactive data in your React components: +- a hook: `useTracker` (v2 only, requires React `^16.8`) +- a higher-order component (HOC): `withTracker` (v1 and v2). + +The `useTracker` hook, introduced in version 2.0.0, embraces the [benefits of hooks](https://reactjs.org/docs/hooks-faq.html). Like all React hooks, it can only be used in function components, not in class components. + +The `withTracker` HOC can be used with all components, function or class based. + +It is not necessary to rewrite existing applications to use the `useTracker` hook instead of the existing `withTracker` HOC. + +#### `useTracker(reactiveFn, deps)` hook + +You can use the `useTracker` hook to get the value of a Tracker reactive function in your (function) components. The reactive function will get re-run whenever its reactive inputs change, and the component will re-render with the new value. + +Arguments: +- `reactiveFn`: A Tracker reactive function (receives the current computation). +- `deps`: An optional array of "dependencies" of the reactive function. This is very similar to how the `deps` argument for [React's built-in `useEffect`, `useCallback` or `useMemo` hooks](https://reactjs.org/docs/hooks-reference.html) work. If omitted, the Tracker computation will be recreated on every render (Note: `withTracker` has always done this). If provided, the computation will be retained, and reactive updates after the first run will run asynchronously from the react render cycle. This array typically includes all variables from the outer scope "captured" in the closure passed as the 1st argument. For example, the value of a prop used in a subscription or a Minimongo query; see example below. + +```js +import { useTracker } from 'meteor/react-meteor-data'; + +// React function component. +function Foo({ listId }) { + // This computation uses no value from the outer scope, + // and thus does not needs to pass a 'deps' argument. + // However, we can optimize the use of the computation + // by providing an empty deps array. With it, the + // computation will be retained instead of torn down and + // rebuilt on every render. useTracker will produce the + // same results either way. + const currentUser = useTracker(() => Meteor.user(), []); + + // The following two computations both depend on the + // listId prop. When deps are specified, the computation + // will be retained. + const listLoading = useTracker(() => { + // Note that this subscription will get cleaned up + // when your component is unmounted or deps change. + const handle = Meteor.subscribe('todoList', listId); + return !handle.ready(); + }, [listId]); + const tasks = useTracker(() => Tasks.find({ listId }).fetch(), [listId]); + + return ( +

Hello {currentUser.username}

+ {listLoading ? +
Loading
: +
+ Here is the Todo list {listId}: + + Hello {currentUser.username} + {listLoading ? +
Loading
: +
+ Here is the Todo list {listId}: + + { - // Do all your reactive data access in this method. +export default withTracker(({ listId }) => { + // Do all your reactive data access in this function. // Note that this subscription will get cleaned up when your component is unmounted - const handle = Meteor.subscribe('todoList', props.id); + const handle = Meteor.subscribe('todoList', listId); return { currentUser: Meteor.user(), listLoading: !handle.ready(), - tasks: Tasks.find({ listId: props.id }).fetch(), + tasks: Tasks.find({ listId }).fetch(), }; })(Foo); ``` -The first argument to `withTracker` is a reactive function that will get re-run whenever its reactive inputs change. -The returned component will, when rendered, render `Foo` (the "lower-order" component) with its provided `props` in addition to the result of the reactive function. So `Foo` will receive `FooContainer`'s `props` as well as `{currentUser, listLoading, tasks}`. +The returned component will, when rendered, render `Foo` (the "lower-order" component) with its provided props in addition to the result of the reactive function. So `Foo` will receive `{ listId }` (provided by its parent) as well as `{ currentUser, listLoading, tasks }` (added by the `withTracker` HOC). For more information, see the [React article](http://guide.meteor.com/react.html) in the Meteor Guide. -### Note on `withTracker` and `createContainer` +### Concurrent Mode, Suspense and Error Boundaries -The new `withTracker` function replaces `createContainer` (however it remains for backwards compatibility). For `createContainer` usage, please [see prior documentation](https://github.com/meteor/react-packages/blob/ac251a6d6c2d0ddc22daad36a7484ef04b11862e/packages/react-meteor-data/README.md). The purpose of the new function is to better allow for container composability. For example when combining Meteor data with Redux and GraphQL: +There are some additional considerations to keep in mind when using Concurrent Mode, Suspense and Error Boundaries, as each of these can cause React to cancel and discard (toss) a render, including the result of the first run of your reactive function. One of the things React developers often stress is that we should not create "side-effects" directly in the render method or in functional components. There are a number of good reasons for this, including allowing the React runtime to cancel renders. Limiting the use of side-effects allows features such as concurrent mode, suspense and error boundaries to work deterministically, without leaking memory or creating rogue processes. Care should be taken to avoid side effects in your reactive function for these reasons. (Note: this caution does not apply to Meteor specific side-effects like subscriptions, since those will be automatically cleaned up when `useTracker`'s computation is disposed.) -```js -const FooWithAllTheThings = compose( - connect(...), // some Redux - graphql(...), // some GraphQL - withTracker(...), // some Tracker data -)(Foo); -``` +Ideally, side-effects such as creating a Meteor computation would be done in `useEffect`. However, this is problematic for Meteor, which mixes an initial data query with setting up the computation to watch those data sources all in one initial run. If we wait to do that in `useEffect`, we'll end up rendering a minimum of 2 times (and using hacks for the first one) for every component which uses `useTracker` or `withTracker`, or not running at all in the initial render and still requiring a minimum of 2 renders, and complicating the API. + +To work around this and keep things running fast, we are creating the computation in the render method directly, and doing a number of checks later in `useEffect` to make sure we keep that computation fresh and everything up to date, while also making sure to clean things up if we detect the render has been tossed. For the most part, this should all be transparent. + +The important thing to understand is that your reactive function can be initially called more than once for a single render, because sometimes the work will be tossed. Additionally, `useTracker` will not call your reactive function reactively until the render is committed (until `useEffect` runs). If you have a particularly fast changing data source, this is worth understanding. With this very short possible suspension, there are checks in place to make sure the eventual result is always up to date with the current state of the reactive function. Once the render is "committed", and the component mounted, the computation is kept running, and everything will run as expected. + +### Version compatibility notes + +- `react-meteor-data` v2.x : + - `useTracker` hook + `withTracker` HOC + - Requires React `^16.8`. + - Implementation is compatible with "React Suspense", concurrent mode and error boundaries. + - The `withTracker` HOC is strictly backwards-compatible with the one provided in v1.x, the major version number is only motivated by the bump of React version requirement. Provided a compatible React version, existing Meteor apps leveraging the `withTracker` HOC can freely upgrade from v1.x to v2.x, and gain compatibility with future React versions. + - The previously deprecated `createContainer` has been removed. + +- `react-meteor-data` v1.x / v0.x : + - `withTracker` HOC (+ `createContainer`, kept for backwards compatibility with early v0.x releases) + - Requires React `^15.3` or `^16.0`. + - Implementation relies on React lifecycle methods (`componentWillMount` / `componentWillUpdate`) that are [marked for deprecation in future React versions](https://reactjs.org/blog/2018/03/29/react-v-16-3.html#component-lifecycle-changes) ("React Suspense"). diff --git a/packages/react-meteor-data/ReactMeteorData.jsx b/packages/react-meteor-data/ReactMeteorData.jsx deleted file mode 100644 index c987f382..00000000 --- a/packages/react-meteor-data/ReactMeteorData.jsx +++ /dev/null @@ -1,188 +0,0 @@ -/* global Package */ -/* eslint-disable react/prefer-stateless-function */ - -import React from 'react'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -// A class to keep the state and utility methods needed to manage -// the Meteor data for a component. -class MeteorDataManager { - constructor(component) { - this.component = component; - this.computation = null; - this.oldData = null; - } - - dispose() { - if (this.computation) { - this.computation.stop(); - this.computation = null; - } - } - - calculateData() { - const component = this.component; - - if (!component.getMeteorData) { - return null; - } - - // When rendering on the server, we don't want to use the Tracker. - // We only do the first rendering on the server so we can get the data right away - if (Meteor.isServer) { - return component.getMeteorData(); - } - - if (this.computation) { - this.computation.stop(); - this.computation = null; - } - - let data; - // Use Tracker.nonreactive in case we are inside a Tracker Computation. - // This can happen if someone calls `ReactDOM.render` inside a Computation. - // In that case, we want to opt out of the normal behavior of nested - // Computations, where if the outer one is invalidated or stopped, - // it stops the inner one. - this.computation = Tracker.nonreactive(() => ( - Tracker.autorun((c) => { - if (c.firstRun) { - const savedSetState = component.setState; - try { - component.setState = () => { - throw new Error( - 'Can\'t call `setState` inside `getMeteorData` as this could ' - + 'cause an endless loop. To respond to Meteor data changing, ' - + 'consider making this component a \"wrapper component\" that ' - + 'only fetches data and passes it in as props to a child ' - + 'component. Then you can use `componentWillReceiveProps` in ' - + 'that child component.'); - }; - - data = component.getMeteorData(); - } finally { - component.setState = savedSetState; - } - } else { - // Stop this computation instead of using the re-run. - // We use a brand-new autorun for each call to getMeteorData - // to capture dependencies on any reactive data sources that - // are accessed. The reason we can't use a single autorun - // for the lifetime of the component is that Tracker only - // re-runs autoruns at flush time, while we need to be able to - // re-call getMeteorData synchronously whenever we want, e.g. - // from componentWillUpdate. - c.stop(); - // Calling forceUpdate() triggers componentWillUpdate which - // recalculates getMeteorData() and re-renders the component. - component.forceUpdate(); - } - }) - )); - - if (Package.mongo && Package.mongo.Mongo) { - Object.keys(data).forEach((key) => { - if (data[key] instanceof Package.mongo.Mongo.Cursor) { - console.warn( - 'Warning: you are returning a Mongo cursor from getMeteorData. ' - + 'This value will not be reactive. You probably want to call ' - + '`.fetch()` on the cursor before returning it.' - ); - } - }); - } - - return data; - } - - updateData(newData) { - const component = this.component; - const oldData = this.oldData; - - if (!(newData && (typeof newData) === 'object')) { - throw new Error('Expected object returned from getMeteorData'); - } - // update componentData in place based on newData - for (let key in newData) { - component.data[key] = newData[key]; - } - // if there is oldData (which is every time this method is called - // except the first), delete keys in newData that aren't in - // oldData. don't interfere with other keys, in case we are - // co-existing with something else that writes to a component's - // this.data. - if (oldData) { - for (let key in oldData) { - if (!(key in newData)) { - delete component.data[key]; - } - } - } - this.oldData = newData; - } -} - -export const ReactMeteorData = { - componentWillMount() { - this.data = {}; - this._meteorDataManager = new MeteorDataManager(this); - const newData = this._meteorDataManager.calculateData(); - this._meteorDataManager.updateData(newData); - }, - - componentWillUpdate(nextProps, nextState) { - const saveProps = this.props; - const saveState = this.state; - let newData; - try { - // Temporarily assign this.state and this.props, - // so that they are seen by getMeteorData! - // This is a simulation of how the proposed Observe API - // for React will work, which calls observe() after - // componentWillUpdate and after props and state are - // updated, but before render() is called. - // See https://github.com/facebook/react/issues/3398. - this.props = nextProps; - this.state = nextState; - newData = this._meteorDataManager.calculateData(); - } finally { - this.props = saveProps; - this.state = saveState; - } - - this._meteorDataManager.updateData(newData); - }, - - componentWillUnmount() { - this._meteorDataManager.dispose(); - }, -}; - -class ReactComponent extends React.Component {} -Object.assign(ReactComponent.prototype, ReactMeteorData); -class ReactPureComponent extends React.PureComponent {} -Object.assign(ReactPureComponent.prototype, ReactMeteorData); - -export default function connect(options) { - let expandedOptions = options; - if (typeof options === 'function') { - expandedOptions = { - getMeteorData: options, - }; - } - - const { getMeteorData, pure = true } = expandedOptions; - - const BaseComponent = pure ? ReactPureComponent : ReactComponent; - return (WrappedComponent) => ( - class ReactMeteorDataComponent extends BaseComponent { - getMeteorData() { - return getMeteorData(this.props); - } - render() { - return ; - } - } - ); -} diff --git a/packages/react-meteor-data/createContainer.jsx b/packages/react-meteor-data/createContainer.jsx deleted file mode 100644 index c413362d..00000000 --- a/packages/react-meteor-data/createContainer.jsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Container helper using react-meteor-data. - */ - -import { Meteor } from 'meteor/meteor'; -import React from 'react'; -import connect from './ReactMeteorData.jsx'; - -let hasDisplayedWarning = false; - -export default function createContainer(options, Component) { - if (!hasDisplayedWarning && Meteor.isDevelopment) { - console.warn( - 'Warning: createContainer was deprecated in react-meteor-data@0.2.13. Use withTracker instead.\n' + - 'https://github.com/meteor/react-packages/tree/devel/packages/react-meteor-data#usage', - ); - hasDisplayedWarning = true; - } - - return connect(options)(Component); -} diff --git a/packages/react-meteor-data/index.js b/packages/react-meteor-data/index.js new file mode 100644 index 00000000..05efe9f6 --- /dev/null +++ b/packages/react-meteor-data/index.js @@ -0,0 +1,12 @@ +/* global Meteor*/ +import React from 'react'; + +if (Meteor.isDevelopment) { + const v = React.version.split('.'); + if (v[0] < 16 || v[1] < 8) { + console.warn('react-meteor-data 2.x requires React version >= 16.8.'); + } +} + +export { default as useTracker } from './useTracker'; +export { default as withTracker } from './withTracker.jsx'; diff --git a/packages/react-meteor-data/package-lock.json b/packages/react-meteor-data/package-lock.json new file mode 100644 index 00000000..a256c256 --- /dev/null +++ b/packages/react-meteor-data/package-lock.json @@ -0,0 +1,256 @@ +{ + "name": "react-meteor-data", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "@jest/types": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.8.0.tgz", + "integrity": "sha512-g17UxVr2YfBtaMUxn9u/4+siG1ptg9IGYAYwvpwn61nBg779RXnjE/m7CxYcIzEt0AbHZZAHSEZNhkE2WxURVg==", + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^12.0.9" + } + }, + "@sheerun/mutationobserver-shim": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz", + "integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==" + }, + "@testing-library/dom": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-5.6.1.tgz", + "integrity": "sha512-Y1T2bjtvQMewffn1CJ28kpgnuvPYKsBcZMagEH0ppfEMZPDc8AkkEnTk4smrGZKw0cblNB3lhM2FMnpfLExlHg==", + "requires": { + "@babel/runtime": "^7.5.5", + "@sheerun/mutationobserver-shim": "^0.3.2", + "aria-query": "3.0.0", + "pretty-format": "^24.8.0", + "wait-for-expect": "^1.2.0" + } + }, + "@testing-library/react": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-8.0.7.tgz", + "integrity": "sha512-6XoeWSr3UCdxMswbkW0BmuXYw8a6w+stt+5gg4D4zAcljfhXETQ5o28bjJFwNab4OPg8gBNK8KIVot86L4Q8Vg==", + "requires": { + "@babel/runtime": "^7.5.4", + "@testing-library/dom": "^5.5.4" + } + }, + "@testing-library/react-hooks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-1.1.0.tgz", + "integrity": "sha512-piE/ceQoNf134FFVXBABDbttBJ8eLPD4eg7zIciVJv92RyvoIsBHCvvG8Vd4IG5pyuWYrkLsZTO8ucZBwa4twA==", + "requires": { + "@babel/runtime": "^7.4.2", + "@types/react": "^16.8.22", + "@types/react-test-renderer": "^16.8.2" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==" + }, + "@types/istanbul-lib-report": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz", + "integrity": "sha512-3BUTyMzbZa2DtDI2BkERNC6jJw2Mr2Y0oGI7mRxYNBPxppbtEK1F66u3bKwU2g+wxwWI7PAoRpJnOY1grJqzHg==", + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz", + "integrity": "sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==", + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "@types/prop-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.1.tgz", + "integrity": "sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg==" + }, + "@types/react": { + "version": "16.8.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.23.tgz", + "integrity": "sha512-abkEOIeljniUN9qB5onp++g0EY38h7atnDHxwKUFz1r3VH1+yG1OKi2sNPTyObL40goBmfKFpdii2lEzwLX1cA==", + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-test-renderer": { + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.8.2.tgz", + "integrity": "sha512-cm42QR9S9V3aOxLh7Fh7PUqQ8oSfSdnSni30T7TiTmlKvE6aUlo+LhQAzjnZBPriD9vYmgG8MXI8UDK4Nfb7gg==", + "requires": { + "@types/react": "*" + } + }, + "@types/yargs": { + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz", + "integrity": "sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw==" + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "aria-query": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz", + "integrity": "sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w=", + "requires": { + "ast-types-flow": "0.0.7", + "commander": "^2.11.0" + } + }, + "ast-types-flow": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=" + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "commander": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==" + }, + "csstype": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.6.tgz", + "integrity": "sha512-RpFbQGUE74iyPgvr46U9t1xoQBM8T4BL8SxrN66Le2xYAPSaDJJKeztV3awugusb3g3G9iL8StmkBBXhcbbXhg==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "pretty-format": { + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.8.0.tgz", + "integrity": "sha512-P952T7dkrDEplsR+TuY7q3VXDae5Sr7zmQb12JU/NDQa/3CH7/QW0yvqLcGN6jL+zQFKaoJcPc+yJxMTGmosqw==", + "requires": { + "@jest/types": "^24.8.0", + "ansi-regex": "^4.0.0", + "ansi-styles": "^3.2.0", + "react-is": "^16.8.4" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "react": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", + "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-dom": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", + "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" + }, + "react-test-renderer": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", + "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.13.6" + } + }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + }, + "scheduler": { + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", + "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "wait-for-expect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-1.2.0.tgz", + "integrity": "sha512-EJhKpA+5UHixduMBEGhTFuLuVgQBKWxkFbefOdj2bbk2/OpA5Opsc4aUTGmF+qJ+v3kTGxDRNYwKaT4j6g5n8Q==" + } + } +} diff --git a/packages/react-meteor-data/package.js b/packages/react-meteor-data/package.js index 83b59714..b50c6313 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -1,3 +1,5 @@ +/* global Package */ + Package.describe({ name: 'react-meteor-data', summary: 'React higher-order component for reactively tracking Meteor data', @@ -10,9 +12,13 @@ Package.onUse(function (api) { api.versionsFrom('1.3'); api.use('tracker'); api.use('ecmascript'); - api.use('tmeasday:check-npm-versions@0.3.2'); - api.export(['ReactMeteorData']); + api.mainModule('index.js'); +}); - api.mainModule('react-meteor-data.jsx'); +Package.onTest(function (api) { + api.use(['ecmascript', 'reactive-dict', 'reactive-var', 'tracker', 'tinytest', 'underscore', 'mongo']); + api.use('test-helpers'); + api.use('react-meteor-data'); + api.mainModule('tests.js'); }); diff --git a/packages/react-meteor-data/package.json b/packages/react-meteor-data/package.json new file mode 100644 index 00000000..3f489f9c --- /dev/null +++ b/packages/react-meteor-data/package.json @@ -0,0 +1,10 @@ +{ + "name": "react-meteor-data", + "dependencies": { + "react": "16.8.6", + "react-dom": "16.8.6", + "react-test-renderer": "16.8.6", + "@testing-library/react": "^8.0.7", + "@testing-library/react-hooks": "1.1.0" + } +} diff --git a/packages/react-meteor-data/react-meteor-data.jsx b/packages/react-meteor-data/react-meteor-data.jsx deleted file mode 100644 index 0c0a9e30..00000000 --- a/packages/react-meteor-data/react-meteor-data.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; - -checkNpmVersions({ - react: '15.3 - 16', -}, 'react-meteor-data'); - -export { default as createContainer } from './createContainer.jsx'; -export { default as withTracker } from './ReactMeteorData.jsx'; -export { ReactMeteorData } from './ReactMeteorData.jsx'; diff --git a/packages/react-meteor-data/tests.js b/packages/react-meteor-data/tests.js new file mode 100644 index 00000000..e444261a --- /dev/null +++ b/packages/react-meteor-data/tests.js @@ -0,0 +1,2 @@ +import './useTracker.tests.js'; +import './withTracker.tests.js'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js new file mode 100644 index 00000000..ca6edc7b --- /dev/null +++ b/packages/react-meteor-data/useTracker.js @@ -0,0 +1,208 @@ +/* global Meteor, Package, Tracker */ +import React, { useReducer, useEffect, useRef, useMemo } from 'react'; + +// Use React.warn() if available (should ship in React 16.9). +const warn = React.warn || console.warn.bind(console); + +// Warns if data is a Mongo.Cursor or a POJO containing a Mongo.Cursor. +function checkCursor(data) { + let shouldWarn = false; + if (Package.mongo && Package.mongo.Mongo && data && typeof data === 'object') { + if (data instanceof Package.mongo.Mongo.Cursor) { + shouldWarn = true; + } else if (Object.getPrototypeOf(data) === Object.prototype) { + Object.keys(data).forEach((key) => { + if (data[key] instanceof Package.mongo.Mongo.Cursor) { + shouldWarn = true; + } + }); + } + } + if (shouldWarn) { + warn( + 'Warning: your reactive function is returning a Mongo cursor. ' + + 'This value will not be reactive. You probably want to call ' + + '`.fetch()` on the cursor before returning it.' + ); + } +} + +// Used to create a forceUpdate from useReducer. Forces update by +// incrementing a number whenever the dispatch method is invoked. +const fur = x => x + 1; + +// The follow functions were hoisted out of the closure to reduce allocations. +// Since they no longer have access to the local vars, we pass them in and mutate here. +/* eslint-disable no-param-reassign */ +const dispose = (refs) => { + if (refs.computationCleanup) { + refs.computationCleanup(); + delete refs.computationCleanup; + } + if (refs.computation) { + refs.computation.stop(); + refs.computation = null; + } +}; +const runReactiveFn = Meteor.isDevelopment + ? (refs, c) => { + const data = refs.reactiveFn(c); + checkCursor(data); + refs.trackerData = data; + } + : (refs, c) => { + refs.trackerData = refs.reactiveFn(c); + }; +const clear = (refs) => { + if (refs.disposeId) { + clearTimeout(refs.disposeId); + delete refs.disposeId; + } +}; +const tracked = (refs, c, forceUpdate) => { + if (c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (refs.computationHandler) { + const cleanupHandler = refs.computationHandler(c); + if (cleanupHandler) { + if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { + warn( + 'Warning: Computation handler should return a function ' + + 'to be used for cleanup or return nothing.' + ); + } + refs.computationCleanup = cleanupHandler; + } + } + // Always run the reactiveFn on firstRun + runReactiveFn(refs, c); + } else { + // If deps are anything other than an array, stop computation and let next render + // handle reactiveFn. These null and undefined checks are optimizations to avoid + // calling Array.isArray in these cases. + if (refs.deps === null || refs.deps === undefined || !Array.isArray(refs.deps)) { + dispose(refs); + forceUpdate(); + } else if (refs.isMounted) { + // Only run the reactiveFn if the component is mounted. + runReactiveFn(refs, c); + forceUpdate(); + } else { + // If we got here, then a reactive update happened before the render was + // committed - before useEffect has run. We don't want to run the reactiveFn + // while we are not sure this render will be committed, so we'll dispose of the + // computation, and set everything up to be restarted in useEffect if needed. + // NOTE: If we don't run the user's reactiveFn when a computation updates, we'll + // leave the computation in a non-reactive state - so we'll dispose here and let + // the useEffect hook recreate the computation later. + dispose(refs); + // Might as well clear the timeout! + clear(refs); + } + } +}; +/* eslint-enable no-param-reassign */ + +function useTrackerClient(reactiveFn, deps, computationHandler) { + const { current: refs } = useRef({}); + const [, forceUpdate] = useReducer(fur, 0); + + // Always have up to date deps and computations in all contexts + refs.reactiveFn = reactiveFn; + refs.deps = deps; + refs.computationHandler = computationHandler; + + // We are abusing useMemo a little bit, using it for it's deps + // compare, but not for it's memoization. + useMemo(() => { + // if we are re-creating the computation, we need to stop the old one. + dispose(refs); + + // Use Tracker.nonreactive in case we are inside a Tracker Computation. + // This can happen if someone calls `ReactDOM.render` inside a Computation. + // In that case, we want to opt out of the normal behavior of nested + // Computations, where if the outer one is invalidated or stopped, + // it stops the inner one. + refs.computation = Tracker.nonreactive(() => + Tracker.autorun((c) => { + tracked(refs, c, forceUpdate); + }) + ); + + // We are creating a side effect in render, which can be problematic in some cases, such as + // Suspense or concurrent rendering or if an error is thrown and handled by an error boundary. + // We still want synchronous rendering for a number of reason (see readme), so we work around + // possible memory/resource leaks by setting a time out to automatically clean everything up, + // and watching a set of references to make sure everything is choreographed correctly. + if (!refs.isMounted) { + // Components yield to allow the DOM to update and the browser to paint before useEffect + // is run. In concurrent mode this can take quite a long time, so we set a 1000ms timeout + // to allow for that. + refs.disposeId = setTimeout(() => { + if (!refs.isMounted) { + dispose(refs); + } + }, 1000); + } + }, deps); + + useEffect(() => { + // Now that we are mounted, we can set the flag, and cancel the timeout + refs.isMounted = true; + + // We are committed, clear the dispose timeout + clear(refs); + + // If it took longer than 1000ms to get to useEffect, or a reactive update happened + // before useEffect, we will need to forceUpdate, and restart the computation. + if (!refs.computation) { + // If we have deps, we need to set up a new computation before forcing update. + // If we have NO deps, it'll be recreated and rerun on the next render. + if (Array.isArray(deps)) { + // This also runs runReactiveFn + refs.computation = Tracker.nonreactive(() => + Tracker.autorun((c) => { + tracked(refs, c, forceUpdate); + }) + ); + } + forceUpdate(); + } + + // stop the computation on unmount + return () => dispose(refs); + }, []); + + return refs.trackerData; +} + +// When rendering on the server, we don't want to use the Tracker. +// We only do the first rendering on the server so we can get the data right away +const useTracker = Meteor.isServer + ? (reactiveFn) => Tracker.nonreactive(reactiveFn) + : useTrackerClient; + +export default Meteor.isDevelopment + ? (reactiveFn, deps, computationHandler) => { + if (typeof reactiveFn !== 'function') { + warn( + 'Warning: useTracker expected a function in it\'s first argument ' + + `(reactiveFn), but got type of ${typeof reactiveFn}.` + ); + } + if (deps && !Array.isArray(deps)) { + warn( + 'Warning: useTracker expected an array in it\'s second argument ' + + `(dependency), but got type of ${typeof deps}.` + ); + } + if (computationHandler && typeof computationHandler !== 'function') { + warn( + 'Warning: useTracker expected a function in it\'s third argument' + + `(computationHandler), but got type of ${typeof computationHandler}.` + ); + } + return useTracker(reactiveFn, deps, computationHandler); + } + : useTracker; diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js new file mode 100644 index 00000000..2bd3ae16 --- /dev/null +++ b/packages/react-meteor-data/useTracker.tests.js @@ -0,0 +1,551 @@ +/* global Meteor, Tinytest */ +import React, { Suspense, useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; + +import { renderHook, act } from '@testing-library/react-hooks'; +import { render, cleanup, waitForDomChange } from '@testing-library/react' +import { ReactiveDict } from 'meteor/reactive-dict'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import useTracker from './useTracker'; + +Tinytest.add('useTracker - no deps', async function (test) { + const reactiveDict = new ReactiveDict(); + let runCount = 0; + let computation; + let createdCount = 0; + let destroyedCount = 0; + + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + ({ name }) => useTracker(() => { + runCount++; + reactiveDict.setDefault(name, 'initial'); + return reactiveDict.get(name); + }, null, (c) => { + test.isFalse(c === computation, 'The new computation should always be a new instance'); + computation = c; + createdCount++; + return () => { + destroyedCount++; + } + }), + { initialProps: { name: 'key' } } + ); + + test.equal(result.current, 'initial', 'Expect initial value to be "initial"'); + test.equal(runCount, 1, 'Should have run 1 times'); + if (Meteor.isClient) { + test.equal(createdCount, 1, 'Should have been created 1 times'); + test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + } + + act(() => { + reactiveDict.set('key', 'changed'); + Tracker.flush({_throwFirstError: true}); + }); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect new value to be "changed"'); + test.equal(runCount, 2, 'Should have run 2 times'); + if (Meteor.isClient) { + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 1, 'Should have been destroyed 1 less than created'); + } + + rerender(); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect value of "changed" to persist after rerender'); + test.equal(runCount, 3, 'Should have run 3 times'); + if (Meteor.isClient) { + test.equal(createdCount, 3, 'Should have been created 3 times'); + test.equal(destroyedCount, 2, 'Should have been destroyed 1 less than created'); + } + + rerender({ name: 'different' }); + await waitForNextUpdate(); + + test.equal(result.current, 'default', 'After deps change, the default value should have returned'); + test.equal(runCount, 4, 'Should have run 4 times'); + if (Meteor.isClient) { + test.equal(createdCount, 4, 'Should have been created 4 times'); + test.equal(destroyedCount, 3, 'Should have been destroyed 1 less than created'); + } + + unmount(); + test.equal(runCount, 4, 'Unmount should not cause a tracker run'); + if (Meteor.isClient) { + test.equal(createdCount, 4, 'Should have been created 4 times'); + test.equal(destroyedCount, 4, 'Should have been destroyed the same number of times as created'); + } + + act(() => { + reactiveDict.set('different', 'changed again'); + Tracker.flush({_throwFirstError: true}); + }); + // we can't use await waitForNextUpdate() here because it doesn't trigger re-render - is there a way to test that? + + test.equal(result.current, 'default', 'After unmount, changes to the reactive source should not update the value.'); + test.equal(runCount, 4, 'After unmount, useTracker should no longer be tracking'); + if (Meteor.isClient) { + test.equal(createdCount, 4, 'Should have been created 4 times'); + test.equal(destroyedCount, 4, 'Should have been destroyed the same number of times as created'); + } + + reactiveDict.destroy(); +}); + +Tinytest.add('useTracker - with deps', async function (test) { + const reactiveDict = new ReactiveDict(); + let runCount = 0; + let computation; + let createdCount = 0; + let destroyedCount = 0; + + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + ({ name }) => useTracker(() => { + runCount++; + reactiveDict.setDefault(name, 'default'); + return reactiveDict.get(name); + }, [name], (c) => { + test.isFalse(c === computation, 'The new computation should always be a new instance'); + computation = c; + createdCount++; + return () => { + destroyedCount++; + } + }), + { initialProps: { name: 'name' } } + ); + + test.equal(result.current, 'default', 'Expect the default value for given name to be "default"'); + test.equal(runCount, 1, 'Should have run 1 times'); + if (Meteor.isClient) { + test.equal(createdCount, 1, 'Should have been created 1 times'); + test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + } + + act(() => { + reactiveDict.set('name', 'changed'); + Tracker.flush({_throwFirstError: true}); + }); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect the new value for given name to be "changed"'); + test.equal(runCount, 2, 'Should have run 2 times'); + if (Meteor.isClient) { + test.equal(createdCount, 1, 'Should have been created 1 times'); + test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + } + + rerender(); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect the new value "changed" for given name to have persisted through render'); + test.equal(runCount, 3, 'Should have run 3 times'); + if (Meteor.isClient) { + test.equal(createdCount, 1, 'Should have been created 1 times'); + test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + } + + rerender({ name: 'different' }); + await waitForNextUpdate(); + + test.equal(result.current, 'default', 'After deps change, the default value should have returned'); + test.equal(runCount, 4, 'Should have run 4 times'); + if (Meteor.isClient) { + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 1, 'Should have been destroyed 1 times'); + } + + unmount(); + // we can't use await waitForNextUpdate() here because it doesn't trigger re-render - is there a way to test that? + test.equal(runCount, 4, 'Unmount should not cause a tracker run'); + if (Meteor.isClient) { + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); + } + + act(() => { + reactiveDict.set('different', 'changed again'); + Tracker.flush({_throwFirstError: true}); + }); + + test.equal(result.current, 'default', 'After unmount, changes to the reactive source should not update the value.'); + test.equal(runCount, 4, 'After unmount, useTracker should no longer be tracking'); + if (Meteor.isClient) { + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); + } + + reactiveDict.destroy(); +}); + +const getInnerHtml = function (elem) { + // clean up elem.innerHTML and strip data-reactid attributes too + return canonicalizeHtml(elem.innerHTML).replace(/ data-reactroot=".*?"/g, ''); +}; + +if (Meteor.isClient) { + Tinytest.addAsync('useTracker - suspense, no deps', async function (test) { + const x = new ReactiveVar(0); + + const cache = { value: 0 }; + const timeout = time => new Promise(resolve => setTimeout(resolve, time)); + const wait = async () => { + await timeout(100); + cache.value = 1; + return cache.value; + }; + + // this simulates a bunch of outside reactive activity + let i = 0; + const iId = setInterval(() => { + x.set(++i); + }, 2); + setTimeout(() => { + clearInterval(iId); + }, 200); + + let reactiveCount = 0; + let renderCount = 0; + const Loading = () => loading + const Foo = () => { + const data = useTracker(() => { + return { + x: x.get() + }; + }); + + const { value } = cache; + + if (!value) { + throw wait(); + } + + return complete; + }; + + var { getByText, container } = render(}>); + test.isTrue(getByText('loading')); + + test.equal(getInnerHtml(container), 'loading', 'When suspended on first render, loading text should be displayed.'); + await waitForDomChange({ container, timeout: 250 }); + + test.equal(getInnerHtml(container), 'complete', 'Once the thrown promise resoles, we should have the complete content.'); + + cleanup(); + }); + + Tinytest.add('useTracker - basic track', function (test) { + var div = document.createElement("DIV"); + + var x = new ReactiveVar('aaa'); + + var Foo = () => { + const data = useTracker(() => { + return { + x: x.get() + }; + }) + return {data.x}; + }; + + ReactDOM.render(, div); + test.equal(getInnerHtml(div), 'aaa'); + + x.set('bbb'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'bbb'); + + test.equal(x._numListeners(), 1); + + ReactDOM.unmountComponentAtNode(div); + + test.equal(x._numListeners(), 0); + }); + + // Make sure that calling ReactDOM.render() from an autorun doesn't + // associate that autorun with the mixin's autorun. When autoruns are + // nested, invalidating the outer one stops the inner one, unless + // Tracker.nonreactive is used. This test tests for the use of + // Tracker.nonreactive around the mixin's autorun. + Tinytest.add('useTracker - render in autorun', function (test) { + var div = document.createElement("DIV"); + + var x = new ReactiveVar('aaa'); + + var Foo = () => { + const data = useTracker(() => { + return { + x: x.get() + }; + }); + return {data.x}; + }; + + Tracker.autorun(function (c) { + ReactDOM.render(, div); + // Stopping this autorun should not affect the mixin's autorun. + c.stop(); + }); + test.equal(getInnerHtml(div), 'aaa'); + + x.set('bbb'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'bbb'); + + ReactDOM.unmountComponentAtNode(div); + }); + + Tinytest.add('useTracker - track based on props and state', function (test) { + var xs = [new ReactiveVar('aaa'), + new ReactiveVar('bbb'), + new ReactiveVar('ccc')]; + + let setState; + var Foo = (props) => { + const [state, _setState] = useState({ m: 0 }); + setState = _setState; + const data = useTracker(() => { + return { + x: xs[state.m + props.n].get() + }; + }); + return {data.x}; + }; + + var { getByText } = render(); + + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in initial render'); + xs[0].set('AAA'); + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in the dom, since we haven’t flushed yet'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('AAA'), 'Content should still be “AAA” in the dom after Tracker flush'); + + cleanup(); + var { getByText } = render(); + + test.isTrue(getByText('bbb')); + xs[1].set('BBB'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('BBB')); + + setState({m: 1}); + + test.isTrue(getByText('ccc')); + xs[2].set('CCC'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('CCC')); + + cleanup(); + var { getByText } = render(); + + setState({m: 0}); + test.isTrue(getByText('AAA')); + + cleanup(); + }); + + Tinytest.add('useTracker - track based on props and state (with deps)', function (test) { + var xs = [new ReactiveVar('aaa'), + new ReactiveVar('bbb'), + new ReactiveVar('ccc')]; + + let setState; + var Foo = (props) => { + const [state, _setState] = useState({ m: 0 }); + setState = _setState; + const data = useTracker(() => { + return { + x: xs[state.m + props.n].get() + }; + }, [state.m, props.n]); + return {data.x}; + }; + + var { getByText } = render(); + + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in initial render'); + xs[0].set('AAA'); + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in the dom, since we haven’t flushed yet'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('AAA'), 'Content should still be “AAA” in the dom after Tracker flush'); + + cleanup(); + var { getByText } = render(); + + test.isTrue(getByText('bbb')); + xs[1].set('BBB'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('BBB')); + + setState({m: 1}); + test.isTrue(getByText('ccc')); + xs[2].set('CCC'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('CCC')); + + cleanup(); + var { getByText } = render(); + + setState({m: 0}); + test.isTrue(getByText('AAA')); + + cleanup(); + }); + + function waitFor(func, callback) { + Tracker.autorun(function (c) { + if (func()) { + c.stop(); + callback(); + } + }); + }; + + testAsyncMulti('useTracker - resubscribe', [ + function (test, expect) { + var self = this; + self.div = document.createElement("DIV"); + self.collection = new Mongo.Collection("useTracker-mixin-coll"); + self.num = new ReactiveVar(1); + self.someOtherVar = new ReactiveVar('foo'); + self.Foo = () => { + const data = useTracker(() => { + self.handle = + Meteor.subscribe("useTracker-mixin-sub", + self.num.get()); + + return { + v: self.someOtherVar.get(), + docs: self.collection.find().fetch() + }; + }); + self.data = data; + return
{ + _.map(data.docs, (doc) => {doc._id}) + }
; + }; + + self.component = ReactDOM.render(, self.div); + test.equal(getInnerHtml(self.div), '
'); + + var handle = self.handle; + test.isFalse(handle.ready()); + + waitFor(() => handle.ready(), + expect()); + }, + function (test, expect) { + var self = this; + test.isTrue(self.handle.ready()); + test.equal(getInnerHtml(self.div), '
id1
'); + + self.someOtherVar.set('bar'); + self.oldHandle1 = self.handle; + + // can't call Tracker.flush() here (we are in a Tracker.flush already) + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + var oldHandle = self.oldHandle1; + var newHandle = self.handle; + test.notEqual(oldHandle, newHandle); // new handle + test.equal(newHandle.subscriptionId, oldHandle.subscriptionId); // same sub + test.isTrue(newHandle.ready()); // doesn't become unready + // no change to the content + test.equal(getInnerHtml(self.div), '
id1
'); + + // ok, now change the `num` argument to the subscription + self.num.set(2); + self.oldHandle2 = newHandle; + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + // data is still there + test.equal(getInnerHtml(self.div), '
id1
'); + // handle is no longer ready + var handle = self.handle; + test.isFalse(handle.ready()); + // different sub ID + test.isTrue(self.oldHandle2.subscriptionId); + test.isTrue(handle.subscriptionId); + test.notEqual(handle.subscriptionId, self.oldHandle2.subscriptionId); + + waitFor(() => handle.ready(), + expect()); + }, + function (test, expect) { + var self = this; + // now we see the new data! (and maybe the old data, because + // when a subscription goes away, its data doesn't disappear right + // away; the server has to tell the client which documents or which + // properties to remove, and this is not easy to wait for either; see + // https://github.com/meteor/meteor/issues/2440) + test.equal(getInnerHtml(self.div).replace('id1', ''), + '
id2
'); + + self.someOtherVar.set('baz'); + self.oldHandle3 = self.handle; + + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + test.equal(self.data.v, 'baz'); + test.notEqual(self.oldHandle3, self.handle); + test.equal(self.oldHandle3.subscriptionId, + self.handle.subscriptionId); + test.isTrue(self.handle.ready()); + }, + function (test, expect) { + ReactDOM.unmountComponentAtNode(this.div); + // break out of flush time, so we don't call the test's + // onComplete from within Tracker.flush + Meteor.defer(expect()); + } + ]); + + // Tinytest.add( + // "useTracker - print warning if return cursor from useTracker", + // function (test) { + // var coll = new Mongo.Collection(null); + // var ComponentWithCursor = () => { + // useTracker(() => { + // return { + // theCursor: coll.find() + // }; + // }); + // return ; + // }; + + // // Check if we print a warning to console about props + // // You can be sure this test is correct because we have an identical one in + // // react-runtime-dev + // let warning; + // try { + // var oldWarn = console.warn; + // console.warn = function specialWarn(message) { + // warning = message; + // }; + + // var div = document.createElement("DIV"); + // ReactDOM.render(, div); + + // test.matches(warning, /cursor before returning it/); + // } finally { + // console.warn = oldWarn; + // } + // }); + +} else { + Meteor.publish("useTracker-mixin-sub", function (num) { + Meteor.defer(() => { // because subs are blocking + this.added("useTracker-mixin-coll", 'id'+num, {}); + this.ready(); + }); + }); +} diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx new file mode 100644 index 00000000..e6e6a9f2 --- /dev/null +++ b/packages/react-meteor-data/withTracker.jsx @@ -0,0 +1,16 @@ +import React, { forwardRef, memo } from 'react'; +import useTracker from './useTracker.js'; + +export default function withTracker(options) { + return Component => { + const expandedOptions = typeof options === 'function' ? { getMeteorData: options } : options; + const { getMeteorData, pure = true } = expandedOptions; + + const WithTracker = forwardRef((props, ref) => { + const data = useTracker(() => getMeteorData(props) || {}); + return ; + }); + + return pure ? memo(WithTracker) : WithTracker; + }; +} diff --git a/packages/react-meteor-data/withTracker.tests.js b/packages/react-meteor-data/withTracker.tests.js new file mode 100644 index 00000000..c4e44ccb --- /dev/null +++ b/packages/react-meteor-data/withTracker.tests.js @@ -0,0 +1,335 @@ +/* global Tinytest */ +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; + +import { renderHook, act } from '@testing-library/react-hooks'; +import { ReactiveDict } from 'meteor/reactive-dict'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import withTracker from './withTracker'; + +const getInnerHtml = function (elem) { + // clean up elem.innerHTML and strip data-reactid attributes too + return canonicalizeHtml(elem.innerHTML).replace(/ data-reactroot=".*?"/g, ''); +}; + +if (Meteor.isClient) { + Tinytest.add('withTracker - basic track', function (test) { + var div = document.createElement("DIV"); + + var x = new ReactiveVar('aaa'); + + var Foo = withTracker(() => { + return { + x: x.get() + }; + })((props) => { + return {props.x}; + }); + + ReactDOM.render(, div); + test.equal(getInnerHtml(div), 'aaa'); + + x.set('bbb'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'bbb'); + + test.equal(x._numListeners(), 1); + + ReactDOM.unmountComponentAtNode(div); + + test.equal(x._numListeners(), 0); + }); + + // Make sure that calling ReactDOM.render() from an autorun doesn't + // associate that autorun with the mixin's autorun. When autoruns are + // nested, invalidating the outer one stops the inner one, unless + // Tracker.nonreactive is used. This test tests for the use of + // Tracker.nonreactive around the mixin's autorun. + Tinytest.add('withTracker - render in autorun', function (test) { + var div = document.createElement("DIV"); + + var x = new ReactiveVar('aaa'); + + var Foo = withTracker(() => { + return { + x: x.get() + }; + })((props) => { + return {props.x}; + }); + + Tracker.autorun(function (c) { + ReactDOM.render(, div); + // Stopping this autorun should not affect the mixin's autorun. + c.stop(); + }); + test.equal(getInnerHtml(div), 'aaa'); + + x.set('bbb'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'bbb'); + + ReactDOM.unmountComponentAtNode(div); + }); + + Tinytest.add('withTracker - track based on props and state', function (test) { + var div = document.createElement("DIV"); + + var xs = [new ReactiveVar('aaa'), + new ReactiveVar('bbb'), + new ReactiveVar('ccc')]; + + let setState; + var Foo = (props) => { + const [state, _setState] = useState({ m: 0 }); + setState = _setState; + const Component = withTracker((props) => { + return { + x: xs[state.m + props.n].get() + }; + })((props) => { + return {props.x}; + }); + return + }; + + var comp = ReactDOM.render(, div); + + test.equal(getInnerHtml(div), 'aaa'); + xs[0].set('AAA'); + test.equal(getInnerHtml(div), 'aaa'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'AAA'); + + { + let comp2 = ReactDOM.render(, div); + test.isTrue(comp === comp2); + } + + test.equal(getInnerHtml(div), 'bbb'); + xs[1].set('BBB'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'BBB'); + + setState({m: 1}); + test.equal(getInnerHtml(div), 'ccc'); + xs[2].set('CCC'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'CCC'); + + ReactDOM.render(, div); + setState({m: 0}); + test.equal(getInnerHtml(div), 'AAA'); + + ReactDOM.unmountComponentAtNode(div); + }); + + Tinytest.add('withTracker - track based on props and state (with deps)', function (test) { + var div = document.createElement("DIV"); + + var xs = [new ReactiveVar('aaa'), + new ReactiveVar('bbb'), + new ReactiveVar('ccc')]; + + let setState; + var Foo = (props) => { + const [state, _setState] = useState({ m: 0 }); + setState = _setState; + const Component = withTracker({ + getMeteorData () { + return { + x: xs[state.m + props.n].get() + }; + }, + deps: [state.m, props.n] + })((props) => { + return {props.x}; + }); + return + }; + + var comp = ReactDOM.render(, div); + + test.equal(getInnerHtml(div), 'aaa'); + xs[0].set('AAA'); + test.equal(getInnerHtml(div), 'aaa'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'AAA'); + + { + let comp2 = ReactDOM.render(, div); + test.isTrue(comp === comp2); + } + + test.equal(getInnerHtml(div), 'bbb'); + xs[1].set('BBB'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'BBB'); + + setState({m: 1}); + test.equal(getInnerHtml(div), 'ccc'); + xs[2].set('CCC'); + Tracker.flush({_throwFirstError: true}); + test.equal(getInnerHtml(div), 'CCC'); + + ReactDOM.render(, div); + setState({m: 0}); + test.equal(getInnerHtml(div), 'AAA'); + + ReactDOM.unmountComponentAtNode(div); + }); + + function waitFor(func, callback) { + Tracker.autorun(function (c) { + if (func()) { + c.stop(); + callback(); + } + }); + }; + + testAsyncMulti('withTracker - resubscribe', [ + function (test, expect) { + var self = this; + self.div = document.createElement("DIV"); + self.collection = new Mongo.Collection("withTracker-mixin-coll"); + self.num = new ReactiveVar(1); + self.someOtherVar = new ReactiveVar('foo'); + self.Foo = withTracker(() => { + self.handle = + Meteor.subscribe("withTracker-mixin-sub", + self.num.get()); + + return { + v: self.someOtherVar.get(), + docs: self.collection.find().fetch() + }; + })((props) => { + self.data = props; + return
{ + _.map(props.docs, (doc) => {doc._id}) + }
; + }); + + self.component = ReactDOM.render(, self.div); + test.equal(getInnerHtml(self.div), '
'); + + var handle = self.handle; + test.isFalse(handle.ready()); + + waitFor(() => handle.ready(), + expect()); + }, + function (test, expect) { + var self = this; + test.isTrue(self.handle.ready()); + test.equal(getInnerHtml(self.div), '
id1
'); + + self.someOtherVar.set('bar'); + self.oldHandle1 = self.handle; + + // can't call Tracker.flush() here (we are in a Tracker.flush already) + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + var oldHandle = self.oldHandle1; + var newHandle = self.handle; + test.notEqual(oldHandle, newHandle); // new handle + test.equal(newHandle.subscriptionId, oldHandle.subscriptionId); // same sub + test.isTrue(newHandle.ready()); // doesn't become unready + // no change to the content + test.equal(getInnerHtml(self.div), '
id1
'); + + // ok, now change the `num` argument to the subscription + self.num.set(2); + self.oldHandle2 = newHandle; + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + // data is still there + test.equal(getInnerHtml(self.div), '
id1
'); + // handle is no longer ready + var handle = self.handle; + test.isFalse(handle.ready()); + // different sub ID + test.isTrue(self.oldHandle2.subscriptionId); + test.isTrue(handle.subscriptionId); + test.notEqual(handle.subscriptionId, self.oldHandle2.subscriptionId); + + waitFor(() => handle.ready(), + expect()); + }, + function (test, expect) { + var self = this; + // now we see the new data! (and maybe the old data, because + // when a subscription goes away, its data doesn't disappear right + // away; the server has to tell the client which documents or which + // properties to remove, and this is not easy to wait for either; see + // https://github.com/meteor/meteor/issues/2440) + test.equal(getInnerHtml(self.div).replace('id1', ''), + '
id2
'); + + self.someOtherVar.set('baz'); + self.oldHandle3 = self.handle; + + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + test.equal(self.data.v, 'baz'); + test.notEqual(self.oldHandle3, self.handle); + test.equal(self.oldHandle3.subscriptionId, + self.handle.subscriptionId); + test.isTrue(self.handle.ready()); + }, + function (test, expect) { + ReactDOM.unmountComponentAtNode(this.div); + // break out of flush time, so we don't call the test's + // onComplete from within Tracker.flush + Meteor.defer(expect()); + } + ]); + + // Tinytest.add( + // "withTracker - print warning if return cursor from withTracker", + // function (test) { + // var coll = new Mongo.Collection(null); + // var ComponentWithCursor = () => { + // withTracker(() => { + // return { + // theCursor: coll.find() + // }; + // }); + // return ; + // }; + + // // Check if we print a warning to console about props + // // You can be sure this test is correct because we have an identical one in + // // react-runtime-dev + // let warning; + // try { + // var oldWarn = console.warn; + // console.warn = function specialWarn(message) { + // warning = message; + // }; + + // var div = document.createElement("DIV"); + // ReactDOM.render(, div); + + // test.matches(warning, /cursor before returning it/); + // } finally { + // console.warn = oldWarn; + // } + // }); + +} else { + Meteor.publish("withTracker-mixin-sub", function (num) { + Meteor.defer(() => { // because subs are blocking + this.added("withTracker-mixin-coll", 'id'+num, {}); + this.ready(); + }); + }); +}