diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 51ef929c..e4d953c4 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -22,7 +22,7 @@ This package provides two ways to use Tracker reactive data in your React compon - 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 `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. @@ -33,8 +33,8 @@ You can use the `useTracker` hook to get the value of a Tracker reactive functio 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. +- `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, the Tracker computation will be recreated on every call. ```js import { useTracker } from 'meteor/react-meteor-data'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2e25d3b4..fff0af45 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,6 +1,8 @@ -import React, { useState, useEffect } from 'react'; -import { Tracker } from 'meteor/tracker'; -import { Meteor } from 'meteor/meteor'; +/* global Meteor, Package, Tracker */ +import React, { useState, 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) { @@ -8,8 +10,7 @@ function checkCursor(data) { 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) { + } else if (Object.getPrototypeOf(data) === Object.prototype) { Object.keys(data).forEach((key) => { if (data[key] instanceof Package.mongo.Mongo.Cursor) { shouldWarn = true; @@ -18,8 +19,6 @@ function checkCursor(data) { } } 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 ' @@ -28,47 +27,127 @@ function checkCursor(data) { } } -// 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; - }); +// 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 + ); +} - useEffect(() => { - // Set up the reactive computation. - const computation = Tracker.nonreactive(() => - Tracker.autorun(() => { - const data = reactiveFn(); - Meteor.isDevelopment && checkCursor(data); - setTrackerData(data); +// 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 (!Array.isArray(nextDeps)) { + if (Meteor.isDevelopment) { + warn( + 'Warning: useTracker expected an dependency value of ' + + `type array 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; +} + +let uniqueCounter = 0; + +function useTracker(reactiveFn, deps) { + const { current: refs } = useRef({}); + + const [, forceUpdate] = useState(); + + 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(); + 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 falsy, stop computation and let next render handle reactiveFn. + if (!refs.previousDeps) { + dispose(); + } else { + runReactiveFn(); + } + // use a uniqueCounter to trigger a state change to force a re-render + forceUpdate(++uniqueCounter); + } }) - ); - // On effect cleanup, stop the computation. - return () => computation.stop(); - }, deps); + )); + } + + // stop the computation on unmount only + useEffect(() => { + if (Meteor.isDevelopment + && deps !== null && deps !== undefined + && !Array.isArray(deps)) { + warn( + 'Warning: useTracker expected an initial dependency value of ' + + `type array but got type of ${typeof deps} instead.` + ); + } + + return dispose; + }, []); - return trackerData; + 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 useTracker__server(reactiveFn, deps) { +function useTrackerServer(reactiveFn) { return reactiveFn(); } -export default (Meteor.isServer ? useTracker__server : useTracker); +export default (Meteor.isServer ? useTrackerServer : useTracker); diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index 78455467..e6e6a9f2 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -7,7 +7,7 @@ export default function withTracker(options) { const { getMeteorData, pure = true } = expandedOptions; const WithTracker = forwardRef((props, ref) => { - const data = useTracker(() => getMeteorData(props) || {}, [props]); + const data = useTracker(() => getMeteorData(props) || {}); return ; });