From 3f504ec147479374757987da6d66c77cb9b4675b Mon Sep 17 00:00:00 2001 From: David Aurelio Date: Fri, 12 Aug 2016 09:41:54 -0700 Subject: [PATCH] Make reloads faster for simple file changes Summary: This is a very hacky solution to make reloads from packager faster for simple file changes. Simple means that no dependencies have changed, otherwise packager will abort the attempt to update and fall back to the usual rebuilding method. In principle, this change avoids re-walking and analyzing the whole dependency tree, and just updates modules in existing bundles. Reviewed By: bestander Differential Revision: D3703896 fbshipit-source-id: 671206618dc093965822aed7161e3a99db69a529 --- packager/react-packager/src/Bundler/Bundle.js | 2 +- .../react-packager/src/Bundler/BundleBase.js | 10 +- .../src/Bundler/__tests__/Bundler-test.js | 11 +- packager/react-packager/src/Bundler/index.js | 17 +- packager/react-packager/src/Server/index.js | 171 ++++++++++++++++-- .../DependencyGraph/ResolutionResponse.js | 8 +- 6 files changed, 192 insertions(+), 27 deletions(-) diff --git a/packager/react-packager/src/Bundler/Bundle.js b/packager/react-packager/src/Bundler/Bundle.js index ccaaffa88..833bfe98c 100644 --- a/packager/react-packager/src/Bundler/Bundle.js +++ b/packager/react-packager/src/Bundler/Bundle.js @@ -59,7 +59,7 @@ class Bundle extends BundleBase { this._addRequireCall(super.getMainModuleId()); } - super.finalize(); + super.finalize(options); } _addRequireCall(moduleId) { diff --git a/packager/react-packager/src/Bundler/BundleBase.js b/packager/react-packager/src/Bundler/BundleBase.js index 838ffa1d6..f086dc7db 100644 --- a/packager/react-packager/src/Bundler/BundleBase.js +++ b/packager/react-packager/src/Bundler/BundleBase.js @@ -59,8 +59,10 @@ class BundleBase { } finalize(options) { - Object.freeze(this._modules); - Object.freeze(this._assets); + if (!options.allowUpdates) { + Object.freeze(this._modules); + Object.freeze(this._assets); + } this._finalized = true; } @@ -76,6 +78,10 @@ class BundleBase { return this._source; } + invalidateSource() { + this._source = null; + } + assertFinalized(message) { if (!this._finalized) { throw new Error(message || 'Bundle needs to be finalized before getting any source'); diff --git a/packager/react-packager/src/Bundler/__tests__/Bundler-test.js b/packager/react-packager/src/Bundler/__tests__/Bundler-test.js index bbf71a30f..dbc00f759 100644 --- a/packager/react-packager/src/Bundler/__tests__/Bundler-test.js +++ b/packager/react-packager/src/Bundler/__tests__/Bundler-test.js @@ -129,6 +129,7 @@ describe('Bundler', function() { dependencies: modules, transformOptions, getModuleId: () => 123, + getResolvedDependencyPairs: () => [], }) ); @@ -141,7 +142,7 @@ describe('Bundler', function() { }); }); - pit('create a bundle', function() { + it('create a bundle', function() { assetServer.getAssetData.mockImpl(() => { return { scales: [1,2,3], @@ -170,9 +171,11 @@ describe('Bundler', function() { expect(ithAddedModule(3)).toEqual('/root/img/new_image.png'); expect(ithAddedModule(4)).toEqual('/root/file.json'); - expect(bundle.finalize.mock.calls[0]).toEqual([ - {runMainModule: true, runBeforeMainModule: []} - ]); + expect(bundle.finalize.mock.calls[0]).toEqual([{ + runMainModule: true, + runBeforeMainModule: [], + allowUpdates: false, + }]); expect(bundle.addAsset.mock.calls[0]).toEqual([{ __packager_asset: true, diff --git a/packager/react-packager/src/Bundler/index.js b/packager/react-packager/src/Bundler/index.js index 2056f5138..27c424301 100644 --- a/packager/react-packager/src/Bundler/index.js +++ b/packager/react-packager/src/Bundler/index.js @@ -89,6 +89,10 @@ const validateOpts = declareOpts({ type: 'boolean', default: false, }, + allowBundleUpdates: { + type: 'boolean', + default: false, + }, }); const assetPropertyBlacklist = new Set([ @@ -280,6 +284,7 @@ class Bundler { bundle.finalize({ runMainModule, runBeforeMainModule: runBeforeMainModuleIds, + allowUpdates: this._opts.allowBundleUpdates, }); return bundle; }); @@ -402,6 +407,7 @@ class Bundler { entryFilePath, transformOptions: response.transformOptions, getModuleId: response.getModuleId, + dependencyPairs: response.getResolvedDependencyPairs(module), }).then(transformed => { modulesByName[transformed.name] = module; onModuleTransformed({ @@ -533,7 +539,14 @@ class Bundler { ); } - _toModuleTransport({module, bundle, entryFilePath, transformOptions, getModuleId}) { + _toModuleTransport({ + module, + bundle, + entryFilePath, + transformOptions, + getModuleId, + dependencyPairs, + }) { let moduleTransport; const moduleId = getModuleId(module); @@ -566,7 +579,7 @@ class Bundler { id: moduleId, code, map, - meta: {dependencies, dependencyOffsets, preloaded}, + meta: {dependencies, dependencyOffsets, preloaded, dependencyPairs}, sourceCode: source, sourcePath: module.path }); diff --git a/packager/react-packager/src/Server/index.js b/packager/react-packager/src/Server/index.js index 87db0787f..b957c846a 100644 --- a/packager/react-packager/src/Server/index.js +++ b/packager/react-packager/src/Server/index.js @@ -22,11 +22,18 @@ const mime = require('mime-types'); const path = require('path'); const url = require('url'); -function debounce(fn, delay) { - var timeout; - return () => { +const debug = require('debug')('ReactNativePackager:Server'); + +function debounceAndBatch(fn, delay) { + let timeout, args = []; + return (value) => { + args.push(value); clearTimeout(timeout); - timeout = setTimeout(fn, delay); + timeout = setTimeout(() => { + const a = args; + args = []; + fn(a); + }, delay); }; } @@ -139,7 +146,10 @@ const bundleOpts = declareOpts({ isolateModuleIDs: { type: 'boolean', default: false - } + }, + resolutionResponse: { + type: 'object', + }, }); const dependencyOpts = declareOpts({ @@ -163,8 +173,14 @@ const dependencyOpts = declareOpts({ type: 'boolean', default: false, }, + minify: { + type: 'boolean', + default: undefined, + }, }); +const bundleDeps = new WeakMap(); + class Server { constructor(options) { const opts = validateOpts(options); @@ -209,12 +225,27 @@ class Server { const bundlerOpts = Object.create(opts); bundlerOpts.fileWatcher = this._fileWatcher; bundlerOpts.assetServer = this._assetServer; + bundlerOpts.allowBundleUpdates = !options.nonPersistent; this._bundler = new Bundler(bundlerOpts); this._fileWatcher.on('all', this._onFileChange.bind(this)); - this._debouncedFileChangeHandler = debounce(filePath => { - this._clearBundles(); + this._debouncedFileChangeHandler = debounceAndBatch(filePaths => { + // only clear bundles for non-JS changes + if (filePaths.every(RegExp.prototype.test, /\.js(?:on)?$/i)) { + for (const key in this._bundles) { + this._bundles[key].then(bundle => { + const deps = bundleDeps.get(bundle); + filePaths.forEach(filePath => { + if (deps.files.has(filePath)) { + deps.outdated.add(filePath); + } + }); + }); + } + } else { + this._clearBundles(); + } this._informChangeWatchers(); }, 50); } @@ -243,7 +274,25 @@ class Server { } const opts = bundleOpts(options); - return this._bundler.bundle(opts); + const building = this._bundler.bundle(opts); + building.then(bundle => { + const modules = bundle.getModules().filter(m => !m.virtual); + bundleDeps.set(bundle, { + files: new Map( + modules + .map(({sourcePath, meta = {dependencies: []}}) => + [sourcePath, meta.dependencies]) + ), + idToIndex: new Map(modules.map(({id}, i) => [id, i])), + dependencyPairs: new Map( + modules + .filter(({meta}) => meta && meta.dependencyPairs) + .map(m => [m.sourcePath, m.meta.dependencyPairs]) + ), + outdated: new Set(), + }); + }); + return building; }); } @@ -428,6 +477,91 @@ class Server { ).done(() => Activity.endEvent(assetEvent)); } + _useCachedOrUpdateOrCreateBundle(options) { + const optionsJson = JSON.stringify(options); + const bundleFromScratch = () => { + const building = this.buildBundle(options); + this._bundles[optionsJson] = building; + return building; + }; + + if (optionsJson in this._bundles) { + return this._bundles[optionsJson].then(bundle => { + const deps = bundleDeps.get(bundle); + const {dependencyPairs, files, idToIndex, outdated} = deps; + if (outdated.size) { + debug('Attempt to update existing bundle'); + const changedModules = + Array.from(outdated, this.getModuleForPath, this); + deps.outdated = new Set(); + + const opts = bundleOpts(options); + const {platform, dev, minify, hot} = opts; + + // Need to create a resolution response to pass to the bundler + // to process requires after transform. By providing a + // specific response we can compute a non recursive one which + // is the least we need and improve performance. + const bundlePromise = this._bundles[optionsJson] = + this.getDependencies({ + platform, dev, hot, minify, + entryFile: options.entryFile, + recursive: false, + }).then(response => { + debug('Update bundle: rebuild shallow bundle'); + + changedModules.forEach(m => { + response.setResolvedDependencyPairs( + m, + dependencyPairs.get(m.path), + {ignoreFinalized: true}, + ); + }); + + return this.buildBundle({ + ...options, + resolutionResponse: response.copy({ + dependencies: changedModules, + }) + }).then(updateBundle => { + const oldModules = bundle.getModules(); + const newModules = updateBundle.getModules(); + for (let i = 0, n = newModules.length; i < n; i++) { + const moduleTransport = newModules[i]; + const {meta, sourcePath} = moduleTransport; + if (outdated.has(sourcePath)) { + if (!contentsEqual(meta.dependencies, new Set(files.get(sourcePath)))) { + // bail out if any dependencies changed + return Promise.reject(Error( + `Dependencies of ${sourcePath} changed from [${ + files.get(sourcePath).join(', ') + }] to [${meta.dependencies.join(', ')}]` + )); + } + + oldModules[idToIndex.get(moduleTransport.id)] = moduleTransport; + } + } + + bundle.invalidateSource(); + debug('Successfully updated existing bundle'); + return bundle; + }); + }).catch(e => { + debug('Failed to update existing bundle, rebuilding...', e.stack || e.message); + return bundleFromScratch(); + }); + return bundlePromise; + } else { + debug('Using cached bundle'); + return bundle; + } + }); + } + + return bundleFromScratch(); + } + processRequest(req, res, next) { const urlObj = url.parse(req.url, true); const pathname = urlObj.pathname; @@ -458,26 +592,29 @@ class Server { const startReqEventId = Activity.startEvent('request:' + req.url); const options = this._getOptionsFromUrl(req.url); - const optionsJson = JSON.stringify(options); - const building = this._bundles[optionsJson] || this.buildBundle(options); - - this._bundles[optionsJson] = building; + debug('Getting bundle for request'); + const building = this._useCachedOrUpdateOrCreateBundle(options); building.then( p => { if (requestType === 'bundle') { + debug('Generating source code'); const bundleSource = p.getSource({ inlineSourceMap: options.inlineSourceMap, minify: options.minify, dev: options.dev, }); + debug('Writing response headers'); res.setHeader('Content-Type', 'application/javascript'); res.setHeader('ETag', p.getEtag()); if (req.headers['if-none-match'] === res.getHeader('ETag')){ + debug('Responding with 304'); res.statusCode = 304; res.end(); } else { + debug('Writing request body'); res.end(bundleSource); } + debug('Finished response'); Activity.endEvent(startReqEventId); } else if (requestType === 'map') { let sourceMap = p.getSourceMap({ @@ -499,7 +636,7 @@ class Server { Activity.endEvent(startReqEventId); } }, - this._handleError.bind(this, res, optionsJson) + error => this._handleError(res, JSON.stringify(options), error) ).done(); } @@ -564,9 +701,7 @@ class Server { _sourceMapForURL(reqUrl) { const options = this._getOptionsFromUrl(reqUrl); - const optionsJson = JSON.stringify(options); - const building = this._bundles[optionsJson] || this.buildBundle(options); - this._bundles[optionsJson] = building; + const building = this._useCachedOrUpdateOrCreateBundle(options); return building.then(p => { const sourceMap = p.getSourceMap({ minify: options.minify, @@ -659,4 +794,8 @@ class Server { } } +function contentsEqual(array, set) { + return array.length === set.size && array.every(set.has, set); +} + module.exports = Server; diff --git a/packager/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js b/packager/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js index fdee65c73..28740746f 100644 --- a/packager/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js +++ b/packager/react-packager/src/node-haste/DependencyGraph/ResolutionResponse.js @@ -8,6 +8,8 @@ */ 'use strict'; +const NO_OPTIONS = {}; + class ResolutionResponse { constructor({transformOptions}) { this.transformOptions = transformOptions; @@ -76,8 +78,10 @@ class ResolutionResponse { this.numPrependedDependencies += 1; } - setResolvedDependencyPairs(module, pairs) { - this._assertNotFinalized(); + setResolvedDependencyPairs(module, pairs, options = NO_OPTIONS) { + if (!options.ignoreFinalized) { + this._assertNotFinalized(); + } const hash = module.hash(); if (this._mappings[hash] == null) { this._mappings[hash] = pairs;