diff --git a/packages/react-meteor-data/suspense/useTracker.tests.js b/packages/react-meteor-data/suspense/useTracker.tests.js new file mode 100644 index 00000000..c20fe05c --- /dev/null +++ b/packages/react-meteor-data/suspense/useTracker.tests.js @@ -0,0 +1,343 @@ +/* global Meteor, Tinytest */ +import React, { Suspense } from 'react'; +import { renderToString } from 'react-dom/server'; +import { Mongo } from 'meteor/mongo'; +import { render } from '@testing-library/react'; +import { useTracker, cacheMap } from './useTracker'; + +const clearCache = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + cacheMap.clear(); +}; + +const setupTest = (data = { id: 0, updated: 0 }) => { + const Coll = new Mongo.Collection(null); + data && Coll.insertAsync(data); + + return { Coll, simpleFetch: () => Coll.find().fetchAsync() }; +}; + +const TestSuspense = ({ children }) => { + return Loading...}>{children}; +}; + +/** + * Test for useTracker with Suspense + */ +Tinytest.addAsync( + 'suspense/useTracker - Data query validation', + async (test) => { + const { simpleFetch } = setupTest(); + + let returnValue; + + const Test = () => { + returnValue = useTracker('TestDocs', simpleFetch); + + return null; + }; + + // first return promise + renderToString( + + + + ); + test.isUndefined( + returnValue, + 'Return value should be undefined as find promise unresolved' + ); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + + test.equal( + returnValue.length, + 1, + 'Return value should be an array with one document' + ); + + await clearCache(); + } +); + +Tinytest.addAsync( + 'suspense/useTracker - Test proper cache invalidation', + async function (test) { + const { Coll, simpleFetch } = setupTest(); + + let returnValue; + + const Test = () => { + returnValue = useTracker('TestDocs', simpleFetch); + return null; + }; + + // first return promise + renderToString( + + + + ); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + + test.equal( + returnValue[0].updated, + 0, + 'Return value should be an array with initial value as find promise resolved' + ); + + Coll.updateAsync({ id: 0 }, { $inc: { updated: 1 } }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // second return promise + renderToString( + + + + ); + + test.equal( + returnValue[0].updated, + 0, + 'Return value should still not updated as second find promise unresolved' + ); + + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + renderToString( + + + + ); + renderToString( + + + + ); + + test.equal( + returnValue[0].updated, + 1, + 'Return value should be an array with one document with value updated' + ); + + await clearCache(); + } +); + +Meteor.isClient && + Tinytest.addAsync( + 'suspense/useTracker - Test useTracker with skipUpdate', + async function (test) { + const { Coll, simpleFetch } = setupTest({ id: 0, updated: 0, other: 0 }); + + let returnValue; + + const Test = () => { + returnValue = useTracker('TestDocs', simpleFetch, (prev, next) => { + // Skip update if the document has not changed + return prev[0].updated === next[0].updated; + }); + + return null; + }; + + // first return promise + renderToString( + + + + ); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + + test.equal( + returnValue[0].updated, + 0, + 'Return value should be an array with initial value as find promise resolved' + ); + + Coll.updateAsync({ id: 0 }, { $inc: { other: 1 } }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // second return promise + renderToString( + + + + ); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + + test.equal( + returnValue[0].other, + 0, + 'Return value should still not updated as skipUpdate returned true' + ); + + await clearCache(); + } + ); + +// https://github.com/meteor/react-packages/issues/454 +Meteor.isClient && + Tinytest.addAsync( + 'suspense/useTracker - Testing performance with multiple Trackers', + async (test) => { + const TestCollections = []; + let returnDocs = new Map(); + + for (let i = 0; i < 100; i++) { + const { Coll } = setupTest(null); + + for (let i = 0; i < 100; i++) { + Coll.insertAsync({ id: i }); + } + + TestCollections.push(Coll); + } + + const Test = ({ collection, index }) => { + const docsCount = useTracker(`TestDocs${index}`, () => + collection.find().fetchAsync() + ).length; + + returnDocs.set(`TestDocs${index}`, docsCount); + + return null; + }; + const TestWrap = () => { + return ( + + {TestCollections.map((collection, index) => ( + + ))} + + ); + }; + + // first return promise + renderToString(); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString(); + + test.equal(returnDocs.size, 100, 'should return 100 collections'); + + const docsCount = Array.from(returnDocs.values()).reduce( + (a, b) => a + b, + 0 + ); + + test.equal(docsCount, 10000, 'should return 10000 documents'); + + await clearCache(); + } + ); + +Meteor.isServer && + Tinytest.addAsync( + 'suspense/useTracker - Test no memory leaks', + async function (test) { + const { simpleFetch } = setupTest(); + + let returnValue; + + const Test = () => { + returnValue = useTracker('TestDocs', simpleFetch); + + return null; + }; + + // first return promise + renderToString( + + + + ); + // wait promise + await new Promise((resolve) => setTimeout(resolve, 100)); + // return data + renderToString( + + + + ); + // wait cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + test.equal( + cacheMap.size, + 0, + 'Cache map should be empty as server cache should be cleared after render' + ); + } + ); + +Meteor.isClient && + Tinytest.addAsync( + 'suspense/useTracker - Test no memory leaks', + async function (test) { + const { simpleFetch } = setupTest({ id: 0, name: 'a' }); + + const Test = () => { + const docs = useTracker('TestDocs', simpleFetch); + + return
{docs[0]?.name}
; + }; + + const { queryByText, findByText, unmount } = render(, { + container: document.createElement('container'), + wrapper: TestSuspense, + }); + + test.isNotNull( + queryByText('Loading...'), + 'Throw Promise as needed to trigger the fallback.' + ); + + test.isTrue(await findByText('a'), 'Need to return data'); + + unmount(); + // wait cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + test.equal( + cacheMap.size, + 0, + 'Cache map should be empty as component unmounted and cache cleared' + ); + } + ); diff --git a/packages/react-meteor-data/suspense/useTracker.ts b/packages/react-meteor-data/suspense/useTracker.ts index 0ae01e75..504a1211 100644 --- a/packages/react-meteor-data/suspense/useTracker.ts +++ b/packages/react-meteor-data/suspense/useTracker.ts @@ -44,12 +44,6 @@ export type IReactiveFn = (c?: Tracker.Computation) => Promise export type ISkipUpdate = (prev: T, next: T) => boolean -interface TrackerRefs { - computation?: Tracker.Computation - isMounted: boolean - trackerData: any -} - function resolveAsync(key: string, promise: Promise | null, deps: DependencyList = []): typeof promise extends null ? null : T { const cached = cacheMap.get(key) useEffect(() => @@ -62,14 +56,13 @@ function resolveAsync(key: string, promise: Promise | null, deps: Dependen if (promise === null) return null if (cached !== undefined) { - if ('error' in cached) throw cached.error - if ('result' in cached) { - const result = cached.result as T + if (Meteor.isServer && ('error' in cached || 'result' in cached)) { setTimeout(() => { cacheMap.delete(key) }, 0) - return result } + if ('error' in cached) throw cached.error + if ('result' in cached) return cached.result as T throw cached.promise } @@ -91,75 +84,48 @@ function resolveAsync(key: string, promise: Promise | null, deps: Dependen throw entry.promise } -export function useTrackerNoDeps(key: string, reactiveFn: IReactiveFn, skipUpdate: ISkipUpdate = null): T { - const { current: refs } = useRef({ +export function useTrackerSuspenseNoDeps(key: string, reactiveFn: IReactiveFn, skipUpdate: ISkipUpdate = null): T { + const { current: refs } = useRef<{ + isMounted: boolean + computation?: Tracker.Computation + trackerData: any + }>({ isMounted: false, trackerData: null }) const forceUpdate = useForceUpdate() - // Without deps, always dispose and recreate the computation with every render. - if (refs.computation != null) { - refs.computation.stop() - // @ts-expect-error This makes TS think ref.computation is "never" set - delete refs.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(() => - Tracker.autorun(async (c: Tracker.Computation) => { - refs.computation = c + Tracker.autorun(async (comp: Tracker.Computation) => { + if (refs.computation) { + refs.computation.stop() + delete refs.computation + } - const data: Promise = Tracker.withComputation(c, async () => reactiveFn(c)) - if (c.firstRun) { + refs.computation = comp + + const data: Promise = Tracker.withComputation(comp, async () => reactiveFn(comp)) + + if (comp.firstRun) { // Always run the reactiveFn on firstRun refs.trackerData = data } else if (!skipUpdate || !skipUpdate(await refs.trackerData, await data)) { - // For any reactive change, forceUpdate and let the next render rebuild the computation. - forceUpdate() - } - })) + cacheMap.delete(key) - // To clean up side effects in render, stop the computation immediately - if (!refs.isMounted) { - Meteor.defer(() => { - if (!refs.isMounted && (refs.computation != null)) { - refs.computation.stop() - delete refs.computation + // For any reactive change, forceUpdate and let the next render rebuild the computation. + refs.isMounted && forceUpdate() } - }) - } + })) useEffect(() => { // Let subsequent renders know we are mounted (render is committed). refs.isMounted = true - // In some cases, the useEffect hook will run before Meteor.defer, such as - // when React.lazy is used. In those cases, we might as well leave the - // computation alone! - if (refs.computation == null) { - // Render is committed, but we no longer have a computation. Invoke - // forceUpdate and let the next render recreate the computation. - if (!skipUpdate) { - forceUpdate() - } else { - Tracker.nonreactive(() => - Tracker.autorun(async (c: Tracker.Computation) => { - const data = Tracker.withComputation(c, async () => reactiveFn(c)) - - refs.computation = c - if (!skipUpdate(await refs.trackerData, await data)) { - // For any reactive change, forceUpdate and let the next render rebuild the computation. - forceUpdate() - } - })) - } - } - // stop the computation on unmount return () => { refs.computation?.stop() @@ -171,16 +137,20 @@ export function useTrackerNoDeps(key: string, reactiveFn: IReactiveFn(key: string, reactiveFn: IReactiveFn, deps: DependencyList, skipUpdate: ISkipUpdate = null): T => { +export const useTrackerSuspenseWithDeps = + (key: string, reactiveFn: IReactiveFn, deps: DependencyList, skipUpdate?: ISkipUpdate = null): T => { const forceUpdate = useForceUpdate() const { current: refs } = useRef<{ reactiveFn: IReactiveFn - data?: Promise - comp?: Tracker.Computation - isMounted?: boolean - }>({ reactiveFn }) + isMounted: boolean + trackerData?: Promise + computation?: Tracker.Computation + }>({ + reactiveFn, + isMounted: false, + trackerData: null + }) // keep reactiveFn ref fresh refs.reactiveFn = reactiveFn @@ -188,88 +158,66 @@ export const useTrackerWithDeps = useMemo(() => { // To jive with the lifecycle interplay between Tracker/Subscribe, run the // reactive function in a computation, then stop it, to force flush cycle. - const comp = Tracker.nonreactive( - () => Tracker.autorun(async (c: Tracker.Computation) => { - const data = Tracker.withComputation(c, async () => refs.reactiveFn(c)) - if (c.firstRun) { - refs.data = data - } else if (!skipUpdate || !skipUpdate(await refs.data, await data)) { - refs.data = data - forceUpdate() + Tracker.nonreactive( + () => Tracker.autorun(async (comp: Tracker.Computation) => { + if (refs.computation) { + refs.computation.stop() + delete refs.computation + } + + refs.computation = comp + + const data = Tracker.withComputation(comp, async () => refs.reactiveFn(comp)) + + if (comp.firstRun) { + refs.trackerData = data + } else if (!skipUpdate || !skipUpdate(await refs.trackerData, await data)) { + cacheMap.delete(key) + + refs.isMounted && forceUpdate() } }) ) - - // Stop the computation immediately to avoid creating side effects in render. - // refers to this issues: - // https://github.com/meteor/react-packages/issues/382 - // https://github.com/meteor/react-packages/issues/381 - if (refs.comp != null) refs.comp.stop() - - // In some cases, the useEffect hook will run before Meteor.defer, such as - // when React.lazy is used. This will allow it to be stopped earlier in - // useEffect if needed. - refs.comp = comp - // To avoid creating side effects in render, stop the computation immediately - Meteor.defer(() => { - if (!refs.isMounted && (refs.comp != null)) { - refs.comp.stop() - delete refs.comp - } - }) }, deps) useEffect(() => { // Let subsequent renders know we are mounted (render is committed). refs.isMounted = true - if (refs.comp == null) { - refs.comp = Tracker.nonreactive( - () => Tracker.autorun(async (c) => { - const data: Promise = Tracker.withComputation(c, async () => refs.reactiveFn()) - if (!skipUpdate || !skipUpdate(await refs.data, await data)) { - refs.data = data - forceUpdate() - } - }) - ) - } - return () => { - // @ts-expect-error - refs.comp.stop() - delete refs.comp + refs.computation.stop() + delete refs.computation refs.isMounted = false } }, deps) - return resolveAsync(key, refs.data as Promise, deps) + return resolveAsync(key, refs.trackerData, deps) } -function useTrackerClient(key: string, reactiveFn: IReactiveFn, skipUpdate?: ISkipUpdate): T -function useTrackerClient(key: string, reactiveFn: IReactiveFn, deps?: DependencyList, skipUpdate?: ISkipUpdate): T -function useTrackerClient(key: string, reactiveFn: IReactiveFn, deps: DependencyList | ISkipUpdate = null, skipUpdate: ISkipUpdate = null): T { +export function useTrackerSuspenseClient(key: string, reactiveFn: IReactiveFn, skipUpdate?: ISkipUpdate): T +export function useTrackerSuspenseClient(key: string, reactiveFn: IReactiveFn, deps?: DependencyList, skipUpdate?: ISkipUpdate): T +export function useTrackerSuspenseClient(key: string, 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(key, reactiveFn, skipUpdate) + return useTrackerSuspenseNoDeps(key, reactiveFn, skipUpdate) } else { - return useTrackerWithDeps(key, reactiveFn, deps, skipUpdate) + return useTrackerSuspenseWithDeps(key, reactiveFn, deps, skipUpdate) } } -const useTrackerServer: typeof useTrackerClient = (key, reactiveFn) => { +export const useTrackerSuspenseServer: typeof useTrackerSuspenseClient = (key, reactiveFn) => { return resolveAsync(key, 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 -const _useTracker = Meteor.isServer - ? useTrackerServer - : useTrackerClient +export const useTracker = Meteor.isServer + ? useTrackerSuspenseServer + : useTrackerSuspenseClient -function useTrackerDev(key: string, reactiveFn, deps: DependencyList | null = null, skipUpdate = null) { +function useTrackerDev(key: string, reactiveFn: any, deps: DependencyList | null = null, skipUpdate = null) { function warn(expects: string, pos: string, arg: string, type: string) { console.warn( `Warning: useTracker expected a ${expects} in it\'s ${pos} argument ` + @@ -293,11 +241,11 @@ function useTrackerDev(key: string, reactiveFn, deps: DependencyList | null = nu } } - const data = _useTracker(key, reactiveFn, deps, skipUpdate) + const data = useTracker(key, reactiveFn, deps, skipUpdate) checkCursor(data) return data } -export const useTracker = Meteor.isDevelopment - ? useTrackerDev as typeof useTrackerClient - : _useTracker +export default Meteor.isDevelopment + ? useTrackerDev + : useTracker \ No newline at end of file diff --git a/packages/react-meteor-data/tests.js b/packages/react-meteor-data/tests.js index 13539827..8fc74c85 100644 --- a/packages/react-meteor-data/tests.js +++ b/packages/react-meteor-data/tests.js @@ -1,5 +1,6 @@ import './useTracker.tests.js' import './withTracker.tests.js' import './useFind.tests.js' +import './suspense/useTracker.tests.js' import './suspense/useSubscribe.tests.js' import './suspense/useFind.tests.js'