diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md
index b64c53d2..c1960da1 100644
--- a/packages/react-meteor-data/README.md
+++ b/packages/react-meteor-data/README.md
@@ -18,42 +18,117 @@ 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}:
+
{tasks.map(task => - {task.label}
)}
+
Hello {currentUser.username}
+ {listLoading ?
+ Loading
:
+
+ Here is the Todo list {listId}:
+
{tasks.map(task => - {task.label}
)}
+
{
- // 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`
+### Version compatibility notes
-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:
+- `react-meteor-data` v2.x :
+ - `useTracker` hook + `withTracker` HOC
+ - Requires React `^16.8`.
+ - Implementation is **not** compatible with the forthcoming "React Suspense" features. You can use it, but make sure to call `useTracker` *after* any hooks which may throw a Promise. We are looking at ways to improve support for Suspense without sacrificing performance.
+ - 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.
-```js
-const FooWithAllTheThings = compose(
- connect(...), // some Redux
- graphql(...), // some GraphQL
- withTracker(...), // some Tracker data
-)(Foo);
-```
+- `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..8769794b
--- /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 withTracker } from './withTracker.jsx';
+export { default as useTracker } from './useTracker.js';
diff --git a/packages/react-meteor-data/package-lock.json b/packages/react-meteor-data/package-lock.json
new file mode 100644
index 00000000..1e4c7b2e
--- /dev/null
+++ b/packages/react-meteor-data/package-lock.json
@@ -0,0 +1,132 @@
+{
+ "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"
+ }
+ },
+ "@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/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": "*"
+ }
+ },
+ "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="
+ },
+ "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"
+ }
+ }
+ }
+}
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..7514840f
--- /dev/null
+++ b/packages/react-meteor-data/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "react-meteor-data",
+ "dependencies": {
+ "react": "16.8.6",
+ "react-dom": "16.8.6",
+ "react-test-renderer": "16.8.6",
+ "@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..75a99084
--- /dev/null
+++ b/packages/react-meteor-data/useTracker.js
@@ -0,0 +1,155 @@
+/* global Meteor, Package, Tracker */
+import React, { useReducer, useEffect, useRef } 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.'
+ );
+ }
+}
+
+// taken from https://github.com/facebook/react/blob/
+// 34ce57ae751e0952fd12ab532a3e5694445897ea/packages/shared/objectIs.js
+function is(x, y) {
+ return (
+ (x === y && (x !== 0 || 1 / x === 1 / y))
+ || (x !== x && y !== y) // eslint-disable-line no-self-compare
+ );
+}
+
+// inspired by https://github.com/facebook/react/blob/
+// 34ce57ae751e0952fd12ab532a3e5694445897ea/packages/
+// react-reconciler/src/ReactFiberHooks.js#L307-L354
+// used to replicate dep change behavior and stay consistent
+// with React.useEffect()
+function areHookInputsEqual(nextDeps, prevDeps) {
+ if (prevDeps === null || prevDeps === undefined || !Array.isArray(prevDeps)) {
+ return false;
+ }
+
+ if (nextDeps === null || nextDeps === undefined || !Array.isArray(nextDeps)) {
+ // falsy deps is okay, but if deps is not falsy, it must be an array
+ if (Meteor.isDevelopment && (nextDeps && !Array.isArray(nextDeps))) {
+ warn(
+ 'Warning: useTracker expected an dependency value of '
+ + `type array, null or undefined but got type of ${typeof nextDeps} instead.`
+ );
+ }
+ return false;
+ }
+
+ const len = nextDeps.length;
+
+ if (prevDeps.length !== len) {
+ return false;
+ }
+
+ for (let i = 0; i < len; i++) {
+ if (!is(nextDeps[i], prevDeps[i])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+// Used to create a forceUpdate from useReducer. Forces update by
+// incrementing a number whenever the dispatch method is invoked.
+const fur = x => x + 1;
+
+function useTracker(reactiveFn, deps) {
+ const { current: refs } = useRef({});
+
+ const [, forceUpdate] = useReducer(fur, 0);
+
+ const dispose = () => {
+ if (refs.computation) {
+ refs.computation.stop();
+ refs.computation = null;
+ }
+ };
+
+ // this is called like at componentWillMount and componentWillUpdate equally
+ // in order to support render calls with synchronous data from the reactive computation
+ // if prevDeps or deps are not set areHookInputsEqual always returns false
+ // and the reactive functions is always called
+ if (!areHookInputsEqual(deps, refs.previousDeps)) {
+ // if we are re-creating the computation, we need to stop the old one.
+ dispose();
+
+ // store the deps for comparison on next render
+ refs.previousDeps = deps;
+
+ // 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) => {
+ const runReactiveFn = () => {
+ const data = reactiveFn(c);
+ if (Meteor.isDevelopment) checkCursor(data);
+ refs.trackerData = data;
+ };
+
+ if (c.firstRun) {
+ // This will capture data synchronously on first run (and after deps change).
+ // Additional cycles will follow the normal computation behavior.
+ runReactiveFn();
+ } 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 (deps === null || deps === undefined || !Array.isArray(deps)) {
+ dispose();
+ } else {
+ runReactiveFn();
+ }
+ forceUpdate();
+ }
+ })
+ ));
+ }
+
+ // stop the computation on unmount only
+ useEffect(() => {
+ // falsy deps is okay, but if deps is not falsy, it must be an array
+ if (Meteor.isDevelopment && (deps && !Array.isArray(deps))) {
+ warn(
+ 'Warning: useTracker expected an initial dependency value of '
+ + `type array, null or undefined but got type of ${typeof deps} instead.`
+ );
+ }
+
+ return dispose;
+ }, []);
+
+ 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
+function useTrackerServer(reactiveFn) {
+ return reactiveFn();
+}
+
+export default (Meteor.isServer ? useTrackerServer : 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..ccaf416d
--- /dev/null
+++ b/packages/react-meteor-data/useTracker.tests.js
@@ -0,0 +1,435 @@
+/* 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 useTracker from './useTracker';
+
+Tinytest.add('useTracker - no deps', async function (test) {
+ const reactiveDict = new ReactiveDict();
+ let runCount = 0;
+
+ const { result, rerender, unmount, waitForNextUpdate } = renderHook(
+ ({ name }) => useTracker(() => {
+ runCount++;
+ reactiveDict.setDefault(name, 'initial');
+ return reactiveDict.get(name);
+ }),
+ { initialProps: { name: 'key' } }
+ );
+
+ test.equal(result.current, 'initial', 'Expect initial value to be "initial"');
+ test.equal(runCount, 1, 'Should have run 1 times');
+
+ 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');
+
+ 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');
+
+ 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');
+
+ unmount();
+ test.equal(runCount, 4, 'Unmount should not cause a tracker run');
+
+ 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');
+
+ reactiveDict.destroy();
+});
+
+Tinytest.add('useTracker - with deps', async function (test) {
+ const reactiveDict = new ReactiveDict();
+ let runCount = 0;
+
+ const { result, rerender, unmount, waitForNextUpdate } = renderHook(
+ ({ name }) => useTracker(() => {
+ runCount++;
+ reactiveDict.setDefault(name, 'default');
+ return reactiveDict.get(name);
+ }, [name]),
+ { 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');
+
+ 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');
+
+ 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');
+
+ 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');
+
+ unmount();
+ test.equal(runCount, 4, 'Unmount should not cause a tracker run');
+ // we can't use await waitForNextUpdate() here because it doesn't trigger re-render - is there a way to test that?
+
+ 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');
+
+ 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.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 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 data = useTracker(() => {
+ return {
+ x: xs[state.m + props.n].get()
+ };
+ });
+ return {data.x};
+ };
+
+ 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('useTracker - 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 data = useTracker(() => {
+ return {
+ x: xs[state.m + props.n].get()
+ };
+ }, [state.m, props.n]);
+ return {data.x};
+ };
+
+ 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('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();
+ });
+ });
+}