diff --git a/.vscode/launch.json b/.vscode/launch.json index 64efa291f..2a5155e81 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,8 +20,9 @@ "type": "node", "request": "launch", "name": "Launch Current Jest Suite", + "cwd": "${workspaceFolder}/desktop", "program": "${workspaceFolder}/desktop/node_modules/.bin/jest", - "args": ["--runInBand", "${relativeFile}"] + "args": ["--runInBand", "${file}"] }, { "type": "node", diff --git a/desktop/babel-transformer/src/__tests__/flipper-requires.node.ts b/desktop/babel-transformer/src/__tests__/plugin-flipper-requires.node.ts similarity index 97% rename from desktop/babel-transformer/src/__tests__/flipper-requires.node.ts rename to desktop/babel-transformer/src/__tests__/plugin-flipper-requires.node.ts index 2d99c50a2..e104aca78 100644 --- a/desktop/babel-transformer/src/__tests__/flipper-requires.node.ts +++ b/desktop/babel-transformer/src/__tests__/plugin-flipper-requires.node.ts @@ -10,7 +10,7 @@ import {parse} from '@babel/parser'; import {transformFromAstSync} from '@babel/core'; import {default as generate} from '@babel/generator'; -const flipperRequires = require('../flipper-requires'); +const flipperRequires = require('../plugin-flipper-requires'); const babelOptions = { ast: true, diff --git a/desktop/babel-transformer/src/app-flipper-requires.ts b/desktop/babel-transformer/src/app-flipper-requires.ts new file mode 100644 index 000000000..3571685bb --- /dev/null +++ b/desktop/babel-transformer/src/app-flipper-requires.ts @@ -0,0 +1,43 @@ +/** + * 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 {CallExpression, Identifier} from '@babel/types'; +import {NodePath} from '@babel/traverse'; +import { + tryReplaceFlipperRequire, + tryReplaceGlobalReactUsage, +} from './replace-flipper-requires'; + +import {resolve} from 'path'; + +const sourceRootDir = resolve(__dirname, '..', '..'); + +function isExcludedPath(path: string) { + // We shouldn't apply transformations for the plugins which are part of the repository + // as they are bundled with Flipper app and all use the single "react" dependency. + // But we should apply it for the plugins outside of Flipper folder, so they can be loaded + // in dev mode and use "react" from Flipper bundle. + return path.startsWith(sourceRootDir); +} +module.exports = () => ({ + visitor: { + CallExpression(path: NodePath, state: any) { + if (isExcludedPath(state.file.opts.filename)) { + return; + } + tryReplaceFlipperRequire(path); + }, + Identifier(path: NodePath, state: any) { + if (isExcludedPath(state.file.opts.filename)) { + return; + } + tryReplaceGlobalReactUsage(path); + }, + }, +}); diff --git a/desktop/babel-transformer/src/flipper-requires.ts b/desktop/babel-transformer/src/plugin-flipper-requires.ts similarity index 52% rename from desktop/babel-transformer/src/flipper-requires.ts rename to desktop/babel-transformer/src/plugin-flipper-requires.ts index d0b33b76e..41ae79e4d 100644 --- a/desktop/babel-transformer/src/flipper-requires.ts +++ b/desktop/babel-transformer/src/plugin-flipper-requires.ts @@ -7,22 +7,20 @@ * @format */ -import { - CallExpression, - Identifier, - isStringLiteral, - identifier, -} from '@babel/types'; +import {CallExpression, Identifier, isStringLiteral} from '@babel/types'; import {NodePath} from '@babel/traverse'; +import {resolve, dirname} from 'path'; -import {resolve, dirname, relative} from 'path'; +import { + tryReplaceFlipperRequire, + tryReplaceGlobalReactUsage, +} from './replace-flipper-requires'; // do not apply this transform for these paths const EXCLUDE_PATHS = [ '/node_modules/react-devtools-core/', 'relay-devtools/DevtoolsUI', ]; - function isExcludedPath(path: string) { for (const epath of EXCLUDE_PATHS) { if (path.indexOf(epath) > -1) { @@ -31,36 +29,20 @@ function isExcludedPath(path: string) { } return false; } -function isReactImportIdentifier(path: NodePath) { - return ( - path.parentPath.node.type === 'ImportNamespaceSpecifier' && - path.parentPath.node.local.name === 'React' - ); -} module.exports = () => ({ visitor: { CallExpression(path: NodePath, state: any) { if (isExcludedPath(state.file.opts.filename)) { return; } - const node = path.node; - const args = node.arguments || []; - - if ( - node.callee.type === 'Identifier' && - node.callee.name === 'require' && - args.length === 1 && - isStringLiteral(args[0]) - ) { - if (args[0].value === 'flipper') { - path.replaceWith(identifier('global.Flipper')); - } else if (args[0].value === 'react') { - path.replaceWith(identifier('global.React')); - } else if (args[0].value === 'react-dom') { - path.replaceWith(identifier('global.ReactDOM')); - } else if (args[0].value === 'adbkit') { - path.replaceWith(identifier('global.adbkit')); - } else if ( + if (!tryReplaceFlipperRequire(path)) { + const node = path.node; + const args = node.arguments || []; + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + args.length === 1 && + isStringLiteral(args[0]) && // require a file not a pacakge args[0].value.indexOf('/') > -1 && // in the plugin itself and not inside one of its dependencies @@ -68,14 +50,14 @@ module.exports = () => ({ // the resolved path for this file is outside the plugins root !resolve( state.file.opts.root, - relative(state.file.opts.cwd, dirname(state.file.opts.filename)), + dirname(state.file.opts.filename), args[0].value, ).startsWith(state.file.opts.root) ) { throw new Error( `Plugins cannot require files from outside their folder. Attempted to require ${resolve( state.file.opts.root, - relative(state.file.opts.cwd, dirname(state.file.opts.filename)), + dirname(state.file.opts.filename), args[0].value, )} which isn't inside ${state.file.opts.root}`, ); @@ -83,14 +65,10 @@ module.exports = () => ({ } }, Identifier(path: NodePath, state: any) { - if ( - path.node.name === 'React' && - (path.parentPath.node as any).id !== path.node && - !isReactImportIdentifier(path) && - !isExcludedPath(state.file.opts.filename) - ) { - path.replaceWith(identifier('global.React')); + if (isExcludedPath(state.file.opts.filename)) { + return; } + tryReplaceGlobalReactUsage(path); }, }, }); diff --git a/desktop/babel-transformer/src/replace-flipper-requires.ts b/desktop/babel-transformer/src/replace-flipper-requires.ts new file mode 100644 index 000000000..d5d94f823 --- /dev/null +++ b/desktop/babel-transformer/src/replace-flipper-requires.ts @@ -0,0 +1,60 @@ +/** + * 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 { + CallExpression, + isStringLiteral, + identifier, + Identifier, +} from '@babel/types'; +import {NodePath} from '@babel/traverse'; + +const requireReplacements: any = { + flipper: 'global.Flipper', + react: 'global.React', + 'react-dom': 'global.ReactDOM', + adbkit: 'global.adbkit', +}; + +export function tryReplaceFlipperRequire(path: NodePath) { + const node = path.node; + const args = node.arguments || []; + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + args.length === 1 && + isStringLiteral(args[0]) + ) { + const replacement = requireReplacements[args[0].value]; + if (replacement) { + path.replaceWith(identifier(replacement)); + return true; + } + } + return false; +} + +export function tryReplaceGlobalReactUsage(path: NodePath) { + if ( + path.node.name === 'React' && + (path.parentPath.node as any).id !== path.node && + !isReactImportIdentifier(path) + ) { + path.replaceWith(identifier('global.React')); + return true; + } + return false; +} + +function isReactImportIdentifier(path: NodePath) { + return ( + path.parentPath.node.type === 'ImportNamespaceSpecifier' && + path.parentPath.node.local.name === 'React' + ); +} diff --git a/desktop/babel-transformer/src/transform-app.ts b/desktop/babel-transformer/src/transform-app.ts index 9eec2edcc..cce5b3ec0 100644 --- a/desktop/babel-transformer/src/transform-app.ts +++ b/desktop/babel-transformer/src/transform-app.ts @@ -35,5 +35,6 @@ function transform({ } plugins.push(require('./electron-requires')); plugins.push(require('./import-react')); + plugins.push(require('./app-flipper-requires')); return doTransform({filename, options, src, presets, plugins}); } diff --git a/desktop/babel-transformer/src/transform-plugin.ts b/desktop/babel-transformer/src/transform-plugin.ts index 12a16da53..240234c7d 100644 --- a/desktop/babel-transformer/src/transform-plugin.ts +++ b/desktop/babel-transformer/src/transform-plugin.ts @@ -32,6 +32,6 @@ export default function transform({ plugins.push(require('./electron-stubs')); } plugins.push(require('./electron-requires')); - plugins.push(require('./flipper-requires')); + plugins.push(require('./plugin-flipper-requires')); return doTransform({filename, options, src, presets, plugins}); } diff --git a/desktop/babel-transformer/src/transform.ts b/desktop/babel-transformer/src/transform.ts index 1871f060d..452b9bcd4 100644 --- a/desktop/babel-transformer/src/transform.ts +++ b/desktop/babel-transformer/src/transform.ts @@ -11,6 +11,7 @@ import {default as generate} from '@babel/generator'; import {parse} from '@babel/parser'; import {transformFromAstSync} from '@babel/core'; import {default as flipperEnv} from './flipper-env'; +import {resolve} from 'path'; export default function transform({ filename, @@ -25,6 +26,7 @@ export default function transform({ presets?: any[]; plugins?: any[]; }) { + filename = resolve(options.projectRoot, filename); presets = presets ?? [require('@babel/preset-react')]; plugins = plugins ?? []; const isTypeScript = filename.endsWith('.tsx') || filename.endsWith('.ts'); diff --git a/desktop/scripts/start-dev-server.ts b/desktop/scripts/start-dev-server.ts index 99b7dee81..4b2ca6422 100644 --- a/desktop/scripts/start-dev-server.ts +++ b/desktop/scripts/start-dev-server.ts @@ -28,6 +28,7 @@ import getAppWatchFolders from './get-app-watch-folders'; import getPlugins from '../static/getPlugins'; import getPluginFolders from '../static/getPluginFolders'; import startWatchPlugins from '../static/startWatchPlugins'; +import ensurePluginFoldersWatchable from '../static/ensurePluginFoldersWatchable'; const ansiToHtmlConverter = new AnsiToHtmlConverter(); @@ -268,12 +269,13 @@ function outputScreen(socket?: socketIo.Server) { } (async () => { + await generatePluginEntryPoints(); + await ensurePluginFoldersWatchable(); const port = await detect(DEFAULT_PORT); const {app, server} = await startAssetServer(port); const socket = await addWebsocket(server); await startMetroServer(app, server); outputScreen(socket); await compileMain(); - await generatePluginEntryPoints(); shutdownElectron = launchElectron(port); })(); diff --git a/desktop/static/compilePlugins.ts b/desktop/static/compilePlugins.ts index 7d80f8060..5230d6965 100644 --- a/desktop/static/compilePlugins.ts +++ b/desktop/static/compilePlugins.ts @@ -16,6 +16,7 @@ import {homedir} from 'os'; import {runBuild, PluginDetails} from 'flipper-pkg-lib'; import getPlugins from './getPlugins'; import startWatchPlugins from './startWatchPlugins'; +import ensurePluginFoldersWatchable from './ensurePluginFoldersWatchable'; const HOME_DIR = homedir(); @@ -44,6 +45,7 @@ export default async function ( ); return []; } + await ensurePluginFoldersWatchable(); options = Object.assign({}, DEFAULT_COMPILE_OPTIONS, options); const defaultPlugins = ( await fs.readJson(path.join(__dirname, 'defaultPlugins', 'index.json')) diff --git a/desktop/static/ensurePluginFoldersWatchable.ts b/desktop/static/ensurePluginFoldersWatchable.ts new file mode 100644 index 000000000..8ed54032d --- /dev/null +++ b/desktop/static/ensurePluginFoldersWatchable.ts @@ -0,0 +1,38 @@ +/** + * 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 getPluginFolders from './getPluginFolders'; +import fs from 'fs-extra'; + +const watchmanconfigName = '.watchmanconfig'; + +import path from 'path'; + +export default async function ensurePluginFoldersWatchable() { + const pluginFolders = await getPluginFolders(); + for (const pluginFolder of pluginFolders) { + if (!(await hasParentWithWatchmanConfig(pluginFolder))) { + // If no watchman config found in the plugins folder or any its parent, we need to create it. + // Otherwise we won't be able to listen for plugin changes. + await fs.writeJson(path.join(pluginFolder, watchmanconfigName), {}); + } + } +} + +async function hasParentWithWatchmanConfig(dir: string): Promise { + if (await fs.pathExists(path.join(dir, watchmanconfigName))) { + return true; + } else { + const parent = path.dirname(dir); + if (parent && parent != '' && parent !== dir) { + return await hasParentWithWatchmanConfig(parent); + } + } + return false; +}