Skip to content

Commit bd16281

Browse files
committed
Reuse JSDOM instances across targets
I've been investigating a performance problem we ran into at Airbnb the last couple of days. We have some components that end up with very very large dependency graphs, which makes the webpack bundle quite large. Looking at the CI output, it seems to take about 2 min to bundle, which isn't the most terrible thing. But, then I've noticed that the timings for the targets are pretty crazy. Look at these logs: ``` [2020-06-17T19:36:15Z] image_analytics was not bootstrapped [2020-06-17T19:36:37Z] - chrome-medium ✓ (22034.4ms) [2020-06-17T19:38:38Z] No examples found for target chrome-mediumPlus, skipping [2020-06-17T19:38:38Z] - chrome-mediumPlus ✓ (4.0ms) [2020-06-17T19:41:05Z] p3_defer_modals was not bootstrapped ``` Here chrome-medium had examples, so it took snapshots and that took a while which makes sense. mediumPlus didn't have examples, so it took no snapshots. Happo says that mediumPlus took 4ms, but if you look at the timestamps in the log lines, you can see that it actually took 2 minutes. I believe that the time spent on empty targets is in initializing the JSDOM and loading up the webpack bundle. Since we have 6 targets, this adds up to 12 minutes of overhead per job just for loading the bundle, and another 12 if we have to check out previous SHA. This is much too long. I think we can improve performance by reusing the JSDOM instance for all of the targets. In the example above, this should save us about 10 minutes on a run where the previous SHA report exists, and 20 minutes on a run where the previous SHA report does not exist. One potential issue that could arise from this is if there is any side-effecty code in the bundle that looks at the viewport size when it is first executed, that will no longer work quite right since we run the bundle and then resize the window later. In order to preserve backwards compatibility with other DomProvider plugins, like the puppeteer plugin, I left in the width/height in the initialization call, and included a guard around the resize call in case it does not exist yet. We should clean these up in the next breaking change.
1 parent 3e4bd1b commit bd16281

File tree

4 files changed

+53
-16
lines changed

4 files changed

+53
-16
lines changed

src/JSDOMDomProvider.js

+38-11
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import { JSDOM, VirtualConsole } from 'jsdom';
22

3+
import Logger from './Logger';
4+
35
const { VERBOSE } = process.env;
46

57
const MAX_ERROR_DETAIL_LENGTH = 200;
68

7-
export default class JSDOMDomProvider {
8-
constructor(jsdomOptions, { width, height, webpackBundle }) {
9+
// Cache the JSDOM instance because re-loading the webpack bundle for every
10+
// target can be very expensive. This assumes that jsdomOptions and
11+
// webpackBundle do not change.
12+
let dom;
13+
function getCachedDOM(jsdomOptions, webpackBundle) {
14+
if (!dom) {
15+
const logger = new Logger();
16+
logger.start('Initializing JSDOM with the bundle...');
17+
918
const virtualConsole = new VirtualConsole();
1019
virtualConsole.on('jsdomError', (e) => {
1120
const { stack, detail = '' } = e;
@@ -19,7 +28,7 @@ export default class JSDOMDomProvider {
1928
});
2029
virtualConsole.sendTo(console, { omitJSDOMErrors: true });
2130

22-
this.dom = new JSDOM(
31+
dom = new JSDOM(
2332
`
2433
<!DOCTYPE html>
2534
<html>
@@ -37,21 +46,28 @@ export default class JSDOMDomProvider {
3746
url: 'http://localhost',
3847
virtualConsole,
3948
beforeParse(win) {
40-
win.outerWidth = win.innerWidth = width;
41-
win.outerHeight = win.innerHeight = height;
42-
Object.defineProperties(win.screen, {
43-
width: { value: width },
44-
availWidth: { value: width },
45-
height: { value: height },
46-
availHeight: { value: height },
47-
});
4849
win.requestAnimationFrame = (callback) => setTimeout(callback, 0);
4950
win.cancelAnimationFrame = clearTimeout;
5051
},
5152
},
5253
jsdomOptions,
5354
),
5455
);
56+
57+
logger.success();
58+
}
59+
60+
return dom;
61+
}
62+
63+
// Useful for tests
64+
export function clearCachedDOM() {
65+
dom = undefined;
66+
}
67+
68+
export default class JSDOMDomProvider {
69+
constructor(jsdomOptions, { webpackBundle }) {
70+
this.dom = getCachedDOM(jsdomOptions, webpackBundle);
5571
}
5672

5773
async init({ targetName }) {
@@ -61,6 +77,17 @@ export default class JSDOMDomProvider {
6177
return this.dom.window.happoProcessor.init({ targetName });
6278
}
6379

80+
resize({ width, height }) {
81+
this.dom.window.outerWidth = this.dom.window.innerWidth = width;
82+
this.dom.window.outerHeight = this.dom.window.innerHeight = height;
83+
Object.defineProperties(this.dom.window.screen, {
84+
width: { value: width },
85+
availWidth: { value: width },
86+
height: { value: height },
87+
availHeight: { value: height },
88+
});
89+
}
90+
6491
next() {
6592
return this.dom.window.happoProcessor.next();
6693
}

src/processSnapsInBundle.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@ export default async function processSnapsInBundle(
55
{ viewport, DomProvider, targetName },
66
) {
77
const [width, height] = viewport.split('x').map((s) => parseInt(s, 10));
8-
const domProvider = new DomProvider({
9-
webpackBundle,
10-
width,
11-
height,
12-
});
8+
9+
// TODO Remove width and height in next breaking change after puppeteer plugin
10+
// has been updated.
11+
const domProvider = new DomProvider({ webpackBundle, width, height });
1312

1413
const result = {
1514
snapPayloads: [],
1615
};
1716
try {
17+
// TODO remove resize guard in next breaking change and after puppeteer
18+
// plugin has been updated.
19+
if (typeof domProvider.resize !== 'undefined') {
20+
domProvider.resize({ width, height });
21+
}
22+
1823
await domProvider.init({ targetName });
1924

2025
// Disabling eslint here because we actually want to run things serially.

test/integrations/error-test.js

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import MockTarget from './MockTarget';
44
import * as defaultConfig from '../../src/DEFAULTS';
55
import makeRequest from '../../src/makeRequest';
66
import runCommand from '../../src/commands/run';
7+
import { clearCachedDOM } from '../../src/JSDOMDomProvider';
78

89
jest.mock('../../src/makeRequest');
910

@@ -15,6 +16,7 @@ let config;
1516
let sha;
1617

1718
beforeEach(() => {
19+
clearCachedDOM();
1820
console.warn = jest.fn(originalConsoleWarn);
1921
console.error = jest.fn(originalConsoleErr);
2022
console.error.mockReset();

test/integrations/react-test.js

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import MockTarget from './MockTarget';
88
import * as defaultConfig from '../../src/DEFAULTS';
99
import makeRequest from '../../src/makeRequest';
1010
import runCommand from '../../src/commands/run';
11+
import { clearCachedDOM } from '../../src/JSDOMDomProvider';
1112

1213
jest.mock('../../src/makeRequest');
1314

@@ -16,6 +17,8 @@ let config;
1617
let sha;
1718

1819
beforeEach(() => {
20+
clearCachedDOM();
21+
1922
makeRequest.mockImplementation(() => Promise.resolve({}));
2023
sha = 'foobar';
2124
config = Object.assign({}, defaultConfig, {

0 commit comments

Comments
 (0)