diff --git a/.flowconfig b/.flowconfig index eb41d69f4..b42fac450 100644 --- a/.flowconfig +++ b/.flowconfig @@ -10,6 +10,7 @@ /desktop/static/.* /desktop/pkg/.* /desktop/doctor/.* +/desktop/app/.* /desktop/babel-transformer/.* /desktop/plugins/fb/relaydevtools/relay-devtools/DevtoolsUI.js$ /website/.* diff --git a/desktop/app/src/HMRClient.js b/desktop/app/src/HMRClient.js new file mode 100644 index 000000000..5907546e7 --- /dev/null +++ b/desktop/app/src/HMRClient.js @@ -0,0 +1,294 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +/** + * * * * * * * ** * * * * * * * * * * * * * * * * * ** * * * * * * * * * * * * * * * * * ** * * * * * * * * * + * This implementation of HMR Client is based on React Native implementation with some code commented out: * + * https://github.com/facebook/react-native/blob/master/Libraries/Utilities/HMRClient.js * + * * * * * * * ** * * * * * * * * * * * * * * * * * ** * * * * * * * * * * * * * * * * * ** * * * * * * * * * + */ + +// // const DevSettings = require('./DevSettings'); +import invariant from 'invariant'; +import {default as MetroHMRClient} from 'metro/src/lib/bundle-modules/HMRClient'; +// // const Platform = require('./Platform'); +import prettyFormat from 'pretty-format'; + +const pendingEntryPoints = []; +let hmrClient = null; +let hmrUnavailableReason = null; +let currentCompileErrorMessage = null; +let didConnect = false; +const pendingLogs = []; + +/** + * HMR Client that receives from the server HMR updates and propagates them + * runtime to reflects those changes. + */ +const HMRClient = { + enable() { + if (hmrUnavailableReason !== null) { + // If HMR became unavailable while you weren't using it, + // explain why when you try to turn it on. + // This is an error (and not a warning) because it is shown + // in response to a direct user action. + throw new Error(hmrUnavailableReason); + } + + invariant(hmrClient, 'Expected HMRClient.setup() call at startup.'); + //// const LoadingView = require('./LoadingView'); + + // We use this for internal logging only. + // It doesn't affect the logic. + hmrClient.send(JSON.stringify({type: 'log-opt-in'})); + + // When toggling Fast Refresh on, we might already have some stashed updates. + // Since they'll get applied now, we'll show a banner. + const hasUpdates = hmrClient.hasPendingUpdates(); + + if (hasUpdates) { + //// LoadingView.showMessage('Refreshing...', 'refresh'); + console.log('Loading start: Refreshing...'); + } + try { + hmrClient.enable(); + } finally { + if (hasUpdates) { + //// LoadingView.hide(); + console.log('Loading end'); + } + } + + // There could be a compile error while Fast Refresh was off, + // but we ignored it at the time. Show it now. + showCompileError(); + }, + + disable() { + invariant(hmrClient, 'Expected HMRClient.setup() call at startup.'); + hmrClient.disable(); + }, + + registerBundle(requestUrl) { + invariant(hmrClient, 'Expected HMRClient.setup() call at startup.'); + pendingEntryPoints.push(requestUrl); + registerBundleEntryPoints(hmrClient); + }, + + log(level, data) { + if (!hmrClient) { + // Catch a reasonable number of early logs + // in case hmrClient gets initialized later. + pendingLogs.push([level, data]); + if (pendingLogs.length > 100) { + pendingLogs.shift(); + } + return; + } + try { + hmrClient.send( + JSON.stringify({ + type: 'log', + level, + data: data.map((item) => + typeof item === 'string' + ? item + : prettyFormat(item, { + escapeString: true, + highlight: true, + maxDepth: 3, + min: true, + plugins: [prettyFormat.plugins.ReactElement], + }), + ), + }), + ); + } catch (error) { + // If sending logs causes any failures we want to silently ignore them + // to ensure we do not cause infinite-logging loops. + } + }, + + // Called once by the bridge on startup, even if Fast Refresh is off. + // It creates the HMR client but doesn't actually set up the socket yet. + setup(platform, bundleEntry, host, port, isEnabled) { + invariant(platform, 'Missing required parameter `platform`'); + invariant(bundleEntry, 'Missing required parameter `bundleEntry`'); + invariant(host, 'Missing required parameter `host`'); + invariant(!hmrClient, 'Cannot initialize hmrClient twice'); + + //// const LoadingView = require('./LoadingView'); + + const wsHost = port !== null && port !== '' ? `${host}:${port}` : host; + const client = new MetroHMRClient(`ws://${wsHost}/hot`); + hmrClient = client; + + pendingEntryPoints.push( + `ws://${wsHost}/hot?bundleEntry=${bundleEntry}&platform=${platform}`, + ); + + client.on('connection-error', (e) => { + let error = `Cannot connect to the Metro server. +Try the following to fix the issue: +- Ensure that the Metro server is running and available on the same network`; + + error += ` +- Ensure that your device/emulator is connected to your machine and has USB debugging enabled - run 'adb devices' to see a list of connected devices +- If you're on a physical device connected to the same machine, run 'adb reverse tcp:8081 tcp:8081' to forward requests from your device +- If your device is on the same Wi-Fi network, set 'Debug server host & port for device' in 'Dev settings' to your machine's IP address and the port of the local dev server - e.g. 10.0.1.1:8081`; + + error += ` +URL: ${host}:${port} +Error: ${e.message}`; + + setHMRUnavailableReason(error); + }); + + client.on('update-start', ({isInitialUpdate}) => { + currentCompileErrorMessage = null; + didConnect = true; + + if (client.isEnabled() && !isInitialUpdate) { + //// LoadingView.showMessage('Refreshing...', 'refresh'); + console.log('Loading start: Refreshing...'); + } + }); + + client.on('update', ({isInitialUpdate}) => { + if (client.isEnabled() && !isInitialUpdate) { + dismissRedbox(); + //// LogBoxData.clear(); + } + }); + + client.on('update-done', () => { + //// LoadingView.hide(); + console.log('Loading end'); + }); + + client.on('error', (data) => { + //// LoadingView.hide(); + console.log('Loading end'); + + if (data.type === 'GraphNotFoundError') { + client.close(); + setHMRUnavailableReason( + 'The Metro server has restarted since the last edit. Reload to reconnect.', + ); + } else if (data.type === 'RevisionNotFoundError') { + client.close(); + setHMRUnavailableReason( + 'The Metro server and the client are out of sync. Reload to reconnect.', + ); + } else { + currentCompileErrorMessage = `${data.type} ${data.message}`; + if (client.isEnabled()) { + showCompileError(); + } + } + }); + + client.on('close', (data) => { + //// LoadingView.hide(); + console.log('Loading end'); + setHMRUnavailableReason('Disconnected from the Metro server.'); + }); + + if (isEnabled) { + HMRClient.enable(); + } else { + HMRClient.disable(); + } + + registerBundleEntryPoints(hmrClient); + flushEarlyLogs(hmrClient); + }, +}; + +function setHMRUnavailableReason(reason) { + invariant(hmrClient, 'Expected HMRClient.setup() call at startup.'); + if (hmrUnavailableReason !== null) { + // Don't show more than one warning. + return; + } + hmrUnavailableReason = reason; + + // We only want to show a warning if Fast Refresh is on *and* if we ever + // previously managed to connect successfully. We don't want to show + // the warning to native engineers who use cached bundles without Metro. + if (hmrClient.isEnabled() && didConnect) { + console.warn(reason); + // (Not using the `warning` module to prevent a Buck cycle.) + } +} + +function registerBundleEntryPoints(client) { + if (hmrUnavailableReason) { + // // DevSettings.reload('Bundle Splitting – Metro disconnected'); + console.log('Bundle Spliiting - Metro disconnected'); + return; + } + + if (pendingEntryPoints.length > 0) { + client.send( + JSON.stringify({ + type: 'register-entrypoints', + entryPoints: pendingEntryPoints, + }), + ); + pendingEntryPoints.length = 0; + } +} + +function flushEarlyLogs(client) { + try { + pendingLogs.forEach(([level, data]) => { + HMRClient.log(level, data); + }); + } finally { + pendingLogs.length = 0; + } +} + +function dismissRedbox() { + // // if ( + // // Platform.OS === 'ios' && + // // NativeRedBox != null && + // // NativeRedBox.dismiss != null + // // ) { + // // NativeRedBox.dismiss(); + // // } else { + // // const NativeExceptionsManager = require('../Core/NativeExceptionsManager') + // // .default; + // // NativeExceptionsManager && + // // NativeExceptionsManager.dismissRedbox && + // // NativeExceptionsManager.dismissRedbox(); + // // } +} + +function showCompileError() { + if (currentCompileErrorMessage === null) { + return; + } + + // Even if there is already a redbox, syntax errors are more important. + // Otherwise you risk seeing a stale runtime error while a syntax error is more recent. + dismissRedbox(); + + const message = currentCompileErrorMessage; + currentCompileErrorMessage = null; + + const error = new Error(message); + // Symbolicating compile errors is wasted effort + // because the stack trace is meaningless: + error.preventSymbolication = true; + throw error; +} + +export default HMRClient; diff --git a/desktop/app/src/init-fast-refresh.js b/desktop/app/src/init-fast-refresh.js new file mode 100644 index 000000000..39c1dfda1 --- /dev/null +++ b/desktop/app/src/init-fast-refresh.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {default as HmrClient} from './HMRClient'; +import {default as ReactRefreshRuntime} from 'react-refresh/runtime'; + +HmrClient.setup( + 'web', + '/src/init-fast-refresh.bundle', + 'localhost', + '3000', + true, +); + +ReactRefreshRuntime.injectIntoGlobalHook(window); + +const Refresh = { + performFullRefresh(reason) { + console.log('Perform full refresh', reason); + window.location.reload(); + }, + + createSignatureFunctionForTransform: + ReactRefreshRuntime.createSignatureFunctionForTransform, + + isLikelyComponentType: ReactRefreshRuntime.isLikelyComponentType, + + getFamilyByType: ReactRefreshRuntime.getFamilyByType, + + register: ReactRefreshRuntime.register, + + performReactRefresh() { + if (ReactRefreshRuntime.hasUnrecoverableErrors()) { + console.error('Fast refresh - Unrecolverable'); + window.location.reload(); + return; + } + ReactRefreshRuntime.performReactRefresh(); + console.log('Perform react refresh'); + }, +}; + +require.Refresh = Refresh; + +// eslint-disable-next-line import/no-commonjs +require('./init.tsx'); diff --git a/desktop/package.json b/desktop/package.json index 6d16f8dea..e595e98f5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -83,6 +83,8 @@ "acorn": "7.1.1", "minimist": "1.2.3", "metro/temp": "0.9.0", + "metro/ws": "1.1.5", + "metro/**/ws": "1.1.5", "ws": "7.2.3", "kind-of": "6.0.3" }, @@ -170,6 +172,7 @@ "flow-bin": "^0.121.0", "fs-extra": "^8.1.0", "glob": "^7.1.2", + "invariant": "^2.2.4", "jest": "^25.1.0", "jest-environment-jsdom-sixteen": "^1.0.3", "jest-fetch-mock": "^3.0.0", @@ -178,7 +181,9 @@ "p-filter": "^2.1.0", "p-map": "^4.0.0", "prettier": "^2.0.0", + "pretty-format": "^25.2.6", "react-async": "^10.0.0", + "react-refresh": "^0.8.1", "recursive-readdir": "^2.2.2", "redux-mock-store": "^1.5.3", "rimraf": "^3.0.2", diff --git a/desktop/scripts/start-dev-server.ts b/desktop/scripts/start-dev-server.ts index dcda0e507..245cb29d4 100644 --- a/desktop/scripts/start-dev-server.ts +++ b/desktop/scripts/start-dev-server.ts @@ -35,24 +35,26 @@ const DEFAULT_PORT = (process.env.PORT || 3000) as number; let shutdownElectron: (() => void) | undefined = undefined; -function launchElectron({ - devServerURL, - bundleURL, - electronURL, -}: { - devServerURL: string; - bundleURL: string; - electronURL: string; -}) { - if (process.argv.includes('--no-embedded-plugins')) { - process.env.FLIPPER_NO_EMBEDDED_PLUGINS = 'true'; - } +if (isFB && process.env.FLIPPER_FB === undefined) { + process.env.FLIPPER_FB = 'true'; +} +if (process.argv.includes('--no-embedded-plugins')) { + process.env.FLIPPER_NO_EMBEDDED_PLUGINS = 'true'; +} +if (process.argv.includes('--fast-refresh')) { + process.env.FLIPPER_FAST_REFRESH = 'true'; +} + +function launchElectron(port: number) { + const entry = process.env.FLIPPER_FAST_REFRESH ? 'init-fast-refresh' : 'init'; + const devServerURL = `http://localhost:${port}`; + const bundleURL = `http://localhost:${port}/src/${entry}.bundle?platform=web&dev=true&minify=false`; + const electronURL = `http://localhost:${port}/index.dev.html`; const args = [ path.join(staticDir, 'index.js'), '--remote-debugging-port=9222', ...process.argv, ]; - const proc = child.spawn(electronBinary, args, { cwd: staticDir, env: { @@ -83,17 +85,20 @@ function launchElectron({ }; } -async function startMetroServer(app: Express) { +async function startMetroServer(app: Express, server: http.Server) { const watchFolders = (await getAppWatchFolders()).concat( await getPluginFolders(), ); - const metroBundlerServer = await Metro.runMetro({ + const baseConfig = await Metro.loadConfig(); + const config = Object.assign({}, baseConfig, { projectRoot: appDir, watchFolders, transformer: { + ...baseConfig.transformer, babelTransformerPath: path.join(babelTransformationsDir, 'transform-app'), }, resolver: { + ...baseConfig.resolver, resolverMainFields: ['flipper:source', 'module', 'main'], blacklistRE: /\.native\.js$/, resolveRequest: (context: any, moduleName: string, platform: string) => { @@ -110,7 +115,9 @@ async function startMetroServer(app: Express) { }, watch: true, }); - app.use(metroBundlerServer.processRequest.bind(metroBundlerServer)); + const connectMiddleware = await Metro.createConnectMiddleware(config); + app.use(connectMiddleware.middleware); + connectMiddleware.attachHmrServer(server); } function startAssetServer( @@ -137,11 +144,7 @@ function startAssetServer( if (shutdownElectron) { shutdownElectron(); } - shutdownElectron = launchElectron({ - devServerURL: `http://localhost:${port}`, - bundleURL: `http://localhost:${port}/src/init.bundle`, - electronURL: `http://localhost:${port}/index.dev.html`, - }); + shutdownElectron = launchElectron(port); res.end(); }); @@ -176,8 +179,16 @@ async function addWebsocket(server: http.Server) { } }); - // refresh the app on changes - // this can be removed once metroServer notifies us about file changes + // Refresh the app on changes. + // When Fast Refresh enabled, reloads are performed by HMRClient, so don't need to watch manually here. + if (!process.env.FLIPPER_FAST_REFRESH) { + await startWatchChanges(io); + } + + return io; +} + +async function startWatchChanges(io: socketIo.Server) { try { const watchman = new Watchman(path.resolve(__dirname, '..')); await watchman.initialize(); @@ -204,8 +215,6 @@ async function addWebsocket(server: http.Server) { err, ); } - - return io; } const knownErrors: {[key: string]: any} = {}; @@ -259,19 +268,12 @@ function outputScreen(socket?: socketIo.Server) { } (async () => { - if (isFB && process.env.FLIPPER_FB === undefined) { - process.env.FLIPPER_FB = 'true'; - } const port = await detect(DEFAULT_PORT); const {app, server} = await startAssetServer(port); const socket = await addWebsocket(server); - await startMetroServer(app); + await startMetroServer(app, server); outputScreen(socket); await compileMain(); await generatePluginEntryPoints(); - shutdownElectron = launchElectron({ - devServerURL: `http://localhost:${port}`, - bundleURL: `http://localhost:${port}/src/init.bundle`, - electronURL: `http://localhost:${port}/index.dev.html`, - }); + shutdownElectron = launchElectron(port); })(); diff --git a/desktop/static/compilePlugins.ts b/desktop/static/compilePlugins.ts index 2765cc989..faee602f3 100644 --- a/desktop/static/compilePlugins.ts +++ b/desktop/static/compilePlugins.ts @@ -41,7 +41,13 @@ export default async function ( reloadCallback: (() => void) | null, pluginCache: string, options: CompileOptions = DEFAULT_COMPILE_OPTIONS, -) { +): Promise { + if (process.env.FLIPPER_FAST_REFRESH) { + console.log( + '🥫 Skipping loading of third-party plugins because Fast Refresh is enabled', + ); + return []; + } options = Object.assign({}, DEFAULT_COMPILE_OPTIONS, options); const defaultPlugins = ( await fs.readJson(path.join(__dirname, 'defaultPlugins', 'index.json')) diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 27ffa22c7..e4d5c1cf7 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -9001,6 +9001,11 @@ optionator@^0.8.1, optionator@^0.8.3: type-check "~0.3.2" word-wrap "~1.2.3" +options@>=0.0.5: + version "0.0.6" + resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" + integrity sha1-7CLTEoBrtT5zF3Pnza788cZDEo8= + os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -9779,6 +9784,11 @@ react-refresh@^0.4.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.4.2.tgz#54a277a6caaac2803d88f1d6f13c1dcfbd81e334" integrity sha512-kv5QlFFSZWo7OlJFNYbxRtY66JImuP2LcrFgyJfQaf85gSP+byzG21UbDQEYjU7f//ny8rwiEkO6py2Y+fEgAQ== +react-refresh@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.1.tgz#5500506ad6fc891fdd057d0bf3581f9310abc6a2" + integrity sha512-xZIKi49RtLUUSAZ4a4ut2xr+zr4+glOD5v0L413B55MPvlg4EQ6Ctx8PD4CmjlPGoAWmSCTmmkY59TErizNsow== + react-resize-detector@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" @@ -11708,6 +11718,11 @@ uid2@0.0.3: resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" integrity sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I= +ultron@1.0.x: + version "1.0.2" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" + integrity sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po= + unbzip2-stream@^1.0.9: version "1.4.0" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.0.tgz#097ca7b18b5b71e6c8bc8e514a0f1884a12d6eb1" @@ -12287,7 +12302,15 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@7.1.0, ws@7.2.3, ws@^1.1.5, ws@^5.2.0, ws@^7, ws@^7.0.0, ws@^7.1.2, ws@^7.2.3, ws@~6.1.0: +ws@1.1.5, ws@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.5.tgz#cbd9e6e75e09fc5d2c90015f21f0c40875e0dd51" + integrity sha512-o3KqipXNUdS7wpQzBHSe180lBGO60SoK0yVo3CYJgb2MkobuWuBX6dhkYP5ORCLd55y+SaflMOV5fqAB53ux4w== + dependencies: + options ">=0.0.5" + ultron "1.0.x" + +ws@7.1.0, ws@7.2.3, ws@^5.2.0, ws@^7, ws@^7.0.0, ws@^7.1.2, ws@^7.2.3, ws@~6.1.0: version "7.2.3" resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46" integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==