diff --git a/packages/react-meteor-data/README.md b/packages/react-meteor-data/README.md
index 1b76be9a..c2c6dd29 100644
--- a/packages/react-meteor-data/README.md
+++ b/packages/react-meteor-data/README.md
@@ -30,13 +30,26 @@ 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
+#### `useTracker(reactiveFn)` basic 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.
+You can use the `useTracker` hook to get the value of a Tracker reactive function in your React "function components." The reactive function will get re-run whenever its reactive inputs change, and the component will re-render with the new value.
+
+`useTracker` manages its own state, and causes re-renders when necessary. There is no need to call React state setters from inside your `reactiveFn`. Instead, return the values from your `reactiveFn` and assign those to variables directly. When the `reactiveFn` updates, the variables will be updated, and the React component will re-render.
Arguments:
- `reactiveFn`: A Tracker reactive function (receives the current computation).
-- `deps`: An optional array of "dependencies" of the reactive function. This is very similar to how the `deps` argument for [React's built-in `useEffect`, `useCallback` or `useMemo` hooks](https://reactjs.org/docs/hooks-reference.html) work. If omitted, the Tracker computation will be recreated on every render (Note: `withTracker` has always done this). If provided, the computation will be retained, and reactive updates after the first run will run asynchronously from the react render cycle. This array typically includes all variables from the outer scope "captured" in the closure passed as the 1st argument. For example, the value of a prop used in a subscription or a Minimongo query; see example below.
+
+The basic way to use `useTracker` is to simply pass it a reactive function, with no further fuss. This is the preferred configuration in many cases.
+
+#### `useTracker(reacitveFn, deps)` hook with deps
+
+You can pass an optional deps array as a second value. When provided, the computation will be retained, and reactive updates after the first run will run asynchronously from the react render execution frame. 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.
+
+This should be considered a low level optimization step for cases where your computations are somewhat long running - like a complex minimongo query. In many cases it's safe and even preferred to omit deps and allow the computation to run synchronously with render.
+
+Arguments:
+- `reactiveFn`
+- `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.
```js
import { useTracker } from 'meteor/react-meteor-data';
@@ -87,6 +100,44 @@ function Foo({ listId }) {
"react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "useTracker|useSomeOtherHook|..." }]
```
+#### `useTracker(reactiveFn, deps, skipUpdate)` or `useTracker(reactiveFn, skipUpdate)`
+
+You may optionally pass a function as a second or third argument. The `skipUpdate` function can evaluate the return value of `reactiveFn` for changes, and control re-renders in sensitive cases. *Note:* This is not meant to be used iwth a deep compare (even fast-deep-equals), as in many cases that may actually lead to worse performance than allowing React to do it's thing. But as an example, you could use this to compare an `updatedAt` field between updates, or a subset of specific fields, if you aren't using the entire document in a subscription. As always with any optimization, measure first, then optimize second. Make sure you really need this before implementing it.
+
+Arguments:
+- `reactiveFn`
+- `deps?` - optional - you may omit this, or pass a "falsy" value.
+- `skipUpdate` - A function which receives two arguments: `(prev, next) => (prev === next)`. `prev` and `next` will match the type or data shape as that returned by `reactiveFn`. Note: A return value of `true` means the update will be "skipped". `false` means re-render will occur as normal. So the function should be looking for equivalence.
+
+```jsx
+import { useTracker } from 'meteor/react-meteor-data';
+
+// React function component.
+function Foo({ listId }) {
+ const tasks = useTracker(
+ () => Tasks.find({ listId }).fetch(), [listId],
+ (prev, next) => {
+ // prev and next will match the type returned by the reactiveFn
+ return prev.every((doc, i) => (
+ doc._id === next[i] && doc.updatedAt === next[i]
+ )) && prev.length === next.length;
+ }
+ );
+
+ return (
+
Hello {currentUser.username}
+
+ Here is the Todo list {listId}:
+
+ {tasks.map(task => (
+ - {task.label}
+ ))}
+
+
+ );
+}
+```
+
#### `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.
@@ -127,6 +178,59 @@ 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.
+#### `withTracker({ reactiveFn, pure, skipUpdate })` advanced container config
+
+The `withTracker` HOC can receive a config object instead of a simple reactive function.
+
+- `getMeteorData` - The `reactiveFn`.
+- `pure` - `true` by default. Causes the resulting Container to be wrapped with React's `memo()`.
+- `skipUpdate` - A function which receives two arguments: `(prev, next) => (prev === next)`. `prev` and `next` will match the type or data shape as that returned by `reactiveFn`. Note: A return value of `true` means the update will be "skipped". `false` means re-render will occur as normal. So the function should be looking for equivalence.
+
+```js
+import { withTracker } from 'meteor/react-meteor-data';
+
+// React component (function or class).
+function Foo({ listId, currentUser, listLoading, tasks }) {
+ return (
+ Hello {currentUser.username}
+ {listLoading ?
+ Loading
:
+
+ Here is the Todo list {listId}:
+
{tasks.map(task => - {task.label}
)}
+
(
+ doc._id === next[i] && doc.updatedAt === next[i]
+ ))
+ && prev.tasks.length === next.tasks.length
+ );
+ }
+})(Foo);
+```
+
### 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 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.)
diff --git a/packages/react-meteor-data/package-lock.json b/packages/react-meteor-data/package-lock.json
index 86349087..bf514a1c 100644
--- a/packages/react-meteor-data/package-lock.json
+++ b/packages/react-meteor-data/package-lock.json
@@ -54,9 +54,9 @@
}
},
"@types/bson": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz",
- "integrity": "sha512-+uWmsejEHfmSjyyM/LkrP0orfE2m5Mx9Xel4tXNeqi1ldK5XMQcDsFkBmLDtuyKUbxj2jGDo0H240fbCRJZo7Q==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.3.tgz",
+ "integrity": "sha512-mVRvYnTOZJz3ccpxhr3wgxVmSeiYinW+zlzQz3SXWaJmD1DuL05Jeq7nKw3SnbKmbleW5qrLG5vdyWe/A9sXhw==",
"requires": {
"@types/node": "*"
}
@@ -67,9 +67,9 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
},
"@types/connect": {
- "version": "3.4.33",
- "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
- "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==",
+ "version": "3.4.34",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
+ "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==",
"requires": {
"@types/node": "*"
}
@@ -97,9 +97,9 @@
}
},
"@types/meteor": {
- "version": "1.4.42",
- "resolved": "https://registry.npmjs.org/@types/meteor/-/meteor-1.4.42.tgz",
- "integrity": "sha512-gaYksL5mAhvEAgGYZyz1HOYTSZx4Fg3RG9yA3qSGi0bFbvut3lbTVORU7Ry2Z29n//ghgt+grxVmCIXBPjfcLw==",
+ "version": "1.4.66",
+ "resolved": "https://registry.npmjs.org/@types/meteor/-/meteor-1.4.66.tgz",
+ "integrity": "sha512-gBKrGYqe3AFkP50eI2ELQa3Y7gjtjXEGEagUvcxTfhAf5LkHBzxm1FO9hBlVrJXyVYaoH5PDExaHMYgTwq+hcw==",
"requires": {
"@types/connect": "*",
"@types/mongodb": "*",
@@ -108,18 +108,18 @@
}
},
"@types/mongodb": {
- "version": "3.5.5",
- "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.5.tgz",
- "integrity": "sha512-iIWBu740IPolqzpYUBdQs1T4LMiJv1CIIcJb6gX2mGtb/VmnFWONSiyUG+lJF4IUg+QEuIqpalW3eshiTCc8dQ==",
+ "version": "3.6.3",
+ "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.3.tgz",
+ "integrity": "sha512-6YNqGP1hk5bjUFaim+QoFFuI61WjHiHE1BNeB41TA00Xd2K7zG4lcWyLLq/XtIp36uMavvS5hoAUJ+1u/GcX2Q==",
"requires": {
"@types/bson": "*",
"@types/node": "*"
}
},
"@types/node": {
- "version": "13.11.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz",
- "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g=="
+ "version": "14.14.22",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
+ "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
},
"@types/prop-types": {
"version": "15.7.3",
@@ -162,9 +162,9 @@
}
},
"@types/underscore": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.9.4.tgz",
- "integrity": "sha512-CjHWEMECc2/UxOZh0kpiz3lEyX2Px3rQS9HzD20lxMvx571ivOBQKeLnqEjxUY0BMgp6WJWo/pQLRBwMW5v4WQ=="
+ "version": "1.10.24",
+ "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.10.24.tgz",
+ "integrity": "sha512-T3NQD8hXNW2sRsSbLNjF/aBo18MyJlbw0lSpQHB/eZZtScPdexN4HSa8cByYwTw9Wy7KuOFr81mlDQcQQaZ79w=="
},
"@types/yargs": {
"version": "15.0.4",
@@ -239,6 +239,11 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.4.3.tgz",
"integrity": "sha512-JZ8iPuEHDQzq6q0k7PKMGbrIdsgBB7TRrtVOUm4nSMCExlg5qQG4KXWTH2k90yggjM4tTumRGwTKJSldMzKyLA=="
},
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
diff --git a/packages/react-meteor-data/package.json b/packages/react-meteor-data/package.json
index f2ad7573..ff4701c6 100644
--- a/packages/react-meteor-data/package.json
+++ b/packages/react-meteor-data/package.json
@@ -1,11 +1,12 @@
{
"name": "react-meteor-data",
"dependencies": {
+ "@testing-library/react": "^10.0.2",
+ "@types/meteor": "^1.4.66",
+ "@types/react": "^16.9.34",
+ "fast-deep-equal": "^3.1.3",
"react": "16.13.1",
"react-dom": "16.13.1",
- "react-test-renderer": "16.13.1",
- "@testing-library/react": "^10.0.2",
- "@types/meteor": "^1.4.42",
- "@types/react": "^16.9.34"
+ "react-test-renderer": "16.13.1"
}
}
diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js
index b473e923..128e403f 100644
--- a/packages/react-meteor-data/useTracker.tests.js
+++ b/packages/react-meteor-data/useTracker.tests.js
@@ -185,6 +185,61 @@ if (Meteor.isClient) {
completed();
});
+ async function testSkipUpdate (test, deps) {
+ /**
+ * In cases where a state change causes rerender before the render is
+ * committed, useMemo will only run on the first render. This can cause the
+ * value to get lost (unexpected undefined), if we aren't careful.
+ */
+ const container = document.createElement("DIV");
+ const reactiveDict = new ReactiveDict();
+ let value;
+ let renders = 0;
+ const skipUpdate = (prev, next) => {
+ // only update when second changes, not first
+ return prev.second === next.second;
+ };
+ const Test = () => {
+ renders++;
+ value = useTracker(
+ () => {
+ reactiveDict.setDefault('key', { first: 0, second: 0 });
+ return reactiveDict.get('key');
+ },
+ deps || skipUpdate,
+ deps ? skipUpdate : undefined
+ );
+ return {JSON.stringify(value)};
+ };
+
+ ReactDOM.render(, container);
+ test.equal(renders, 1, 'Should have rendered only once');
+
+ // wait for useEffect
+ await waitFor(() => {}, { container, timeout: 250 });
+ test.equal(renders, 1, 'Should have rendered only once after mount');
+
+ reactiveDict.set('key', { first: 1, second: 0 });
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(renders, 1, "Should still have rendered only once");
+
+ reactiveDict.set('key', { first: 1, second: 1 });
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(renders, 2, "Should have rendered a second time");
+ }
+
+ Tinytest.addAsync('useTracker - skipUpdate prevents rerenders', async function (test, completed) {
+ await testSkipUpdate(test, []);
+ completed();
+ });
+
+ Tinytest.addAsync('useTracker (no deps) - skipUpdate prevents rerenders', async function (test, completed) {
+ await testSkipUpdate(test);
+ completed();
+ });
+
Tinytest.addAsync('useTracker (no deps) - basic track', async function (test, completed) {
var container = document.createElement("DIV");
@@ -220,6 +275,41 @@ if (Meteor.isClient) {
completed();
});
+ Tinytest.addAsync('useTracker - basic track', async function (test, completed) {
+ var container = document.createElement("DIV");
+
+ var x = new ReactiveVar('aaa');
+
+ var Foo = () => {
+ const data = useTracker(() => {
+ return {
+ x: x.get()
+ };
+ }, []);
+ return {data.x};
+ };
+
+ ReactDOM.render(, container);
+ test.equal(getInnerHtml(container), 'aaa');
+
+ x.set('bbb');
+ await waitFor(() => {
+ Tracker.flush({_throwFirstError: true});
+ }, { container, timeout: 250 });
+
+ test.equal(getInnerHtml(container), 'bbb');
+
+ test.equal(x._numListeners(), 1);
+
+ await waitFor(() => {
+ ReactDOM.unmountComponentAtNode(container);
+ }, { container, timeout: 250 });
+
+ test.equal(x._numListeners(), 0);
+
+ completed();
+ });
+
// 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
@@ -644,6 +734,63 @@ if (Meteor.isClient) {
}
]);
+ Tinytest.addAsync('useTracker - immediate rerender does not result in `undefined`', async function (test, completed) {
+ /**
+ * In cases where a state change causes rerender before the render is
+ * committed, useMemo will only run on the first render. This can cause the
+ * value to get lost (unexpected undefined), if we aren't careful.
+ */
+ const container = document.createElement("DIV");
+ const reactiveDict = new ReactiveDict();
+ let value;
+ let renderCount = 0;
+ const Test = ({ afterMountInc = false }) => {
+ renderCount++;
+ const [num, setNum] = useState(0);
+ value = useTracker(() => {
+ reactiveDict.setDefault('key', 'initial');
+ return reactiveDict.get('key');
+ }, []);
+ if (num === 0) {
+ reactiveDict.set('key', 'secondary');
+ setNum(1);
+ }
+ if (afterMountInc && num !== 2) {
+ reactiveDict.set('key', 'third');
+ setNum(2);
+ }
+ return {value};
+ };
+
+ const strict = 2;
+ let afterMountInc, setAfterMountInc;
+ const TestContainer = () => {
+ [afterMountInc, setAfterMountInc] = useState(false);
+ return ;
+ };
+
+ ReactDOM.render(, container);
+ test.equal(value, 'secondary', 'value should be "secondary" and not undefined');
+ test.equal(renderCount, 2 * strict, "Should have rendered twice before mount");
+
+ // wait for useEffect
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(value, 'secondary', 'value should still be "secondary" after mount');
+ test.equal(renderCount, 3 * strict, "Should have rendered 3 times after mount");
+
+ renderCount = 0;
+ // trigger after mount immediate rerender
+ setAfterMountInc(true);
+
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(value, 'third', 'value should still be "third" after immediate rerender after mount');
+ test.equal(renderCount, 3 * strict, "Should have rendered 3 times");
+
+ completed();
+ });
+
// Tinytest.add(
// "useTracker - print warning if return cursor from useTracker",
// function (test) {
diff --git a/packages/react-meteor-data/useTracker.ts b/packages/react-meteor-data/useTracker.ts
index 01063193..2f75d579 100644
--- a/packages/react-meteor-data/useTracker.ts
+++ b/packages/react-meteor-data/useTracker.ts
@@ -31,16 +31,21 @@ function checkCursor (data: any): void {
const fur = (x: number): number => x + 1;
const useForceUpdate = () => useReducer(fur, 0)[1];
-interface IReactiveFn {
+export interface IReactiveFn {
(c?: Tracker.Computation): T
}
+
+export interface ISkipUpdate {
+ (prev: T, next: T): boolean
+}
+
type TrackerRefs = {
computation?: Tracker.Computation;
isMounted: boolean;
trackerData: any;
}
-const useTrackerNoDeps = (reactiveFn: IReactiveFn) => {
+const useTrackerNoDeps = (reactiveFn: IReactiveFn, skipUpdate: ISkipUpdate = null) => {
const { current: refs } = useRef({
isMounted: false,
trackerData: null
@@ -63,12 +68,8 @@ const useTrackerNoDeps = (reactiveFn: IReactiveFn) => {
refs.computation = c;
if (c.firstRun) {
// Always run the reactiveFn on firstRun
- const data = reactiveFn(c);
- if (Meteor.isDevelopment) {
- checkCursor(data);
- }
- refs.trackerData = data;
- } else {
+ refs.trackerData = reactiveFn(c);
+ } else if (!skipUpdate || !skipUpdate(refs.trackerData, reactiveFn(c))) {
// For any reactive change, forceUpdate and let the next render rebuild the computation.
forceUpdate();
}
@@ -87,12 +88,22 @@ const useTrackerNoDeps = (reactiveFn: IReactiveFn) => {
}
useEffect(() => {
- // Let subsequent renders know we are mounted (render is comitted).
+ // Let subsequent renders know we are mounted (render is committed).
refs.isMounted = true;
// Render is committed. Since useTracker without deps always runs synchronously,
// forceUpdate and let the next render recreate the computation.
- forceUpdate();
+ if (!skipUpdate) {
+ forceUpdate();
+ } else {
+ Tracker.nonreactive(() => Tracker.autorun((c: Tracker.Computation) => {
+ refs.computation = c;
+ if (!skipUpdate(refs.trackerData, reactiveFn(c))) {
+ // For any reactive change, forceUpdate and let the next render rebuild the computation.
+ forceUpdate();
+ }
+ }));
+ }
// stop the computation on unmount
return () =>{
@@ -103,10 +114,15 @@ const useTrackerNoDeps = (reactiveFn: IReactiveFn) => {
return refs.trackerData;
}
-const useTrackerWithDeps = (reactiveFn: IReactiveFn, deps: DependencyList): T => {
- let [data, setData] = useState();
+const useTrackerWithDeps = (reactiveFn: IReactiveFn, deps: DependencyList, skipUpdate: ISkipUpdate = null): T => {
+ const forceUpdate = useForceUpdate();
+
+ const { current: refs } = useRef<{
+ reactiveFn: IReactiveFn;
+ data?: T;
+ }>({ reactiveFn });
- const { current: refs } = useRef({ reactiveFn });
+ // keep reactiveFn ref fresh
refs.reactiveFn = reactiveFn;
useMemo(() => {
@@ -114,37 +130,47 @@ const useTrackerWithDeps = (reactiveFn: IReactiveFn, deps: Dependenc
// reactive function in a computation, then stop it, to force flush cycle.
const comp = Tracker.nonreactive(
() => Tracker.autorun((c: Tracker.Computation) => {
- if (c.firstRun) data = refs.reactiveFn();
+ refs.data = refs.reactiveFn();
})
);
// To avoid creating side effects in render, stop the computation immediately
Meteor.defer(() => { comp.stop() });
- if (Meteor.isDevelopment) {
- checkCursor(data);
- }
}, deps);
useEffect(() => {
- const computation = Tracker.nonreactive(
+ const computation = Tracker.nonreactive(
() => Tracker.autorun((c) => {
- setData(refs.reactiveFn(c));
+ const data: T = refs.reactiveFn(c);
+ if (!skipUpdate || !skipUpdate(refs.data, data)) {
+ refs.data = data;
+ forceUpdate();
+ }
})
);
return () => {
computation.stop();
- }
+ };
}, deps);
- return data as T;
-}
+ return refs.data as T;
+};
-const useTrackerClient = (reactiveFn: IReactiveFn, deps: DependencyList = null): T =>
- (deps === null || deps === undefined || !Array.isArray(deps))
- ? useTrackerNoDeps(reactiveFn)
- : useTrackerWithDeps(reactiveFn, deps);
+function useTrackerClient (reactiveFn: IReactiveFn, skipUpdate?: ISkipUpdate): T;
+function useTrackerClient (reactiveFn: IReactiveFn, deps?: DependencyList, skipUpdate?: ISkipUpdate): T;
+function useTrackerClient (reactiveFn: IReactiveFn, deps: DependencyList | ISkipUpdate = null, skipUpdate: ISkipUpdate = null): T {
+ if (deps === null || deps === undefined || !Array.isArray(deps)) {
+ if (typeof deps === "function") {
+ skipUpdate = deps;
+ }
+ return useTrackerNoDeps(reactiveFn, skipUpdate);
+ } else {
+ return useTrackerWithDeps(reactiveFn, deps, skipUpdate);
+ }
+}
-const useTrackerServer = (reactiveFn: IReactiveFn, deps: DependencyList): T =>
- Tracker.nonreactive(reactiveFn) as T;
+const useTrackerServer: typeof useTrackerClient = (reactiveFn) => {
+ return Tracker.nonreactive(reactiveFn);
+}
// 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
@@ -152,22 +178,35 @@ const useTracker = Meteor.isServer
? useTrackerServer
: useTrackerClient;
-const useTrackerDev = (reactiveFn: IReactiveFn, deps: DependencyList): T => {
- if (typeof reactiveFn !== 'function') {
+function useTrackerDev (reactiveFn, deps = null, skipUpdate = null) {
+ function warn (expects: string, pos: string, arg: string, type: string) {
console.warn(
- 'Warning: useTracker expected a function in it\'s first argument '
- + `(reactiveFn), but got type of ${typeof reactiveFn}.`
+ `Warning: useTracker expected a ${expects} in it\'s ${pos} argument `
+ + `(${arg}), but got type of \`${type}\`.`
);
}
- if (deps && !Array.isArray(deps)) {
- console.warn(
- 'Warning: useTracker expected an array in it\'s second argument '
- + `(dependency), but got type of ${typeof deps}.`
- );
+
+ if (typeof reactiveFn !== 'function') {
+ warn("function", "1st", "reactiveFn", reactiveFn);
}
- return useTracker(reactiveFn, deps);
-}
+
+ if (deps && skipUpdate && !Array.isArray(deps) && typeof skipUpdate === "function") {
+ warn("array & function", "2nd and 3rd", "deps, skipUpdate",
+ `${typeof deps} & ${typeof skipUpdate}`);
+ } else {
+ if (deps && !Array.isArray(deps) && typeof deps !== "function") {
+ warn("array or function", "2nd", "deps or skipUpdate", typeof deps);
+ }
+ if (skipUpdate && typeof skipUpdate !== "function") {
+ warn("function", "3rd", "skipUpdate", typeof skipUpdate);
+ }
+ }
+
+ const data = useTracker(reactiveFn, deps, skipUpdate);
+ checkCursor(data);
+ return data;
+};
export default Meteor.isDevelopment
- ? useTrackerDev
+ ? useTrackerDev as typeof useTrackerClient
: useTracker;
diff --git a/packages/react-meteor-data/withTracker.tests.js b/packages/react-meteor-data/withTracker.tests.js
index 31f666bd..faa3f156 100644
--- a/packages/react-meteor-data/withTracker.tests.js
+++ b/packages/react-meteor-data/withTracker.tests.js
@@ -12,6 +12,55 @@ const getInnerHtml = function (elem) {
};
if (Meteor.isClient) {
+ Tinytest.addAsync('withTracker - skipUpdate prevents rerenders', async function (test, completed) {
+ /**
+ * In cases where a state change causes rerender before the render is
+ * committed, useMemo will only run on the first render. This can cause the
+ * value to get lost (unexpected undefined), if we aren't careful.
+ */
+ const container = document.createElement("DIV");
+ const reactiveDict = new ReactiveDict();
+ let value;
+ let renders = 0;
+ const skipUpdate = (prev, next) => {
+ // only update when second changes, not first
+ return prev.value.second === next.value.second;
+ };
+ const Test = withTracker({
+ pure: true,
+ getMeteorData: () => {
+ reactiveDict.setDefault('key', { first: 0, second: 0 });
+ return {
+ value: reactiveDict.get('key')
+ };
+ },
+ skipUpdate: skipUpdate,
+ })((props) => {
+ console.log(props)
+ renders++;
+ return {JSON.stringify(props.value)};
+ });
+
+ ReactDOM.render(, container);
+ test.equal(renders, 1, 'Should have rendered only once');
+
+ // wait for useEffect
+ await waitFor(() => {}, { container, timeout: 250 });
+ test.equal(renders, 1, 'Should have rendered only once after mount');
+
+ reactiveDict.set('key', { first: 1, second: 0 });
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(renders, 1, "Should still have rendered only once");
+
+ reactiveDict.set('key', { first: 1, second: 1 });
+ await waitFor(() => {}, { container, timeout: 250 });
+
+ test.equal(renders, 2, "Should have rendered a second time");
+
+ completed();
+ });
+
Tinytest.addAsync('withTracker - basic track', async function (test, completed) {
var container = document.createElement("DIV");
diff --git a/packages/react-meteor-data/withTracker.tsx b/packages/react-meteor-data/withTracker.tsx
index 94862f1c..d8531fdc 100644
--- a/packages/react-meteor-data/withTracker.tsx
+++ b/packages/react-meteor-data/withTracker.tsx
@@ -5,6 +5,7 @@ type ReactiveFn = (props: object) => any;
type ReactiveOptions = {
getMeteorData: ReactiveFn;
pure?: boolean;
+ skipUpdate?: (prev: any, next: any) => boolean;
}
export default function withTracker(options: ReactiveFn | ReactiveOptions) {
@@ -14,14 +15,16 @@ export default function withTracker(options: ReactiveFn | ReactiveOptions) {
: options.getMeteorData;
const WithTracker = forwardRef((props, ref) => {
- const data = useTracker(() => getMeteorData(props) || {});
+ const data = useTracker(
+ () => getMeteorData(props) || {},
+ (options as ReactiveOptions).skipUpdate
+ );
return (
);
});
- // @ts-ignore
- const { pure = true } = options;
+ const { pure = true } = options as ReactiveOptions;
return pure ? memo(WithTracker) : WithTracker;
- }
-};
+ };
+}