Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

keep reactive computations synchronous with the render phase #1

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fe4fa42
- rewrite useTracker in order to stay fully consistent with current w…
Jun 21, 2019
fbc33c6
- withTracker should always recompute on re-render so deps for useTra…
Jun 21, 2019
c8d8645
update Readme to reflect omitted deps behavior
Jun 21, 2019
44b5247
fix code comment
Jun 21, 2019
ddfb7cd
get rid of Math.random(), wasn't needed at all
Jun 21, 2019
365584f
don't handle Meteor.isServer as it's already been taken care of in ex…
Jun 21, 2019
c3803b9
replace Math.random() when enforcing an update
Jun 21, 2019
eeb115a
- align areHookInputsEqual with
Jun 22, 2019
1eb71dd
warn if dep is not an array when Meteor isDevelopment
Jun 22, 2019
6b540e6
fix prevDeps isArray check
Jun 22, 2019
8d64e41
warn if initial deps is not an array
Jun 22, 2019
b1996a2
Retain synchronous render behavior for firstRun (including after deps…
CaptainN Jul 1, 2019
af6a4a0
Fix eslint errors
CaptainN Jul 1, 2019
f31b95d
Merge branch 'hooks' into half-duplex
CaptainN Jul 1, 2019
be11569
disambiguate the disposition of previous computation - this works the…
CaptainN Jul 2, 2019
d77fab5
Merge pull request #1 from CaptainN/half-duplex
menelike Jul 4, 2019
bfbf823
Merge branch 'half-duplex' into hooks
CaptainN Jul 4, 2019
80fef10
Use 1 useRef instead of multiple
CaptainN Jul 4, 2019
8ae0db4
If reactiveFn will run synchrously next render, don't bother running …
CaptainN Jul 4, 2019
f68ae5b
Dispose of the computation early, if the deps are falsy on meteor rea…
CaptainN Jul 4, 2019
c587026
previousDeps will always equal deps at this point, so just check prev…
CaptainN Jul 5, 2019
d801348
Merge pull request #3 from CaptainN/hooks
menelike Jul 5, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/react-meteor-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand Down
161 changes: 120 additions & 41 deletions packages/react-meteor-data/useTracker.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Tracker } from 'meteor/tracker';
import { Meteor } from 'meteor/meteor';
/* global Meteor, Package, Tracker */
import React, { useState, useEffect, useRef } from 'react';

// Use React.warn() if available (should ship in React 16.9).
const warn = React.warn || console.warn.bind(console);

// Warns if data is a Mongo.Cursor or a POJO containing a Mongo.Cursor.
function checkCursor(data) {
let shouldWarn = false;
if (Package.mongo && Package.mongo.Mongo && data && typeof data === 'object') {
if (data instanceof Package.mongo.Mongo.Cursor) {
shouldWarn = true;
}
else if (Object.getPrototypeOf(data) === Object.prototype) {
} else if (Object.getPrototypeOf(data) === Object.prototype) {
Object.keys(data).forEach((key) => {
if (data[key] instanceof Package.mongo.Mongo.Cursor) {
shouldWarn = true;
Expand All @@ -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 '
Expand All @@ -28,47 +27,127 @@ function checkCursor(data) {
}
}

// Forgetting the deps parameter would cause an infinite rerender loop, so we default to [].
function useTracker(reactiveFn, deps = []) {
// Note : we always run the reactiveFn in Tracker.nonreactive in case
// we are already inside a Tracker Computation. This can happen if someone calls
// `ReactDOM.render` inside a Computation. In that case, we want to opt out
// of the normal behavior of nested Computations, where if the outer one is
// invalidated or stopped, it stops the inner one too.

const [trackerData, setTrackerData] = useState(() => {
// No side-effects are allowed when computing the initial value.
// To get the initial return value for the 1st render on mount,
// we run reactiveFn without autorun or subscriptions.
// Note: maybe when React Suspense is officially available we could
// throw a Promise instead to skip the 1st render altogether ?
const realSubscribe = Meteor.subscribe;
Meteor.subscribe = () => ({ stop: () => {}, ready: () => false });
const initialData = Tracker.nonreactive(reactiveFn);
Meteor.subscribe = realSubscribe;
return initialData;
});
// taken from https://github.com/facebook/react/blob/
// 34ce57ae751e0952fd12ab532a3e5694445897ea/packages/shared/objectIs.js
function is(x, y) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y))
|| (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}

useEffect(() => {
// Set up the reactive computation.
const computation = Tracker.nonreactive(() =>
Tracker.autorun(() => {
const data = reactiveFn();
Meteor.isDevelopment && checkCursor(data);
setTrackerData(data);
// inspired by https://github.com/facebook/react/blob/
// 34ce57ae751e0952fd12ab532a3e5694445897ea/packages/
// react-reconciler/src/ReactFiberHooks.js#L307-L354
// used to replicate dep change behavior and stay consistent
// with React.useEffect()
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null || prevDeps === undefined || !Array.isArray(prevDeps)) {
return false;
}

if (!Array.isArray(nextDeps)) {
if (Meteor.isDevelopment) {
warn(
'Warning: useTracker expected an dependency value of '
+ `type array but got type of ${typeof nextDeps} instead.`
);
}
return false;
}

const len = nextDeps.length;

if (prevDeps.length !== len) {
return false;
}

for (let i = 0; i < len; i++) {
if (!is(nextDeps[i], prevDeps[i])) {
return false;
}
}

return true;
}

let uniqueCounter = 0;

function useTracker(reactiveFn, deps) {
const { current: refs } = useRef({});

const [, forceUpdate] = useState();

const dispose = () => {
if (refs.computation) {
refs.computation.stop();
refs.computation = null;
}
};

// this is called like at componentWillMount and componentWillUpdate equally
// in order to support render calls with synchronous data from the reactive computation
// if prevDeps or deps are not set areHookInputsEqual always returns false
// and the reactive functions is always called
if (!areHookInputsEqual(deps, refs.previousDeps)) {
// if we are re-creating the computation, we need to stop the old one.
dispose();

// store the deps for comparison on next render
refs.previousDeps = deps;

// Use Tracker.nonreactive in case we are inside a Tracker Computation.
// This can happen if someone calls `ReactDOM.render` inside a Computation.
// In that case, we want to opt out of the normal behavior of nested
// Computations, where if the outer one is invalidated or stopped,
// it stops the inner one.
refs.computation = Tracker.nonreactive(() => (
Tracker.autorun((c) => {
const runReactiveFn = () => {
const data = reactiveFn();
if (Meteor.isDevelopment) checkCursor(data);
refs.trackerData = data;
};

if (c.firstRun) {
// This will capture data synchronously on first run (and after deps change).
// Additional cycles will follow the normal computation behavior.
runReactiveFn();
} else {
// If deps are falsy, stop computation and let next render handle reactiveFn.
if (!refs.previousDeps) {
dispose();
} else {
runReactiveFn();
}
// use a uniqueCounter to trigger a state change to force a re-render
forceUpdate(++uniqueCounter);
}
})
);
// On effect cleanup, stop the computation.
return () => computation.stop();
}, deps);
));
}

// stop the computation on unmount only
useEffect(() => {
if (Meteor.isDevelopment
&& deps !== null && deps !== undefined
&& !Array.isArray(deps)) {
warn(
'Warning: useTracker expected an initial dependency value of '
+ `type array but got type of ${typeof deps} instead.`
);
}

return dispose;
}, []);

return trackerData;
return refs.trackerData;
}

// When rendering on the server, we don't want to use the Tracker.
// We only do the first rendering on the server so we can get the data right away
function useTracker__server(reactiveFn, deps) {
function useTrackerServer(reactiveFn) {
return reactiveFn();
}

export default (Meteor.isServer ? useTracker__server : useTracker);
export default (Meteor.isServer ? useTrackerServer : useTracker);
2 changes: 1 addition & 1 deletion packages/react-meteor-data/withTracker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Component ref={ref} {...props} {...data} />;
});

Expand Down