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`. 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..b50c6313 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', @@ -13,3 +15,10 @@ Package.onUse(function (api) { api.mainModule('index.js'); }); + +Package.onTest(function (api) { + 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/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..e444261a --- /dev/null +++ b/packages/react-meteor-data/tests.js @@ -0,0 +1,2 @@ +import './useTracker.tests.js'; +import './withTracker.tests.js'; diff --git a/packages/react-meteor-data/useTracker.js b/packages/react-meteor-data/useTracker.js index fff0af45..be5ef731 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); @@ -46,13 +46,7 @@ function areHookInputsEqual(nextDeps, 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.` - ); - } + if (nextDeps === null || nextDeps === undefined || !Array.isArray(nextDeps)) { return false; } @@ -71,14 +65,20 @@ function areHookInputsEqual(nextDeps, prevDeps) { return true; } -let uniqueCounter = 0; +// 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) { +function useTracker(reactiveFn, deps, computationHandler) { const { current: refs } = useRef({}); - const [, forceUpdate] = useState(); + const [, forceUpdate] = useReducer(fur, 0); const dispose = () => { + if (refs.computationCleanup) { + refs.computationCleanup(); + delete refs.computationCleanup; + } if (refs.computation) { refs.computation.stop(); refs.computation = null; @@ -96,58 +96,82 @@ function useTracker(reactiveFn, deps) { // 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(); + 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 return nothing.' + ); + } + refs.computationCleanup = cleanupHandler; } - // use a uniqueCounter to trigger a state change to force a re-render - forceUpdate(++uniqueCounter); } - }) - )); - } + // 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. + if (deps === null || deps === undefined || !Array.isArray(deps)) { + dispose(); + } else { + runReactiveFn(); + } + forceUpdate(); + } + } - // 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 = 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)); } + } - 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 Meteor.isDevelopment + ? (reactiveFn, deps, 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); + } + : useTracker; diff --git a/packages/react-meteor-data/useTracker.tests.js b/packages/react-meteor-data/useTracker.tests.js new file mode 100644 index 00000000..0cecd716 --- /dev/null +++ b/packages/react-meteor-data/useTracker.tests.js @@ -0,0 +1,479 @@ +/* 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 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) => { + test.isFalse(c === computation, 'The new computation should always be a new instance'); + 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'); + Tracker.flush({_throwFirstError: true}); + }); + 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'); + 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.'); + 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(); +}); + +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], (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'); + Tracker.flush({_throwFirstError: true}); + }); + 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(); + // 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'); + 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'); + test.equal(createdCount, 2, 'Should have been created 2 times'); + test.equal(destroyedCount, 2, 'Should have been destroyed 2 times'); + + reactiveDict.destroy(); +}); + +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('useTracker - 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('useTracker - 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('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')]; + + 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); + }); + + 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')]; + + 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()) { + c.stop(); + callback(); + } + }); + }; + + testAsyncMulti('useTracker - resubscribe', [ + function (test, expect) { + var self = this; + self.div = document.createElement("DIV"); + 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("useTracker-mixin-sub", + self.num.get()); + + return { + v: self.someOtherVar.get(), + docs: self.collection.find().fetch() + }; + }); + self.data = data; + 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.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( + // "useTracker - 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("useTracker-mixin-sub", function (num) { + Meteor.defer(() => { // because subs are blocking + this.added("useTracker-mixin-coll", 'id'+num, {}); + this.ready(); + }); + }); +} 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(); + }); + }); +}