Skip to content

Commit 7ab8d33

Browse files
committed
fix(useTrackerSuspense): fix performance issues when multiple useTracker with suspense exist and rebuild test cases. Closes #454
1 parent aabf046 commit 7ab8d33

File tree

3 files changed

+393
-119
lines changed

3 files changed

+393
-119
lines changed
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/* global Meteor, Tinytest */
2+
import React, { Suspense } from 'react';
3+
import { renderToString } from 'react-dom/server';
4+
import { Mongo } from 'meteor/mongo';
5+
import { render } from '@testing-library/react';
6+
import { useTracker, cacheMap } from './useTracker';
7+
8+
const clearCache = async () => {
9+
await new Promise((resolve) => setTimeout(resolve, 100));
10+
cacheMap.clear();
11+
};
12+
13+
/**
14+
* Test for useTracker with Suspense
15+
*/
16+
Tinytest.addAsync(
17+
'suspense/useTracker - Data query validation',
18+
async (test) => {
19+
const TestDocs = new Mongo.Collection(null);
20+
21+
TestDocs.insertAsync({ id: 0, updated: 0 });
22+
23+
let returnValue;
24+
25+
const Test = () => {
26+
returnValue = useTracker('TestDocs', () => TestDocs.find().fetchAsync());
27+
28+
return null;
29+
};
30+
const TestSuspense = () => {
31+
return (
32+
<Suspense fallback={<div>Loading...</div>}>
33+
<Test />
34+
</Suspense>
35+
);
36+
};
37+
38+
// first return promise
39+
renderToString(<TestSuspense />);
40+
test.isUndefined(
41+
returnValue,
42+
'Return value should be undefined as find promise unresolved'
43+
);
44+
// wait promise
45+
await new Promise((resolve) => setTimeout(resolve, 100));
46+
// return data
47+
renderToString(<TestSuspense />);
48+
49+
test.equal(
50+
returnValue.length,
51+
1,
52+
'Return value should be an array with one document'
53+
);
54+
55+
await clearCache();
56+
}
57+
);
58+
59+
Tinytest.addAsync(
60+
'suspense/useTracker - Test proper cache invalidation',
61+
async function (test) {
62+
const TestDocs = new Mongo.Collection(null);
63+
64+
TestDocs.insertAsync({ id: 0, updated: 0 });
65+
66+
let returnValue;
67+
68+
const Test = () => {
69+
returnValue = useTracker('TestDocs', () => TestDocs.find().fetchAsync());
70+
return null;
71+
};
72+
const TestSuspense = () => {
73+
return (
74+
<Suspense fallback={<div>Loading...</div>}>
75+
<Test />
76+
</Suspense>
77+
);
78+
};
79+
80+
// first return promise
81+
renderToString(<TestSuspense />);
82+
// wait promise
83+
await new Promise((resolve) => setTimeout(resolve, 100));
84+
// return data
85+
renderToString(<TestSuspense />);
86+
87+
test.equal(
88+
returnValue[0].updated,
89+
0,
90+
'Return value should be an array with initial value as find promise resolved'
91+
);
92+
93+
TestDocs.updateAsync({ id: 0 }, { $inc: { updated: 1 } });
94+
await new Promise((resolve) => setTimeout(resolve, 100));
95+
96+
// second return promise
97+
renderToString(<TestSuspense />);
98+
99+
test.equal(
100+
returnValue[0].updated,
101+
0,
102+
'Return value should still not updated as second find promise unresolved'
103+
);
104+
105+
// wait promise
106+
await new Promise((resolve) => setTimeout(resolve, 100));
107+
// return data
108+
renderToString(<TestSuspense />);
109+
renderToString(<TestSuspense />);
110+
renderToString(<TestSuspense />);
111+
112+
test.equal(
113+
returnValue[0].updated,
114+
1,
115+
'Return value should be an array with one document with value updated'
116+
);
117+
118+
await clearCache();
119+
}
120+
);
121+
122+
if (Meteor.isClient) {
123+
Tinytest.addAsync(
124+
'suspense/useTracker - Test useTracker with skipUpdate',
125+
async function (test) {
126+
const TestDocs = new Mongo.Collection(null);
127+
128+
TestDocs.insertAsync({ id: 0, updated: 0, other: 0 });
129+
130+
let returnValue;
131+
132+
const Test = () => {
133+
returnValue = useTracker(
134+
'TestDocs',
135+
() => TestDocs.find().fetchAsync(),
136+
(prev, next) => {
137+
// Skip update if the document has not changed
138+
return prev[0].updated === next[0].updated;
139+
}
140+
);
141+
142+
return null;
143+
};
144+
const TestSuspense = () => {
145+
return (
146+
<Suspense fallback={<div>Loading...</div>}>
147+
<Test />
148+
</Suspense>
149+
);
150+
};
151+
152+
// first return promise
153+
renderToString(<TestSuspense />);
154+
// wait promise
155+
await new Promise((resolve) => setTimeout(resolve, 100));
156+
// return data
157+
renderToString(<TestSuspense />);
158+
159+
test.equal(
160+
returnValue[0].updated,
161+
0,
162+
'Return value should be an array with initial value as find promise resolved'
163+
);
164+
165+
TestDocs.updateAsync({ id: 0 }, { $inc: { other: 1 } });
166+
await new Promise((resolve) => setTimeout(resolve, 100));
167+
168+
// second return promise
169+
renderToString(<TestSuspense />);
170+
// wait promise
171+
await new Promise((resolve) => setTimeout(resolve, 100));
172+
// return data
173+
renderToString(<TestSuspense />);
174+
175+
test.equal(
176+
returnValue[0].other,
177+
0,
178+
'Return value should still not updated as skipUpdate returned true'
179+
);
180+
181+
await clearCache();
182+
}
183+
);
184+
}
185+
186+
// https://github.com/meteor/react-packages/issues/454
187+
if (Meteor.isClient) {
188+
Tinytest.addAsync(
189+
'suspense/useTracker - Testing performance with multiple Trackers',
190+
async (test) => {
191+
const TestCollections = [];
192+
let returnDocs = new Map();
193+
194+
for (let i = 0; i < 100; i++) {
195+
const collection = new Mongo.Collection(null);
196+
197+
for (let i = 0; i < 100; i++) {
198+
collection.insertAsync({ id: i });
199+
}
200+
201+
TestCollections.push(collection);
202+
}
203+
204+
const Test = ({ collection, index }) => {
205+
const docsCount = useTracker(`TestDocs${index}`, () =>
206+
collection.find().fetchAsync()
207+
).length;
208+
209+
returnDocs.set(`TestDocs${index}`, docsCount);
210+
211+
return null;
212+
};
213+
const TestSuspense = () => {
214+
return (
215+
<Suspense fallback={<div>Loading...</div>}>
216+
{TestCollections.map((collection, index) => (
217+
<Test key={index} collection={collection} index={index} />
218+
))}
219+
</Suspense>
220+
);
221+
};
222+
223+
// first return promise
224+
renderToString(<TestSuspense />);
225+
// wait promise
226+
await new Promise((resolve) => setTimeout(resolve, 100));
227+
// return data
228+
renderToString(<TestSuspense />);
229+
230+
test.equal(returnDocs.size, 100, 'should return 100 collections');
231+
232+
const docsCount = Array.from(returnDocs.values()).reduce((a, b) => a + b, 0);
233+
234+
test.equal(docsCount, 10000, 'should return 10000 documents');
235+
236+
await clearCache();
237+
}
238+
);
239+
}
240+
241+
if (Meteor.isServer) {
242+
Tinytest.addAsync(
243+
'suspense/useTracker - Test no memory leaks',
244+
async function (test) {
245+
const TestDocs = new Mongo.Collection(null);
246+
247+
TestDocs.insertAsync({ id: 0, updated: 0 });
248+
249+
let returnValue;
250+
251+
const Test = () => {
252+
returnValue = useTracker('TestDocs', () =>
253+
TestDocs.find().fetchAsync()
254+
);
255+
256+
return null;
257+
};
258+
const TestSuspense = () => {
259+
return (
260+
<Suspense fallback={<div>Loading...</div>}>
261+
<Test />
262+
</Suspense>
263+
);
264+
};
265+
266+
// first return promise
267+
renderToString(<TestSuspense />);
268+
// wait promise
269+
await new Promise((resolve) => setTimeout(resolve, 100));
270+
// return data
271+
renderToString(<TestSuspense />);
272+
// wait cleanup
273+
await new Promise((resolve) => setTimeout(resolve, 100));
274+
275+
test.equal(
276+
cacheMap.size,
277+
0,
278+
'Cache map should be empty as server cache should be cleared after render'
279+
);
280+
}
281+
);
282+
} else {
283+
Tinytest.addAsync(
284+
'suspense/useTracker - Test no memory leaks',
285+
async function (test) {
286+
const TestDocs = new Mongo.Collection(null);
287+
288+
TestDocs.insertAsync({ id: 0, name: 'a' });
289+
290+
const Test = () => {
291+
const docs = useTracker('TestDocs', () => TestDocs.find().fetchAsync());
292+
293+
return <div>{docs[0]?.name}</div>;
294+
};
295+
const TestSuspense = () => {
296+
return (
297+
<Suspense fallback={<div>Loading...</div>}>
298+
<Test />
299+
</Suspense>
300+
);
301+
};
302+
303+
const { queryByText, findByText, unmount } = render(<TestSuspense />, {
304+
container: document.createElement('container'),
305+
});
306+
307+
test.isNotNull(
308+
queryByText('Loading...'),
309+
'Throw Promise as needed to trigger the fallback.'
310+
);
311+
312+
test.isTrue(await findByText('a'), 'Need to return data');
313+
314+
unmount();
315+
// wait cleanup
316+
await new Promise((resolve) => setTimeout(resolve, 100));
317+
318+
test.equal(
319+
cacheMap.size,
320+
0,
321+
'Cache map should be empty as component unmounted and cache cleared'
322+
);
323+
}
324+
);
325+
}

0 commit comments

Comments
 (0)