From f9c6ae3f6aa5556b2e7502ccac8c477e5e373ea6 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 4 Nov 2018 23:33:55 +0100 Subject: [PATCH 001/117] Expose useTracker hook and reimplement withTracker HOC on top of it --- packages/react-meteor-data/README.md | 2 + .../react-meteor-data/ReactMeteorData.jsx | 30 +------------ .../react-meteor-data/createContainer.jsx | 4 +- .../react-meteor-data/react-meteor-data.jsx | 5 ++- packages/react-meteor-data/useTracker.js | 44 +++++++++++++++++++ packages/react-meteor-data/withTracker.jsx | 29 ++++++++++++ 6 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 packages/react-meteor-data/useTracker.js create mode 100644 packages/react-meteor-data/withTracker.jsx diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index b64c53d2..e3c09dce 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -18,6 +18,8 @@ npm install --save react ### Usage +@TODO document `useTracker` hook + This package exports a symbol `withTracker`, which you can use to wrap your components with data returned from Tracker reactive functions. ```js diff --git a/packages/react-meteor-data/ReactMeteorData.jsx b/packages/react-meteor-data/ReactMeteorData.jsx index c987f382..df0be895 100644 --- a/packages/react-meteor-data/ReactMeteorData.jsx +++ b/packages/react-meteor-data/ReactMeteorData.jsx @@ -123,7 +123,7 @@ class MeteorDataManager { } } -export const ReactMeteorData = { +export default ReactMeteorData = { componentWillMount() { this.data = {}; this._meteorDataManager = new MeteorDataManager(this); @@ -158,31 +158,3 @@ export const ReactMeteorData = { 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 index c413362d..c2618dcf 100644 --- a/packages/react-meteor-data/createContainer.jsx +++ b/packages/react-meteor-data/createContainer.jsx @@ -4,7 +4,7 @@ import { Meteor } from 'meteor/meteor'; import React from 'react'; -import connect from './ReactMeteorData.jsx'; +import withTracker from './withTracker.jsx'; let hasDisplayedWarning = false; @@ -17,5 +17,5 @@ export default function createContainer(options, Component) { hasDisplayedWarning = true; } - return connect(options)(Component); + return withTracker(options)(Component); } diff --git a/packages/react-meteor-data/react-meteor-data.jsx b/packages/react-meteor-data/react-meteor-data.jsx index 0c0a9e30..66ccb513 100644 --- a/packages/react-meteor-data/react-meteor-data.jsx +++ b/packages/react-meteor-data/react-meteor-data.jsx @@ -5,5 +5,6 @@ checkNpmVersions({ }, 'react-meteor-data'); export { default as createContainer } from './createContainer.jsx'; -export { default as withTracker } from './ReactMeteorData.jsx'; -export { ReactMeteorData } from './ReactMeteorData.jsx'; +export { default as ReactMeteorData } from './ReactMeteorData.jsx'; +export { default as withTracker } from './withTracker.jsx'; +export { default as useTracker } from './useTracker.js'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js new file mode 100644 index 00000000..c3bf73a3 --- /dev/null +++ b/packages/react-meteor-data/useTracker.js @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Tracker } from 'meteor/tracker'; +import { Meteor } from 'meteor/meteor'; + +let useTracker; + +if (Meteor.isServer) { + // 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 + useTracker = reactiveFn => reactiveFn(); +} +else { + useTracker = (reactiveFn, dependencies) => { + const [trackerData, setTrackerData] = useState(null); + const callback = useCallback(reactiveFn, dependencies); + + useEffect(() => { + let computation; + // 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. + Tracker.nonreactive(() => { + computation = Tracker.autorun(() => { + const data = callback(); + if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { + console.warn( + 'Warning: you are returning a Mongo cursor from useEffect. ' + + 'This value will not be reactive. You probably want to call ' + + '`.fetch()` on the cursor before returning it.' + ); + } + setTrackerData(data); + }); + }); + return () => computation.stop(); + }, [callback]); + + return trackerData; + }; +} + +export default useTracker; diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx new file mode 100644 index 00000000..ddab8b90 --- /dev/null +++ b/packages/react-meteor-data/withTracker.jsx @@ -0,0 +1,29 @@ +import React, { 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; + + function WithTracker(props) { + const data = useTracker(() => getMeteorData(props) || {}, [props]); + + if (Package.mongo && Package.mongo.Mongo && data) { + Object.keys(data).forEach((key) => { + if (data[key] instanceof Package.mongo.Mongo.Cursor) { + console.warn( + 'Warning: you are returning a Mongo cursor from withTracker. ' + + 'This value will not be reactive. You probably want to call ' + + '`.fetch()` on the cursor before returning it.' + ); + } + }); + } + + return data ? : null; + } + + return pure ? memo(WithTracker) : WithTracker; + }; +} From eecab69c6c7cec716a75137b53e44d0af729d40d Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 5 Nov 2018 01:02:45 +0100 Subject: [PATCH 002/117] run the callback non-reactively in the initial render --- packages/react-meteor-data/useTracker.js | 12 +++++++++++- packages/react-meteor-data/withTracker.jsx | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c3bf73a3..68cb633c 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -11,9 +11,19 @@ if (Meteor.isServer) { } else { useTracker = (reactiveFn, dependencies) => { - const [trackerData, setTrackerData] = useState(null); const callback = useCallback(reactiveFn, dependencies); + // Run the function once with no autorun to get the initial return value. + // @todo Reach out to the React team to see if there's a better way ? Maybe abort the initial render instead ? + const [trackerData, setTrackerData] = useState(() => { + // We need to prevent subscriptions from running in that initial run. + const realSubscribe = Meteor.subscribe; + Meteor.subscribe = () => ({ stop: () => {}, ready: () => false }); + const initialData = Tracker.nonreactive(callback); + Meteor.subscribe = realSubscribe; + return initialData; + }); + useEffect(() => { let computation; // Use Tracker.nonreactive in case we are inside a Tracker Computation. diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index ddab8b90..494f1757 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -21,7 +21,7 @@ export default function withTracker(options) { }); } - return data ? : null; + return ; } return pure ? memo(WithTracker) : WithTracker; From 46c243a45d5deaf5e16f81bec5c35709a8a08bd2 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 5 Nov 2018 11:41:48 +0100 Subject: [PATCH 003/117] no need for useCallback --- packages/react-meteor-data/useTracker.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 68cb633c..c026b5bb 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { Tracker } from 'meteor/tracker'; import { Meteor } from 'meteor/meteor'; @@ -11,15 +11,13 @@ if (Meteor.isServer) { } else { useTracker = (reactiveFn, dependencies) => { - const callback = useCallback(reactiveFn, dependencies); - // Run the function once with no autorun to get the initial return value. // @todo Reach out to the React team to see if there's a better way ? Maybe abort the initial render instead ? const [trackerData, setTrackerData] = useState(() => { // We need to prevent subscriptions from running in that initial run. const realSubscribe = Meteor.subscribe; Meteor.subscribe = () => ({ stop: () => {}, ready: () => false }); - const initialData = Tracker.nonreactive(callback); + const initialData = Tracker.nonreactive(reactiveFn); Meteor.subscribe = realSubscribe; return initialData; }); @@ -33,7 +31,7 @@ else { // it stops the inner one. Tracker.nonreactive(() => { computation = Tracker.autorun(() => { - const data = callback(); + const data = reactiveFn(); if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { console.warn( 'Warning: you are returning a Mongo cursor from useEffect. ' @@ -45,7 +43,7 @@ else { }); }); return () => computation.stop(); - }, [callback]); + }, dependencies); return trackerData; }; From a2cc1202e4df0c1378c79634fcd2815747cc4cd6 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Tue, 6 Nov 2018 21:19:12 +0100 Subject: [PATCH 004/117] setup computation once on mount, deferring subscriptions until didMount --- packages/react-meteor-data/useTracker.js | 75 ++++++++++++++++++------ 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c026b5bb..d1754b2d 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Tracker } from 'meteor/tracker'; import { Meteor } from 'meteor/meteor'; @@ -10,28 +10,36 @@ if (Meteor.isServer) { useTracker = reactiveFn => reactiveFn(); } else { + // @todo specify a default value for dependencies ? omitting them can be very bad perf-wise useTracker = (reactiveFn, dependencies) => { - // Run the function once with no autorun to get the initial return value. - // @todo Reach out to the React team to see if there's a better way ? Maybe abort the initial render instead ? - const [trackerData, setTrackerData] = useState(() => { - // We need to prevent subscriptions from running in that initial run. - const realSubscribe = Meteor.subscribe; - Meteor.subscribe = () => ({ stop: () => {}, ready: () => false }); - const initialData = Tracker.nonreactive(reactiveFn); - Meteor.subscribe = realSubscribe; - return initialData; - }); + const computation = useRef(); - useEffect(() => { - let computation; + // We setup the computation at mount time so that we can return the first results, + // but need to defer subscriptions until didMount in useEffect below. + const deferredSubscriptions = useRef([]); + let realSubscribe = Meteor.subscribe; + Meteor.subscribe = (name, ...args) => { + deferredSubscriptions.current.push([name, ...args]); + return { stop: () => {}, isReady: () => false }; + }; + + // Also, the lazy initialization callback we provide to useState cannot use + // the setState callback useState will give us. We provide a no-op stub for the first run. + let setState = () => {}; + + // The first run at mount time will use the stubbed Meteor.subscribe and setState above. + const setUpComputation = () => { + // console.log('setup'); + 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. Tracker.nonreactive(() => { - computation = Tracker.autorun(() => { - const data = reactiveFn(); + computation.current = Tracker.autorun(() => { + // console.log('run'); + data = reactiveFn(); if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { console.warn( 'Warning: you are returning a Mongo cursor from useEffect. ' @@ -39,13 +47,44 @@ else { + '`.fetch()` on the cursor before returning it.' ); } - setTrackerData(data); + setState(data); }); }); - return () => computation.stop(); + return data; + }; + + // Set initial state and generate the setter. + const [state, doSetState] = useState(() => setUpComputation()); + + // Replace the stubs with the actual implementations for the subsequent re-runs. + setState = doSetState; + Meteor.subscribe = realSubscribe; + + useEffect(() => { + // If we collected deferred subscriptions at mount time, we run them. + if (computation.current && deferredSubscriptions.current) { + // console.log('setup deferred subscriptions'); + deferredSubscriptions.current.forEach(([name, ...args]) => { + const { stop } = Meteor.subscribe(name, ...args); + computation.current.onStop(stop); + }); + deferredSubscriptions.current = null; + } + // If the computation was stopped during cleanup, we create the new one. + if (!computation.current) { + setUpComputation(); + } + // On cleanup, stop the current computation. + return () => { + if (computation.current) { + // console.log('cleanup'); + computation.current.stop(); + computation.current = null; + } + }; }, dependencies); - return trackerData; + return state; }; } From b61f8094c523599a42f54414da1f2137f285b6b2 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Thu, 8 Nov 2018 09:41:05 +0100 Subject: [PATCH 005/117] stop deferred inital subscriptions on computation invalidate rather than stop --- packages/react-meteor-data/useTracker.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d1754b2d..62e41e8f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -66,7 +66,11 @@ else { // console.log('setup deferred subscriptions'); deferredSubscriptions.current.forEach(([name, ...args]) => { const { stop } = Meteor.subscribe(name, ...args); - computation.current.onStop(stop); + // Manually stop the subscriptions when the computation invalidates, + // as Meteor would do for "regular" subscriptions made during autotrun. + // @todo if the computation then resubscribes to the same publication, + // the usual "skip unsubscribe / resubscribe" optimisation doesn't get applied. + computation.current.onInvalidate(stop); }); deferredSubscriptions.current = null; } From 2589ac7d51121b506b6a7ad72fe06d245e12d3e0 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Fri, 9 Nov 2018 11:28:49 +0100 Subject: [PATCH 006/117] Revert last two commits: no reactivity on mount --- packages/react-meteor-data/useTracker.js | 79 ++++++------------------ 1 file changed, 18 insertions(+), 61 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 62e41e8f..c026b5bb 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { Tracker } from 'meteor/tracker'; import { Meteor } from 'meteor/meteor'; @@ -10,36 +10,28 @@ if (Meteor.isServer) { useTracker = reactiveFn => reactiveFn(); } else { - // @todo specify a default value for dependencies ? omitting them can be very bad perf-wise useTracker = (reactiveFn, dependencies) => { - const computation = useRef(); + // Run the function once with no autorun to get the initial return value. + // @todo Reach out to the React team to see if there's a better way ? Maybe abort the initial render instead ? + const [trackerData, setTrackerData] = useState(() => { + // We need to prevent subscriptions from running in that initial run. + const realSubscribe = Meteor.subscribe; + Meteor.subscribe = () => ({ stop: () => {}, ready: () => false }); + const initialData = Tracker.nonreactive(reactiveFn); + Meteor.subscribe = realSubscribe; + return initialData; + }); - // We setup the computation at mount time so that we can return the first results, - // but need to defer subscriptions until didMount in useEffect below. - const deferredSubscriptions = useRef([]); - let realSubscribe = Meteor.subscribe; - Meteor.subscribe = (name, ...args) => { - deferredSubscriptions.current.push([name, ...args]); - return { stop: () => {}, isReady: () => false }; - }; - - // Also, the lazy initialization callback we provide to useState cannot use - // the setState callback useState will give us. We provide a no-op stub for the first run. - let setState = () => {}; - - // The first run at mount time will use the stubbed Meteor.subscribe and setState above. - const setUpComputation = () => { - // console.log('setup'); - let data; + useEffect(() => { + let computation; // 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. Tracker.nonreactive(() => { - computation.current = Tracker.autorun(() => { - // console.log('run'); - data = reactiveFn(); + computation = Tracker.autorun(() => { + const data = reactiveFn(); if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { console.warn( 'Warning: you are returning a Mongo cursor from useEffect. ' @@ -47,48 +39,13 @@ else { + '`.fetch()` on the cursor before returning it.' ); } - setState(data); + setTrackerData(data); }); }); - return data; - }; - - // Set initial state and generate the setter. - const [state, doSetState] = useState(() => setUpComputation()); - - // Replace the stubs with the actual implementations for the subsequent re-runs. - setState = doSetState; - Meteor.subscribe = realSubscribe; - - useEffect(() => { - // If we collected deferred subscriptions at mount time, we run them. - if (computation.current && deferredSubscriptions.current) { - // console.log('setup deferred subscriptions'); - deferredSubscriptions.current.forEach(([name, ...args]) => { - const { stop } = Meteor.subscribe(name, ...args); - // Manually stop the subscriptions when the computation invalidates, - // as Meteor would do for "regular" subscriptions made during autotrun. - // @todo if the computation then resubscribes to the same publication, - // the usual "skip unsubscribe / resubscribe" optimisation doesn't get applied. - computation.current.onInvalidate(stop); - }); - deferredSubscriptions.current = null; - } - // If the computation was stopped during cleanup, we create the new one. - if (!computation.current) { - setUpComputation(); - } - // On cleanup, stop the current computation. - return () => { - if (computation.current) { - // console.log('cleanup'); - computation.current.stop(); - computation.current = null; - } - }; + return () => computation.stop(); }, dependencies); - return state; + return trackerData; }; } From c8c1c5ff4706f00626c1df711eb9243f47175287 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 11 Nov 2018 15:41:38 +0100 Subject: [PATCH 007/117] comments --- packages/react-meteor-data/useTracker.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c026b5bb..9ec9b11f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -10,9 +10,12 @@ if (Meteor.isServer) { useTracker = reactiveFn => reactiveFn(); } else { + // @todo specify a default value for dependencies ? Omitting them can be very bad perf-wise. useTracker = (reactiveFn, dependencies) => { - // Run the function once with no autorun to get the initial return value. - // @todo Reach out to the React team to see if there's a better way ? Maybe abort the initial render instead ? + // Run the function once on mount without autorun or subscriptions, + // to get the initial return value. + // Note: maybe when React Suspense is officially available we could + // throw a Promise instead to skip the 1st render altogether ? const [trackerData, setTrackerData] = useState(() => { // We need to prevent subscriptions from running in that initial run. const realSubscribe = Meteor.subscribe; From 24be369a5d1d89ee256335cd793952788856b0d5 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Fri, 15 Feb 2019 23:41:51 +0100 Subject: [PATCH 008/117] minor: code style --- packages/react-meteor-data/withTracker.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index 494f1757..490be1e4 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -21,7 +21,7 @@ export default function withTracker(options) { }); } - return ; + return ; } return pure ? memo(WithTracker) : WithTracker; From e509e2636d685b906e440802776a2e3b005ce6d4 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sat, 30 Mar 2019 18:57:39 +0100 Subject: [PATCH 009/117] minor: coding style --- packages/react-meteor-data/useTracker.js | 2 +- packages/react-meteor-data/withTracker.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 9ec9b11f..6474378b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -37,7 +37,7 @@ else { const data = reactiveFn(); if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { console.warn( - 'Warning: you are returning a Mongo cursor from useEffect. ' + 'Warning: you are returning a Mongo cursor from useTracker. ' + 'This value will not be reactive. You probably want to call ' + '`.fetch()` on the cursor before returning it.' ); diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index 490be1e4..d203e6e8 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -21,7 +21,7 @@ export default function withTracker(options) { }); } - return ; + return ; } return pure ? memo(WithTracker) : WithTracker; From 5d25bf2671a265631b21ecfc898b32c34a3e7769 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sat, 30 Mar 2019 19:36:21 +0100 Subject: [PATCH 010/117] Handle Mongo.Cursor warnings checks inside useTracker, and user React.warn if available --- packages/react-meteor-data/useTracker.js | 37 ++++++++++++++++------ packages/react-meteor-data/withTracker.jsx | 13 -------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 6474378b..4798ea45 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,9 +1,34 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { Tracker } from 'meteor/tracker'; import { Meteor } from 'meteor/meteor'; -let useTracker; +// 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 && 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.' + ); + } +} +let useTracker; if (Meteor.isServer) { // 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 @@ -35,13 +60,7 @@ else { Tracker.nonreactive(() => { computation = Tracker.autorun(() => { const data = reactiveFn(); - if (Package.mongo && Package.mongo.Mongo && data instanceof Package.mongo.Mongo.Cursor) { - console.warn( - 'Warning: you are returning a Mongo cursor from useTracker. ' - + 'This value will not be reactive. You probably want to call ' - + '`.fetch()` on the cursor before returning it.' - ); - } + checkCursor(data); setTrackerData(data); }); }); diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index d203e6e8..b02362b1 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -8,19 +8,6 @@ export default function withTracker(options) { function WithTracker(props) { const data = useTracker(() => getMeteorData(props) || {}, [props]); - - if (Package.mongo && Package.mongo.Mongo && data) { - Object.keys(data).forEach((key) => { - if (data[key] instanceof Package.mongo.Mongo.Cursor) { - console.warn( - 'Warning: you are returning a Mongo cursor from withTracker. ' - + 'This value will not be reactive. You probably want to call ' - + '`.fetch()` on the cursor before returning it.' - ); - } - }); - } - return ; } From ae3f32381e7b06c8444cb0c943c4caf0e9a7fff0 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sat, 30 Mar 2019 21:11:59 +0100 Subject: [PATCH 011/117] Add a note about refs BC break and #262 --- packages/react-meteor-data/withTracker.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index b02362b1..16418b61 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -6,6 +6,8 @@ export default function withTracker(options) { const expandedOptions = typeof options === 'function' ? { getMeteorData: options } : options; const { getMeteorData, pure = true } = expandedOptions; + // Note : until https://github.com/meteor/react-packages/pull/266 is merged (which forwards ref to the inner Component), + // moving from a class to a function component will break existing code giving refs to withTracker-decorated components. function WithTracker(props) { const data = useTracker(() => getMeteorData(props) || {}, [props]); return ; From 3caa109b94396b643d50f160b6943748b0011575 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sat, 30 Mar 2019 21:43:29 +0100 Subject: [PATCH 012/117] bump React minimum version to 16.8 --- packages/react-meteor-data/react-meteor-data.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/react-meteor-data.jsx b/packages/react-meteor-data/react-meteor-data.jsx index 66ccb513..dc6d7bfa 100644 --- a/packages/react-meteor-data/react-meteor-data.jsx +++ b/packages/react-meteor-data/react-meteor-data.jsx @@ -1,7 +1,7 @@ import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; checkNpmVersions({ - react: '15.3 - 16', + react: '16.8', }, 'react-meteor-data'); export { default as createContainer } from './createContainer.jsx'; From 61ef31ede4c6bda0eb18909b951712b5e1a22cff Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 14:51:24 +0200 Subject: [PATCH 013/117] minor: streamline comments --- packages/react-meteor-data/useTracker.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 4798ea45..2acde0ce 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -37,12 +37,18 @@ if (Meteor.isServer) { else { // @todo specify a default value for dependencies ? Omitting them can be very bad perf-wise. useTracker = (reactiveFn, dependencies) => { - // Run the function once on mount without autorun or subscriptions, - // to get the initial return value. - // Note: maybe when React Suspense is officially available we could - // throw a Promise instead to skip the 1st render altogether ? + // 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(() => { - // We need to prevent subscriptions from running in that initial run. + // 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); @@ -52,11 +58,6 @@ else { useEffect(() => { let computation; - // 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. Tracker.nonreactive(() => { computation = Tracker.autorun(() => { const data = reactiveFn(); From 65e619d684b699398f674af6bbf03ceee8e6f3ef Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 15:06:28 +0200 Subject: [PATCH 014/117] minor: streamline useEffect --- packages/react-meteor-data/useTracker.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2acde0ce..01a9d186 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -57,14 +57,15 @@ else { }); useEffect(() => { - let computation; - Tracker.nonreactive(() => { - computation = Tracker.autorun(() => { + // Set up the reactive computation. + const computation = Tracker.nonreactive(() => + Tracker.autorun(() => { const data = reactiveFn(); checkCursor(data); setTrackerData(data); - }); - }); + }) + ); + // On effect cleanup, stop the computation. return () => computation.stop(); }, dependencies); From 3841a4c07482a52787ec57121eb7d339c594cd52 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 15:24:21 +0200 Subject: [PATCH 015/117] minor: streamline client / server versions of useTracker --- packages/react-meteor-data/useTracker.js | 80 ++++++++++++------------ 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 01a9d186..10a30e71 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -28,49 +28,47 @@ function checkCursor(data) { } } -let useTracker; -if (Meteor.isServer) { - // 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 - useTracker = reactiveFn => reactiveFn(); -} -else { - // @todo specify a default value for dependencies ? Omitting them can be very bad perf-wise. - useTracker = (reactiveFn, dependencies) => { - // 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. +// @todo specify a default value for dependencies ? Omitting them can be very bad perf-wise. +function useTracker(reactiveFn, dependencies) { + // 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; + }); - 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(); + checkCursor(data); + setTrackerData(data); + }) + ); + // On effect cleanup, stop the computation. + return () => computation.stop(); + }, dependencies); - useEffect(() => { - // Set up the reactive computation. - const computation = Tracker.nonreactive(() => - Tracker.autorun(() => { - const data = reactiveFn(); - checkCursor(data); - setTrackerData(data); - }) - ); - // On effect cleanup, stop the computation. - return () => computation.stop(); - }, dependencies); + return trackerData; +} - 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, dependencies) { + return reactiveFn(); } -export default useTracker; +export default (Meteor.isServer ? useTracker__server : useTracker); From ef64751256dab8d641dcf35ac771879ccd21e75e Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 16:57:38 +0200 Subject: [PATCH 016/117] default useTracker's deps param to [] to avoid infinte rerender loop if omitted --- packages/react-meteor-data/useTracker.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 10a30e71..3e53d02b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -28,8 +28,8 @@ function checkCursor(data) { } } -// @todo specify a default value for dependencies ? Omitting them can be very bad perf-wise. -function useTracker(reactiveFn, dependencies) { +// 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 @@ -60,14 +60,14 @@ function useTracker(reactiveFn, dependencies) { ); // On effect cleanup, stop the computation. return () => computation.stop(); - }, dependencies); + }, 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, dependencies) { +function useTracker__server(reactiveFn, deps) { return reactiveFn(); } From 44865319badcb3959c3c455b14d705bb480791b9 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sat, 30 Mar 2019 22:30:05 +0100 Subject: [PATCH 017/117] Docs --- packages/react-meteor-data/README.md | 86 ++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index e3c09dce..be9ee1a9 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -18,33 +18,95 @@ npm install --save react ### Usage -@TODO document `useTracker` hook - -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`, +- a higher-order component (HOC): `withTracker`. + +The `useTracker` hook, introduced in recent versions of `react-meteor-data`, is slightly more straightforward to use (lets you access reactive data sources directly within your compnenent, 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}: +
    {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. From 0041ce716e611486cf48d755aec2cd1671aedb40 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 17:08:33 +0200 Subject: [PATCH 018/117] minor: linebreak fix --- packages/react-meteor-data/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index be9ee1a9..fee9d9d3 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -74,8 +74,7 @@ Note : the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plug You can use the `withTracker` HOC to wrap your components and pass them additional props values from a Tracker reactive function. The reactive function will get re-run whenever its reactive inputs change, and the wrapped component will re-render with the new values for the additional props. Arguments: -- `reactiveFn`: a Tracker reactive function, getting the props as a parameter, -and returning an object of additional props to pass to the wrapped component. +- `reactiveFn`: a Tracker reactive function, getting the props as a parameter, and returning an object of additional props to pass to the wrapped component. ```js import { withTracker } from 'meteor/react-meteor-data'; From 92f85762c060cf88d6322e2dfc52e9546e1ed45b Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Sun, 31 Mar 2019 17:15:32 +0200 Subject: [PATCH 019/117] docs : unify headers for useTracker / withTracker sections (include args) --- packages/react-meteor-data/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index fee9d9d3..03114f6a 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -33,7 +33,7 @@ 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. +- `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 @@ -69,7 +69,7 @@ function Foo({ listId }) { Note : the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) package provides ESLint hints to help detect missing values in the `deps` argument of React built-in hooks. It can be configured with `options: [{additionalHooks: 'useTracker|useSomeOtherHook|...'}]` to also validate the `deps` argument of the `useTracker` hook or some other hooks. -#### `withTracker` higher-prder component +#### `withTracker(reactiveFn)` higher-prder component You can use the `withTracker` HOC to wrap your components and pass them additional props values from a Tracker reactive function. The reactive function will get re-run whenever its reactive inputs change, and the wrapped component will re-render with the new values for the additional props. From d8922dd07939ad9f60cf5f881cf39d1e925d0ed1 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 1 Apr 2019 13:29:37 +0200 Subject: [PATCH 020/117] docs : typos --- packages/react-meteor-data/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 03114f6a..f7c1647b 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`, - a higher-order component (HOC): `withTracker`. -The `useTracker` hook, introduced in recent versions of `react-meteor-data`, is slightly more straightforward to use (lets you access reactive data sources directly within your compnenent, 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 recent versions of `react-meteor-data`, 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. @@ -69,7 +69,7 @@ function Foo({ listId }) { Note : the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) package provides ESLint hints to help detect missing values in the `deps` argument of React built-in hooks. It can be configured with `options: [{additionalHooks: 'useTracker|useSomeOtherHook|...'}]` to also validate the `deps` argument of the `useTracker` hook or some other hooks. -#### `withTracker(reactiveFn)` higher-prder component +#### `withTracker(reactiveFn)` higher-order component You can use the `withTracker` HOC to wrap your components and pass them additional props values from a Tracker reactive function. The reactive function will get re-run whenever its reactive inputs change, and the wrapped component will re-render with the new values for the additional props. From c4e24f2093d151e7dde6ec2874c17957a0f0ae27 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 1 Apr 2019 17:50:17 +0200 Subject: [PATCH 021/117] remove createContainer / ReactMeteorData --- packages/react-meteor-data/README.md | 12 -- .../react-meteor-data/ReactMeteorData.jsx | 160 ------------------ .../react-meteor-data/createContainer.jsx | 21 --- .../{react-meteor-data.jsx => index.js} | 2 - packages/react-meteor-data/package.js | 4 +- 5 files changed, 1 insertion(+), 198 deletions(-) delete mode 100644 packages/react-meteor-data/ReactMeteorData.jsx delete mode 100644 packages/react-meteor-data/createContainer.jsx rename packages/react-meteor-data/{react-meteor-data.jsx => index.js} (64%) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index f7c1647b..26235a22 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -108,15 +108,3 @@ export default withTracker(({ listId }) => { 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); -``` diff --git a/packages/react-meteor-data/ReactMeteorData.jsx b/packages/react-meteor-data/ReactMeteorData.jsx deleted file mode 100644 index df0be895..00000000 --- a/packages/react-meteor-data/ReactMeteorData.jsx +++ /dev/null @@ -1,160 +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 default 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(); - }, -}; diff --git a/packages/react-meteor-data/createContainer.jsx b/packages/react-meteor-data/createContainer.jsx deleted file mode 100644 index c2618dcf..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 withTracker from './withTracker.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 withTracker(options)(Component); -} diff --git a/packages/react-meteor-data/react-meteor-data.jsx b/packages/react-meteor-data/index.js similarity index 64% rename from packages/react-meteor-data/react-meteor-data.jsx rename to packages/react-meteor-data/index.js index dc6d7bfa..36d40e42 100644 --- a/packages/react-meteor-data/react-meteor-data.jsx +++ b/packages/react-meteor-data/index.js @@ -4,7 +4,5 @@ checkNpmVersions({ react: '16.8', }, 'react-meteor-data'); -export { default as createContainer } from './createContainer.jsx'; -export { default as ReactMeteorData } from './ReactMeteorData.jsx'; 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..2cbccd31 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -12,7 +12,5 @@ Package.onUse(function (api) { 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'); }); From b7a92d6ec639638c4bdc9bf6c506a963f63cb51d Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 1 Apr 2019 18:17:09 +0200 Subject: [PATCH 022/117] docs : added compatibility notes --- packages/react-meteor-data/README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 26235a22..1004adb9 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -19,10 +19,10 @@ npm install --save react ### Usage This package provides two ways to use Tracker reactive data in your React components: -- a hook: `useTracker`, -- a higher-order component (HOC): `withTracker`. +- a hook: `useTracker` (v2 only, requires React ^16.8) +- a higher-order component (HOC): `withTracker` (v1 and v2). -The `useTracker` hook, introduced in recent versions of `react-meteor-data`, 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. @@ -108,3 +108,19 @@ export default withTracker(({ listId }) => { 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. + +### 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 + +- `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 ("React Suspense"). + + From c135ef078184a99a741202cdbfef6f9291379495 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Mon, 1 Apr 2019 21:33:23 +0200 Subject: [PATCH 023/117] docs : adjust formatting / fix unfinished sentence / link to React lifecycle deprecation notice --- packages/react-meteor-data/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 1004adb9..34e43608 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -19,7 +19,7 @@ npm install --save react ### Usage This package provides two ways to use Tracker reactive data in your React components: -- a hook: `useTracker` (v2 only, requires React ^16.8) +- 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. @@ -113,14 +113,12 @@ For more information, see the [React article](http://guide.meteor.com/react.html - `react-meteor-data` v2.x : - `useTracker` hook + `withTracker` HOC - - Requires React ^16.8. + - 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 +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 ("React Suspense"). - - + - `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"). From e79b596fea3f98535a7efcae41aab526ca018d8f Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Wed, 3 Apr 2019 21:51:21 +0200 Subject: [PATCH 024/117] docs : better doc for the react-hooks/exhaustive-deps ESLint config --- packages/react-meteor-data/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 34e43608..51ef929c 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -67,7 +67,11 @@ function Foo({ listId }) { } ``` -Note : the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) package provides ESLint hints to help detect missing values in the `deps` argument of React built-in hooks. It can be configured with `options: [{additionalHooks: 'useTracker|useSomeOtherHook|...'}]` to also validate the `deps` argument of the `useTracker` hook or some other hooks. +**Note:** the [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) package provides ESLint hints to help detect missing values in the `deps` argument of React built-in hooks. It can be configured to also validate the `deps` argument of the `useTracker` hook or some other hooks, with the following `eslintrc` config: + +``` +"react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "useTracker|useSomeOtherHook|..." }] +``` #### `withTracker(reactiveFn)` higher-order component From 17322a554b50ac50281eac999df1bee0cc99643d Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Wed, 3 Apr 2019 21:58:52 +0200 Subject: [PATCH 025/117] remove dependency on tmeasday:check-npm-versions --- packages/react-meteor-data/index.js | 11 +++++++---- packages/react-meteor-data/package.js | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react-meteor-data/index.js b/packages/react-meteor-data/index.js index 36d40e42..bf99268a 100644 --- a/packages/react-meteor-data/index.js +++ b/packages/react-meteor-data/index.js @@ -1,8 +1,11 @@ -import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; +import React from 'react'; -checkNpmVersions({ - react: '16.8', -}, 'react-meteor-data'); +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 2cbccd31..80df8ff5 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -10,7 +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.mainModule('index.js'); }); From f0a0328c5e54c5223b055240925c229e26800290 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Wed, 3 Apr 2019 22:01:23 +0200 Subject: [PATCH 026/117] optim : only warn about Mongo.Cursor in dev environment --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 3e53d02b..7562498f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -54,7 +54,7 @@ function useTracker(reactiveFn, deps = []) { const computation = Tracker.nonreactive(() => Tracker.autorun(() => { const data = reactiveFn(); - checkCursor(data); + Meteor.isDevelopment && checkCursor(data); setTrackerData(data); }) ); From eb55a1600d7f6e8ff8f7970273ccecf2467163fc Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Wed, 3 Apr 2019 22:42:54 +0200 Subject: [PATCH 027/117] forward references to the inner component (supercedes #266) --- packages/react-meteor-data/withTracker.jsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index 16418b61..78455467 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { forwardRef, memo } from 'react'; import useTracker from './useTracker.js'; export default function withTracker(options) { @@ -6,12 +6,10 @@ export default function withTracker(options) { const expandedOptions = typeof options === 'function' ? { getMeteorData: options } : options; const { getMeteorData, pure = true } = expandedOptions; - // Note : until https://github.com/meteor/react-packages/pull/266 is merged (which forwards ref to the inner Component), - // moving from a class to a function component will break existing code giving refs to withTracker-decorated components. - function WithTracker(props) { + const WithTracker = forwardRef((props, ref) => { const data = useTracker(() => getMeteorData(props) || {}, [props]); - return ; - } + return ; + }); return pure ? memo(WithTracker) : WithTracker; }; From 514a87d263bca09587f361325934474d95f80dc6 Mon Sep 17 00:00:00 2001 From: Yves Chedemois Date: Tue, 23 Apr 2019 13:58:34 +0200 Subject: [PATCH 028/117] fix checkCursor() when reactiveFn returns null --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 7562498f..2e25d3b4 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -5,7 +5,7 @@ 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 && typeof data === 'object') { + if (Package.mongo && Package.mongo.Mongo && data && typeof data === 'object') { if (data instanceof Package.mongo.Mongo.Cursor) { shouldWarn = true; } From fe4fa4216b33d3691c8b72c5529a91c30538e352 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 18:24:37 +0200 Subject: [PATCH 029/117] - rewrite useTracker in order to stay fully consistent with current withTracker behavior - compare deps in order to keep API consistency to React.useEffect() --- packages/react-meteor-data/useTracker.js | 132 +++++++++++++++++------ 1 file changed, 98 insertions(+), 34 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2e25d3b4..0b676ecf 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Tracker } from 'meteor/tracker'; import { Meteor } from 'meteor/meteor'; @@ -28,41 +28,105 @@ 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; - }); - - useEffect(() => { - // Set up the reactive computation. - const computation = Tracker.nonreactive(() => - Tracker.autorun(() => { - const data = reactiveFn(); - Meteor.isDevelopment && checkCursor(data); - setTrackerData(data); +// 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 (!nextDeps || !prevDeps) { + 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; +} + +function useTracker(reactiveFn, deps) { + // 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 reactiveFn(); + } + + const previousDeps = useRef(); + const computation = useRef(); + const trackerData = useRef(); + + const [, forceUpdate] = useState(); + + const dispose = () => { + if (computation.current) { + computation.current.stop(); + computation.current = null; + } + }; + + // this is called like at componentWillMount and componentWillUpdate equally + // simulates a synchronous useEffect, as a replacement for calculateData() + // if prevDeps or deps are not set shallowEqualArray always returns false + if (!areHookInputsEqual(deps, previousDeps.current)) { + dispose(); + + // 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. + computation.current = Tracker.nonreactive(() => ( + Tracker.autorun((c) => { + if (c.firstRun) { + const data = reactiveFn(); + Meteor.isDevelopment && checkCursor(data); + + // store the deps for comparison on next render + previousDeps.current = deps; + trackerData.current = data; + } else { + // makes sure that shallowEqualArray returns false on next render + previousDeps.current = Math.random(); + // 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 the reactive function synchronously whenever we want, e.g. + // from next render. + c.stop(); + // use Math.random() to trigger a state change to enforce a re-render + // Calling forceUpdate() triggers componentWillUpdate which + // calls the reactive function and re-renders the component. + forceUpdate(Math.random()); + } }) - ); - // On effect cleanup, stop the computation. - return () => computation.stop(); - }, deps); + )); + } + + // stop the computation on unmount only + useEffect(() => dispose, []); - return trackerData; + return trackerData.current; } // When rendering on the server, we don't want to use the Tracker. From fbc33c6888813b8f029bf84501623644c7ffb3c5 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 18:30:31 +0200 Subject: [PATCH 030/117] - withTracker should always recompute on re-render so deps for useTracker() can be omitted - also React.memo() already has a check for prop change so there is no need to check for changed deps again --- packages/react-meteor-data/withTracker.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ; }); From c8d8645887cc1fa7c433446daa4a4694bef163b6 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 18:39:04 +0200 Subject: [PATCH 031/117] update Readme to reflect omitted deps behavior --- packages/react-meteor-data/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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'; From 44b5247a9ede8582e86a137398b041498543c070 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 18:47:02 +0200 Subject: [PATCH 032/117] fix code comment --- packages/react-meteor-data/useTracker.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 0b676ecf..d057f7ab 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -83,8 +83,9 @@ function useTracker(reactiveFn, deps) { }; // this is called like at componentWillMount and componentWillUpdate equally - // simulates a synchronous useEffect, as a replacement for calculateData() - // if prevDeps or deps are not set shallowEqualArray always returns false + // 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, previousDeps.current)) { dispose(); From ddfb7cd61b1e564d9a5144ee67797399ccc08055 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 20:29:33 +0200 Subject: [PATCH 033/117] get rid of Math.random(), wasn't needed at all --- packages/react-meteor-data/useTracker.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d057f7ab..d3ed1da0 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -104,8 +104,9 @@ function useTracker(reactiveFn, deps) { previousDeps.current = deps; trackerData.current = data; } else { - // makes sure that shallowEqualArray returns false on next render - previousDeps.current = Math.random(); + // makes sure that shallowEqualArray returns false + // which is always the case when prev or nextDeps are not set + previousDeps.current = null; // 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 From 365584f02b0a94dd6552a55827e2dc99596a6634 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 20:30:49 +0200 Subject: [PATCH 034/117] don't handle Meteor.isServer as it's already been taken care of in exports --- packages/react-meteor-data/useTracker.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d3ed1da0..adfa44a9 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -63,12 +63,6 @@ function areHookInputsEqual(nextDeps, prevDeps) { } function useTracker(reactiveFn, deps) { - // 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 reactiveFn(); - } - const previousDeps = useRef(); const computation = useRef(); const trackerData = useRef(); From c3803b96cf2eb843c8df549753586d398352f201 Mon Sep 17 00:00:00 2001 From: menelike Date: Fri, 21 Jun 2019 21:02:57 +0200 Subject: [PATCH 035/117] replace Math.random() when enforcing an update --- packages/react-meteor-data/useTracker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index adfa44a9..aca41184 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -62,6 +62,7 @@ function areHookInputsEqual(nextDeps, prevDeps) { return true; } +let uniqueCounter = 0; function useTracker(reactiveFn, deps) { const previousDeps = useRef(); const computation = useRef(); @@ -113,7 +114,7 @@ function useTracker(reactiveFn, deps) { // use Math.random() to trigger a state change to enforce a re-render // Calling forceUpdate() triggers componentWillUpdate which // calls the reactive function and re-renders the component. - forceUpdate(Math.random()); + forceUpdate(++uniqueCounter); } }) )); From eeb115a942c7711166c9859d4ae59ecc788d11b2 Mon Sep 17 00:00:00 2001 From: menelike Date: Sat, 22 Jun 2019 08:16:42 +0200 Subject: [PATCH 036/117] - align areHookInputsEqual with https://github.com/facebook/react/blob/d77d12510b1a1c37484d771a323e0a02cbeb9ba7/packages/react-reconciler/src/ReactFiberHooks.js#L231 https://github.com/facebook/react/blob/d77d12510b1a1c37484d771a323e0a02cbeb9ba7/packages/react-reconciler/src/ReactFiberHooks.js#L318-L329 https://github.com/facebook/react/blob/d77d12510b1a1c37484d771a323e0a02cbeb9ba7/packages/react-reconciler/src/ReactFiberHooks.js#L878 - update comments --- packages/react-meteor-data/useTracker.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index aca41184..23724755 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -43,7 +43,12 @@ function is(x, y) { // used to replicate dep change behavior and stay consistent // with React.useEffect() function areHookInputsEqual(nextDeps, prevDeps) { - if (!nextDeps || !prevDeps) { + if (prevDeps === null || prevDeps === undefined) { + return false; + } + + // checking prevDeps is unnecessary as prevDeps is always the last version of nextDeps + if (!Array.isArray(nextDeps)) { return false; } @@ -100,10 +105,10 @@ function useTracker(reactiveFn, deps) { trackerData.current = data; } else { // makes sure that shallowEqualArray returns false - // which is always the case when prev or nextDeps are not set + // which is always the case when prevDeps is null previousDeps.current = null; // Stop this computation instead of using the re-run. - // We use a brand-new autorun for each call to getMeteorData + // We use a brand-new autorun for each call // 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 @@ -111,9 +116,9 @@ function useTracker(reactiveFn, deps) { // re-call the reactive function synchronously whenever we want, e.g. // from next render. c.stop(); - // use Math.random() to trigger a state change to enforce a re-render - // Calling forceUpdate() triggers componentWillUpdate which - // calls the reactive function and re-renders the component. + // use a uniqueCounter to trigger a state change to enforce a re-render + // which calls the reactive function and re-renders the component with + // new data from the reactive function. forceUpdate(++uniqueCounter); } }) From 1eb71ddd28becbccc9c89a28601b25fa74c5d5b0 Mon Sep 17 00:00:00 2001 From: menelike Date: Sat, 22 Jun 2019 08:28:33 +0200 Subject: [PATCH 037/117] warn if dep is not an array when Meteor isDevelopment --- packages/react-meteor-data/useTracker.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 23724755..8b2b727e 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -49,6 +49,14 @@ function areHookInputsEqual(nextDeps, prevDeps) { // checking prevDeps is unnecessary as prevDeps is always the last version of nextDeps if (!Array.isArray(nextDeps)) { + if (Meteor.isDevelopment) { + // Use React.warn() if available (should ship in React 16.9). + const warn = React.warn || console.warn.bind(console); + warn( + 'Warning: useTracker expected an dependency value of ' + + `type array but got type of ${typeof nextDeps} instead.` + ); + } return false; } @@ -68,6 +76,7 @@ function areHookInputsEqual(nextDeps, prevDeps) { } let uniqueCounter = 0; + function useTracker(reactiveFn, deps) { const previousDeps = useRef(); const computation = useRef(); From 6b540e6d57c1d922220cf67e6ecd0771f54e12d4 Mon Sep 17 00:00:00 2001 From: menelike Date: Sat, 22 Jun 2019 10:26:55 +0200 Subject: [PATCH 038/117] fix prevDeps isArray check --- packages/react-meteor-data/useTracker.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 8b2b727e..2d27b6c4 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -43,11 +43,10 @@ function is(x, y) { // used to replicate dep change behavior and stay consistent // with React.useEffect() function areHookInputsEqual(nextDeps, prevDeps) { - if (prevDeps === null || prevDeps === undefined) { + if (prevDeps === null || prevDeps === undefined || !Array.isArray(prevDeps)) { return false; } - // checking prevDeps is unnecessary as prevDeps is always the last version of nextDeps if (!Array.isArray(nextDeps)) { if (Meteor.isDevelopment) { // Use React.warn() if available (should ship in React 16.9). From 8d64e41c9168ba905e5ff9153aeb7cbf4cdc9274 Mon Sep 17 00:00:00 2001 From: menelike Date: Sat, 22 Jun 2019 11:21:21 +0200 Subject: [PATCH 039/117] warn if initial deps is not an array --- packages/react-meteor-data/useTracker.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2d27b6c4..1bbb851a 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -134,7 +134,20 @@ function useTracker(reactiveFn, deps) { } // stop the computation on unmount only - useEffect(() => dispose, []); + useEffect(() => { + if (Meteor.isDevelopment + && deps !== null && deps !== undefined + && !Array.isArray(deps)) { + // Use React.warn() if available (should ship in React 16.9). + const warn = React.warn || console.warn.bind(console); + warn( + 'Warning: useTracker expected an initial dependency value of ' + + `type array but got type of ${typeof deps} instead.` + ); + } + + return dispose; + }, []); return trackerData.current; } From b1996a263419bf15569d4f4b30c7d51479632260 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 1 Jul 2019 15:12:59 -0400 Subject: [PATCH 040/117] Retain synchronous render behavior for firstRun (including after deps change), but allow async and computation reuse after. --- packages/react-meteor-data/useTracker.js | 34 +++++++++--------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 1bbb851a..21c8ad57 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -95,38 +95,30 @@ function useTracker(reactiveFn, deps) { // if prevDeps or deps are not set areHookInputsEqual always returns false // and the reactive functions is always called if (!areHookInputsEqual(deps, previousDeps.current)) { - dispose(); - // 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. - computation.current = Tracker.nonreactive(() => ( + Tracker.nonreactive(() => ( Tracker.autorun((c) => { + // This will capture data synchronously on first run (and after deps change). + // Additional cycles will follow the normal computation behavior. + const data = reactiveFn(); + if (Meteor.isDevelopment) checkCursor(data); + trackerData.current = data; + if (c.firstRun) { - const data = reactiveFn(); - Meteor.isDevelopment && checkCursor(data); + // if we are re-creating the computation, we need to stop the old one. + dispose(); + + // store the new computation + computation.current = c; // store the deps for comparison on next render previousDeps.current = deps; - trackerData.current = data; } else { - // makes sure that shallowEqualArray returns false - // which is always the case when prevDeps is null - previousDeps.current = null; - // Stop this computation instead of using the re-run. - // We use a brand-new autorun for each call - // 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 the reactive function synchronously whenever we want, e.g. - // from next render. - c.stop(); - // use a uniqueCounter to trigger a state change to enforce a re-render - // which calls the reactive function and re-renders the component with - // new data from the reactive function. + // use a uniqueCounter to trigger a state change to force a re-render forceUpdate(++uniqueCounter); } }) From af6a4a0296d0df24296369c47ecb12babb4a8d4f Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 1 Jul 2019 15:17:33 -0400 Subject: [PATCH 041/117] Fix eslint errors --- packages/react-meteor-data/useTracker.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 1bbb851a..c16bb3bf 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,6 +1,8 @@ +/* global Meteor, Package, Tracker */ import React, { useState, useEffect, useRef } from 'react'; -import { Tracker } from 'meteor/tracker'; -import { Meteor } from 'meteor/meteor'; + +// 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 ' @@ -49,8 +48,6 @@ function areHookInputsEqual(nextDeps, prevDeps) { if (!Array.isArray(nextDeps)) { if (Meteor.isDevelopment) { - // Use React.warn() if available (should ship in React 16.9). - const warn = React.warn || console.warn.bind(console); warn( 'Warning: useTracker expected an dependency value of ' + `type array but got type of ${typeof nextDeps} instead.` @@ -106,7 +103,7 @@ function useTracker(reactiveFn, deps) { Tracker.autorun((c) => { if (c.firstRun) { const data = reactiveFn(); - Meteor.isDevelopment && checkCursor(data); + if (Meteor.isDevelopment) checkCursor(data); // store the deps for comparison on next render previousDeps.current = deps; @@ -138,8 +135,6 @@ function useTracker(reactiveFn, deps) { if (Meteor.isDevelopment && deps !== null && deps !== undefined && !Array.isArray(deps)) { - // Use React.warn() if available (should ship in React 16.9). - const warn = React.warn || console.warn.bind(console); warn( 'Warning: useTracker expected an initial dependency value of ' + `type array but got type of ${typeof deps} instead.` @@ -154,8 +149,8 @@ function useTracker(reactiveFn, deps) { // 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); From be11569d96011112a577cd448e23776dab8cbb92 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 2 Jul 2019 17:50:57 -0400 Subject: [PATCH 042/117] disambiguate the disposition of previous computation - this works the same as before, but is easier to read --- packages/react-meteor-data/useTracker.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index a9e02993..82a1b36f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -92,12 +92,15 @@ function useTracker(reactiveFn, deps) { // if prevDeps or deps are not set areHookInputsEqual always returns false // and the reactive functions is always called if (!areHookInputsEqual(deps, previousDeps.current)) { + // if we are re-creating the computation, we need to stop the old one. + dispose(); + // 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. - Tracker.nonreactive(() => ( + computation.current = Tracker.nonreactive(() => ( Tracker.autorun((c) => { // This will capture data synchronously on first run (and after deps change). // Additional cycles will follow the normal computation behavior. @@ -106,12 +109,6 @@ function useTracker(reactiveFn, deps) { trackerData.current = data; if (c.firstRun) { - // if we are re-creating the computation, we need to stop the old one. - dispose(); - - // store the new computation - computation.current = c; - // store the deps for comparison on next render previousDeps.current = deps; } else { From 80fef10086f345b6f375682e9ebcb291d87b4b1c Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 4 Jul 2019 13:26:03 -0400 Subject: [PATCH 043/117] Use 1 useRef instead of multiple --- packages/react-meteor-data/useTracker.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 82a1b36f..52c4efed 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -74,16 +74,14 @@ function areHookInputsEqual(nextDeps, prevDeps) { let uniqueCounter = 0; function useTracker(reactiveFn, deps) { - const previousDeps = useRef(); - const computation = useRef(); - const trackerData = useRef(); + const { current: refs } = useRef({}); const [, forceUpdate] = useState(); const dispose = () => { - if (computation.current) { - computation.current.stop(); - computation.current = null; + if (refs.computation) { + refs.computation.stop(); + refs.computation = null; } }; @@ -91,7 +89,7 @@ function useTracker(reactiveFn, deps) { // 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, previousDeps.current)) { + if (!areHookInputsEqual(deps, refs.previousDeps)) { // if we are re-creating the computation, we need to stop the old one. dispose(); @@ -100,17 +98,17 @@ function useTracker(reactiveFn, deps) { // 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. - computation.current = Tracker.nonreactive(() => ( + refs.computation = Tracker.nonreactive(() => ( Tracker.autorun((c) => { // This will capture data synchronously on first run (and after deps change). // Additional cycles will follow the normal computation behavior. const data = reactiveFn(); if (Meteor.isDevelopment) checkCursor(data); - trackerData.current = data; + refs.trackerData = data; if (c.firstRun) { // store the deps for comparison on next render - previousDeps.current = deps; + refs.previousDeps = deps; } else { // use a uniqueCounter to trigger a state change to force a re-render forceUpdate(++uniqueCounter); @@ -133,7 +131,7 @@ function useTracker(reactiveFn, deps) { return dispose; }, []); - return trackerData.current; + return refs.trackerData; } // When rendering on the server, we don't want to use the Tracker. From 8ae0db41e95d818391689759521fb751cab84906 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 4 Jul 2019 13:36:45 -0400 Subject: [PATCH 044/117] If reactiveFn will run synchrously next render, don't bother running it asynchronously --- packages/react-meteor-data/useTracker.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 52c4efed..ef37f3e6 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -93,6 +93,9 @@ function useTracker(reactiveFn, deps) { // 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 @@ -100,16 +103,22 @@ function useTracker(reactiveFn, deps) { // it stops the inner one. refs.computation = Tracker.nonreactive(() => ( Tracker.autorun((c) => { - // This will capture data synchronously on first run (and after deps change). - // Additional cycles will follow the normal computation behavior. - const data = reactiveFn(); - if (Meteor.isDevelopment) checkCursor(data); - refs.trackerData = data; + const runReactiveFn = () => { + const data = reactiveFn(); + if (Meteor.isDevelopment) checkCursor(data); + refs.trackerData = data; + }; if (c.firstRun) { - // store the deps for comparison on next render - refs.previousDeps = deps; + // This will capture data synchronously on first run (and after deps change). + // Additional cycles will follow the normal computation behavior. + runReactiveFn(); } else { + // Only run reactiveFn if the hooks have not change, or are not falsy. + if (areHookInputsEqual(deps, refs.previousDeps)) { + runReactiveFn(); + } + // If deps have changed or are falsy, let the reactiveFn run on next render. // use a uniqueCounter to trigger a state change to force a re-render forceUpdate(++uniqueCounter); } From f68ae5be17eae82e46f391fc1cc1f4abc2ae8dd7 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 4 Jul 2019 14:21:15 -0400 Subject: [PATCH 045/117] Dispose of the computation early, if the deps are falsy on meteor reactive changes --- packages/react-meteor-data/useTracker.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index ef37f3e6..7b4ab5bf 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -114,11 +114,13 @@ function useTracker(reactiveFn, deps) { // Additional cycles will follow the normal computation behavior. runReactiveFn(); } else { - // Only run reactiveFn if the hooks have not change, or are not falsy. - if (areHookInputsEqual(deps, refs.previousDeps)) { + // Only run reactiveFn if the deps or are not falsy. + if (!deps || !refs.previousDeps) { + // Dispose early, if refs are falsy - we'll rebuild and run on the next render. + dispose(); + } else { runReactiveFn(); } - // If deps have changed or are falsy, let the reactiveFn run on next render. // use a uniqueCounter to trigger a state change to force a re-render forceUpdate(++uniqueCounter); } From c58702612c5c86b6adab6193e6e6a133adf318b6 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 5 Jul 2019 01:53:23 -0400 Subject: [PATCH 046/117] previousDeps will always equal deps at this point, so just check previousDeps --- packages/react-meteor-data/useTracker.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 7b4ab5bf..fff0af45 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -114,9 +114,8 @@ function useTracker(reactiveFn, deps) { // Additional cycles will follow the normal computation behavior. runReactiveFn(); } else { - // Only run reactiveFn if the deps or are not falsy. - if (!deps || !refs.previousDeps) { - // Dispose early, if refs are falsy - we'll rebuild and run on the next render. + // If deps are falsy, stop computation and let next render handle reactiveFn. + if (!refs.previousDeps) { dispose(); } else { runReactiveFn(); From e63cbd031ce780f18d062facf78b41b7e783c5ef Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 18 Jul 2019 15:02:51 -0400 Subject: [PATCH 047/117] Implement a computationHandler API, with a cleanup method --- packages/react-meteor-data/useTracker.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index fff0af45..8ad4df3f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -73,12 +73,16 @@ function areHookInputsEqual(nextDeps, prevDeps) { let uniqueCounter = 0; -function useTracker(reactiveFn, deps) { +function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); const [, forceUpdate] = useState(); const dispose = () => { + if (refs.computationCleanup) { + refs.computationCleanup(); + delete refs.computationCleanup; + } if (refs.computation) { refs.computation.stop(); refs.computation = null; @@ -110,6 +114,11 @@ function useTracker(reactiveFn, deps) { }; if (c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (computationHandler) { + refs.computationCleanup = computationHandler(c); + } // This will capture data synchronously on first run (and after deps change). // Additional cycles will follow the normal computation behavior. runReactiveFn(); From c9d2e2f2de4eeaf32c58f292649b570df738fde4 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 18 Jul 2019 17:14:48 -0400 Subject: [PATCH 048/117] use a self contained forceUpdate value --- packages/react-meteor-data/useTracker.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index fff0af45..8ca6c487 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -71,12 +71,10 @@ function areHookInputsEqual(nextDeps, prevDeps) { return true; } -let uniqueCounter = 0; - function useTracker(reactiveFn, deps) { const { current: refs } = useRef({}); - const [, forceUpdate] = useState(); + const [counter, forceUpdate] = useState(0); const dispose = () => { if (refs.computation) { @@ -121,7 +119,7 @@ function useTracker(reactiveFn, deps) { runReactiveFn(); } // use a uniqueCounter to trigger a state change to force a re-render - forceUpdate(++uniqueCounter); + forceUpdate(counter + 1); } }) )); From 1315b55c11912da6a39969c7d3afa8e2f9c098c7 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 12:42:55 -0400 Subject: [PATCH 049/117] Add a check and a warning for the appropriate return type from computationHandler --- packages/react-meteor-data/useTracker.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 8ad4df3f..acd70615 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -117,7 +117,16 @@ function useTracker(reactiveFn, deps, computationHandler) { // If there is a computationHandler, pass it the computation, and store the // result, which may be a cleanup method. if (computationHandler) { - refs.computationCleanup = computationHandler(c); + const cleanupHandler = computationHandler(c); + if (cleanupHandler) { + if (Meteor.isDevelopment && cleanupHandler !== 'function') { + warn( + 'Warning: Computation handler should only return a function ' + + 'to be used for cleanup, and never return any other value.' + ); + } + refs.computationCleanup = cleanupHandler; + } } // This will capture data synchronously on first run (and after deps change). // Additional cycles will follow the normal computation behavior. From bb29e321301c28e235e9709e4c73fa6c372bf35c Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 13:48:53 -0400 Subject: [PATCH 050/117] Rewrite readme for brevity and to update and explain optional `deps` --- packages/react-meteor-data/README.md | 38 ++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index e4d953c4..c75a837b 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -22,19 +22,19 @@ 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 `withTracker` HOC can be used with all components, function or class. +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. -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. +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 (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, the Tracker computation will be recreated on every call. +- `reactiveFn`: A Tracker reactive function (with no parameters). +- `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'; @@ -42,14 +42,20 @@ 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. + // 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. + // 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]); @@ -119,9 +125,9 @@ For more information, see the [React article](http://guide.meteor.com/react.html - `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. - + - 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`. From 574416a6ff0d00c5a789faa783b3bb644f909c23 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 14:59:57 -0400 Subject: [PATCH 051/117] Add some basic tests for useTracker --- packages/react-meteor-data/index.js | 1 + packages/react-meteor-data/package-lock.json | 132 ++++++++++++++++++ packages/react-meteor-data/package.js | 10 +- packages/react-meteor-data/package.json | 9 ++ packages/react-meteor-data/tests.js | 1 + .../react-meteor-data/useTracker.tests.js | 77 ++++++++++ 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/react-meteor-data/package-lock.json create mode 100644 packages/react-meteor-data/package.json create mode 100644 packages/react-meteor-data/tests.js create mode 100644 packages/react-meteor-data/useTracker.tests.js diff --git a/packages/react-meteor-data/index.js b/packages/react-meteor-data/index.js index bf99268a..8769794b 100644 --- a/packages/react-meteor-data/index.js +++ b/packages/react-meteor-data/index.js @@ -1,3 +1,4 @@ +/* global Meteor*/ import React from 'react'; if (Meteor.isDevelopment) { 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 80df8ff5..ebd1987d 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', @@ -6,10 +8,16 @@ Package.describe({ git: 'https://github.com/meteor/react-packages', }); -Package.onUse(function (api) { +Package.onUse((api) => { api.versionsFrom('1.3'); api.use('tracker'); api.use('ecmascript'); api.mainModule('index.js'); }); + +Package.onTest((api) => { + api.use(['ecmascript', 'reactive-dict', 'tracker', 'tinytest']); + 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/tests.js b/packages/react-meteor-data/tests.js new file mode 100644 index 00000000..db53dbe6 --- /dev/null +++ b/packages/react-meteor-data/tests.js @@ -0,0 +1 @@ +import './useTracker.tests.js'; diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js new file mode 100644 index 00000000..5dcdb378 --- /dev/null +++ b/packages/react-meteor-data/useTracker.tests.js @@ -0,0 +1,77 @@ +/* global Tinytest */ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { ReactiveDict } from 'meteor/reactive-dict'; + +import useTracker from './useTracker'; + +Tinytest.add('useTracker - no deps', async function (test) { + const reactiveDict = new ReactiveDict('test1', { key: 'initial' }); + let renderCount = 0; + + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + () => useTracker(() => { + renderCount++; + return reactiveDict.get('key'); + }) + ); + + test.equal(result.current, 'initial', 'Expect initial value to be "initial"'); + test.equal(renderCount, 1, 'Should run rendered 1 times'); + + if (Meteor.isServer) return; + + act(() => reactiveDict.set('key', 'changed')); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect new value to be "changed"'); + test.equal(renderCount, 2, 'Should run rendered 2 times'); + + rerender(); + + test.equal(result.current, 'changed', 'Expect value of "changed" to persist after rerender'); + test.equal(renderCount, 3, 'Should run rendered 3 times'); + + unmount(); + reactiveDict.destroy(); + test.equal(renderCount, 3, 'Should run rendered 3 times'); +}); + +Tinytest.add('useTracker - with deps', async function (test) { + const reactiveDict = new ReactiveDict('test2', {}); + let renderCount = 0; + + const { result, rerender, unmount, waitForNextUpdate } = renderHook( + ({ name }) => useTracker(() => { + renderCount++; + 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(renderCount, 1, 'Should run rendered 1 times'); + + if (Meteor.isServer) return; + + act(() => reactiveDict.set('name', 'changed')); + await waitForNextUpdate(); + + test.equal(result.current, 'changed', 'Expect the new value for given name to be "changed"'); + test.equal(renderCount, 2, 'Should run rendered 2 times'); + + rerender(); + + test.equal(result.current, 'changed', 'Expect the new value "changed" for given name to have persisted through render'); + test.equal(renderCount, 3, 'Should run rendered 3 times'); + + rerender({ name: 'different' }); + + test.equal(result.current, 'default', 'After deps change, the default value should have returned'); + test.equal(renderCount, 4, 'Should run rendered 4 times'); + + unmount(); + reactiveDict.destroy(); + test.equal(renderCount, 4, 'Should run rendered 4 times'); +}); From 796ce6d3cbae451e200f236047954936a0ecac40 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 15:18:20 -0400 Subject: [PATCH 052/117] add some after unmount tests --- .../react-meteor-data/useTracker.tests.js | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 5dcdb378..5f8d0549 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -7,17 +7,17 @@ import useTracker from './useTracker'; Tinytest.add('useTracker - no deps', async function (test) { const reactiveDict = new ReactiveDict('test1', { key: 'initial' }); - let renderCount = 0; + let runCount = 0; const { result, rerender, unmount, waitForNextUpdate } = renderHook( () => useTracker(() => { - renderCount++; + runCount++; return reactiveDict.get('key'); }) ); test.equal(result.current, 'initial', 'Expect initial value to be "initial"'); - test.equal(renderCount, 1, 'Should run rendered 1 times'); + test.equal(runCount, 1, 'Should have run 1 times'); if (Meteor.isServer) return; @@ -25,25 +25,32 @@ Tinytest.add('useTracker - no deps', async function (test) { await waitForNextUpdate(); test.equal(result.current, 'changed', 'Expect new value to be "changed"'); - test.equal(renderCount, 2, 'Should run rendered 2 times'); + test.equal(runCount, 2, 'Should have run 2 times'); rerender(); test.equal(result.current, 'changed', 'Expect value of "changed" to persist after rerender'); - test.equal(renderCount, 3, 'Should run rendered 3 times'); + test.equal(runCount, 3, 'Should have run 3 times'); unmount(); + test.equal(runCount, 3, 'Unmount should not cause a tracker run'); + + act(() => reactiveDict.set('different', 'changed again')); + // 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, 3, 'After unmount, useTracker should no longer be tracking'); + reactiveDict.destroy(); - test.equal(renderCount, 3, 'Should run rendered 3 times'); }); Tinytest.add('useTracker - with deps', async function (test) { const reactiveDict = new ReactiveDict('test2', {}); - let renderCount = 0; + let runCount = 0; const { result, rerender, unmount, waitForNextUpdate } = renderHook( ({ name }) => useTracker(() => { - renderCount++; + runCount++; reactiveDict.setDefault(name, 'default'); return reactiveDict.get(name); }, [name]), @@ -51,7 +58,7 @@ Tinytest.add('useTracker - with deps', async function (test) { ); test.equal(result.current, 'default', 'Expect the default value for given name to be "default"'); - test.equal(renderCount, 1, 'Should run rendered 1 times'); + test.equal(runCount, 1, 'Should have run 1 times'); if (Meteor.isServer) return; @@ -59,19 +66,28 @@ Tinytest.add('useTracker - with deps', async function (test) { await waitForNextUpdate(); test.equal(result.current, 'changed', 'Expect the new value for given name to be "changed"'); - test.equal(renderCount, 2, 'Should run rendered 2 times'); + 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(renderCount, 3, 'Should run rendered 3 times'); + 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(renderCount, 4, 'Should run rendered 4 times'); + 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')); + + 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(); - test.equal(renderCount, 4, 'Should run rendered 4 times'); }); From f0df78a443080a6f137cd6bd3a9bdc267fff2455 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 15:18:55 -0400 Subject: [PATCH 053/117] make sure we aren't accidentally triggering reactiveDict's migration framework --- packages/react-meteor-data/useTracker.tests.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 5f8d0549..b0b8b868 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -6,7 +6,8 @@ import { ReactiveDict } from 'meteor/reactive-dict'; import useTracker from './useTracker'; Tinytest.add('useTracker - no deps', async function (test) { - const reactiveDict = new ReactiveDict('test1', { key: 'initial' }); + const reactiveDict = new ReactiveDict(); + reactiveDict.setDefault('key', 'initial') let runCount = 0; const { result, rerender, unmount, waitForNextUpdate } = renderHook( @@ -45,7 +46,7 @@ Tinytest.add('useTracker - no deps', async function (test) { }); Tinytest.add('useTracker - with deps', async function (test) { - const reactiveDict = new ReactiveDict('test2', {}); + const reactiveDict = new ReactiveDict(); let runCount = 0; const { result, rerender, unmount, waitForNextUpdate } = renderHook( From 0c1dd3c4835e89d7248a666f5f79d8eea0d43bc3 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 15:40:29 -0400 Subject: [PATCH 054/117] Test changes to enclosed values when not using deps too --- .../react-meteor-data/useTracker.tests.js | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index b0b8b868..c0060e2f 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -7,21 +7,20 @@ import useTracker from './useTracker'; Tinytest.add('useTracker - no deps', async function (test) { const reactiveDict = new ReactiveDict(); - reactiveDict.setDefault('key', 'initial') let runCount = 0; const { result, rerender, unmount, waitForNextUpdate } = renderHook( - () => useTracker(() => { + ({ name }) => useTracker(() => { runCount++; - return reactiveDict.get('key'); - }) + 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'); - if (Meteor.isServer) return; - act(() => reactiveDict.set('key', 'changed')); await waitForNextUpdate(); @@ -29,18 +28,25 @@ Tinytest.add('useTracker - no deps', async function (test) { 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, 3, 'Unmount should not cause a tracker run'); + test.equal(runCount, 4, 'Unmount should not cause a tracker run'); act(() => reactiveDict.set('different', 'changed again')); // 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, 3, 'After unmount, useTracker should no longer be tracking'); + test.equal(runCount, 4, 'After unmount, useTracker should no longer be tracking'); reactiveDict.destroy(); }); @@ -61,8 +67,6 @@ Tinytest.add('useTracker - with deps', async function (test) { 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.isServer) return; - act(() => reactiveDict.set('name', 'changed')); await waitForNextUpdate(); From 68ec9c953939b0872f250f80ce93eebfd48686e7 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 22:38:24 -0400 Subject: [PATCH 055/117] Allow setting deps in withTracker options --- packages/react-meteor-data/withTracker.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index e6e6a9f2..6eee1ba3 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -4,10 +4,10 @@ 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 { getMeteorData, pure = true, deps = null } = expandedOptions; const WithTracker = forwardRef((props, ref) => { - const data = useTracker(() => getMeteorData(props) || {}); + const data = useTracker(() => getMeteorData(props) || {}, deps); return ; }); From a4ce87dad31d4b5f4adfa4fbcc998d01d538b2d8 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 19 Jul 2019 22:48:41 -0400 Subject: [PATCH 056/117] add missing typeof operator --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2cab12c6..d3393018 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -117,7 +117,7 @@ function useTracker(reactiveFn, deps, computationHandler) { if (computationHandler) { const cleanupHandler = computationHandler(c); if (cleanupHandler) { - if (Meteor.isDevelopment && cleanupHandler !== 'function') { + if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { warn( 'Warning: Computation handler should only return a function ' + 'to be used for cleanup, and never return any other value.' From f44809fd9ee5881000040be776331ef4818a5bfc Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sat, 20 Jul 2019 12:18:57 -0400 Subject: [PATCH 057/117] Use the full hook on the server, to allow complete computation lifecycle --- packages/react-meteor-data/useTracker.js | 104 ++++++++++------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d3393018..8b6ce1a8 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -98,72 +98,60 @@ function useTracker(reactiveFn, deps, computationHandler) { // 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) { - // If there is a computationHandler, pass it the computation, and store the - // result, which may be a cleanup method. - if (computationHandler) { - const cleanupHandler = computationHandler(c); - if (cleanupHandler) { - if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { - warn( - 'Warning: Computation handler should only return a function ' - + 'to be used for cleanup, and never return any other value.' - ); - } - refs.computationCleanup = cleanupHandler; + const tracked = (c) => { + const runReactiveFn = () => { + const data = reactiveFn(); + if (Meteor.isDevelopment) checkCursor(data); + refs.trackerData = data; + }; + + if (c === null || c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (computationHandler) { + const cleanupHandler = computationHandler(c); + if (cleanupHandler) { + if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { + warn( + 'Warning: Computation handler should return a function ' + + 'to be used for cleanup or nothing.' + ); } + refs.computationCleanup = cleanupHandler; } - // This will capture data synchronously on first run (and after deps change). - // Additional cycles will follow the normal computation behavior. - runReactiveFn(); + } + // 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 { - // 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(counter + 1); + runReactiveFn(); } - }) - )); - } + // use a uniqueCounter to trigger a state change to force a re-render + forceUpdate(counter + 1); + } + } - // 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.` - ); + // When rendering on the server, we don't want to use the Tracker. + if (Meteor.isServer) { + refs.computation = tracked(null); + } else { + // 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(tracked)); } + } - return dispose; - }, []); + // stop the computation on unmount + useEffect(() => 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); +export default useTracker; From 85fb4844e36f6643cd7a9b0b4ddb13e0585a2fb1 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sat, 20 Jul 2019 12:19:36 -0400 Subject: [PATCH 058/117] Add some argument checks and move the deps check to the same block --- packages/react-meteor-data/useTracker.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 8b6ce1a8..199b341c 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -72,6 +72,27 @@ function areHookInputsEqual(nextDeps, prevDeps) { } function useTracker(reactiveFn, deps, computationHandler) { + if (Meteor.isDevelopment) { + 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 (typeof computationHandler !== 'function') { + warn( + `Warning: useTracker expected a function in it's third argument` + + `(computationHandler), but got type of ${typeof computationHandler}.` + ); + } + } + const { current: refs } = useRef({}); const [counter, forceUpdate] = useState(0); From 9975c73e3e73cb6a82993cca3abbb69f33f09c2f Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sat, 20 Jul 2019 12:20:06 -0400 Subject: [PATCH 059/117] Add tests for the computation lifecycle --- .../react-meteor-data/useTracker.tests.js | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index c0060e2f..b476c70b 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -8,45 +8,66 @@ 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) => { + 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'); + 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')); await waitForNextUpdate(); test.equal(result.current, 'changed', 'Expect new value to be "changed"'); test.equal(runCount, 2, 'Should have run 2 times'); + 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'); + 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'); + 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'); + 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')); // 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'); + 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(); }); @@ -54,45 +75,67 @@ Tinytest.add('useTracker - no deps', async function (test) { 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]), + }, [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'); + 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')); 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'); + 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'); + 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'); + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 1, 'Should have been destroyed 1 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? + test.equal(runCount, 4, 'Unmount should not cause a tracker run'); + 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')); 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'); + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); reactiveDict.destroy(); }); From 7de2ea65b5223f4b1bf4516ebe08945333bd53a3 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sat, 20 Jul 2019 12:51:09 -0400 Subject: [PATCH 060/117] Fix forceUpdate by using a reference to update the counter value --- packages/react-meteor-data/useTracker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 8ca6c487..f4c5fd43 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -75,6 +75,7 @@ function useTracker(reactiveFn, deps) { const { current: refs } = useRef({}); const [counter, forceUpdate] = useState(0); + refs.counter = counter const dispose = () => { if (refs.computation) { @@ -119,7 +120,7 @@ function useTracker(reactiveFn, deps) { runReactiveFn(); } // use a uniqueCounter to trigger a state change to force a re-render - forceUpdate(counter + 1); + forceUpdate(refs.counter + 1); } }) )); From 9e59a83c69a30971b3aa3fa5de19c101dfd49ddc Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sat, 20 Jul 2019 13:50:53 -0400 Subject: [PATCH 061/117] add a comment explaining the use of a reference with forceUpdate and the counter var --- packages/react-meteor-data/useTracker.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index f4c5fd43..4112c768 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -119,7 +119,10 @@ function useTracker(reactiveFn, deps) { } else { runReactiveFn(); } - // use a uniqueCounter to trigger a state change to force a re-render + // Increment a reference to counter to trigger a state change to force a re-render + // Since this computation callback is reused, we'll need to make sure to access the + // counter value from a reference instead of using the enclosed value, so we can + // get the value of any updates. forceUpdate(refs.counter + 1); } }) From bff72c7c291743acd5348f81846ea2679000666c Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Sun, 21 Jul 2019 15:02:42 -0400 Subject: [PATCH 062/117] Use a much cleaner forceUpdate method based on useReducer --- packages/react-meteor-data/useTracker.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 4112c768..cf442365 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,5 +1,5 @@ /* global Meteor, Package, Tracker */ -import React, { useState, useEffect, useRef } from 'react'; +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); @@ -71,11 +71,14 @@ function areHookInputsEqual(nextDeps, prevDeps) { 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 [counter, forceUpdate] = useState(0); - refs.counter = counter + const [, forceUpdate] = useReducer(fur, 0); const dispose = () => { if (refs.computation) { @@ -119,11 +122,7 @@ function useTracker(reactiveFn, deps) { } else { runReactiveFn(); } - // Increment a reference to counter to trigger a state change to force a re-render - // Since this computation callback is reused, we'll need to make sure to access the - // counter value from a reference instead of using the enclosed value, so we can - // get the value of any updates. - forceUpdate(refs.counter + 1); + forceUpdate(); } }) )); From b9d69114b052a3a58a78f33479762e5e5be90864 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 22 Jul 2019 10:58:57 -0400 Subject: [PATCH 063/117] Update to avoid running side effects in render, cause double invocation of reactiveFn on mount, possibly more with suspense --- packages/react-meteor-data/useTracker.js | 48 ++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index cf442365..13cade04 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -87,6 +87,12 @@ function useTracker(reactiveFn, deps) { } }; + const runReactiveFn = () => { + const data = reactiveFn(); + if (Meteor.isDevelopment) checkCursor(data); + refs.trackerData = data; + }; + // 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 @@ -95,8 +101,30 @@ function useTracker(reactiveFn, deps) { // if we are re-creating the computation, we need to stop the old one. dispose(); + // 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 }); + Tracker.nonreactive(runReactiveFn); + Meteor.subscribe = realSubscribe; + // store the deps for comparison on next render refs.previousDeps = 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.` + ); + } // Use Tracker.nonreactive in case we are inside a Tracker Computation. // This can happen if someone calls `ReactDOM.render` inside a Computation. @@ -105,12 +133,6 @@ function useTracker(reactiveFn, deps) { // 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. @@ -126,21 +148,9 @@ function useTracker(reactiveFn, 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; - }, []); + }, deps); return refs.trackerData; } From 7e71790ead404da65b959a31cbc825178afcbc37 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 22 Jul 2019 14:33:29 -0400 Subject: [PATCH 064/117] Revert "Update to avoid running side effects in render, cause double invocation of reactiveFn on mount, possibly more with suspense" This reverts commit b9d69114b052a3a58a78f33479762e5e5be90864. --- packages/react-meteor-data/useTracker.js | 48 ++++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 13cade04..cf442365 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -87,12 +87,6 @@ function useTracker(reactiveFn, deps) { } }; - const runReactiveFn = () => { - const data = reactiveFn(); - if (Meteor.isDevelopment) checkCursor(data); - refs.trackerData = data; - }; - // 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 @@ -101,30 +95,8 @@ function useTracker(reactiveFn, deps) { // if we are re-creating the computation, we need to stop the old one. dispose(); - // 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 }); - Tracker.nonreactive(runReactiveFn); - Meteor.subscribe = realSubscribe; - // store the deps for comparison on next render refs.previousDeps = 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.` - ); - } // Use Tracker.nonreactive in case we are inside a Tracker Computation. // This can happen if someone calls `ReactDOM.render` inside a Computation. @@ -133,6 +105,12 @@ function useTracker(reactiveFn, deps) { // 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. @@ -148,9 +126,21 @@ function useTracker(reactiveFn, 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; - }, deps); + }, []); return refs.trackerData; } From 5d73c1a9d5e9840aee55807656ba7458d6f3dfd4 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 22 Jul 2019 19:04:30 -0400 Subject: [PATCH 065/117] Make "falsy" deps check in computation consistent with other checks. --- packages/react-meteor-data/useTracker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index cf442365..c05108ca 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -117,7 +117,8 @@ function useTracker(reactiveFn, deps) { runReactiveFn(); } else { // If deps are falsy, stop computation and let next render handle reactiveFn. - if (!refs.previousDeps) { + if (!refs.previousDeps !== null && refs.previousDeps !== undefined + && !Array.isArray(refs.previousDeps)) { dispose(); } else { runReactiveFn(); From 044e16349fd4dee3b7c0a972b001af142054a159 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 22 Jul 2019 23:42:40 -0400 Subject: [PATCH 066/117] Port the old outdated tests to useTracker (all tests are without deps) --- packages/react-meteor-data/package.js | 2 +- .../react-meteor-data/useTracker.tests.js | 381 +++++++++++++++++- 2 files changed, 381 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/package.js b/packages/react-meteor-data/package.js index ebd1987d..030d3e32 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -17,7 +17,7 @@ Package.onUse((api) => { }); Package.onTest((api) => { - api.use(['ecmascript', 'reactive-dict', 'tracker', 'tinytest']); + api.use(['ecmascript', 'reactive-dict', 'reactive-var', 'tracker', 'tinytest', 'underscore']); api.use('react-meteor-data'); api.mainModule('tests.js'); }); diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index c0060e2f..19e9ad83 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -1,7 +1,10 @@ /* global Tinytest */ -import React from 'react'; +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'; @@ -96,3 +99,379 @@ Tinytest.add('useTracker - with deps', async function (test) { reactiveDict.destroy(); }); + +const canonicalizeHtml = function(html) { + var h = html; + // kill IE-specific comments inserted by DomRange + h = h.replace(//g, ''); + h = h.replace(//g, ''); + // ignore exact text of comments + h = h.replace(//g, ''); + // make all tags lowercase + h = h.replace(/<\/?(\w+)/g, function(m) { + return m.toLowerCase(); }); + // replace whitespace sequences with spaces + h = h.replace(/\s+/g, ' '); + // Trim leading and trailing whitespace + h = h.replace(/^\s+|\s+$/g, ''); + // remove whitespace before and after tags + h = h.replace(/\s*(<\/?\w.*?>)\s*/g, function (m, tag) { + return tag; }); + // make tag attributes uniform + h = h.replace(/<(\w+)\s+(.*?)\s*>/g, function(m, tagName, attrs) { + // Drop expando property used by Sizzle (part of jQuery) which leaks into + // attributes in IE8. Note that its value always contains spaces. + attrs = attrs.replace(/sizcache[0-9]+="[^"]*"/g, ' '); + // Similarly for expando properties used by jQuery to track data. + attrs = attrs.replace(/jQuery[0-9]+="[0-9]+"/g, ' '); + // Similarly for expando properties used to DOMBackend to keep + // track of callbacks to fire when an element is removed + attrs = attrs.replace(/\$blaze_teardown_callbacks="[^"]*"/g, ' '); + // And by DOMRange to keep track of the element's DOMRange + attrs = attrs.replace(/\$blaze_range="[^"]*"/g, ' '); + + attrs = attrs.replace(/\s*=\s*/g, '='); + attrs = attrs.replace(/^\s+/g, ''); + attrs = attrs.replace(/\s+$/g, ''); + attrs = attrs.replace(/\s+/g, ' '); + // quote unquoted attribute values, as in `type=checkbox`. This + // will do the wrong thing if there's an `=` in an attribute value. + attrs = attrs.replace(/(\w)=([^'" >/]+)/g, '$1="$2"'); + + // for the purpose of splitting attributes in a string like 'a="b" + // c="d"', assume they are separated by a single space and values + // are double- or single-quoted, but allow for spaces inside the + // quotes. Split on space following quote. + var attrList = attrs.replace(/(\w)='([^']*)' /g, "$1='$2'\u0000"); + attrList = attrList.replace(/(\w)="([^"]*)" /g, '$1="$2"\u0000'); + attrList = attrList.split("\u0000"); + // put attributes in alphabetical order + attrList.sort(); + + var tagContents = [tagName]; + + for(var i=0; i'; + }); + return h; +}; + +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('react-meteor-data - 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('react-meteor-data - 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('react-meteor-data - 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); + }); + + function waitFor(func, callback) { + Tracker.autorun(function (c) { + if (func()) { + c.stop(); + callback(); + } + }); + }; + + testAsyncMulti('react-meteor-data - resubscribe', [ + function (test, expect) { + var self = this; + self.div = document.createElement("DIV"); + self.collection = new Mongo.Collection("react-meteor-data-mixin-coll"); + self.num = new ReactiveVar(1); + self.someOtherVar = new ReactiveVar('foo'); + self.Foo = () => { + const data = useTracker(() => { + self.handle = + Meteor.subscribe("react-meteor-data-mixin-sub", + self.num.get()); + + return { + v: self.someOtherVar.get(), + docs: self.collection.find().fetch() + }; + }); + 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.component.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.component.handle; + + Tracker.afterFlush(expect()); + }, + function (test, expect) { + var self = this; + test.equal(self.component.data.v, 'baz'); + test.notEqual(self.oldHandle3, self.component.handle); + test.equal(self.oldHandle3.subscriptionId, + self.component.handle.subscriptionId); + test.isTrue(self.component.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( + "react-meteor-data - print warning if return cursor from getMeteorData", + function (test) { + var coll = new Mongo.Collection(null); + var ComponentWithCursor = React.createClass({ + mixins: [ReactMeteorData], + getMeteorData() { + return { + theCursor: coll.find() + }; + }, + render() { + 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 from getMeteorData/); + } finally { + console.warn = oldWarn; + } + }); + +} else { + Meteor.publish("react-meteor-data-mixin-sub", function (num) { + Meteor.defer(() => { // because subs are blocking + this.added("react-meteor-data-mixin-coll", 'id'+num, {}); + this.ready(); + }); +}); + +} From 884246c302af7964f6b371bf1caaaa4799cba336 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 00:25:57 -0400 Subject: [PATCH 067/117] get more ported tests working --- packages/react-meteor-data/package.js | 3 +- .../react-meteor-data/useTracker.tests.js | 179 ++++-------------- 2 files changed, 40 insertions(+), 142 deletions(-) diff --git a/packages/react-meteor-data/package.js b/packages/react-meteor-data/package.js index 030d3e32..49c2a0dd 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -17,7 +17,8 @@ Package.onUse((api) => { }); Package.onTest((api) => { - api.use(['ecmascript', 'reactive-dict', 'reactive-var', 'tracker', 'tinytest', 'underscore']); + 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/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 19e9ad83..6c136729 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -100,107 +100,6 @@ Tinytest.add('useTracker - with deps', async function (test) { reactiveDict.destroy(); }); -const canonicalizeHtml = function(html) { - var h = html; - // kill IE-specific comments inserted by DomRange - h = h.replace(//g, ''); - h = h.replace(//g, ''); - // ignore exact text of comments - h = h.replace(//g, ''); - // make all tags lowercase - h = h.replace(/<\/?(\w+)/g, function(m) { - return m.toLowerCase(); }); - // replace whitespace sequences with spaces - h = h.replace(/\s+/g, ' '); - // Trim leading and trailing whitespace - h = h.replace(/^\s+|\s+$/g, ''); - // remove whitespace before and after tags - h = h.replace(/\s*(<\/?\w.*?>)\s*/g, function (m, tag) { - return tag; }); - // make tag attributes uniform - h = h.replace(/<(\w+)\s+(.*?)\s*>/g, function(m, tagName, attrs) { - // Drop expando property used by Sizzle (part of jQuery) which leaks into - // attributes in IE8. Note that its value always contains spaces. - attrs = attrs.replace(/sizcache[0-9]+="[^"]*"/g, ' '); - // Similarly for expando properties used by jQuery to track data. - attrs = attrs.replace(/jQuery[0-9]+="[0-9]+"/g, ' '); - // Similarly for expando properties used to DOMBackend to keep - // track of callbacks to fire when an element is removed - attrs = attrs.replace(/\$blaze_teardown_callbacks="[^"]*"/g, ' '); - // And by DOMRange to keep track of the element's DOMRange - attrs = attrs.replace(/\$blaze_range="[^"]*"/g, ' '); - - attrs = attrs.replace(/\s*=\s*/g, '='); - attrs = attrs.replace(/^\s+/g, ''); - attrs = attrs.replace(/\s+$/g, ''); - attrs = attrs.replace(/\s+/g, ' '); - // quote unquoted attribute values, as in `type=checkbox`. This - // will do the wrong thing if there's an `=` in an attribute value. - attrs = attrs.replace(/(\w)=([^'" >/]+)/g, '$1="$2"'); - - // for the purpose of splitting attributes in a string like 'a="b" - // c="d"', assume they are separated by a single space and values - // are double- or single-quoted, but allow for spaces inside the - // quotes. Split on space following quote. - var attrList = attrs.replace(/(\w)='([^']*)' /g, "$1='$2'\u0000"); - attrList = attrList.replace(/(\w)="([^"]*)" /g, '$1="$2"\u0000'); - attrList = attrList.split("\u0000"); - // put attributes in alphabetical order - attrList.sort(); - - var tagContents = [tagName]; - - for(var i=0; i'; - }); - return h; -}; - const getInnerHtml = function (elem) { // clean up elem.innerHTML and strip data-reactid attributes too return canonicalizeHtml(elem.innerHTML).replace(/ data-reactroot=".*?"/g, ''); @@ -345,6 +244,7 @@ if (Meteor.isClient) { docs: self.collection.find().fetch() }; }); + self.data = data; return
{ _.map(data.docs, (doc) => {doc._id}) }
; @@ -390,7 +290,7 @@ if (Meteor.isClient) { // data is still there test.equal(getInnerHtml(self.div), '
id1
'); // handle is no longer ready - var handle = self.component.handle; + var handle = self.handle; test.isFalse(handle.ready()); // different sub ID test.isTrue(self.oldHandle2.subscriptionId); @@ -411,17 +311,17 @@ if (Meteor.isClient) { '
id2
'); self.someOtherVar.set('baz'); - self.oldHandle3 = self.component.handle; + self.oldHandle3 = self.handle; Tracker.afterFlush(expect()); }, function (test, expect) { var self = this; - test.equal(self.component.data.v, 'baz'); - test.notEqual(self.oldHandle3, self.component.handle); + test.equal(self.data.v, 'baz'); + test.notEqual(self.oldHandle3, self.handle); test.equal(self.oldHandle3.subscriptionId, - self.component.handle.subscriptionId); - test.isTrue(self.component.handle.ready()); + self.handle.subscriptionId); + test.isTrue(self.handle.ready()); }, function (test, expect) { ReactDOM.unmountComponentAtNode(this.div); @@ -431,40 +331,37 @@ if (Meteor.isClient) { } ]); - Tinytest.add( - "react-meteor-data - print warning if return cursor from getMeteorData", - function (test) { - var coll = new Mongo.Collection(null); - var ComponentWithCursor = React.createClass({ - mixins: [ReactMeteorData], - getMeteorData() { - return { - theCursor: coll.find() - }; - }, - render() { - 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 from getMeteorData/); - } finally { - console.warn = oldWarn; - } - }); + // Tinytest.add( + // "react-meteor-data - 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("react-meteor-data-mixin-sub", function (num) { From 4976a3b6bb50b6bce1ee399d69cdd5de20b69595 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 00:26:20 -0400 Subject: [PATCH 068/117] add a version of one of the ported tests with deps --- .../react-meteor-data/useTracker.tests.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 6c136729..d20ad42c 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -217,6 +217,56 @@ if (Meteor.isClient) { ReactDOM.unmountComponentAtNode(div); }); + Tinytest.add('react-meteor-data - 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()) { From 774350c82e76ec521ddf9fbea908bb2e0d9c4c2f Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 00:52:44 -0400 Subject: [PATCH 069/117] Add withTracker tests --- packages/react-meteor-data/tests.js | 1 + .../react-meteor-data/withTracker.tests.js | 335 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 packages/react-meteor-data/withTracker.tests.js diff --git a/packages/react-meteor-data/tests.js b/packages/react-meteor-data/tests.js index db53dbe6..e444261a 100644 --- a/packages/react-meteor-data/tests.js +++ b/packages/react-meteor-data/tests.js @@ -1 +1,2 @@ import './useTracker.tests.js'; +import './withTracker.tests.js'; 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(); + }); + }); +} From c3c73f66b543659bcd57b18257eefa312e53d09a Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 00:53:22 -0400 Subject: [PATCH 070/117] Fix some previous stall points with new knowledge from old tests --- .../react-meteor-data/useTracker.tests.js | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index d20ad42c..ccaf416d 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -24,7 +24,10 @@ Tinytest.add('useTracker - no deps', async function (test) { 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')); + act(() => { + reactiveDict.set('key', 'changed'); + Tracker.flush({_throwFirstError: true}); + }); await waitForNextUpdate(); test.equal(result.current, 'changed', 'Expect new value to be "changed"'); @@ -45,7 +48,10 @@ Tinytest.add('useTracker - no deps', async function (test) { unmount(); test.equal(runCount, 4, 'Unmount should not cause a tracker run'); - act(() => reactiveDict.set('different', 'changed again')); + 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.'); @@ -70,7 +76,10 @@ Tinytest.add('useTracker - with deps', async function (test) { 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')); + 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"'); @@ -92,7 +101,10 @@ Tinytest.add('useTracker - with deps', async function (test) { 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')); + 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'); @@ -106,7 +118,7 @@ const getInnerHtml = function (elem) { }; if (Meteor.isClient) { - Tinytest.add('react-meteor-data - basic track', function (test) { + Tinytest.add('useTracker - basic track', function (test) { var div = document.createElement("DIV"); var x = new ReactiveVar('aaa'); @@ -139,7 +151,7 @@ if (Meteor.isClient) { // 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('react-meteor-data - render in autorun', function (test) { + Tinytest.add('useTracker - render in autorun', function (test) { var div = document.createElement("DIV"); var x = new ReactiveVar('aaa'); @@ -167,7 +179,7 @@ if (Meteor.isClient) { ReactDOM.unmountComponentAtNode(div); }); - Tinytest.add('react-meteor-data - track based on props and state', function (test) { + Tinytest.add('useTracker - track based on props and state', function (test) { var div = document.createElement("DIV"); var xs = [new ReactiveVar('aaa'), @@ -217,7 +229,7 @@ if (Meteor.isClient) { ReactDOM.unmountComponentAtNode(div); }); - Tinytest.add('react-meteor-data - track based on props and state (with deps)', function (test) { + Tinytest.add('useTracker - track based on props and state (with deps)', function (test) { var div = document.createElement("DIV"); var xs = [new ReactiveVar('aaa'), @@ -276,17 +288,17 @@ if (Meteor.isClient) { }); }; - testAsyncMulti('react-meteor-data - resubscribe', [ + testAsyncMulti('useTracker - resubscribe', [ function (test, expect) { var self = this; self.div = document.createElement("DIV"); - self.collection = new Mongo.Collection("react-meteor-data-mixin-coll"); + 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("react-meteor-data-mixin-sub", + Meteor.subscribe("useTracker-mixin-sub", self.num.get()); return { @@ -382,7 +394,7 @@ if (Meteor.isClient) { ]); // Tinytest.add( - // "react-meteor-data - print warning if return cursor from useTracker", + // "useTracker - print warning if return cursor from useTracker", // function (test) { // var coll = new Mongo.Collection(null); // var ComponentWithCursor = () => { @@ -414,11 +426,10 @@ if (Meteor.isClient) { // }); } else { - Meteor.publish("react-meteor-data-mixin-sub", function (num) { - Meteor.defer(() => { // because subs are blocking - this.added("react-meteor-data-mixin-coll", 'id'+num, {}); - this.ready(); + Meteor.publish("useTracker-mixin-sub", function (num) { + Meteor.defer(() => { // because subs are blocking + this.added("useTracker-mixin-coll", 'id'+num, {}); + this.ready(); + }); }); -}); - } From 317e2cfcadcfc349480368a0b5c6e85ac527a9a5 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 01:48:56 -0400 Subject: [PATCH 071/117] use useMemo to avoid incorporating all those deps comparison methods --- packages/react-meteor-data/useTracker.js | 72 +++++------------------- 1 file changed, 15 insertions(+), 57 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c05108ca..f26fd4a2 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,5 +1,5 @@ /* global Meteor, Package, Tracker */ -import React, { useReducer, useEffect, useRef } from 'react'; +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); @@ -27,50 +27,6 @@ function checkCursor(data) { } } -// 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 (!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; -} - // Used to create a forceUpdate from useReducer. Forces update by // incrementing a number whenever the dispatch method is invoked. const fur = x => x + 1; @@ -78,7 +34,7 @@ const fur = x => x + 1; function useTracker(reactiveFn, deps) { const { current: refs } = useRef({}); - const [, forceUpdate] = useReducer(fur, 0); + const [counter, forceUpdate] = useReducer(fur, 0); const dispose = () => { if (refs.computation) { @@ -87,17 +43,20 @@ function useTracker(reactiveFn, deps) { } }; - // 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)) { + // useMemo is used only to leverage React's core deps compare algorithm. useMemo + // runs synchronously with render, so we can think of it being called like + // componentWillMount or componentWillUpdate. One case we have to work around is + // if deps are falsy. In that case, we need to increment a value for every render + // since this should always run when deps are falsy. Since we already have + // an incrementing value from forceUpdate, we can use that. + const memoDeps = (deps !== null && deps !== undefined && !Array.isArray(deps)) + ? [counter] + : deps; + + useMemo(() => { // 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 @@ -117,8 +76,7 @@ function useTracker(reactiveFn, deps) { runReactiveFn(); } else { // If deps are falsy, stop computation and let next render handle reactiveFn. - if (!refs.previousDeps !== null && refs.previousDeps !== undefined - && !Array.isArray(refs.previousDeps)) { + if (deps !== null && deps !== undefined && !Array.isArray(deps)) { dispose(); } else { runReactiveFn(); @@ -127,7 +85,7 @@ function useTracker(reactiveFn, deps) { } }) )); - } + }, memoDeps); // stop the computation on unmount only useEffect(() => { From ee066426b5c964f47e4dafb90feac088636ab370 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 01:53:17 -0400 Subject: [PATCH 072/117] fix typo in computation deps compare --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c05108ca..79a002eb 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -117,7 +117,7 @@ function useTracker(reactiveFn, deps) { runReactiveFn(); } else { // If deps are falsy, stop computation and let next render handle reactiveFn. - if (!refs.previousDeps !== null && refs.previousDeps !== undefined + if (refs.previousDeps !== null && refs.previousDeps !== undefined && !Array.isArray(refs.previousDeps)) { dispose(); } else { From a127f3c84d020c0346e8a28f2a556846e95615fc Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 02:05:42 -0400 Subject: [PATCH 073/117] only check if third argument is a function if it's not falsy --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 3f7fc0d9..6438d58b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -89,7 +89,7 @@ function useTracker(reactiveFn, deps, computationHandler) { + `(dependency), but got type of ${typeof deps}.` ); } - if (typeof computationHandler !== 'function') { + if (computationHandler && typeof computationHandler !== 'function') { warn( `Warning: useTracker expected a function in it's third argument` + `(computationHandler), but got type of ${typeof computationHandler}.` From ce0e52b46a4ef0444fa48561dc8897ed20b82402 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 02:07:01 -0400 Subject: [PATCH 074/117] tracked does not return a value, so don't assign it --- packages/react-meteor-data/useTracker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 6438d58b..69d9049a 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -162,7 +162,8 @@ function useTracker(reactiveFn, deps, computationHandler) { // When rendering on the server, we don't want to use the Tracker. if (Meteor.isServer) { - refs.computation = tracked(null); + refs.computation = null; + tracked(null); } else { // Use Tracker.nonreactive in case we are inside a Tracker Computation. // This can happen if someone calls `ReactDOM.render` inside a Computation. From d5a2d146e072886925181ce4a8544c4531b19778 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 02:10:41 -0400 Subject: [PATCH 075/117] add back a computation test --- packages/react-meteor-data/useTracker.tests.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 08a40f93..0cecd716 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -21,6 +21,7 @@ Tinytest.add('useTracker - no deps', async function (test) { 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 () => { From 58dfa81a9a0f46d21bbffeca428cf6bc943d0b8b Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 02:18:47 -0400 Subject: [PATCH 076/117] using the forceUpdate counter wasn't enough for useMemo deps, use a reference counter instead --- packages/react-meteor-data/useTracker.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index f26fd4a2..79d79001 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -32,9 +32,9 @@ function checkCursor(data) { const fur = x => x + 1; function useTracker(reactiveFn, deps) { - const { current: refs } = useRef({}); + const { current: refs } = useRef({ memoCounter: 0 }); - const [counter, forceUpdate] = useReducer(fur, 0); + const [, forceUpdate] = useReducer(fur, 0); const dispose = () => { if (refs.computation) { @@ -47,10 +47,9 @@ function useTracker(reactiveFn, deps) { // runs synchronously with render, so we can think of it being called like // componentWillMount or componentWillUpdate. One case we have to work around is // if deps are falsy. In that case, we need to increment a value for every render - // since this should always run when deps are falsy. Since we already have - // an incrementing value from forceUpdate, we can use that. + // since this should always run when deps are falsy. const memoDeps = (deps !== null && deps !== undefined && !Array.isArray(deps)) - ? [counter] + ? [++refs.memoCounter] : deps; useMemo(() => { From 81188373cf2c0a4e173cfae9974dc49facef8084 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 11:29:47 -0400 Subject: [PATCH 077/117] use es5 functions in package.js --- packages/react-meteor-data/package.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/package.js b/packages/react-meteor-data/package.js index 49c2a0dd..b50c6313 100644 --- a/packages/react-meteor-data/package.js +++ b/packages/react-meteor-data/package.js @@ -8,7 +8,7 @@ Package.describe({ git: 'https://github.com/meteor/react-packages', }); -Package.onUse((api) => { +Package.onUse(function (api) { api.versionsFrom('1.3'); api.use('tracker'); api.use('ecmascript'); @@ -16,7 +16,7 @@ Package.onUse((api) => { api.mainModule('index.js'); }); -Package.onTest((api) => { +Package.onTest(function (api) { api.use(['ecmascript', 'reactive-dict', 'reactive-var', 'tracker', 'tinytest', 'underscore', 'mongo']); api.use('test-helpers'); api.use('react-meteor-data'); From aa498b595a9bac577a5ee7e410c66f0fcb902b56 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 23 Jul 2019 19:28:22 -0400 Subject: [PATCH 078/117] Move argument checks to avoid using them in production --- packages/react-meteor-data/useTracker.js | 49 +++++++++++++----------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 69d9049a..a5ad38b0 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -76,27 +76,6 @@ function areHookInputsEqual(nextDeps, prevDeps) { const fur = x => x + 1; function useTracker(reactiveFn, deps, computationHandler) { - if (Meteor.isDevelopment) { - 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}.` - ); - } - } - const { current: refs } = useRef({}); const [, forceUpdate] = useReducer(fur, 0); @@ -139,7 +118,7 @@ function useTracker(reactiveFn, deps, computationHandler) { if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { warn( 'Warning: Computation handler should return a function ' - + 'to be used for cleanup or nothing.' + + 'to be used for cleanup or return nothing.' ); } refs.computationCleanup = cleanupHandler; @@ -180,4 +159,28 @@ function useTracker(reactiveFn, deps, computationHandler) { return refs.trackerData; } -export default useTracker; +export default Meteor.isDevelopment + ? (reactiveFn, deps, computationHandler) => { + if (Meteor.isDevelopment) { + 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; From 2e786d26cafad1831e47bc63a5104d796ba320e5 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 24 Jul 2019 18:12:19 -0400 Subject: [PATCH 079/117] remove remove use of deps in withTracker. It's impractical to use them from that level. --- packages/react-meteor-data/withTracker.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/withTracker.jsx b/packages/react-meteor-data/withTracker.jsx index 6eee1ba3..e6e6a9f2 100644 --- a/packages/react-meteor-data/withTracker.jsx +++ b/packages/react-meteor-data/withTracker.jsx @@ -4,10 +4,10 @@ import useTracker from './useTracker.js'; export default function withTracker(options) { return Component => { const expandedOptions = typeof options === 'function' ? { getMeteorData: options } : options; - const { getMeteorData, pure = true, deps = null } = expandedOptions; + const { getMeteorData, pure = true } = expandedOptions; const WithTracker = forwardRef((props, ref) => { - const data = useTracker(() => getMeteorData(props) || {}, deps); + const data = useTracker(() => getMeteorData(props) || {}); return ; }); From 3e29ac54cf78084b8c681778b0d43e05e8b65f0d Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 24 Jul 2019 18:24:56 -0400 Subject: [PATCH 080/117] Clarify some comments and fix the deps check inside the computation. --- packages/react-meteor-data/useTracker.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 79a002eb..655dcd37 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -116,9 +116,8 @@ function useTracker(reactiveFn, deps) { // 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 !== null && refs.previousDeps !== undefined - && !Array.isArray(refs.previousDeps)) { + // If deps are anything other than an array, stop computation and let next render handle reactiveFn. + if (deps === null || deps === undefined || !Array.isArray(deps)) { dispose(); } else { runReactiveFn(); @@ -136,7 +135,7 @@ function useTracker(reactiveFn, deps) { && !Array.isArray(deps)) { warn( 'Warning: useTracker expected an initial dependency value of ' - + `type array but got type of ${typeof deps} instead.` + + `type array, null or undefined but got type of ${typeof deps} instead.` ); } From f472e2225bb91f3af8341c2a2e913191f4873e4c Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 24 Jul 2019 19:13:01 -0400 Subject: [PATCH 081/117] Only warn the user if the dep value is not undefined, null or an array. --- packages/react-meteor-data/useTracker.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 655dcd37..d300b168 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -46,11 +46,12 @@ function areHookInputsEqual(nextDeps, prevDeps) { return false; } - if (!Array.isArray(nextDeps)) { - if (Meteor.isDevelopment) { + 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 but got type of ${typeof nextDeps} instead.` + + `type array, null or undefined but got type of ${typeof nextDeps} instead.` ); } return false; @@ -130,9 +131,8 @@ function useTracker(reactiveFn, deps) { // stop the computation on unmount only useEffect(() => { - if (Meteor.isDevelopment - && deps !== null && deps !== undefined - && !Array.isArray(deps)) { + // 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.` From d90ac32259a4bc37cc1da2fb8d829fcd7cc65245 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 24 Jul 2019 19:20:02 -0400 Subject: [PATCH 082/117] better checks/optimizations --- packages/react-meteor-data/useTracker.js | 42 ++++++++++-------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 392e31b0..be5ef731 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -47,12 +47,6 @@ function areHookInputsEqual(nextDeps, prevDeps) { } if (nextDeps === null || nextDeps === undefined || !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; } @@ -160,25 +154,23 @@ function useTracker(reactiveFn, deps, computationHandler) { export default Meteor.isDevelopment ? (reactiveFn, deps, computationHandler) => { - if (Meteor.isDevelopment) { - 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}.` - ); - } + 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); } From cfc2a7eea350c22f853f0c05f45923895e27298f Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 12:30:12 -0400 Subject: [PATCH 083/117] pass the current computation to reactiveFn --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d300b168..0eea3637 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -107,7 +107,7 @@ function useTracker(reactiveFn, deps) { refs.computation = Tracker.nonreactive(() => ( Tracker.autorun((c) => { const runReactiveFn = () => { - const data = reactiveFn(); + const data = reactiveFn(c); if (Meteor.isDevelopment) checkCursor(data); refs.trackerData = data; }; From 7a9db7218d76f1cdafae2ca2453d3ce0af4a379b Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 12:43:53 -0400 Subject: [PATCH 084/117] Update readme to include current computation being passed to reactiveFn --- packages/react-meteor-data/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index c75a837b..6bf41c08 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -33,7 +33,7 @@ It is not necessary to rewrite existing applications to use the `useTracker` hoo 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). +- `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 From 092c592f1d0843e327c3e344de1212070980dcf8 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 12:48:33 -0400 Subject: [PATCH 085/117] add note about why we are checking deps for null and undefined --- packages/react-meteor-data/useTracker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 0eea3637..75a99084 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -118,6 +118,7 @@ function useTracker(reactiveFn, deps) { 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 { From 4975a2f4d10c75556dade65975884717fa057180 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 13:19:57 -0400 Subject: [PATCH 086/117] Update note about Suspense support. --- packages/react-meteor-data/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 6bf41c08..c1960da1 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -124,7 +124,7 @@ For more information, see the [React article](http://guide.meteor.com/react.html - `react-meteor-data` v2.x : - `useTracker` hook + `withTracker` HOC - Requires React `^16.8`. - - Implementation is compatible with the forthcoming "React Suspense" features. + - 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. From d8fba790a9a4627fe4620332780ddb539fa7c485 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 14:24:30 -0400 Subject: [PATCH 087/117] we don't need to process deps for useMemo, just pass them along --- packages/react-meteor-data/useTracker.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 39854208..daccc7e4 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -43,15 +43,6 @@ function useTracker(reactiveFn, deps) { } }; - // useMemo is used only to leverage React's core deps compare algorithm. useMemo - // runs synchronously with render, so we can think of it being called like - // componentWillMount or componentWillUpdate. One case we have to work around is - // if deps are falsy. In that case, we need to increment a value for every render - // since this should always run when deps are falsy. - const memoDeps = (deps !== null && deps !== undefined && !Array.isArray(deps)) - ? [++refs.memoCounter] - : deps; - useMemo(() => { // if we are re-creating the computation, we need to stop the old one. dispose(); @@ -85,7 +76,7 @@ function useTracker(reactiveFn, deps) { } }) )); - }, memoDeps); + }, deps); // stop the computation on unmount only useEffect(() => { From f5c424a16803ebc6f43a2234270a7ad46b2a797f Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Thu, 25 Jul 2019 18:13:23 -0400 Subject: [PATCH 088/117] Attempt a solution for React Suspense. --- packages/react-meteor-data/useTracker.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 65c0992b..d149e82d 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -32,7 +32,7 @@ function checkCursor(data) { const fur = x => x + 1; function useTracker(reactiveFn, deps, computationHandler) { - const { current: refs } = useRef({}); + const { current: refs } = useRef({ isSuspended: true }); const [, forceUpdate] = useReducer(fur, 0); @@ -47,6 +47,7 @@ function useTracker(reactiveFn, deps, 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(); @@ -99,11 +100,22 @@ function useTracker(reactiveFn, deps, computationHandler) { // Computations, where if the outer one is invalidated or stopped, // it stops the inner one. refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); + // We are creating a side effect here, which can be problematic in some React contexts, such as + // Suspense. To get around that, we'll set a time out to automatically clean it up, if it's life + // cycle hasn't been confirmed by a flag set in useEffect. + refs.disposeId = setTimeout(() => { + if (refs.isSuspended) dispose(); + }, 50); } }, deps); - // stop the computation on unmount - useEffect(() => dispose, []); + useEffect(() => { + // To make sure the computation is still valid, we set a flag. When React suspends a render + // it does not run useEffect. If useEffect does run, then its dispose method is also run. + refs.isSuspended = false; + // stop the computation on unmount + return dispose; + }, []); return refs.trackerData; } From 1da0e6841a00628b20f77a88ef165806468d9b03 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 5 Aug 2019 05:00:23 -0400 Subject: [PATCH 089/117] Mostly complete the work to suppot suspsense/concurrent mode --- packages/react-meteor-data/useTracker.js | 123 ++++++++++++++--------- 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d149e82d..d02283e2 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -32,7 +32,10 @@ function checkCursor(data) { const fur = x => x + 1; function useTracker(reactiveFn, deps, computationHandler) { - const { current: refs } = useRef({ isSuspended: true }); + const { current: refs } = useRef({ + isMounted: false, + doDeferredRender: false + }); const [, forceUpdate] = useReducer(fur, 0); @@ -47,47 +50,49 @@ function useTracker(reactiveFn, deps, 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(); + const tracked = (c) => { + const runReactiveFn = () => { + const data = reactiveFn(c); + if (Meteor.isDevelopment) checkCursor(data); + refs.trackerData = data; + }; - const tracked = (c) => { - const runReactiveFn = () => { - const data = reactiveFn(c); - if (Meteor.isDevelopment) checkCursor(data); - refs.trackerData = data; - }; - - if (c === null || c.firstRun) { - // If there is a computationHandler, pass it the computation, and store the - // result, which may be a cleanup method. - if (computationHandler) { - const cleanupHandler = 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; + if (c === null || c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (computationHandler) { + const cleanupHandler = 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; } - // This will capture data synchronously on first run (and after deps change). - // Additional cycles will follow the normal computation behavior. - runReactiveFn(); + } + // 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 { - // 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(); + runReactiveFn(); } - }; + // :???: I'm not sure what would happen if we try to update state after a render has been tossed - does + // it throw an error? It seems to cause no problems to forceUpdate unmounted components. + forceUpdate(); + } + }; + + // 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(); // When rendering on the server, we don't want to use the Tracker. if (Meteor.isServer) { @@ -100,19 +105,45 @@ function useTracker(reactiveFn, deps, computationHandler) { // Computations, where if the outer one is invalidated or stopped, // it stops the inner one. refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); - // We are creating a side effect here, which can be problematic in some React contexts, such as - // Suspense. To get around that, we'll set a time out to automatically clean it up, if it's life - // cycle hasn't been confirmed by a flag set in useEffect. - refs.disposeId = setTimeout(() => { - if (refs.isSuspended) dispose(); - }, 50); + + // 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) { + refs.disposeId = setTimeout(() => { + console.log('timed out') + if (!refs.isMounted) { + dispose(); + } + }, 50); + } } }, deps); useEffect(() => { - // To make sure the computation is still valid, we set a flag. When React suspends a render - // it does not run useEffect. If useEffect does run, then its dispose method is also run. - refs.isSuspended = false; + // Now that we are mounted, we can set the flag, and cancel the timeout + refs.isMounted = true; + + if (!Meteor.isServer) { + clearTimeout(refs.disposeId); + + // If it took longer than 50ms to get to useEffect, we may need to restart the computation. + if (!refs.computation) { + if (Array.isArray(deps)) { + refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); + } + // Do a render, to make sure we are up to date with computation data + refs.doDeferredRender = true; + } + + // We may have a queued render from a reactive update which happened before useEffect. + if (refs.doDeferredRender) { + forceUpdate(); + } + } + // stop the computation on unmount return dispose; }, []); From 02c406bae998974d56231dc490dcd9e3bbff905e Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 5 Aug 2019 17:30:55 -0400 Subject: [PATCH 090/117] Only allow reactiveFn to be invoked on first run (synchronously) and after mount - never in between. --- packages/react-meteor-data/useTracker.js | 27 ++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d02283e2..54af8a75 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -50,13 +50,13 @@ function useTracker(reactiveFn, deps, computationHandler) { } }; - const tracked = (c) => { - const runReactiveFn = () => { - const data = reactiveFn(c); - if (Meteor.isDevelopment) checkCursor(data); - refs.trackerData = data; - }; + const runReactiveFn = (c) => { + const data = reactiveFn(c); + if (Meteor.isDevelopment) checkCursor(data); + refs.trackerData = data; + }; + const tracked = (c) => { if (c === null || c.firstRun) { // If there is a computationHandler, pass it the computation, and store the // result, which may be a cleanup method. @@ -74,17 +74,20 @@ function useTracker(reactiveFn, deps, computationHandler) { } // This will capture data synchronously on first run (and after deps change). // Additional cycles will follow the normal computation behavior. - runReactiveFn(); + runReactiveFn(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 (deps === null || deps === undefined || !Array.isArray(deps)) { dispose(); + } else if (refs.isMounted) { + // Only run the reactiveFn if the component is mounted + runReactiveFn(c); } else { - runReactiveFn(); + refs.doDeferredRender = true; } // :???: I'm not sure what would happen if we try to update state after a render has been tossed - does - // it throw an error? It seems to cause no problems to forceUpdate unmounted components. + // it throw an error? It seems to cause no problems to call forceUpdate on unmounted components. forceUpdate(); } }; @@ -113,7 +116,6 @@ function useTracker(reactiveFn, deps, computationHandler) { // and watching a set of references to make sure everything is choreographed correctly. if (!refs.isMounted) { refs.disposeId = setTimeout(() => { - console.log('timed out') if (!refs.isMounted) { dispose(); } @@ -128,8 +130,9 @@ function useTracker(reactiveFn, deps, computationHandler) { if (!Meteor.isServer) { clearTimeout(refs.disposeId); + delete refs.disposeId; - // If it took longer than 50ms to get to useEffect, we may need to restart the computation. + // If it took longer than 50ms to get to useEffect, we might need to restart the computation. if (!refs.computation) { if (Array.isArray(deps)) { refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); @@ -140,7 +143,9 @@ function useTracker(reactiveFn, deps, computationHandler) { // We may have a queued render from a reactive update which happened before useEffect. if (refs.doDeferredRender) { + runReactiveFn(refs.computation); forceUpdate(); + delete refs.doDeferredRender } } From 4cea85e1fae7f9653b93c179b9f82fcad42e83ef Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 01:35:22 -0400 Subject: [PATCH 091/117] Only run unmounted reactiveFn one time, even without deps --- packages/react-meteor-data/useTracker.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 54af8a75..776dd0d6 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -33,7 +33,7 @@ const fur = x => x + 1; function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({ - isMounted: false, + isMounted: null, doDeferredRender: false }); @@ -73,7 +73,13 @@ function useTracker(reactiveFn, deps, computationHandler) { } } // This will capture data synchronously on first run (and after deps change). - // Additional cycles will follow the normal computation behavior. + // Don't run if refs.isMounted === false. Do run if === null, because that's the first run. + if (refs.isMounted === false) { + return; + } + if (refs.isMounted === null) { + refs.isMounted = false; + } runReactiveFn(c); } else { // If deps are anything other than an array, stop computation and let next render handle reactiveFn. @@ -81,9 +87,10 @@ function useTracker(reactiveFn, deps, computationHandler) { if (deps === null || deps === undefined || !Array.isArray(deps)) { dispose(); } else if (refs.isMounted) { - // Only run the reactiveFn if the component is mounted + // Only run the reactiveFn if the component is mounted. runReactiveFn(c); } else { + // If not mounted, defer render until mounted. refs.doDeferredRender = true; } // :???: I'm not sure what would happen if we try to update state after a render has been tossed - does From 5c15306ad75d00593d77e790fd132d4ae0d6be0a Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 01:36:05 -0400 Subject: [PATCH 092/117] Get some basic tests for suspense working. --- .../react-meteor-data/useTracker.tests.js | 130 ++++++++++++------ 1 file changed, 89 insertions(+), 41 deletions(-) diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 0cecd716..34e448fe 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -1,8 +1,9 @@ /* global Tinytest */ -import React, { useState } from 'react'; +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'; @@ -162,6 +163,56 @@ const getInnerHtml = function (elem) { }; 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"); @@ -224,8 +275,6 @@ if (Meteor.isClient) { }); 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')]; @@ -242,40 +291,39 @@ if (Meteor.isClient) { return {data.x}; }; - var comp = ReactDOM.render(, div); + var { getByText } = render(); - test.equal(getInnerHtml(div), 'aaa'); + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in initial render'); xs[0].set('AAA'); - test.equal(getInnerHtml(div), 'aaa'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), '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'); - { - let comp2 = ReactDOM.render(, div); - test.isTrue(comp === comp2); - } + cleanup(); + var { getByText } = render(); - test.equal(getInnerHtml(div), 'bbb'); + test.isTrue(getByText('bbb')); xs[1].set('BBB'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), 'BBB'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('BBB')); setState({m: 1}); - test.equal(getInnerHtml(div), 'ccc'); + + test.isTrue(getByText('ccc')); xs[2].set('CCC'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), 'CCC'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('CCC')); + + cleanup(); + var { getByText } = render(); - ReactDOM.render(, div); setState({m: 0}); - test.equal(getInnerHtml(div), 'AAA'); + test.isTrue(getByText('AAA')); - ReactDOM.unmountComponentAtNode(div); + cleanup(); }); 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')]; @@ -292,35 +340,35 @@ if (Meteor.isClient) { return {data.x}; }; - var comp = ReactDOM.render(, div); + var { getByText } = render(); - test.equal(getInnerHtml(div), 'aaa'); + test.isTrue(getByText('aaa'), 'Content should still be “aaa” in initial render'); xs[0].set('AAA'); - test.equal(getInnerHtml(div), 'aaa'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), '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'); - { - let comp2 = ReactDOM.render(, div); - test.isTrue(comp === comp2); - } + cleanup(); + var { getByText } = render(); - test.equal(getInnerHtml(div), 'bbb'); + test.isTrue(getByText('bbb')); xs[1].set('BBB'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), 'BBB'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('BBB')); setState({m: 1}); - test.equal(getInnerHtml(div), 'ccc'); + test.isTrue(getByText('ccc')); xs[2].set('CCC'); - Tracker.flush({_throwFirstError: true}); - test.equal(getInnerHtml(div), 'CCC'); + act(() => Tracker.flush({_throwFirstError: true})); + test.isTrue(getByText('CCC')); + + cleanup(); + var { getByText } = render(); - ReactDOM.render(, div); setState({m: 0}); - test.equal(getInnerHtml(div), 'AAA'); + test.isTrue(getByText('AAA')); - ReactDOM.unmountComponentAtNode(div); + cleanup(); }); function waitFor(func, callback) { From 407978443032bb2edcfd869576b19b1882261030 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 01:36:38 -0400 Subject: [PATCH 093/117] Add @testing-library/react package to package.json --- packages/react-meteor-data/package-lock.json | 124 +++++++++++++++++++ packages/react-meteor-data/package.json | 1 + 2 files changed, 125 insertions(+) diff --git a/packages/react-meteor-data/package-lock.json b/packages/react-meteor-data/package-lock.json index 1e4c7b2e..a256c256 100644 --- a/packages/react-meteor-data/package-lock.json +++ b/packages/react-meteor-data/package-lock.json @@ -11,6 +11,42 @@ "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", @@ -21,6 +57,28 @@ "@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", @@ -43,6 +101,56 @@ "@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", @@ -66,6 +174,17 @@ "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", @@ -127,6 +246,11 @@ "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.json b/packages/react-meteor-data/package.json index 7514840f..3f489f9c 100644 --- a/packages/react-meteor-data/package.json +++ b/packages/react-meteor-data/package.json @@ -4,6 +4,7 @@ "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" } } From 23c754a1d16ac6a303a5b11302f972efbc68bee5 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 02:08:14 -0400 Subject: [PATCH 094/117] Add a note about Concurrent Mode, Suspense and Error Boundaries to the readme --- packages/react-meteor-data/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index c1960da1..8d53826e 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -119,12 +119,20 @@ The returned component will, when rendered, render `Foo` (the "lower-order" comp For more information, see the [React article](http://guide.meteor.com/react.html) in the Meteor Guide. +### Concurrent Mode, Suspense, and Error Boundaries + +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 without leaking memory. This ability allows certain features such as concurrent mode, suspense, and error boundaries to work. + +Side-effects, such as subscribing to data sources, setting up a callback, or creating a Meteor computation should generally 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 shot. If we wait to do that in `useEffect`, we'll end up rendering 2 times for every use of `useTracker` or `withTracker`. + +To work around that and keep things efficient, we are creating the computation (the side-effect) in the render method, 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 away. For the most part, this should all be transparent. The only thing to note is that even though we create the computation in the render directly, your reactive function will only be called one time, until our internal `useEffect` is run after render to confirm the component will not be tossed away. This will generally happen in miliseconds, but it's worth knowing about if you have a particularly fast changing data source. + ### Version compatibility notes - `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. + - 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. From 17c87c65d1029d8cf69c367569c7731139905b27 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 15:43:51 -0400 Subject: [PATCH 095/117] Only try to force update when the component is mounted. --- packages/react-meteor-data/useTracker.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 776dd0d6..0b68da31 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -86,16 +86,15 @@ function useTracker(reactiveFn, deps, computationHandler) { // These null and undefined checks are optimizations to avoid calling Array.isArray in these cases. if (deps === null || deps === undefined || !Array.isArray(deps)) { dispose(); + forceUpdate(); } else if (refs.isMounted) { // Only run the reactiveFn if the component is mounted. runReactiveFn(c); + forceUpdate(); } else { // If not mounted, defer render until mounted. refs.doDeferredRender = true; } - // :???: I'm not sure what would happen if we try to update state after a render has been tossed - does - // it throw an error? It seems to cause no problems to call forceUpdate on unmounted components. - forceUpdate(); } }; From 8ab9587741f318af85d1dbf61a583d4d5394af09 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 15:59:41 -0400 Subject: [PATCH 096/117] Reduce allocations within the hook body. Don't allocate default values for some refs, and move 2 function definitions outside the hook body. --- packages/react-meteor-data/useTracker.js | 62 +++++++++++++----------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 0b68da31..c8fffbd2 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -31,30 +31,33 @@ function checkCursor(data) { // incrementing a number whenever the dispatch method is invoked. const fur = x => x + 1; +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); + }; + function useTracker(reactiveFn, deps, computationHandler) { - const { current: refs } = useRef({ - isMounted: null, - doDeferredRender: false - }); + const { current: refs } = useRef({}); const [, forceUpdate] = useReducer(fur, 0); - const dispose = () => { - if (refs.computationCleanup) { - refs.computationCleanup(); - delete refs.computationCleanup; - } - if (refs.computation) { - refs.computation.stop(); - refs.computation = null; - } - }; - - const runReactiveFn = (c) => { - const data = reactiveFn(c); - if (Meteor.isDevelopment) checkCursor(data); - refs.trackerData = data; - }; + refs.reactiveFn = reactiveFn; const tracked = (c) => { if (c === null || c.firstRun) { @@ -73,23 +76,24 @@ function useTracker(reactiveFn, deps, computationHandler) { } } // This will capture data synchronously on first run (and after deps change). - // Don't run if refs.isMounted === false. Do run if === null, because that's the first run. + // Don't run if refs.isMounted === false. Do run if === undefined, because that's the first run. if (refs.isMounted === false) { return; } - if (refs.isMounted === null) { + // If isMounted is undefined, we set it to false, to indicate first run is finished. + if (!refs.isMounted) { refs.isMounted = false; } - runReactiveFn(c); + 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 (deps === null || deps === undefined || !Array.isArray(deps)) { - dispose(); + dispose(refs); forceUpdate(); } else if (refs.isMounted) { // Only run the reactiveFn if the component is mounted. - runReactiveFn(c); + runReactiveFn(refs, c); forceUpdate(); } else { // If not mounted, defer render until mounted. @@ -101,7 +105,7 @@ function useTracker(reactiveFn, deps, 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(); + dispose(refs); // When rendering on the server, we don't want to use the Tracker. if (Meteor.isServer) { @@ -123,7 +127,7 @@ function useTracker(reactiveFn, deps, computationHandler) { if (!refs.isMounted) { refs.disposeId = setTimeout(() => { if (!refs.isMounted) { - dispose(); + dispose(refs); } }, 50); } @@ -149,14 +153,14 @@ function useTracker(reactiveFn, deps, computationHandler) { // We may have a queued render from a reactive update which happened before useEffect. if (refs.doDeferredRender) { - runReactiveFn(refs.computation); + runReactiveFn(refs, refs.computation); forceUpdate(); delete refs.doDeferredRender } } // stop the computation on unmount - return dispose; + return () => dispose(refs); }, []); return refs.trackerData; From 6735d22c5913664b368f6638f7aad8505e8f56fa Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 6 Aug 2019 16:13:35 -0400 Subject: [PATCH 097/117] Use more explicit undefined check instead of falsy check --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c8fffbd2..f866a12d 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -81,7 +81,7 @@ function useTracker(reactiveFn, deps, computationHandler) { return; } // If isMounted is undefined, we set it to false, to indicate first run is finished. - if (!refs.isMounted) { + if (refs.isMounted === undefined) { refs.isMounted = false; } runReactiveFn(refs, c); From 0095083602098c7f806474a2a732d3a49f675f2a Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 7 Aug 2019 13:19:22 -0400 Subject: [PATCH 098/117] Rewrite the notes on concurrent mode, suspense, and error boundaries, and remove the (better) oxford comma. --- packages/react-meteor-data/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index 8d53826e..e5c43c0e 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -119,13 +119,15 @@ The returned component will, when rendered, render `Foo` (the "lower-order" comp For more information, see the [React article](http://guide.meteor.com/react.html) in the Meteor Guide. -### Concurrent Mode, Suspense, and Error Boundaries +### Concurrent Mode, Suspense and Error Boundaries -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 without leaking memory. This ability allows certain features such as concurrent mode, suspense, and error boundaries to work. +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 all of 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.) -Side-effects, such as subscribing to data sources, setting up a callback, or creating a Meteor computation should generally 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 shot. If we wait to do that in `useEffect`, we'll end up rendering 2 times for every use of `useTracker` or `withTracker`. +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 that and keep things efficient, we are creating the computation (the side-effect) in the render method, 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 away. For the most part, this should all be transparent. The only thing to note is that even though we create the computation in the render directly, your reactive function will only be called one time, until our internal `useEffect` is run after render to confirm the component will not be tossed away. This will generally happen in miliseconds, but it's worth knowing about if you have a particularly fast changing data source. +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. Also, though unlikely, it is possible that the computation will be temporarily halted if the react component takes longer than 50ms to actually mount, and `useTracker` will not call your reactive function until the render is committed (until `useEffect` runs). React's `useEffect` yields to allow the browser to paint before running and it's possible, if unlikely, that this will take longer than 50ms. If you have a particularly fast changing data source, this is worth understanding. Even 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, and the computation is kept running when it needs to. Once the render is "committed", and the component mounted, everything will run as expected. ### Version compatibility notes From adca979f0de2f0860ed283298269865e9ff76d70 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Wed, 7 Aug 2019 17:56:52 -0400 Subject: [PATCH 099/117] Rewrite last paragraph of the section on concurrent mode for clarity --- packages/react-meteor-data/README.md | 4 ++-- packages/react-meteor-data/useTracker.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md index e5c43c0e..3b90c8d9 100644 --- a/packages/react-meteor-data/README.md +++ b/packages/react-meteor-data/README.md @@ -121,13 +121,13 @@ For more information, see the [React article](http://guide.meteor.com/react.html ### Concurrent Mode, Suspense and Error Boundaries -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 all of 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.) +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.) 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. Also, though unlikely, it is possible that the computation will be temporarily halted if the react component takes longer than 50ms to actually mount, and `useTracker` will not call your reactive function until the render is committed (until `useEffect` runs). React's `useEffect` yields to allow the browser to paint before running and it's possible, if unlikely, that this will take longer than 50ms. If you have a particularly fast changing data source, this is worth understanding. Even 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, and the computation is kept running when it needs to. Once the render is "committed", and the component mounted, everything will run as expected. +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 diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index f866a12d..23959aab 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -155,7 +155,7 @@ function useTracker(reactiveFn, deps, computationHandler) { if (refs.doDeferredRender) { runReactiveFn(refs, refs.computation); forceUpdate(); - delete refs.doDeferredRender + delete refs.doDeferredRender; } } From b35215494afcff4b2f052a7ef507d751bd9d2288 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 9 Aug 2019 10:51:47 -0400 Subject: [PATCH 100/117] Add a note about how useEffect yields for paint --- packages/react-meteor-data/useTracker.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 23959aab..7d2f3652 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -76,7 +76,7 @@ function useTracker(reactiveFn, deps, computationHandler) { } } // This will capture data synchronously on first run (and after deps change). - // Don't run if refs.isMounted === false. Do run if === undefined, because that's the first run. + // Don't run if refs.isMounted === false. Do run if === undefined, because that's the first render. if (refs.isMounted === false) { return; } @@ -125,6 +125,8 @@ function useTracker(reactiveFn, deps, computationHandler) { // 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) { + // Functional components yield to allow the browser to paint before useEffect is run, so we + // set a 50ms timeout to allow for that. refs.disposeId = setTimeout(() => { if (!refs.isMounted) { dispose(refs); From af3fb03fd65864257aaf8cec996a6b6031fdd1d2 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 9 Aug 2019 11:11:36 -0400 Subject: [PATCH 101/117] Fix up eslint errors --- packages/react-meteor-data/useTracker.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 7d2f3652..abd0213a 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -31,6 +31,9 @@ function checkCursor(data) { // 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(); @@ -41,7 +44,6 @@ const dispose = (refs) => { refs.computation = null; } }; - const runReactiveFn = Meteor.isDevelopment ? (refs, c) => { const data = refs.reactiveFn(c); @@ -51,6 +53,7 @@ const runReactiveFn = Meteor.isDevelopment : (refs, c) => { refs.trackerData = refs.reactiveFn(c); }; +/* eslint-enable no-param-reassign */ function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); @@ -76,7 +79,8 @@ function useTracker(reactiveFn, deps, computationHandler) { } } // This will capture data synchronously on first run (and after deps change). - // Don't run if refs.isMounted === false. Do run if === undefined, because that's the first render. + // Don't run if refs.isMounted === false. Do run if === undefined, because + // that's the first render. if (refs.isMounted === false) { return; } @@ -86,8 +90,9 @@ function useTracker(reactiveFn, deps, computationHandler) { } 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 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(refs); forceUpdate(); @@ -102,7 +107,8 @@ function useTracker(reactiveFn, deps, computationHandler) { } }; - // We are abusing useMemo a little bit, using it for it's deps compare, but not for it's memoization. + // 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); @@ -172,19 +178,19 @@ export default Meteor.isDevelopment ? (reactiveFn, deps, computationHandler) => { if (typeof reactiveFn !== 'function') { warn( - `Warning: useTracker expected a function in it's first argument ` + '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 ` + '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` + 'Warning: useTracker expected a function in it\'s third argument' + `(computationHandler), but got type of ${typeof computationHandler}.` ); } From de7041f1ed91a26a3c8ee02972bba19d5521d5e7 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 12 Aug 2019 12:44:12 -0400 Subject: [PATCH 102/117] Make sure reactiveFn is only run once in cases where we restart the computation after a long paint. --- packages/react-meteor-data/useTracker.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index abd0213a..70fab88b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -150,18 +150,16 @@ function useTracker(reactiveFn, deps, computationHandler) { clearTimeout(refs.disposeId); delete refs.disposeId; - // If it took longer than 50ms to get to useEffect, we might need to restart the computation. - if (!refs.computation) { - if (Array.isArray(deps)) { + // If it took longer than 50ms to get to useEffect (a long browser paint), we might need + // to restart the computation. Alternatively, we might have a queued render from a + // reactive update which happened before useEffect. + if (!refs.computation || refs.doDeferredRender) { + // If we have deps, set up a new computation, otherwise it will be created on next render. + if (!refs.computation && Array.isArray(deps)) { + // This also runs runReactiveFn, so no need to set up deferred render refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); } - // Do a render, to make sure we are up to date with computation data - refs.doDeferredRender = true; - } - - // We may have a queued render from a reactive update which happened before useEffect. - if (refs.doDeferredRender) { - runReactiveFn(refs, refs.computation); + // Do a render, to make sure we are up to date with the computation data forceUpdate(); delete refs.doDeferredRender; } From 90b37a40ff4f53343c6334e6816e5dc300330e48 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 12 Aug 2019 17:08:53 -0400 Subject: [PATCH 103/117] Reduce complexity and increase readability by switching to useEffectLayout instead of useEffect --- packages/react-meteor-data/useTracker.js | 76 ++++++++---------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 70fab88b..aa1fc489 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,5 +1,5 @@ /* global Meteor, Package, Tracker */ -import React, { useReducer, useEffect, useRef, useMemo } from 'react'; +import React, { useReducer, useLayoutEffect, useRef, useMemo } from 'react'; // Use React.warn() if available (should ship in React 16.9). const warn = React.warn || console.warn.bind(console); @@ -78,32 +78,22 @@ function useTracker(reactiveFn, deps, computationHandler) { refs.computationCleanup = cleanupHandler; } } - // This will capture data synchronously on first run (and after deps change). - // Don't run if refs.isMounted === false. Do run if === undefined, because - // that's the first render. - if (refs.isMounted === false) { - return; - } - // If isMounted is undefined, we set it to false, to indicate first run is finished. - if (refs.isMounted === undefined) { - refs.isMounted = false; - } + // This will capture data synchronously on first run, after deps change + // or if deps are not an array (usually undefined). 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 + // These null and undefined checks are optimizations to avoid // calling Array.isArray in these cases. if (deps === null || deps === undefined || !Array.isArray(deps)) { + // If deps are anything other than an array, stop computation and + // let next render handle reactiveFn. dispose(refs); - forceUpdate(); - } else if (refs.isMounted) { - // Only run the reactiveFn if the component is mounted. - runReactiveFn(refs, c); - forceUpdate(); } else { - // If not mounted, defer render until mounted. - refs.doDeferredRender = true; + // If deps is an array, run the reactiveFn now. It will not rerun + // in the next render. + runReactiveFn(refs, c); } + forceUpdate(); } }; @@ -127,43 +117,25 @@ function useTracker(reactiveFn, deps, computationHandler) { // 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) { - // Functional components yield to allow the browser to paint before useEffect is run, so we - // set a 50ms timeout to allow for that. + // We still want synchronous rendering for a number of reasons (see readme), so we work around + // possible memory/resource leaks by setting a timeout to automatically clean everything up, + // and immediately cancelling it in `useLayoutEffect` if the render is committed. + if (!refs.isCommitted) { refs.disposeId = setTimeout(() => { - if (!refs.isMounted) { - dispose(refs); - } - }, 50); + dispose(refs); + }, 0); } } }, deps); - useEffect(() => { - // Now that we are mounted, we can set the flag, and cancel the timeout - refs.isMounted = true; - - if (!Meteor.isServer) { - clearTimeout(refs.disposeId); - delete refs.disposeId; - - // If it took longer than 50ms to get to useEffect (a long browser paint), we might need - // to restart the computation. Alternatively, we might have a queued render from a - // reactive update which happened before useEffect. - if (!refs.computation || refs.doDeferredRender) { - // If we have deps, set up a new computation, otherwise it will be created on next render. - if (!refs.computation && Array.isArray(deps)) { - // This also runs runReactiveFn, so no need to set up deferred render - refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); - } - // Do a render, to make sure we are up to date with the computation data - forceUpdate(); - delete refs.doDeferredRender; - } - } + // We are using useEffectLayout here to run synchronously with render. We can use useEffect, but + // it substantially increases complexity, and we aren't doing anything particularly resource + // intensive here anyway. + useLayoutEffect(() => { + // Now that the render is committed, we can set the flag, and cancel the timeout. + refs.isCommitted = true; + clearTimeout(refs.disposeId); + delete refs.disposeId; // stop the computation on unmount return () => dispose(refs); From 30f71e823393ed44924c4935d8ce34b095ee21bf Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Mon, 12 Aug 2019 17:31:20 -0400 Subject: [PATCH 104/117] It's useLayoutEffect, not useEffectLayout... --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index aa1fc489..6e669b9d 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -128,7 +128,7 @@ function useTracker(reactiveFn, deps, computationHandler) { } }, deps); - // We are using useEffectLayout here to run synchronously with render. We can use useEffect, but + // We are using useLayoutEffect here to run synchronously with render. We can use useEffect, but // it substantially increases complexity, and we aren't doing anything particularly resource // intensive here anyway. useLayoutEffect(() => { From 42d7d66ecfd1d679d6227f7ea08fbce0ec6d4ace Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 13 Aug 2019 00:20:02 -0400 Subject: [PATCH 105/117] disentangle the server from the client impl, silences useEffectHook warning in SSR --- packages/react-meteor-data/index.js | 10 ++- packages/react-meteor-data/useTracker.js | 107 +++++++++++------------ 2 files changed, 58 insertions(+), 59 deletions(-) diff --git a/packages/react-meteor-data/index.js b/packages/react-meteor-data/index.js index 8769794b..2abbc3cb 100644 --- a/packages/react-meteor-data/index.js +++ b/packages/react-meteor-data/index.js @@ -8,5 +8,13 @@ if (Meteor.isDevelopment) { } } +// 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 +import useTrackerClient from './useTracker.js'; +const useTrackerServer = (reactiveFn) => reactiveFn(); +const useTracker = (Meteor.isServer) + ? useTrackerServer + : useTrackerClient; + +export { useTracker }; export { default as withTracker } from './withTracker.jsx'; -export { default as useTracker } from './useTracker.js'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 6e669b9d..0c4987cb 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -57,74 +57,65 @@ const runReactiveFn = Meteor.isDevelopment function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); - const [, forceUpdate] = useReducer(fur, 0); refs.reactiveFn = reactiveFn; - const tracked = (c) => { - if (c === null || c.firstRun) { - // If there is a computationHandler, pass it the computation, and store the - // result, which may be a cleanup method. - if (computationHandler) { - const cleanupHandler = 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; - } - } - // This will capture data synchronously on first run, after deps change - // or if deps are not an array (usually undefined). - runReactiveFn(refs, c); - } else { - // These null and undefined checks are optimizations to avoid - // calling Array.isArray in these cases. - if (deps === null || deps === undefined || !Array.isArray(deps)) { - // If deps are anything other than an array, stop computation and - // let next render handle reactiveFn. - dispose(refs); - } else { - // If deps is an array, run the reactiveFn now. It will not rerun - // in the next render. - runReactiveFn(refs, c); - } - forceUpdate(); - } - }; - - // We are abusing useMemo a little bit, using it for it's deps - // compare, but not for it's memoization. + // We are abusing useMemo a little bit, using it for its deps + // compare, but not for its memoization. useMemo(() => { // if we are re-creating the computation, we need to stop the old one. dispose(refs); - // When rendering on the server, we don't want to use the Tracker. - if (Meteor.isServer) { - refs.computation = null; - tracked(null); - } else { - // 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(tracked)); - - // 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 reasons (see readme), so we work around - // possible memory/resource leaks by setting a timeout to automatically clean everything up, - // and immediately cancelling it in `useLayoutEffect` if the render is committed. - if (!refs.isCommitted) { - refs.disposeId = setTimeout(() => { + // 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) => { + if (c === null || c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (computationHandler) { + const cleanupHandler = 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; + } + } + // This will capture data synchronously on first run, after deps change + // or if deps are not an array (usually undefined). + runReactiveFn(refs, c); + } else { + // These null and undefined checks are optimizations to avoid + // calling Array.isArray in these cases. + if (deps === null || deps === undefined || !Array.isArray(deps)) { + // If deps are anything other than an array, stop computation and + // let next render handle reactiveFn. dispose(refs); - }, 0); + } else { + // If deps is an array, run the reactiveFn now. It will not rerun + // in the next render. + runReactiveFn(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 reasons (see readme), so we work around + // possible memory/resource leaks by setting a timeout to automatically clean everything up, + // and immediately cancelling it in `useLayoutEffect` if the render is committed. + if (!refs.isCommitted) { + refs.disposeId = setTimeout(() => { + dispose(refs); + }, 0); } }, deps); From 8199b3f3dc46cbbf99448500246cddde8449ff9c Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 13 Aug 2019 02:56:14 -0400 Subject: [PATCH 106/117] revert to useEffect version for compatibility with concurrent mode --- packages/react-meteor-data/useTracker.js | 153 ++++++++++++++--------- 1 file changed, 95 insertions(+), 58 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 0c4987cb..70fab88b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -1,5 +1,5 @@ /* global Meteor, Package, Tracker */ -import React, { useReducer, useLayoutEffect, useRef, useMemo } from 'react'; +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); @@ -57,76 +57,113 @@ const runReactiveFn = Meteor.isDevelopment function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); + const [, forceUpdate] = useReducer(fur, 0); refs.reactiveFn = reactiveFn; - // We are abusing useMemo a little bit, using it for its deps - // compare, but not for its memoization. + const tracked = (c) => { + if (c === null || c.firstRun) { + // If there is a computationHandler, pass it the computation, and store the + // result, which may be a cleanup method. + if (computationHandler) { + const cleanupHandler = 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; + } + } + // This will capture data synchronously on first run (and after deps change). + // Don't run if refs.isMounted === false. Do run if === undefined, because + // that's the first render. + if (refs.isMounted === false) { + return; + } + // If isMounted is undefined, we set it to false, to indicate first run is finished. + if (refs.isMounted === undefined) { + refs.isMounted = false; + } + 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 (deps === null || deps === undefined || !Array.isArray(deps)) { + dispose(refs); + forceUpdate(); + } else if (refs.isMounted) { + // Only run the reactiveFn if the component is mounted. + runReactiveFn(refs, c); + forceUpdate(); + } else { + // If not mounted, defer render until mounted. + refs.doDeferredRender = true; + } + } + }; + + // 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) => { - if (c === null || c.firstRun) { - // If there is a computationHandler, pass it the computation, and store the - // result, which may be a cleanup method. - if (computationHandler) { - const cleanupHandler = 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; + // When rendering on the server, we don't want to use the Tracker. + if (Meteor.isServer) { + refs.computation = null; + tracked(null); + } else { + // 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(tracked)); + + // 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) { + // Functional components yield to allow the browser to paint before useEffect is run, so we + // set a 50ms timeout to allow for that. + refs.disposeId = setTimeout(() => { + if (!refs.isMounted) { + dispose(refs); } - } - // This will capture data synchronously on first run, after deps change - // or if deps are not an array (usually undefined). - runReactiveFn(refs, c); - } else { - // These null and undefined checks are optimizations to avoid - // calling Array.isArray in these cases. - if (deps === null || deps === undefined || !Array.isArray(deps)) { - // If deps are anything other than an array, stop computation and - // let next render handle reactiveFn. - dispose(refs); - } else { - // If deps is an array, run the reactiveFn now. It will not rerun - // in the next render. - runReactiveFn(refs, c); - } - forceUpdate(); + }, 50); } - })); - - // 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 reasons (see readme), so we work around - // possible memory/resource leaks by setting a timeout to automatically clean everything up, - // and immediately cancelling it in `useLayoutEffect` if the render is committed. - if (!refs.isCommitted) { - refs.disposeId = setTimeout(() => { - dispose(refs); - }, 0); } }, deps); - // We are using useLayoutEffect here to run synchronously with render. We can use useEffect, but - // it substantially increases complexity, and we aren't doing anything particularly resource - // intensive here anyway. - useLayoutEffect(() => { - // Now that the render is committed, we can set the flag, and cancel the timeout. - refs.isCommitted = true; - clearTimeout(refs.disposeId); - delete refs.disposeId; + useEffect(() => { + // Now that we are mounted, we can set the flag, and cancel the timeout + refs.isMounted = true; + + if (!Meteor.isServer) { + clearTimeout(refs.disposeId); + delete refs.disposeId; + + // If it took longer than 50ms to get to useEffect (a long browser paint), we might need + // to restart the computation. Alternatively, we might have a queued render from a + // reactive update which happened before useEffect. + if (!refs.computation || refs.doDeferredRender) { + // If we have deps, set up a new computation, otherwise it will be created on next render. + if (!refs.computation && Array.isArray(deps)) { + // This also runs runReactiveFn, so no need to set up deferred render + refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); + } + // Do a render, to make sure we are up to date with the computation data + forceUpdate(); + delete refs.doDeferredRender; + } + } // stop the computation on unmount return () => dispose(refs); From 2cf9a6262e8b18c2f35ef175d90511db331a2eda Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 13 Aug 2019 03:01:19 -0400 Subject: [PATCH 107/117] remove server pathways from useTracker impl --- packages/react-meteor-data/useTracker.js | 76 +++++++++++------------- 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 70fab88b..00599dd0 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -113,32 +113,26 @@ function useTracker(reactiveFn, deps, computationHandler) { // if we are re-creating the computation, we need to stop the old one. dispose(refs); - // When rendering on the server, we don't want to use the Tracker. - if (Meteor.isServer) { - refs.computation = null; - tracked(null); - } else { - // 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(tracked)); - - // 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) { - // Functional components yield to allow the browser to paint before useEffect is run, so we - // set a 50ms timeout to allow for that. - refs.disposeId = setTimeout(() => { - if (!refs.isMounted) { - dispose(refs); - } - }, 50); - } + // 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(tracked)); + + // 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) { + // Functional components yield to allow the browser to paint before useEffect is run, so we + // set a 50ms timeout to allow for that. + refs.disposeId = setTimeout(() => { + if (!refs.isMounted) { + dispose(refs); + } + }, 50); } }, deps); @@ -146,23 +140,21 @@ function useTracker(reactiveFn, deps, computationHandler) { // Now that we are mounted, we can set the flag, and cancel the timeout refs.isMounted = true; - if (!Meteor.isServer) { - clearTimeout(refs.disposeId); - delete refs.disposeId; - - // If it took longer than 50ms to get to useEffect (a long browser paint), we might need - // to restart the computation. Alternatively, we might have a queued render from a - // reactive update which happened before useEffect. - if (!refs.computation || refs.doDeferredRender) { - // If we have deps, set up a new computation, otherwise it will be created on next render. - if (!refs.computation && Array.isArray(deps)) { - // This also runs runReactiveFn, so no need to set up deferred render - refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); - } - // Do a render, to make sure we are up to date with the computation data - forceUpdate(); - delete refs.doDeferredRender; + clearTimeout(refs.disposeId); + delete refs.disposeId; + + // If it took longer than 50ms to get to useEffect (a long browser paint), we might need + // to restart the computation. Alternatively, we might have a queued render from a + // reactive update which happened before useEffect. + if (!refs.computation || refs.doDeferredRender) { + // If we have deps, set up a new computation, otherwise it will be created on next render. + if (!refs.computation && Array.isArray(deps)) { + // This also runs runReactiveFn, so no need to set up deferred render + refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); } + // Do a render, to make sure we are up to date with the computation data + forceUpdate(); + delete refs.doDeferredRender; } // stop the computation on unmount From 2a069a8a7b501b91901d7b894f28dcc01f7ca5d0 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 13 Aug 2019 03:02:03 -0400 Subject: [PATCH 108/117] increase commit timeout to 1 second - it can take a pretty long while --- packages/react-meteor-data/useTracker.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 00599dd0..16f834fc 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -126,13 +126,14 @@ function useTracker(reactiveFn, deps, computationHandler) { // 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) { - // Functional components yield to allow the browser to paint before useEffect is run, so we - // set a 50ms timeout to allow for that. + // 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); } - }, 50); + }, 1000); } }, deps); @@ -143,9 +144,9 @@ function useTracker(reactiveFn, deps, computationHandler) { clearTimeout(refs.disposeId); delete refs.disposeId; - // If it took longer than 50ms to get to useEffect (a long browser paint), we might need - // to restart the computation. Alternatively, we might have a queued render from a - // reactive update which happened before useEffect. + // If it took longer than 1000ms to get to useEffect, we might need to restart the + // computation. Alternatively, we might have a queued render from a reactive update + // which happened before useEffect. if (!refs.computation || refs.doDeferredRender) { // If we have deps, set up a new computation, otherwise it will be created on next render. if (!refs.computation && Array.isArray(deps)) { From 88b86d74a58212f2555fd8ab57bfcca5731e4f5a Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 14:12:05 -0400 Subject: [PATCH 109/117] Export server version of useTracker from useTracker.js for use in withTracker Also update tests to reflect lack of computation lifecycle on server (this is consistent with useEffect, which doesn't run on the server). --- packages/react-meteor-data/index.js | 10 +-- packages/react-meteor-data/useTracker.js | 8 +- .../react-meteor-data/useTracker.tests.js | 74 ++++++++++++------- 3 files changed, 57 insertions(+), 35 deletions(-) diff --git a/packages/react-meteor-data/index.js b/packages/react-meteor-data/index.js index 2abbc3cb..05efe9f6 100644 --- a/packages/react-meteor-data/index.js +++ b/packages/react-meteor-data/index.js @@ -8,13 +8,5 @@ if (Meteor.isDevelopment) { } } -// 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 -import useTrackerClient from './useTracker.js'; -const useTrackerServer = (reactiveFn) => reactiveFn(); -const useTracker = (Meteor.isServer) - ? useTrackerServer - : useTrackerClient; - -export { useTracker }; +export { default as useTracker } from './useTracker'; export { default as withTracker } from './withTracker.jsx'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 16f834fc..c064dbc1 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -55,7 +55,7 @@ const runReactiveFn = Meteor.isDevelopment }; /* eslint-enable no-param-reassign */ -function useTracker(reactiveFn, deps, computationHandler) { +function useTrackerClient(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); const [, forceUpdate] = useReducer(fur, 0); @@ -165,6 +165,12 @@ function useTracker(reactiveFn, deps, computationHandler) { 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) => reactiveFn() + : useTrackerClient; + export default Meteor.isDevelopment ? (reactiveFn, deps, computationHandler) => { if (typeof reactiveFn !== 'function') { diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js index 34e448fe..2bd3ae16 100644 --- a/packages/react-meteor-data/useTracker.tests.js +++ b/packages/react-meteor-data/useTracker.tests.js @@ -1,4 +1,4 @@ -/* global Tinytest */ +/* global Meteor, Tinytest */ import React, { Suspense, useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; @@ -34,8 +34,10 @@ Tinytest.add('useTracker - no deps', async function (test) { test.equal(result.current, 'initial', 'Expect initial value to be "initial"'); test.equal(runCount, 1, 'Should have run 1 times'); - test.equal(createdCount, 1, 'Should have been created 1 times'); - test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + 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'); @@ -45,29 +47,37 @@ Tinytest.add('useTracker - no deps', async function (test) { test.equal(result.current, 'changed', 'Expect new value to be "changed"'); test.equal(runCount, 2, 'Should have run 2 times'); - test.equal(createdCount, 2, 'Should have been created 2 times'); - test.equal(destroyedCount, 1, 'Should have been destroyed 1 less than created'); + 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'); - test.equal(createdCount, 3, 'Should have been created 3 times'); - test.equal(destroyedCount, 2, 'Should have been destroyed 1 less than created'); + 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'); - test.equal(createdCount, 4, 'Should have been created 4 times'); - test.equal(destroyedCount, 3, 'Should have been destroyed 1 less than created'); + 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'); - 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'); + 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'); @@ -77,8 +87,10 @@ Tinytest.add('useTracker - no deps', async function (test) { 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'); - 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'); + 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(); }); @@ -108,8 +120,10 @@ Tinytest.add('useTracker - with deps', async function (test) { test.equal(result.current, 'default', 'Expect the default value for given name to be "default"'); test.equal(runCount, 1, 'Should have run 1 times'); - test.equal(createdCount, 1, 'Should have been created 1 times'); - test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + 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'); @@ -119,30 +133,38 @@ Tinytest.add('useTracker - with deps', async function (test) { test.equal(result.current, 'changed', 'Expect the new value for given name to be "changed"'); test.equal(runCount, 2, 'Should have run 2 times'); - test.equal(createdCount, 1, 'Should have been created 1 times'); - test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + 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'); - test.equal(createdCount, 1, 'Should have been created 1 times'); - test.equal(destroyedCount, 0, 'Should not have been destroyed yet'); + 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'); - test.equal(createdCount, 2, 'Should have been created 2 times'); - test.equal(destroyedCount, 1, 'Should have been destroyed 1 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'); - test.equal(createdCount, 2, 'Should have been created 2 times'); - test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); + 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'); @@ -151,8 +173,10 @@ Tinytest.add('useTracker - with deps', async function (test) { 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'); - test.equal(createdCount, 2, 'Should have been created 2 times'); - test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); + 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(); }); From bcd1ef0bd9439e0c1c0d9d260ec1d5480547d923 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 15:08:59 -0400 Subject: [PATCH 110/117] Since we provide a separate isServer implementation, tracked will never be called with null --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index c064dbc1..a2112cc7 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -63,7 +63,7 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { refs.reactiveFn = reactiveFn; const tracked = (c) => { - if (c === null || c.firstRun) { + if (c.firstRun) { // If there is a computationHandler, pass it the computation, and store the // result, which may be a cleanup method. if (computationHandler) { From ebdf24cba34011fa61c6deb2cf99602022dd1f64 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 15:14:37 -0400 Subject: [PATCH 111/117] Fix lost reactivity in some cases. If a computation runs, but the user's reactiveFn is not invoked, the computation will lose track of the reactive data sources. To avoid that, in cases where we will not run the user's reactiveFn (a reactive update which happens after first render, but before useEffect) we must always dispose of computation and recreate when the component is committed (useEffect runs). Thanks to @menelike for finding this. --- packages/react-meteor-data/useTracker.js | 33 ++++++++++-------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index a2112cc7..d87e64dc 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -78,16 +78,7 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { refs.computationCleanup = cleanupHandler; } } - // This will capture data synchronously on first run (and after deps change). - // Don't run if refs.isMounted === false. Do run if === undefined, because - // that's the first render. - if (refs.isMounted === false) { - return; - } - // If isMounted is undefined, we set it to false, to indicate first run is finished. - if (refs.isMounted === undefined) { - refs.isMounted = false; - } + // Always run the reactiveFn on firstRun runReactiveFn(refs, c); } else { // If deps are anything other than an array, stop computation and let next render @@ -101,8 +92,12 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { runReactiveFn(refs, c); forceUpdate(); } else { - // If not mounted, defer render until mounted. - refs.doDeferredRender = true; + // 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! + clearTimeout(refs.disposeId); } } }; @@ -144,18 +139,16 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { clearTimeout(refs.disposeId); delete refs.disposeId; - // If it took longer than 1000ms to get to useEffect, we might need to restart the - // computation. Alternatively, we might have a queued render from a reactive update - // which happened before useEffect. - if (!refs.computation || refs.doDeferredRender) { - // If we have deps, set up a new computation, otherwise it will be created on next render. - if (!refs.computation && Array.isArray(deps)) { + // 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. + // If we have NO deps, it'll be recreated and rerun on the next render. + if (Array.isArray(deps)) { // This also runs runReactiveFn, so no need to set up deferred render refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); } - // Do a render, to make sure we are up to date with the computation data forceUpdate(); - delete refs.doDeferredRender; } // stop the computation on unmount From 6e20b9d2d88c04e7b8f69f007086dad3869d0745 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 15:49:27 -0400 Subject: [PATCH 112/117] Make some comments clearer --- packages/react-meteor-data/useTracker.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index d87e64dc..8b4cc92f 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -142,10 +142,10 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // 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. + // 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, so no need to set up deferred render + // This also runs runReactiveFn refs.computation = Tracker.nonreactive(() => Tracker.autorun(tracked)); } forceUpdate(); From d75f6c9eb8c1ebd3285bac01f543f5cc81fc936a Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 17:02:51 -0400 Subject: [PATCH 113/117] Cleanup the clearTimeout methods --- packages/react-meteor-data/useTracker.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 8b4cc92f..55d8b1b7 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -53,6 +53,12 @@ const runReactiveFn = Meteor.isDevelopment : (refs, c) => { refs.trackerData = refs.reactiveFn(c); }; +const clear = (refs) => { + if (refs.disposeId) { + clearTimeout(refs.disposeId); + delete refs.disposeId; + } +}; /* eslint-enable no-param-reassign */ function useTrackerClient(reactiveFn, deps, computationHandler) { @@ -97,7 +103,7 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // the useEffect hook recreate the computation later. dispose(refs); // Might as well clear the timeout! - clearTimeout(refs.disposeId); + clear(refs); } } }; @@ -136,8 +142,8 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // Now that we are mounted, we can set the flag, and cancel the timeout refs.isMounted = true; - clearTimeout(refs.disposeId); - delete refs.disposeId; + // 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. From 629c87f65f2355d9866664fa335e5bfaeec71931 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 17:12:18 -0400 Subject: [PATCH 114/117] Comment about dispose/clear in response to reactive updates when not mounted --- packages/react-meteor-data/useTracker.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 55d8b1b7..ddf33c9d 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -98,6 +98,10 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { 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. From 718c6052085a8b46e01a1b345186d92e36670e73 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 17:52:41 -0400 Subject: [PATCH 115/117] Make sure refs to deps and computationHandler are always up to date --- packages/react-meteor-data/useTracker.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index ddf33c9d..43df7cb2 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -66,14 +66,17 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { 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; const tracked = (c) => { if (c.firstRun) { // If there is a computationHandler, pass it the computation, and store the // result, which may be a cleanup method. - if (computationHandler) { - const cleanupHandler = computationHandler(c); + if (refs.computationHandler) { + const cleanupHandler = refs.computationHandler(c); if (cleanupHandler) { if (Meteor.isDevelopment && typeof cleanupHandler !== 'function') { warn( @@ -90,7 +93,7 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // 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)) { + if (refs.deps === null || refs.deps === undefined || !Array.isArray(refs.deps)) { dispose(refs); forceUpdate(); } else if (refs.isMounted) { From 048b698656bc72ce86fd47ebb225ad0e8ca82625 Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Tue, 27 Aug 2019 18:22:57 -0400 Subject: [PATCH 116/117] Move tracked out of hook body, one less allocation on every render, and clearer code --- packages/react-meteor-data/useTracker.js | 100 ++++++++++++----------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 43df7cb2..2e2ebc53 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -59,11 +59,53 @@ const clear = (refs) => { 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 @@ -71,50 +113,6 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { refs.deps = deps; refs.computationHandler = computationHandler; - const tracked = (c) => { - 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); - } - } - }; - // We are abusing useMemo a little bit, using it for it's deps // compare, but not for it's memoization. useMemo(() => { @@ -126,7 +124,11 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // 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(tracked)); + 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. @@ -159,7 +161,11 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // 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(tracked)); + refs.computation = Tracker.nonreactive(() => + Tracker.autorun((c) => { + tracked(refs, c, forceUpdate); + }) + ); } forceUpdate(); } From e8784e0675b2dcefde4f4971b48fbfe684273d1b Mon Sep 17 00:00:00 2001 From: Kevin Newman Date: Fri, 11 Oct 2019 13:29:21 -0400 Subject: [PATCH 117/117] Run reactiveFn on the server inside nonreactive --- packages/react-meteor-data/useTracker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index 2e2ebc53..ca6edc7b 100644 --- a/packages/react-meteor-data/useTracker.js +++ b/packages/react-meteor-data/useTracker.js @@ -180,7 +180,7 @@ function useTrackerClient(reactiveFn, deps, computationHandler) { // 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) => reactiveFn() + ? (reactiveFn) => Tracker.nonreactive(reactiveFn) : useTrackerClient; export default Meteor.isDevelopment