diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index b64c53d2..51ef929c 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -18,42 +18,111 @@ 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, is slightly more straightforward to use (lets you access reactive data sources directly within your componenent, rather than adding them from an external wrapper), and slightly more performant (avoids adding wrapper layers in the React tree). But, 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. + +It is not necessary to rewrite existing applications to use the `useTracker` hook instead of the existing `withTracker` HOC. But for new components, it is suggested to prefer the `useTracker` hook when dealing with function components. + +#### `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 (with no parameters) +- `deps`: an array of "dependencies" of the reactive function, i.e. the list of values that, when changed, need to stop the current Tracker computation and start a new one - for example, the value of a prop used in a subscription or a Minimongo query; see example below. This array typically includes all variables from the outer scope "captured" in the closure passed as the 1st argument. 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, it defaults to `[]` (no dependency), and the Tracker computation will run unchanged until the component is unmounted. + +```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 (same as passing []). + const currentUser = useTracker(() => Meteor.user()); + // The following two computations both depend on the 'listId' prop, + // and thus need to specify it in the 'deps' argument, + // in order to subscribe to the expected 'todoList' subscription + // or fetch the expected Tasks when the 'listId' prop changes. + const listLoading = useTracker(() => { + // Note that this subscription will get cleaned up when your component is unmounted. + 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` - -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: - -```js -const FooWithAllTheThings = compose( - connect(...), // some Redux - graphql(...), // some GraphQL - withTracker(...), // some Tracker data -)(Foo); -``` +### Version compatibility notes + +- `react-meteor-data` v2.x : + - `useTracker` hook + `withTracker` HOC + - Requires React `^16.8`. + - Implementation is compatible with the forthcoming "React Suspense" features. + - 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 they use 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. + +- `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..bf99268a --- /dev/null +++ b/packages/react-meteor-data/index.js @@ -0,0 +1,11 @@ +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.js b/packages/react-meteor-data/package.js index 83b59714..80df8ff5 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -10,9 +10,6 @@ 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('react-meteor-data.jsx'); + api.mainModule('index.js'); }); 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/useTracker.js b/packages/react-meteor-data/useTracker.js new file mode 100644 index 00000000..2e25d3b4 --- /dev/null +++ b/packages/react-meteor-data/useTracker.js @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react'; +import { Tracker } from 'meteor/tracker'; +import { Meteor } from 'meteor/meteor'; + +// 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) { + // Use React.warn() if available (should ship in React 16.9). + const warn = React.warn || console.warn.bind(console); + 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.' + ); + } +} + +// Forgetting the deps parameter would cause an infinite rerender loop, so we default to []. +function useTracker(reactiveFn, deps = []) { + // Note : we always run the reactiveFn in Tracker.nonreactive in case + // we are already 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 too. + + const [trackerData, setTrackerData] = useState(() => { + // No side-effects are allowed when computing the initial value. + // To get the initial return value for the 1st render on mount, + // we run reactiveFn without autorun or subscriptions. + // Note: maybe when React Suspense is officially available we could + // throw a Promise instead to skip the 1st render altogether ? + const realSubscribe = Meteor.subscribe; + Meteor.subscribe = () => ({ stop: () => {}, ready: () => false }); + const initialData = Tracker.nonreactive(reactiveFn); + Meteor.subscribe = realSubscribe; + return initialData; + }); + + useEffect(() => { + // Set up the reactive computation. + const computation = Tracker.nonreactive(() => + Tracker.autorun(() => { + const data = reactiveFn(); + Meteor.isDevelopment && checkCursor(data); + setTrackerData(data); + }) + ); + // On effect cleanup, stop the computation. + return () => computation.stop(); + }, deps); + + return 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 useTracker__server(reactiveFn, deps) { + return reactiveFn(); +} + +export default (Meteor.isServer ? useTracker__server : useTracker); diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx new file mode 100644 index 00000000..78455467 --- /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) || {}, [props]); + return ; + }); + + return pure ? memo(WithTracker) : WithTracker; + }; +}