diff --git a/package.json b/package.json index 126696df..700d9715 100644 --- a/package.json +++ b/package.json @@ -5,5 +5,32 @@ "repository": { "type": "git", "url": "git@github.com:facebook/react-native.git" + }, + "engines": { + "node": ">=4" + }, + "jest": { + "setupEnvScriptFile": "jest/setup.js", + "testPathIgnorePatterns": [ + "/node_modules/" + ], + "testFileExtensions": [ + "js" + ], + "unmockedModulePathPatterns": [ + "source-map" + ] + }, + "scripts": { + "test": "jest", + "lint": "node linter.js Examples/", + "start": "./packager.sh" + }, + "dependencies": { + "wordwrap": "^1.0.0" + }, + "devDependencies": { + "jest-cli": "git://github.com/facebook/jest#0.5.x", + "eslint": "0.9.2" } } diff --git a/react-packager/index.js b/react-packager/index.js index a0f7c501..e8f31d07 100644 --- a/react-packager/index.js +++ b/react-packager/index.js @@ -45,14 +45,26 @@ function createServer(options) { enableDebug(); } - options = Object.assign({}, options); - delete options.verbose; var Server = require('./src/Server'); - return new Server(options); + return new Server(omit(options, ['verbose'])); } function createNonPersistentServer(options) { Logger.disablePrinting(); - options.watch = options.nonPersistent != null; + // Don't start the filewatcher or the cache. + if (options.nonPersistent == null) { + options.nonPersistent = true; + } + return createServer(options); } + +function omit(obj, blacklistedKeys) { + return Object.keys(obj).reduce((clone, key) => { + if (blacklistedKeys.indexOf(key) === -1) { + clone[key] = obj[key]; + } + + return clone; + }, {}); +} diff --git a/react-packager/src/AssetServer/__tests__/AssetServer-test.js b/react-packager/src/AssetServer/__tests__/AssetServer-test.js index 69b442bf..7e70d6bc 100644 --- a/react-packager/src/AssetServer/__tests__/AssetServer-test.js +++ b/react-packager/src/AssetServer/__tests__/AssetServer-test.js @@ -15,15 +15,17 @@ jest.mock('fs'); const AssetServer = require('../'); const crypto = require('crypto'); +const {EventEmitter} = require('events'); const fs = require('fs'); const {objectContaining} = jasmine; describe('AssetServer', () => { + let fileWatcher; beforeEach(() => { const NodeHaste = require('../../node-haste'); - NodeHaste.getAssetDataFromName = - require.requireActual('../../node-haste/lib/getAssetDataFromName'); + NodeHaste.getAssetDataFromName = require.requireActual('../../node-haste/lib/getAssetDataFromName'); + fileWatcher = new EventEmitter(); }); describe('assetServer.get', () => { @@ -31,6 +33,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -56,6 +59,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -93,6 +97,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png', 'jpg'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -119,6 +124,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -141,6 +147,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -172,6 +179,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root', '/root2'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -200,6 +208,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -232,6 +241,7 @@ describe('AssetServer', () => { const server = new AssetServer({ projectRoots: ['/root'], assetExts: ['png', 'jpeg'], + fileWatcher, }); fs.__setMockFilesystem({ @@ -261,14 +271,15 @@ describe('AssetServer', () => { }); describe('hash:', () => { - let server, mockFS; + let server, fileSystem; beforeEach(() => { server = new AssetServer({ projectRoots: ['/root'], assetExts: ['jpg'], + fileWatcher, }); - mockFS = { + fileSystem = { 'root': { imgs: { 'b@1x.jpg': 'b1 image', @@ -279,13 +290,13 @@ describe('AssetServer', () => { } }; - fs.__setMockFilesystem(mockFS); + fs.__setMockFilesystem(fileSystem); }); it('uses the file contents to build the hash', () => { const hash = crypto.createHash('md5'); - for (const name in mockFS.root.imgs) { - hash.update(mockFS.root.imgs[name]); + for (const name in fileSystem.root.imgs) { + hash.update(fileSystem.root.imgs[name]); } return server.getAssetData('imgs/b.jpg').then(data => @@ -295,8 +306,8 @@ describe('AssetServer', () => { it('changes the hash when the passed-in file watcher emits an `all` event', () => { return server.getAssetData('imgs/b.jpg').then(initialData => { - mockFS.root.imgs['b@4x.jpg'] = 'updated data'; - server.onFileChange('all', '/root/imgs/b@4x.jpg'); + fileSystem.root.imgs['b@4x.jpg'] = 'updated data'; + fileWatcher.emit('all', 'arbitrary', '/root', 'imgs/b@4x.jpg'); return server.getAssetData('imgs/b.jpg').then(data => expect(data.hash).not.toEqual(initialData.hash) ); diff --git a/react-packager/src/AssetServer/index.js b/react-packager/src/AssetServer/index.js index a267eea6..e1a02f65 100644 --- a/react-packager/src/AssetServer/index.js +++ b/react-packager/src/AssetServer/index.js @@ -42,6 +42,10 @@ const validateOpts = declareOpts({ type: 'array', required: true, }, + fileWatcher: { + type: 'object', + required: true, + } }); class AssetServer { @@ -51,6 +55,9 @@ class AssetServer { this._assetExts = opts.assetExts; this._hashes = new Map(); this._files = new Map(); + + opts.fileWatcher + .on('all', (type, root, file) => this._onFileChange(type, root, file)); } get(assetPath, platform = null) { @@ -77,6 +84,7 @@ class AssetServer { data.scales = record.scales; data.files = record.files; + if (this._hashes.has(assetPath)) { data.hash = this._hashes.get(assetPath); return data; @@ -98,8 +106,9 @@ class AssetServer { }); } - onFileChange(type, filePath) { - this._hashes.delete(this._files.get(filePath)); + _onFileChange(type, root, file) { + const asset = this._files.get(path.join(root, file)); + this._hashes.delete(asset); } /** @@ -178,10 +187,7 @@ class AssetServer { } const rootsString = roots.map(s => `'${s}'`).join(', '); - throw new Error( - `'${debugInfoFile}' could not be found, because '${dir}' is not a ` + - `subdirectory of any of the roots (${rootsString})`, - ); + throw new Error(`'${debugInfoFile}' could not be found, because '${dir}' is not a subdirectory of any of the roots (${rootsString})`); }); } diff --git a/react-packager/src/Bundler/index.js b/react-packager/src/Bundler/index.js index 26e9b664..4165edcd 100644 --- a/react-packager/src/Bundler/index.js +++ b/react-packager/src/Bundler/index.js @@ -79,6 +79,10 @@ const validateOpts = declareOpts({ type: 'object', required: false, }, + nonPersistent: { + type: 'boolean', + default: false, + }, assetRoots: { type: 'array', required: false, @@ -87,9 +91,9 @@ const validateOpts = declareOpts({ type: 'array', default: ['png'], }, - watch: { - type: 'boolean', - default: false, + fileWatcher: { + type: 'object', + required: true, }, assetServer: { type: 'object', @@ -120,9 +124,10 @@ type Options = { resetCache: boolean, transformModulePath: string, extraNodeModules: {}, + nonPersistent: boolean, assetRoots: Array, assetExts: Array, - watch: boolean, + fileWatcher: {}, assetServer: AssetServer, transformTimeoutInterval: ?number, allowBundleUpdates: boolean, @@ -186,7 +191,7 @@ class Bundler { blacklistRE: opts.blacklistRE, cache: this._cache, extraNodeModules: opts.extraNodeModules, - watch: opts.watch, + fileWatcher: opts.fileWatcher, minifyCode: this._transformer.minify, moduleFormat: opts.moduleFormat, polyfillModuleNames: opts.polyfillModuleNames, @@ -212,12 +217,9 @@ class Bundler { } } - end() { + kill() { this._transformer.kill(); - return Promise.all([ - this._cache.end(), - this.getResolver().getDependencyGraph().getWatcher().end(), - ]); + return this._cache.end(); } bundle(options: { diff --git a/react-packager/src/Resolver/index.js b/react-packager/src/Resolver/index.js index 47c28123..7588d238 100644 --- a/react-packager/src/Resolver/index.js +++ b/react-packager/src/Resolver/index.js @@ -35,9 +35,9 @@ const validateOpts = declareOpts({ type: 'array', default: [], }, - watch: { - type: 'boolean', - default: false, + fileWatcher: { + type: 'object', + required: true, }, assetExts: { type: 'array', @@ -101,13 +101,14 @@ class Resolver { providesModuleNodeModules: defaults.providesModuleNodeModules, platforms: defaults.platforms, preferNativePlatform: true, - watch: opts.watch, + fileWatcher: opts.fileWatcher, cache: opts.cache, shouldThrowOnUnresolvedErrors: (_, platform) => platform !== 'android', transformCode: opts.transformCode, transformCacheKey: opts.transformCacheKey, extraNodeModules: opts.extraNodeModules, assetDependencies: ['react-native/Libraries/Image/AssetRegistry'], + // for jest-haste-map resetCache: options.resetCache, moduleOptions: { cacheTransformResults: true, @@ -259,7 +260,7 @@ class Resolver { return this._minifyCode(path, code, map); } - getDependencyGraph() { + getDependecyGraph() { return this._depGraph; } } diff --git a/react-packager/src/Server/__tests__/Server-test.js b/react-packager/src/Server/__tests__/Server-test.js index 44f0cddf..d7dfc2aa 100644 --- a/react-packager/src/Server/__tests__/Server-test.js +++ b/react-packager/src/Server/__tests__/Server-test.js @@ -21,6 +21,8 @@ jest.setMock('worker-farm', function() { return () => {}; }) .mock('../../node-haste') .mock('../../Logger'); +let FileWatcher; + describe('processRequest', () => { let SourceMapConsumer, Bundler, Server, AssetServer, Promise; beforeEach(() => { @@ -60,9 +62,12 @@ describe('processRequest', () => { ); const invalidatorFunc = jest.fn(); + const watcherFunc = jest.fn(); let requestHandler; + let triggerFileChange; beforeEach(() => { + FileWatcher = require('../../node-haste').FileWatcher; Bundler.prototype.bundle = jest.fn(() => Promise.resolve({ getSource: () => 'this is the source', @@ -70,10 +75,19 @@ describe('processRequest', () => { getEtag: () => 'this is an etag', })); + FileWatcher.prototype.on = function(eventType, callback) { + if (eventType !== 'all') { + throw new Error('Can only handle "all" event in watcher.'); + } + watcherFunc.apply(this, arguments); + triggerFileChange = callback; + return this; + }; + Bundler.prototype.invalidateFile = invalidatorFunc; Bundler.prototype.getResolver = jest.fn().mockReturnValue({ - getDependencyGraph: jest.fn().mockReturnValue({ + getDependecyGraph: jest.fn().mockReturnValue({ getHasteMap: jest.fn().mockReturnValue({on: jest.fn()}), load: jest.fn(() => Promise.resolve()), }), @@ -83,7 +97,7 @@ describe('processRequest', () => { requestHandler = server.processRequest.bind(server); }); - it('returns JS bundle source on request of *.bundle', () => { + pit('returns JS bundle source on request of *.bundle', () => { return makeRequest( requestHandler, 'mybundle.bundle?runModule=true', @@ -93,7 +107,7 @@ describe('processRequest', () => { ); }); - it('returns JS bundle source on request of *.bundle (compat)', () => { + pit('returns JS bundle source on request of *.bundle (compat)', () => { return makeRequest( requestHandler, 'mybundle.runModule.bundle' @@ -102,7 +116,7 @@ describe('processRequest', () => { ); }); - it('returns ETag header on request of *.bundle', () => { + pit('returns ETag header on request of *.bundle', () => { return makeRequest( requestHandler, 'mybundle.bundle?runModule=true' @@ -111,7 +125,7 @@ describe('processRequest', () => { }); }); - it('returns 304 on request of *.bundle when if-none-match equals the ETag', () => { + pit('returns 304 on request of *.bundle when if-none-match equals the ETag', () => { return makeRequest( requestHandler, 'mybundle.bundle?runModule=true', @@ -121,7 +135,7 @@ describe('processRequest', () => { }); }); - it('returns sourcemap on request of *.map', () => { + pit('returns sourcemap on request of *.map', () => { return makeRequest( requestHandler, 'mybundle.map?runModule=true' @@ -130,7 +144,7 @@ describe('processRequest', () => { ); }); - it('works with .ios.js extension', () => { + pit('works with .ios.js extension', () => { return makeRequest( requestHandler, 'index.ios.includeRequire.bundle' @@ -155,7 +169,7 @@ describe('processRequest', () => { }); }); - it('passes in the platform param', function() { + pit('passes in the platform param', function() { return makeRequest( requestHandler, 'index.bundle?platform=ios' @@ -180,7 +194,7 @@ describe('processRequest', () => { }); }); - it('passes in the assetPlugin param', function() { + pit('passes in the assetPlugin param', function() { return makeRequest( requestHandler, 'index.bundle?assetPlugin=assetPlugin1&assetPlugin=assetPlugin2' @@ -205,13 +219,24 @@ describe('processRequest', () => { }); }); + pit('watches all files in projectRoot', () => { + return makeRequest( + requestHandler, + 'mybundle.bundle?runModule=true' + ).then(() => { + expect(watcherFunc.mock.calls[0][0]).toEqual('all'); + expect(watcherFunc.mock.calls[0][1]).not.toBe(null); + }); + }); + describe('file changes', () => { - it('invalides files in bundle when file is updated', () => { + pit('invalides files in bundle when file is updated', () => { return makeRequest( requestHandler, 'mybundle.bundle?runModule=true' ).then(() => { - server.onFileChange('all', options.projectRoots[0] + '/path/file.js'); + const onFileChange = watcherFunc.mock.calls[0][1]; + onFileChange('all','path/file.js', options.projectRoots[0]); expect(invalidatorFunc.mock.calls[0][0]).toEqual('root/path/file.js'); }); }); @@ -248,7 +273,7 @@ describe('processRequest', () => { jest.runAllTicks(); - server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + triggerFileChange('all','path/file.js', options.projectRoots[0]); jest.runAllTimers(); jest.runAllTicks(); @@ -297,7 +322,7 @@ describe('processRequest', () => { jest.runAllTicks(); - server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + triggerFileChange('all','path/file.js', options.projectRoots[0]); jest.runAllTimers(); jest.runAllTicks(); @@ -330,7 +355,7 @@ describe('processRequest', () => { it('should hold on to request and inform on change', () => { server.processRequest(req, res); - server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + triggerFileChange('all', 'path/file.js', options.projectRoots[0]); jest.runAllTimers(); expect(res.end).toBeCalledWith(JSON.stringify({changed: true})); }); @@ -339,7 +364,7 @@ describe('processRequest', () => { server.processRequest(req, res); req.emit('close'); jest.runAllTimers(); - server.onFileChange('all', options.projectRoots[0] + 'path/file.js'); + triggerFileChange('all', 'path/file.js', options.projectRoots[0]); jest.runAllTimers(); expect(res.end).not.toBeCalled(); }); @@ -403,7 +428,7 @@ describe('processRequest', () => { }); describe('buildbundle(options)', () => { - it('Calls the bundler with the correct args', () => { + pit('Calls the bundler with the correct args', () => { return server.buildBundle({ entryFile: 'foo file' }).then(() => @@ -426,7 +451,7 @@ describe('processRequest', () => { }); describe('buildBundleFromUrl(options)', () => { - it('Calls the bundler with the correct args', () => { + pit('Calls the bundler with the correct args', () => { return server.buildBundleFromUrl('/path/to/foo.bundle?dev=false&runModule=false') .then(() => expect(Bundler.prototype.bundle).toBeCalledWith({ @@ -449,7 +474,7 @@ describe('processRequest', () => { }); describe('/symbolicate endpoint', () => { - it('should symbolicate given stack trace', () => { + pit('should symbolicate given stack trace', () => { const body = JSON.stringify({stack: [{ file: 'http://foo.bundle?platform=ios', lineNumber: 2100, @@ -483,7 +508,7 @@ describe('processRequest', () => { }); }); - it('ignores `/debuggerWorker.js` stack frames', () => { + pit('ignores `/debuggerWorker.js` stack frames', () => { const body = JSON.stringify({stack: [{ file: 'http://localhost:8081/debuggerWorker.js', lineNumber: 123, @@ -507,7 +532,7 @@ describe('processRequest', () => { }); describe('/symbolicate handles errors', () => { - it('should symbolicate given stack trace', () => { + pit('should symbolicate given stack trace', () => { const body = 'clearly-not-json'; console.error = jest.fn(); diff --git a/react-packager/src/Server/index.js b/react-packager/src/Server/index.js index 7fdcffa9..cf63c47e 100644 --- a/react-packager/src/Server/index.js +++ b/react-packager/src/Server/index.js @@ -9,6 +9,7 @@ 'use strict'; const AssetServer = require('../AssetServer'); +const FileWatcher = require('../node-haste').FileWatcher; const getPlatformExtension = require('../node-haste').getPlatformExtension; const Bundler = require('../Bundler'); const MultipartResponse = require('./MultipartResponse'); @@ -75,7 +76,7 @@ const validateOpts = declareOpts({ type: 'object', required: false, }, - watch: { + nonPersistent: { type: 'boolean', default: false, }, @@ -199,32 +200,57 @@ const NODE_MODULES = `${path.sep}node_modules${path.sep}`; class Server { constructor(options) { const opts = this._opts = validateOpts(options); - const processFileChange = - ({type, filePath, stat}) => this.onFileChange(type, filePath, stat); this._projectRoots = opts.projectRoots; this._bundles = Object.create(null); this._changeWatchers = []; this._fileChangeListeners = []; + const assetGlobs = opts.assetExts.map(ext => '**/*.' + ext); + + let watchRootConfigs = opts.projectRoots.map(dir => { + return { + dir: dir, + globs: [ + '**/*.js', + '**/*.json', + ].concat(assetGlobs), + }; + }); + + if (opts.assetRoots != null) { + watchRootConfigs = watchRootConfigs.concat( + opts.assetRoots.map(dir => { + return { + dir: dir, + globs: assetGlobs, + }; + }) + ); + } + + this._fileWatcher = options.nonPersistent + ? FileWatcher.createDummyWatcher() + : new FileWatcher(watchRootConfigs, {useWatchman: true}); + this._assetServer = new AssetServer({ assetExts: opts.assetExts, + fileWatcher: this._fileWatcher, projectRoots: opts.projectRoots, }); const bundlerOpts = Object.create(opts); + bundlerOpts.fileWatcher = this._fileWatcher; bundlerOpts.assetServer = this._assetServer; - bundlerOpts.allowBundleUpdates = options.watch; - bundlerOpts.watch = options.watch; + bundlerOpts.allowBundleUpdates = !options.nonPersistent; this._bundler = new Bundler(bundlerOpts); + this._fileWatcher.on('all', this._onFileChange.bind(this)); + // changes to the haste map can affect resolution of files in the bundle - const dependencyGraph = this._bundler.getResolver().getDependencyGraph(); + const dependencyGraph = this._bundler.getResolver().getDependecyGraph(); + dependencyGraph.load().then(() => { - dependencyGraph.getWatcher().on( - 'change', - ({eventsQueue}) => eventsQueue.forEach(processFileChange), - ); dependencyGraph.getHasteMap().on('change', () => { debug('Clearing bundle cache due to haste map change'); this._clearBundles(); @@ -256,7 +282,10 @@ class Server { } end() { - return this._bundler.end(); + Promise.all([ + this._fileWatcher.end(), + this._bundler.kill(), + ]); } setHMRFileChangeListener(listener) { @@ -270,7 +299,7 @@ class Server { } buildBundle(options) { - return this._bundler.getResolver().getDependencyGraph().load().then(() => { + return this._bundler.getResolver().getDependecyGraph().load().then(() => { if (!options.platform) { options.platform = getPlatformExtension(options.entryFile); } @@ -341,9 +370,9 @@ class Server { }); } - onFileChange(type, filePath, stat) { - this._assetServer.onFileChange(type, filePath, stat); - this._bundler.invalidateFile(filePath); + _onFileChange(type, filepath, root) { + const absPath = path.join(root, filepath); + this._bundler.invalidateFile(absPath); // If Hot Loading is enabled avoid rebuilding bundles and sending live // updates. Instead, send the HMR updates right away and clear the bundles @@ -351,26 +380,26 @@ class Server { if (this._hmrFileChangeListener) { // Clear cached bundles in case user reloads this._clearBundles(); - this._hmrFileChangeListener(filePath, this._bundler.stat(filePath)); + this._hmrFileChangeListener(absPath, this._bundler.stat(absPath)); return; - } else if (type !== 'change' && filePath.indexOf(NODE_MODULES) !== -1) { + } else if (type !== 'change' && absPath.indexOf(NODE_MODULES) !== -1) { // node module resolution can be affected by added or removed files debug('Clearing bundles due to potential node_modules resolution change'); this._clearBundles(); } Promise.all( - this._fileChangeListeners.map(listener => listener(filePath)) + this._fileChangeListeners.map(listener => listener(absPath)) ).then( - () => this._onFileChangeComplete(filePath), - () => this._onFileChangeComplete(filePath) + () => this._onFileChangeComplete(absPath), + () => this._onFileChangeComplete(absPath) ); } - _onFileChangeComplete(filePath) { + _onFileChangeComplete(absPath) { // Make sure the file watcher event runs through the system before // we rebuild the bundles. - this._debouncedFileChangeHandler(filePath); + this._debouncedFileChangeHandler(absPath); } _clearBundles() { diff --git a/react-packager/src/node-haste/DependencyGraph/DeprecatedAssetMap.js b/react-packager/src/node-haste/DependencyGraph/DeprecatedAssetMap.js index e72829a0..fc24c994 100644 --- a/react-packager/src/node-haste/DependencyGraph/DeprecatedAssetMap.js +++ b/react-packager/src/node-haste/DependencyGraph/DeprecatedAssetMap.js @@ -54,14 +54,14 @@ class DeprecatedAssetMap { } } - processFileChange(type, filePath, fstat) { + processFileChange(type, filePath, root, fstat) { const name = assetName(filePath); if (type === 'change' || type === 'delete') { delete this._map[name]; } if (type === 'change' || type === 'add') { - this._processAsset(filePath); + this._processAsset(path.join(root, filePath)); } } } diff --git a/react-packager/src/node-haste/FileWatcher/__mocks__/sane.js b/react-packager/src/node-haste/FileWatcher/__mocks__/sane.js new file mode 100644 index 00000000..f59fa910 --- /dev/null +++ b/react-packager/src/node-haste/FileWatcher/__mocks__/sane.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +module.exports = { + WatchmanWatcher: jest.genMockFromModule('sane/src/watchman_watcher'), + NodeWatcher: jest.genMockFromModule('sane/src/node_watcher'), +}; diff --git a/react-packager/src/node-haste/FileWatcher/__tests__/FileWatcher-test.js b/react-packager/src/node-haste/FileWatcher/__tests__/FileWatcher-test.js new file mode 100644 index 00000000..03f2ad03 --- /dev/null +++ b/react-packager/src/node-haste/FileWatcher/__tests__/FileWatcher-test.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest + .dontMock('util') + .dontMock('events') + .dontMock('../') + .setMock('child_process', { + execSync: () => '/usr/bin/watchman', + }); + +describe('FileWatcher', () => { + let WatchmanWatcher; + let FileWatcher; + let config; + + beforeEach(() => { + jest.resetModules(); + const sane = require('sane'); + WatchmanWatcher = sane.WatchmanWatcher; + WatchmanWatcher.prototype.once.mockImplementation( + (type, callback) => callback() + ); + + FileWatcher = require('../'); + + config = [{ + dir: 'rootDir', + globs: [ + '**/*.js', + '**/*.json', + ], + }]; + }); + + pit('gets the watcher instance when ready', () => { + const fileWatcher = new FileWatcher(config); + return fileWatcher.getWatchers().then(watchers => { + watchers.forEach(watcher => { + expect(watcher instanceof WatchmanWatcher).toBe(true); + }); + }); + }); + + pit('emits events', () => { + let cb; + WatchmanWatcher.prototype.on.mockImplementation((type, callback) => { + cb = callback; + }); + const fileWatcher = new FileWatcher(config); + const handler = jest.genMockFn(); + fileWatcher.on('all', handler); + return fileWatcher.getWatchers().then(watchers => { + cb(1, 2, 3, 4); + jest.runAllTimers(); + expect(handler.mock.calls[0]).toEqual([1, 2, 3, 4]); + }); + }); + + pit('ends the watcher', () => { + const fileWatcher = new FileWatcher(config); + WatchmanWatcher.prototype.close.mockImplementation(callback => callback()); + + return fileWatcher.end().then(() => { + expect(WatchmanWatcher.prototype.close).toBeCalled(); + }); + }); +}); diff --git a/react-packager/src/node-haste/FileWatcher/index.js b/react-packager/src/node-haste/FileWatcher/index.js new file mode 100644 index 00000000..782aed44 --- /dev/null +++ b/react-packager/src/node-haste/FileWatcher/index.js @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +const EventEmitter = require('events').EventEmitter; +const denodeify = require('denodeify'); +const sane = require('sane'); +const execSync = require('child_process').execSync; + +const MAX_WAIT_TIME = 360000; + +const detectWatcherClass = () => { + try { + execSync('watchman version', {stdio: 'ignore'}); + return sane.WatchmanWatcher; + } catch (e) {} + return sane.NodeWatcher; +}; + +const WatcherClass = detectWatcherClass(); + +let inited = false; + +class FileWatcher extends EventEmitter { + + constructor(rootConfigs) { + if (inited) { + throw new Error('FileWatcher can only be instantiated once'); + } + inited = true; + + super(); + this._watcherByRoot = Object.create(null); + + const watcherPromises = rootConfigs.map((rootConfig) => { + return this._createWatcher(rootConfig); + }); + + this._loading = Promise.all(watcherPromises).then(watchers => { + watchers.forEach((watcher, i) => { + this._watcherByRoot[rootConfigs[i].dir] = watcher; + watcher.on( + 'all', + // args = (type, filePath, root, stat) + (...args) => this.emit('all', ...args) + ); + }); + return watchers; + }); + } + + getWatchers() { + return this._loading; + } + + getWatcherForRoot(root) { + return this._loading.then(() => this._watcherByRoot[root]); + } + + isWatchman() { + return Promise.resolve(FileWatcher.canUseWatchman()); + } + + end() { + inited = false; + return this._loading.then( + (watchers) => watchers.map( + watcher => denodeify(watcher.close).call(watcher) + ) + ); + } + + _createWatcher(rootConfig) { + const watcher = new WatcherClass(rootConfig.dir, { + glob: rootConfig.globs, + dot: false, + }); + + return new Promise((resolve, reject) => { + const rejectTimeout = setTimeout( + () => reject(new Error(timeoutMessage(WatcherClass))), + MAX_WAIT_TIME + ); + + watcher.once('ready', () => { + clearTimeout(rejectTimeout); + resolve(watcher); + }); + }); + } + + static createDummyWatcher() { + return Object.assign(new EventEmitter(), { + isWatchman: () => Promise.resolve(false), + end: () => Promise.resolve(), + }); + } + + static canUseWatchman() { + return WatcherClass == sane.WatchmanWatcher; + } +} + +function timeoutMessage(Watcher) { + const lines = [ + 'Watcher took too long to load (' + Watcher.name + ')', + ]; + if (Watcher === sane.WatchmanWatcher) { + lines.push( + 'Try running `watchman version` from your terminal', + 'https://facebook.github.io/watchman/docs/troubleshooting.html', + ); + } + return lines.join('\n'); +} + +module.exports = FileWatcher; diff --git a/react-packager/src/node-haste/ModuleCache.js b/react-packager/src/node-haste/ModuleCache.js index a963c24a..655b01d1 100644 --- a/react-packager/src/node-haste/ModuleCache.js +++ b/react-packager/src/node-haste/ModuleCache.js @@ -16,6 +16,8 @@ const Module = require('./Module'); const Package = require('./Package'); const Polyfill = require('./Polyfill'); +const path = require('path'); + import type Cache from './Cache'; import type { DepGraphHelpers, @@ -67,6 +69,8 @@ class ModuleCache { this._assetDependencies = assetDependencies; this._moduleOptions = moduleOptions; this._packageModuleMap = new WeakMap(); + + fastfs.on('change', this._processFileChange.bind(this)); } getModule(filePath: string) { @@ -145,14 +149,16 @@ class ModuleCache { }); } - processFileChange(type: string, filePath: string) { - if (this._moduleCache[filePath]) { - this._moduleCache[filePath].invalidate(); - delete this._moduleCache[filePath]; + _processFileChange(type, filePath, root) { + const absPath = path.join(root, filePath); + + if (this._moduleCache[absPath]) { + this._moduleCache[absPath].invalidate(); + delete this._moduleCache[absPath]; } - if (this._packageCache[filePath]) { - this._packageCache[filePath].invalidate(); - delete this._packageCache[filePath]; + if (this._packageCache[absPath]) { + this._packageCache[absPath].invalidate(); + delete this._packageCache[absPath]; } } } diff --git a/react-packager/src/node-haste/__tests__/DependencyGraph-test.js b/react-packager/src/node-haste/__tests__/DependencyGraph-test.js index be472c9a..8e58f387 100644 --- a/react-packager/src/node-haste/__tests__/DependencyGraph-test.js +++ b/react-packager/src/node-haste/__tests__/DependencyGraph-test.js @@ -61,6 +61,12 @@ describe('DependencyGraph', function() { jest.resetModules(); Module = require('../Module'); + const fileWatcher = { + on: function() { + return this; + }, + isWatchman: () => Promise.resolve(false), + }; const Cache = jest.genMockFn().mockImplementation(function() { this._maps = Object.create(null); @@ -103,6 +109,7 @@ describe('DependencyGraph', function() { defaults = { assetExts: ['png', 'jpg'], cache: new Cache(), + fileWatcher, forceNodeFilesystemAPI: true, providesModuleNodeModules: [ 'haste-fbjs', @@ -4818,6 +4825,7 @@ describe('DependencyGraph', function() { }); describe('file watch updating', function() { + var triggerFileChange; var mockStat = { isDirectory: () => false, }; @@ -4828,6 +4836,21 @@ describe('DependencyGraph', function() { beforeEach(function() { process.platform = 'linux'; DependencyGraph = require('../index'); + + var callbacks = []; + triggerFileChange = (...args) => + callbacks.map(callback => callback(...args)); + + defaults.fileWatcher = { + on: function(eventType, callback) { + if (eventType !== 'all') { + throw new Error('Can only handle "all" event in watcher.'); + } + callbacks.push(callback); + return this; + }, + isWatchman: () => Promise.resolve(false), + }; }); afterEach(function() { @@ -4868,7 +4891,7 @@ describe('DependencyGraph', function() { return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { filesystem.root['index.js'] = filesystem.root['index.js'].replace('require("foo")', ''); - dgraph.processFileChange('change', root + '/index.js', mockStat); + triggerFileChange('change', 'index.js', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) .toEqual([ @@ -4931,7 +4954,7 @@ describe('DependencyGraph', function() { return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { filesystem.root['index.js'] = filesystem.root['index.js'].replace('require("foo")', ''); - dgraph.processFileChange('change', root + '/index.js', mockStat); + triggerFileChange('change', 'index.js', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) .toEqual([ @@ -4993,7 +5016,7 @@ describe('DependencyGraph', function() { }); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { delete filesystem.root.foo; - dgraph.processFileChange('delete', root + '/foo.js'); + triggerFileChange('delete', 'foo.js', root); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) .toEqual([ @@ -5060,10 +5083,10 @@ describe('DependencyGraph', function() { ' */', 'require("foo")', ].join('\n'); - dgraph.processFileChange('add', root + '/bar.js', mockStat); + triggerFileChange('add', 'bar.js', root, mockStat); filesystem.root.aPackage['main.js'] = 'require("bar")'; - dgraph.processFileChange('change', root + '/aPackage/main.js', mockStat); + triggerFileChange('change', 'aPackage/main.js', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) @@ -5152,7 +5175,7 @@ describe('DependencyGraph', function() { ]); filesystem.root['foo.png'] = ''; - dgraph.processFileChange('add', root + '/foo.png', mockStat); + triggerFileChange('add', 'foo.png', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { expect(deps2) @@ -5222,7 +5245,7 @@ describe('DependencyGraph', function() { ]); filesystem.root['foo.png'] = ''; - dgraph.processFileChange('add', root + '/foo.png', mockStat); + triggerFileChange('add', 'foo.png', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { expect(deps2) @@ -5254,6 +5277,171 @@ describe('DependencyGraph', function() { }); }); + it('runs changes through ignore filter', function() { + var root = '/root'; + var filesystem = setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + ignoreFilePath: function(filePath) { + if (filePath === '/root/bar.js') { + return true; + } + return false; + }, + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + filesystem.root['bar.js'] = [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")', + ].join('\n'); + triggerFileChange('add', 'bar.js', root, mockStat); + + filesystem.root.aPackage['main.js'] = 'require("bar")'; + triggerFileChange('change', 'aPackage/main.js', root, mockStat); + + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: ['bar'], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + + it('should ignore directory updates', function() { + var root = '/root'; + setMockFileSystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")', + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js', + }), + 'main.js': 'main', + }, + }, + }); + var dgraph = new DependencyGraph({ + ...defaults, + roots: [root], + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { + triggerFileChange('change', 'aPackage', '/root', { + isDirectory: () => true, + }); + return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { + expect(deps) + .toEqual([ + { + id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'aPackage/main.js', + path: '/root/aPackage/main.js', + dependencies: [], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + { + id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'], + isAsset: false, + isAsset_DEPRECATED: false, + isJSON: false, + isPolyfill: false, + resolution: undefined, + resolveDependency: undefined, + }, + ]); + }); + }); + }); + it('changes to browser field', function() { var root = '/root'; var filesystem = setMockFileSystem({ @@ -5285,7 +5473,7 @@ describe('DependencyGraph', function() { main: 'main.js', browser: 'browser.js', }); - dgraph.processFileChange('change', root + '/aPackage/package.json', mockStat); + triggerFileChange('change', 'package.json', '/root/aPackage', mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) @@ -5347,7 +5535,7 @@ describe('DependencyGraph', function() { name: 'bPackage', main: 'main.js', }); - dgraph.processFileChange('change', root + '/aPackage/package.json', mockStat); + triggerFileChange('change', 'package.json', '/root/aPackage', mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps) { expect(deps) @@ -5442,7 +5630,7 @@ describe('DependencyGraph', function() { ]); filesystem.root.node_modules.foo['main.js'] = 'lol'; - dgraph.processFileChange('change', root + '/node_modules/foo/main.js', mockStat); + triggerFileChange('change', 'main.js', '/root/node_modules/foo', mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { expect(deps2) @@ -5507,7 +5695,7 @@ describe('DependencyGraph', function() { main: 'main.js', browser: 'browser.js', }); - dgraph.processFileChange('change', root + '/node_modules/foo/package.json', mockStat); + triggerFileChange('change', 'package.json', '/root/node_modules/foo', mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function(deps2) { expect(deps2) @@ -5564,7 +5752,7 @@ describe('DependencyGraph', function() { }); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js').then(function() { - dgraph.processFileChange('add', root + '/index.js', mockStat); + triggerFileChange('add', 'index.js', root, mockStat); return getOrderedDependenciesAsJSON(dgraph, '/root/index.js'); }); }); diff --git a/react-packager/src/node-haste/__tests__/Module-test.js b/react-packager/src/node-haste/__tests__/Module-test.js index 53afd8a5..f7aad9a0 100644 --- a/react-packager/src/node-haste/__tests__/Module-test.js +++ b/react-packager/src/node-haste/__tests__/Module-test.js @@ -47,6 +47,10 @@ function mockIndexFile(indexJs) { } describe('Module', () => { + const fileWatcher = { + on: () => this, + isWatchman: () => Promise.resolve(false), + }; const fileName = '/root/index.js'; let cache, fastfs; @@ -81,6 +85,7 @@ describe('Module', () => { new Fastfs( 'test', ['/root'], + fileWatcher, ['/root/index.js', '/root/package.json'], {ignore: []}, ); @@ -114,22 +119,22 @@ describe('Module', () => { mockIndexFile(source); }); - it('extracts the module name from the header', () => + pit('extracts the module name from the header', () => module.getName().then(name => expect(name).toEqual(moduleId)) ); - it('identifies the module as haste module', () => + pit('identifies the module as haste module', () => module.isHaste().then(isHaste => expect(isHaste).toBe(true)) ); - it('does not transform the file in order to access the name', () => { + pit('does not transform the file in order to access the name', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).getName() .then(() => expect(transformCode).not.toBeCalled()); }); - it('does not transform the file in order to access the haste status', () => { + pit('does not transform the file in order to access the haste status', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).isHaste() @@ -142,22 +147,22 @@ describe('Module', () => { mockIndexFile(source.replace(/@providesModule/, '@provides')); }); - it('extracts the module name from the header if it has a @provides annotation', () => + pit('extracts the module name from the header if it has a @provides annotation', () => module.getName().then(name => expect(name).toEqual(moduleId)) ); - it('identifies the module as haste module', () => + pit('identifies the module as haste module', () => module.isHaste().then(isHaste => expect(isHaste).toBe(true)) ); - it('does not transform the file in order to access the name', () => { + pit('does not transform the file in order to access the name', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).getName() .then(() => expect(transformCode).not.toBeCalled()); }); - it('does not transform the file in order to access the haste status', () => { + pit('does not transform the file in order to access the haste status', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).isHaste() @@ -170,22 +175,22 @@ describe('Module', () => { mockIndexFile('arbitrary(code);'); }); - it('uses the file name as module name', () => + pit('uses the file name as module name', () => module.getName().then(name => expect(name).toEqual(fileName)) ); - it('does not identify the module as haste module', () => + pit('does not identify the module as haste module', () => module.isHaste().then(isHaste => expect(isHaste).toBe(false)) ); - it('does not transform the file in order to access the name', () => { + pit('does not transform the file in order to access the name', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).getName() .then(() => expect(transformCode).not.toBeCalled()); }); - it('does not transform the file in order to access the haste status', () => { + pit('does not transform the file in order to access the haste status', () => { const transformCode = jest.genMockFn().mockReturnValue(Promise.resolve()); return createModule({transformCode}).isHaste() @@ -200,12 +205,12 @@ describe('Module', () => { mockIndexFile(fileContents); }); - it('exposes file contents as `code` property on the data exposed by `read()`', () => + pit('exposes file contents as `code` property on the data exposed by `read()`', () => createModule().read().then(({code}) => expect(code).toBe(fileContents)) ); - it('exposes file contents via the `getCode()` method', () => + pit('exposes file contents via the `getCode()` method', () => createModule().getCode().then(code => expect(code).toBe(fileContents)) ); @@ -236,7 +241,7 @@ describe('Module', () => { mockIndexFile(fileContents); }); - it('passes the module and file contents to the transform function when reading', () => { + pit('passes the module and file contents to the transform function when reading', () => { const module = createModule({transformCode}); return module.read() .then(() => { @@ -244,7 +249,7 @@ describe('Module', () => { }); }); - it('passes any additional options to the transform function when reading', () => { + pit('passes any additional options to the transform function when reading', () => { const module = createModule({transformCode}); const transformOptions = {arbitrary: Object()}; return module.read(transformOptions) @@ -253,7 +258,7 @@ describe('Module', () => { ); }); - it('passes module and file contents if the file is annotated with @extern', () => { + pit('passes the module and file contents to the transform if the file is annotated with @extern', () => { const module = createModule({transformCode}); const customFileContents = ` /** @@ -266,7 +271,7 @@ describe('Module', () => { }); }); - it('passes the module and file contents to the transform for JSON files', () => { + pit('passes the module and file contents to the transform for JSON files', () => { mockPackageFile(); const module = createJSONModule({transformCode}); return module.read().then(() => { @@ -274,7 +279,7 @@ describe('Module', () => { }); }); - it('does not extend the passed options object if the file is annotated with @extern', () => { + pit('does not extend the passed options object if the file is annotated with @extern', () => { const module = createModule({transformCode}); const customFileContents = ` /** @@ -290,7 +295,7 @@ describe('Module', () => { }); }); - it('does not extend the passed options object for JSON files', () => { + pit('does not extend the passed options object for JSON files', () => { mockPackageFile(); const module = createJSONModule({transformCode}); const options = {arbitrary: 'foo'}; @@ -301,7 +306,7 @@ describe('Module', () => { }); }); - it('uses dependencies that `transformCode` resolves to, instead of extracting them', () => { + pit('uses dependencies that `transformCode` resolves to, instead of extracting them', () => { const mockedDependencies = ['foo', 'bar']; transformResult = { code: exampleCode, @@ -314,7 +319,7 @@ describe('Module', () => { }); }); - it('forwards all additional properties of the result provided by `transformCode`', () => { + pit('forwards all additional properties of the result provided by `transformCode`', () => { transformResult = { code: exampleCode, arbitrary: 'arbitrary', @@ -329,7 +334,7 @@ describe('Module', () => { }); }); - it('only stores dependencies if `cacheTransformResults` option is disabled', () => { + pit('does not store anything but dependencies if the `cacheTransformResults` option is disabled', () => { transformResult = { code: exampleCode, arbitrary: 'arbitrary', @@ -349,7 +354,7 @@ describe('Module', () => { }); }); - it('stores all things if options is undefined', () => { + pit('stores all things if options is undefined', () => { transformResult = { code: exampleCode, arbitrary: 'arbitrary', @@ -365,7 +370,7 @@ describe('Module', () => { }); }); - it('exposes the transformed code rather than the raw file contents', () => { + pit('exposes the transformed code rather than the raw file contents', () => { transformResult = {code: exampleCode}; const module = createModule({transformCode}); return Promise.all([module.read(), module.getCode()]) @@ -375,13 +380,13 @@ describe('Module', () => { }); }); - it('exposes the raw file contents as `source` property', () => { + pit('exposes the raw file contents as `source` property', () => { const module = createModule({transformCode}); return module.read() .then(data => expect(data.source).toBe(fileContents)); }); - it('exposes a source map returned by the transform', () => { + pit('exposes a source map returned by the transform', () => { const map = {version: 3}; transformResult = {map, code: exampleCode}; const module = createModule({transformCode}); @@ -392,7 +397,7 @@ describe('Module', () => { }); }); - it('caches the transform result for the same transform options', () => { + pit('caches the transform result for the same transform options', () => { let module = createModule({transformCode}); return module.read() .then(() => { @@ -407,7 +412,7 @@ describe('Module', () => { }); }); - it('triggers a new transform for different transform options', () => { + pit('triggers a new transform for different transform options', () => { const module = createModule({transformCode}); return module.read({foo: 1}) .then(() => { @@ -419,7 +424,7 @@ describe('Module', () => { }); }); - it('triggers a new transform for different source code', () => { + pit('triggers a new transform for different source code', () => { let module = createModule({transformCode}); return module.read() .then(() => { @@ -435,7 +440,7 @@ describe('Module', () => { }); }); - it('triggers a new transform for different transform cache key', () => { + pit('triggers a new transform for different transform cache key', () => { let module = createModule({transformCode}); return module.read() .then(() => { diff --git a/react-packager/src/node-haste/fastfs.js b/react-packager/src/node-haste/fastfs.js index 58f381e2..e8e8220b 100644 --- a/react-packager/src/node-haste/fastfs.js +++ b/react-packager/src/node-haste/fastfs.js @@ -18,6 +18,10 @@ const {EventEmitter} = require('events'); const NOT_FOUND_IN_ROOTS = 'NotFoundInRootsError'; +interface FileWatcher { + on(event: 'all', handler: (type: string, filePath: string, rootPath: string, fstat: fs.Stats) => void): void, +} + const { createActionStartEntry, createActionEndEntry, @@ -28,6 +32,7 @@ const { class Fastfs extends EventEmitter { _name: string; + _fileWatcher: FileWatcher; _ignore: (filePath: string) => boolean; _roots: Array; _fastPaths: {[filePath: string]: File}; @@ -35,6 +40,7 @@ class Fastfs extends EventEmitter { constructor( name: string, roots: Array, + fileWatcher: FileWatcher, files: Array, {ignore}: { ignore: (filePath: string) => boolean, @@ -42,6 +48,7 @@ class Fastfs extends EventEmitter { ) { super(); this._name = name; + this._fileWatcher = fileWatcher; this._ignore = ignore; this._roots = roots.map(root => { // If the path ends in a separator ("/"), remove it to make string @@ -75,6 +82,10 @@ class Fastfs extends EventEmitter { }); print(log(createActionEndEntry(buildingInMemoryFSLogEntry))); + + if (this._fileWatcher) { + this._fileWatcher.on('all', this._processFileChange.bind(this)); + } } stat(filePath: string) { @@ -194,23 +205,33 @@ class Fastfs extends EventEmitter { return this._fastPaths[filePath]; } - processFileChange(type: string, filePath: string) { + _processFileChange(type, filePath, rootPath, fstat) { + const absPath = path.join(rootPath, filePath); + if (this._ignore(absPath) || (fstat && fstat.isDirectory())) { + return; + } + + // Make sure this event belongs to one of our roots. + const root = this._getRoot(absPath); + if (!root) { + return; + } + if (type === 'delete' || type === 'change') { - const file = this._getFile(filePath); + const file = this._getFile(absPath); if (file) { file.remove(); } } - delete this._fastPaths[path.resolve(filePath)]; + delete this._fastPaths[path.resolve(absPath)]; if (type !== 'delete') { - const file = new File(filePath, false); - const root = this._getRoot(filePath); - if (root) { - root.addChild(file, this._fastPaths); - } + const file = new File(absPath, false); + root.addChild(file, this._fastPaths); } + + this.emit('change', type, filePath, rootPath, fstat); } } diff --git a/react-packager/src/node-haste/index.js b/react-packager/src/node-haste/index.js index 1397723b..9946e90a 100644 --- a/react-packager/src/node-haste/index.js +++ b/react-packager/src/node-haste/index.js @@ -15,6 +15,7 @@ const Cache = require('./Cache'); const DependencyGraphHelpers = require('./DependencyGraph/DependencyGraphHelpers'); const DeprecatedAssetMap = require('./DependencyGraph/DeprecatedAssetMap'); const Fastfs = require('./fastfs'); +const FileWatcher = require('./FileWatcher'); const HasteMap = require('./DependencyGraph/HasteMap'); const JestHasteMap = require('jest-haste-map'); const Module = require('./Module'); @@ -46,16 +47,12 @@ const { print, } = require('../Logger'); -const escapePath = (p: string) => { - return (path.sep === '\\') ? p.replace(/(\/|\\(?!\.))/g, '\\\\') : p; -}; - class DependencyGraph { _opts: { roots: Array, ignoreFilePath: (filePath: string) => boolean, - watch: boolean, + fileWatcher: FileWatcher, forceNodeFilesystemAPI: boolean, assetRoots_DEPRECATED: Array, assetExts: Array, @@ -74,23 +71,21 @@ class DependencyGraph { maxWorkers: number, resetCache: boolean, }; - _assetDependencies: mixed; - _assetPattern: RegExp; _cache: Cache; - _deprecatedAssetMap: DeprecatedAssetMap; - _fastfs: Fastfs; - _haste: JestHasteMap; - _hasteMap: HasteMap; - _hasteMapError: ?Error; + _assetDependencies: mixed; _helpers: DependencyGraphHelpers; + _fastfs: Fastfs; _moduleCache: ModuleCache; + _hasteMap: HasteMap; + _deprecatedAssetMap: DeprecatedAssetMap; + _hasteMapError: ?Error; - _loading: Promise; + _loading: ?Promise; constructor({ roots, ignoreFilePath, - watch, + fileWatcher, forceNodeFilesystemAPI, assetRoots_DEPRECATED, assetExts, @@ -114,7 +109,7 @@ class DependencyGraph { }: { roots: Array, ignoreFilePath: (filePath: string) => boolean, - watch: boolean, + fileWatcher: FileWatcher, forceNodeFilesystemAPI?: boolean, assetRoots_DEPRECATED: Array, assetExts: Array, @@ -138,7 +133,7 @@ class DependencyGraph { this._opts = { roots, ignoreFilePath: ignoreFilePath || (() => {}), - watch: !!watch, + fileWatcher, forceNodeFilesystemAPI: !!forceNodeFilesystemAPI, assetRoots_DEPRECATED: assetRoots_DEPRECATED || [], assetExts: assetExts || [], @@ -160,9 +155,6 @@ class DependencyGraph { maxWorkers, resetCache, }; - this._assetPattern = - new RegExp('^' + this._opts.assetRoots_DEPRECATED.map(escapePath).join('|')); - this._cache = cache; this._assetDependencies = assetDependencies; this._helpers = new DependencyGraphHelpers(this._opts); @@ -175,7 +167,7 @@ class DependencyGraph { } const mw = this._opts.maxWorkers; - this._haste = new JestHasteMap({ + const haste = new JestHasteMap({ extensions: this._opts.extensions.concat(this._opts.assetExts), forceNodeFilesystemAPI: this._opts.forceNodeFilesystemAPI, ignorePattern: {test: this._opts.ignoreFilePath}, @@ -188,10 +180,9 @@ class DependencyGraph { retainAllFiles: true, roots: this._opts.roots.concat(this._opts.assetRoots_DEPRECATED), useWatchman: this._opts.useWatchman, - watch: this._opts.watch, }); - this._loading = this._haste.build().then(hasteMap => { + this._loading = haste.build().then(hasteMap => { const initializingPackagerLogEntry = print(log(createActionStartEntry('Initializing Packager'))); @@ -200,12 +191,15 @@ class DependencyGraph { this._fastfs = new Fastfs( 'JavaScript', this._opts.roots, + this._opts.fileWatcher, hasteFSFiles, { ignore: this._opts.ignoreFilePath, } ); + this._fastfs.on('change', this._processFileChange.bind(this)); + this._moduleCache = new ModuleCache({ fastfs: this._fastfs, cache: this._cache, @@ -225,7 +219,14 @@ class DependencyGraph { platforms: this._opts.platforms, }); - const assetFiles = hasteMap.hasteFS.matchFiles(this._assetPattern); + const escapePath = (p: string) => { + return (path.sep === '\\') ? p.replace(/(\/|\\(?!\.))/g, '\\\\') : p; + }; + + const assetPattern = + new RegExp('^' + this._opts.assetRoots_DEPRECATED.map(escapePath).join('|')); + + const assetFiles = hasteMap.hasteFS.matchFiles(assetPattern); this._deprecatedAssetMap = new DeprecatedAssetMap({ helpers: this._helpers, @@ -234,11 +235,11 @@ class DependencyGraph { files: assetFiles, }); - this._haste.on('change', ({eventsQueue}) => - eventsQueue.forEach(({type, filePath, stat}) => - this.processFileChange(type, filePath, stat) - ) - ); + this._fastfs.on('change', (type, filePath, root, fstat) => { + if (assetPattern.test(path.join(root, filePath))) { + this._deprecatedAssetMap.processFileChange(type, filePath, root, fstat); + } + }); const buildingHasteMapLogEntry = print(log(createActionStartEntry('Building Haste Map'))); @@ -278,10 +279,6 @@ class DependencyGraph { return this._fastfs; } - getWatcher() { - return this._haste; - } - /** * Returns the module object for the given path. */ @@ -368,16 +365,21 @@ class DependencyGraph { ); } - processFileChange(type: string, filePath: string, stat: Object) { - this._fastfs.processFileChange(type, filePath, stat); - this._moduleCache.processFileChange(type, filePath, stat); - if (this._assetPattern.test(filePath)) { - this._deprecatedAssetMap.processFileChange(type, filePath, stat); + _processFileChange(type, filePath, root, fstat) { + const absPath = path.join(root, filePath); + if (fstat && fstat.isDirectory() || + this._opts.ignoreFilePath(absPath) || + this._helpers.isNodeModulesDir(absPath)) { + return; } - // This code reports failures but doesn't block recovery in the dev server - // mode. When the hasteMap is left in an incorrect state, we'll rebuild when - // the next file changes. + // Ok, this is some tricky promise code. Our requirements are: + // * we need to report back failures + // * failures shouldn't block recovery + // * Errors can leave `hasteMap` in an incorrect state, and we need to rebuild + // After we process a file change we record any errors which will also be + // reported via the next request. On the next file change, we'll see that + // we are in an error state and we should decide to do a full rebuild. const resolve = () => { if (this._hasteMapError) { console.warn( @@ -389,14 +391,13 @@ class DependencyGraph { // Rebuild the entire map if last change resulted in an error. this._loading = this._hasteMap.build(); } else { - this._loading = this._hasteMap.processFileChange(type, filePath); - this._loading.catch(error => { - this._hasteMapError = error; - }); + this._loading = this._hasteMap.processFileChange(type, absPath); + this._loading.catch((e) => {this._hasteMapError = e;}); } return this._loading; }; - + /* $FlowFixMe: there is a risk this happen before we assign that + * variable in the load() function. */ this._loading = this._loading.then(resolve, resolve); } @@ -410,6 +411,7 @@ class DependencyGraph { static Cache; static Fastfs; + static FileWatcher; static Module; static Polyfill; static getAssetDataFromName; @@ -422,6 +424,7 @@ class DependencyGraph { Object.assign(DependencyGraph, { Cache, Fastfs, + FileWatcher, Module, Polyfill, getAssetDataFromName,