From 373421abc6f6b4300168180ecb5bf5d28b467bb6 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 9 Sep 2021 15:25:26 -0400 Subject: [PATCH] Add named hooks support to react-devtools-inline (#22263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit builds on PR #22260 and makes the following changes: * Adds a DevTools feature flag for named hooks support. (This allows us to disable it entirely for a build via feature flag.) * Adds a new Suspense cache for dynamically imported modules. (This allows a component to suspend while importing an external code chunk– like the hook names parsing code). * DevTools supports a hookNamesModuleLoaderFunction param to import the hook names module. I wish this could be handles as part of the react-devtools-shared package, but I'm not sure how to configure Webpack (4) to serve the chunk from react-devtools-inline. This seemed like a reasonable workaround. The PR also contains an additional unrelated change: * Removes pre-fetch optimization (added in DevTools: Improve named hooks network caching #22198). This optimization was mostly only important for cases where sources needed to be re-downloaded, something which we can now avoid in most cases¹ thanks to using cached responses already loaded by the page. (I tested this locally on Facebook and this change has no negative performance impact. There is still some overhead from serializing the JS through the Bridge but that's constant between the two approaches.) ¹ The case where we don't benefit from cached responses is when DevTools are opened after the page has already loaded certain scripts. This seems uncommon enough that I don't think it justified the added complexity of prefetching. --- .../react-devtools-extensions/src/main.js | 42 +++-- .../webpack.config.js | 1 + packages/react-devtools-inline/README.md | 17 ++ packages/react-devtools-inline/hookNames.js | 1 + packages/react-devtools-inline/package.json | 5 +- .../react-devtools-inline/src/hookNames.js | 9 + .../react-devtools-inline/webpack.config.js | 2 + .../config/DevToolsFeatureFlags.core-fb.js | 2 +- .../config/DevToolsFeatureFlags.core-oss.js | 2 +- .../config/DevToolsFeatureFlags.default.js | 2 +- .../DevToolsFeatureFlags.extension-fb.js | 2 +- .../DevToolsFeatureFlags.extension-oss.js | 2 +- .../Components/FetchFileWithCachingContext.js | 18 ++ .../views/Components/HookNamesContext.js | 26 --- .../HookNamesModuleLoaderContext.js | 22 +++ .../Components/InspectedElementContext.js | 65 ++++--- .../Components/InspectedElementHooksTree.js | 14 +- .../src/devtools/views/DevTools.js | 129 ++++++-------- .../src/dynamicImportCache.js | 159 ++++++++++++++++++ .../src/hookNamesCache.js | 2 +- .../src/hooks/parseHookNames/index.js | 10 +- .../parseHookNames/loadSourceAndMetadata.js | 146 ++++------------ packages/react-devtools-shell/src/devtools.js | 11 ++ .../react-devtools-shell/webpack.config.js | 4 +- 24 files changed, 415 insertions(+), 278 deletions(-) create mode 100644 packages/react-devtools-inline/hookNames.js create mode 100644 packages/react-devtools-inline/src/hookNames.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext.js delete mode 100644 packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext.js create mode 100644 packages/react-devtools-shared/src/dynamicImportCache.js diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index f1283899feecc..8781d6e22dbbc 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -287,32 +287,30 @@ function createPanelIfReactLoaded() { }; } + // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. + const hookNamesModuleLoaderFunction = () => + import('react-devtools-inline/hookNames'); + root = createRoot(document.createElement('div')); render = (overrideTab = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; - import('react-devtools-shared/src/hooks/parseHookNames').then( - ({parseHookNames, prefetchSourceFiles, purgeCachedMetadata}) => { - root.render( - createElement(DevTools, { - bridge, - browserTheme: getBrowserTheme(), - componentsPortalContainer, - enabledInspectedElementContextMenu: true, - fetchFileWithCaching, - loadHookNames: parseHookNames, - overrideTab, - prefetchSourceFiles, - profilerPortalContainer, - purgeCachedHookNamesMetadata: purgeCachedMetadata, - showTabBar: false, - store, - warnIfUnsupportedVersionDetected: true, - viewAttributeSourceFunction, - viewElementSourceFunction, - }), - ); - }, + root.render( + createElement(DevTools, { + bridge, + browserTheme: getBrowserTheme(), + componentsPortalContainer, + enabledInspectedElementContextMenu: true, + fetchFileWithCaching, + hookNamesModuleLoaderFunction, + overrideTab, + profilerPortalContainer, + showTabBar: false, + store, + warnIfUnsupportedVersionDetected: true, + viewAttributeSourceFunction, + viewElementSourceFunction, + }), ); }; diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 7f9e632ffab19..233422276ec41 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -52,6 +52,7 @@ module.exports = { path: __dirname + '/build', publicPath: '/build/', filename: '[name].js', + chunkFilename: '[name].chunk.js', }, node: { // Don't define a polyfill on window.setImmediate diff --git a/packages/react-devtools-inline/README.md b/packages/react-devtools-inline/README.md index 88b2c93b7cb05..35b0127f41d54 100644 --- a/packages/react-devtools-inline/README.md +++ b/packages/react-devtools-inline/README.md @@ -64,6 +64,23 @@ const DevTools = initialize(contentWindow); ## Examples +### Supporting named hooks + +DevTools can display hook "names" for an inspected component, although determining the "names" requires loading the source (and source-maps), parsing the code, and infering the names based on which variables hook values get assigned to. Because the code for this is non-trivial, it's lazy-loaded only if the feature is enabled. + +To configure this package to support this functionality, you'll need to provide a prop that dynamically imports the extra functionality: +```js +// Follow code examples above to configure the backend and frontend. +// When rendering DevTools, the important part is to pass a 'hookNamesModuleLoaderFunction' prop. +const hookNamesModuleLoaderFunction = () => import('react-devtools-inline/hookNames'); + +// Render: +; +``` + ### Configuring a same-origin `iframe` The simplest way to use this package is to install the hook from the parent `window`. This is possible if the `iframe` is not sandboxed and there are no cross-origin restrictions. diff --git a/packages/react-devtools-inline/hookNames.js b/packages/react-devtools-inline/hookNames.js new file mode 100644 index 0000000000000..6a319e2de30b5 --- /dev/null +++ b/packages/react-devtools-inline/hookNames.js @@ -0,0 +1 @@ +module.exports = require('./dist/hookNames'); diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index 8b2e745a85856..160723b21af56 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -20,7 +20,10 @@ "prepublish": "yarn run build", "start": "cross-env NODE_ENV=development webpack --config webpack.config.js --watch" }, - "dependencies": {}, + "dependencies": { + "source-map-js": "^0.6.2", + "sourcemap-codec": "^1.4.8" + }, "devDependencies": { "@babel/core": "^7.11.1", "@babel/plugin-proposal-class-properties": "^7.10.4", diff --git a/packages/react-devtools-inline/src/hookNames.js b/packages/react-devtools-inline/src/hookNames.js new file mode 100644 index 0000000000000..7436ef7d01dba --- /dev/null +++ b/packages/react-devtools-inline/src/hookNames.js @@ -0,0 +1,9 @@ +/** @flow */ + +import { + parseHookNames, + parseSourceAndMetadata, + purgeCachedMetadata, +} from 'react-devtools-shared/src/hooks/parseHookNames'; + +export {parseHookNames, parseSourceAndMetadata, purgeCachedMetadata}; diff --git a/packages/react-devtools-inline/webpack.config.js b/packages/react-devtools-inline/webpack.config.js index 169484f8cc271..bc38a8792e4c4 100644 --- a/packages/react-devtools-inline/webpack.config.js +++ b/packages/react-devtools-inline/webpack.config.js @@ -37,10 +37,12 @@ module.exports = { entry: { backend: './src/backend.js', frontend: './src/frontend.js', + hookNames: './src/hookNames.js', }, output: { path: __dirname + '/dist', filename: '[name].js', + chunkFilename: '[name].chunk.js', library: '[name]', libraryTarget: 'commonjs2', }, diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js index 4da6aef3441b5..6c0568867ba39 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js @@ -15,7 +15,7 @@ export const enableProfilerChangedHookIndices = true; export const isInternalFacebookBuild = true; - +export const enableNamedHooksFeature = false; export const consoleManagedByDevToolsDuringStrictMode = false; /************************************************************************ diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js index e6144b7e4f3a3..341af11e17f79 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js @@ -15,7 +15,7 @@ export const enableProfilerChangedHookIndices = false; export const isInternalFacebookBuild = false; - +export const enableNamedHooksFeature = false; export const consoleManagedByDevToolsDuringStrictMode = false; /************************************************************************ diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js index 0813423712643..e6404254a3850 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js @@ -15,5 +15,5 @@ export const enableProfilerChangedHookIndices = false; export const isInternalFacebookBuild = false; - +export const enableNamedHooksFeature = true; export const consoleManagedByDevToolsDuringStrictMode = true; diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js index ba593ffe72c1b..d4e6160b5e860 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js @@ -15,7 +15,7 @@ export const enableProfilerChangedHookIndices = true; export const isInternalFacebookBuild = true; - +export const enableNamedHooksFeature = true; export const consoleManagedByDevToolsDuringStrictMode = true; /************************************************************************ diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js index c1bb06855d191..382c3fab4c31b 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js @@ -15,7 +15,7 @@ export const enableProfilerChangedHookIndices = true; export const isInternalFacebookBuild = false; - +export const enableNamedHooksFeature = true; export const consoleManagedByDevToolsDuringStrictMode = true; /************************************************************************ diff --git a/packages/react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext.js b/packages/react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext.js new file mode 100644 index 0000000000000..4647ebe9bf76b --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext.js @@ -0,0 +1,18 @@ +/** + * 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. + * + * @flow + */ + +import {createContext} from 'react'; + +export type FetchFileWithCaching = (url: string) => Promise; +export type Context = FetchFileWithCaching | null; + +const FetchFileWithCachingContext = createContext(null); +FetchFileWithCachingContext.displayName = 'FetchFileWithCachingContext'; + +export default FetchFileWithCachingContext; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js deleted file mode 100644 index f9f295c7eb43d..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js +++ /dev/null @@ -1,26 +0,0 @@ -// @flow - -import {createContext} from 'react'; -import type { - FetchFileWithCaching, - LoadHookNamesFunction, - PrefetchSourceFiles, - PurgeCachedHookNamesMetadata, -} from '../DevTools'; - -export type Context = { - fetchFileWithCaching: FetchFileWithCaching | null, - loadHookNames: LoadHookNamesFunction | null, - prefetchSourceFiles: PrefetchSourceFiles | null, - purgeCachedMetadata: PurgeCachedHookNamesMetadata | null, -}; - -const HookNamesContext = createContext({ - fetchFileWithCaching: null, - loadHookNames: null, - prefetchSourceFiles: null, - purgeCachedMetadata: null, -}); -HookNamesContext.displayName = 'HookNamesContext'; - -export default HookNamesContext; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext.js b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext.js new file mode 100644 index 0000000000000..c9a09e64c0e19 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext.js @@ -0,0 +1,22 @@ +/** + * 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. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +import {createContext} from 'react'; +import typeof * as ParseHookNamesModule from 'react-devtools-shared/src/hooks/parseHookNames'; + +export type HookNamesModuleLoaderFunction = () => Thenable; +export type Context = HookNamesModuleLoaderFunction | null; + +// TODO (Webpack 5) Hopefully we can remove this context entirely once the Webpack 5 upgrade is completed. +const HookNamesModuleLoaderContext = createContext(null); +HookNamesModuleLoaderContext.displayName = 'HookNamesModuleLoaderContext'; + +export default HookNamesModuleLoaderContext; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index 967196a3b9052..c02dc5bf9263a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -30,8 +30,11 @@ import { hasAlreadyLoadedHookNames, loadHookNames, } from 'react-devtools-shared/src/hookNamesCache'; -import HookNamesContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesContext'; +import {loadModule} from 'react-devtools-shared/src/dynamicImportCache'; +import FetchFileWithCachingContext from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; +import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import {SettingsContext} from '../Settings/SettingsContext'; +import {enableNamedHooksFeature} from 'react-devtools-feature-flags'; import type {HookNames} from 'react-devtools-shared/src/types'; import type {ReactNodeList} from 'shared/ReactTypes'; @@ -64,16 +67,17 @@ export type Props = {| export function InspectedElementContextController({children}: Props) { const {selectedElementID} = useContext(TreeStateContext); - const { - fetchFileWithCaching, - loadHookNames: loadHookNamesFunction, - prefetchSourceFiles, - purgeCachedMetadata, - } = useContext(HookNamesContext); + const fetchFileWithCaching = useContext(FetchFileWithCachingContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const {parseHookNames: parseHookNamesByDefault} = useContext(SettingsContext); + // parseHookNames has a lot of code. + // Embedding it into a build makes the build large. + // This function enables DevTools to make use of Suspense to lazily import() it only if the feature will be used. + // TODO (Webpack 5) Hopefully we can remove this indirection once the Webpack 5 upgrade is completed. + const hookNamesModuleLoader = useContext(HookNamesModuleLoaderContext); + const refresh = useCacheRefresh(); // Temporarily stores most recently-inspected (hydrated) path. @@ -113,24 +117,40 @@ export function InspectedElementContextController({children}: Props) { setParseHookNames(parseHookNamesByDefault || alreadyLoadedHookNames); } + const purgeCachedMetadataRef = useRef(null); + // Don't load a stale element from the backend; it wastes bridge bandwidth. let hookNames: HookNames | null = null; let inspectedElement = null; if (!elementHasChanged && element !== null) { inspectedElement = inspectElement(element, state.path, store, bridge); - if (parseHookNames || alreadyLoadedHookNames) { - if ( - inspectedElement !== null && - inspectedElement.hooks !== null && - loadHookNamesFunction !== null - ) { - hookNames = loadHookNames( - element, - inspectedElement.hooks, - loadHookNamesFunction, - fetchFileWithCaching, - ); + if (enableNamedHooksFeature) { + if (typeof hookNamesModuleLoader === 'function') { + if (parseHookNames || alreadyLoadedHookNames) { + const hookNamesModule = loadModule(hookNamesModuleLoader); + if (hookNamesModule !== null) { + const { + parseHookNames: loadHookNamesFunction, + purgeCachedMetadata, + } = hookNamesModule; + + purgeCachedMetadataRef.current = purgeCachedMetadata; + + if ( + inspectedElement !== null && + inspectedElement.hooks !== null && + loadHookNamesFunction !== null + ) { + hookNames = loadHookNames( + element, + inspectedElement.hooks, + loadHookNamesFunction, + fetchFileWithCaching, + ); + } + } + } } } } @@ -163,14 +183,11 @@ export function InspectedElementContextController({children}: Props) { inspectedElementRef.current !== inspectedElement ) { inspectedElementRef.current = inspectedElement; - - if (typeof prefetchSourceFiles === 'function') { - prefetchSourceFiles(inspectedElement.hooks, fetchFileWithCaching); - } } - }, [inspectedElement, prefetchSourceFiles]); + }, [inspectedElement]); useEffect(() => { + const purgeCachedMetadata = purgeCachedMetadataRef.current; if (typeof purgeCachedMetadata === 'function') { // When Fast Refresh updates a component, any cached AST metadata may be invalid. const fastRefreshScheduled = () => { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js index b478aeb441297..fb80b0152968e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js @@ -22,8 +22,11 @@ import styles from './InspectedElementHooksTree.css'; import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; -import {enableProfilerChangedHookIndices} from 'react-devtools-feature-flags'; -import HookNamesContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesContext'; +import { + enableNamedHooksFeature, + enableProfilerChangedHookIndices, +} from 'react-devtools-feature-flags'; +import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import type {InspectedElement} from './types'; import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; @@ -53,8 +56,6 @@ export function InspectedElementHooksTree({ }: HooksTreeViewProps) { const {hooks, id} = inspectedElement; - const {loadHookNames: loadHookNamesFunction} = useContext(HookNamesContext); - // Changing parseHookNames is done in a transition, because it suspends. // This value is done outside of the transition, so the UI toggle feels responsive. const [parseHookNamesOptimistic, setParseHookNamesOptimistic] = useState( @@ -65,6 +66,8 @@ export function InspectedElementHooksTree({ toggleParseHookNames(); }; + const hookNamesModuleLoader = useContext(HookNamesModuleLoaderContext); + const hookParsingFailed = parseHookNames && hookNames === null; let toggleTitle; @@ -85,7 +88,8 @@ export function InspectedElementHooksTree({
hooks
- {loadHookNamesFunction !== null && + {enableNamedHooksFeature && + typeof hookNamesModuleLoader === 'function' && (!parseHookNames || hookParsingFailed) && ( Promise; -export type PrefetchSourceFiles = ( - hooksTree: HooksTree, - fetchFileWithCaching: FetchFileWithCaching | null, -) => void; export type ViewElementSource = ( id: number, inspectedElement: InspectedElement, ) => void; -export type LoadHookNamesFunction = ( - hooksTree: HooksTree, -) => Thenable; -export type PurgeCachedHookNamesMetadata = () => void; export type ViewAttributeSource = ( id: number, path: Array, @@ -107,9 +98,8 @@ export type Props = {| // and extracts hook "names" based on the variables the hook return values get assigned to. // Not every DevTools build can load source maps, so this property is optional. fetchFileWithCaching?: ?FetchFileWithCaching, - loadHookNames?: ?LoadHookNamesFunction, - prefetchSourceFiles?: ?PrefetchSourceFiles, - purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata, + // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. + hookNamesModuleLoaderFunction?: ?HookNamesModuleLoaderFunction, |}; const componentsTab = { @@ -135,11 +125,9 @@ export default function DevTools({ defaultTab = 'components', enabledInspectedElementContextMenu = false, fetchFileWithCaching, - loadHookNames, + hookNamesModuleLoaderFunction, overrideTab, profilerPortalContainer, - prefetchSourceFiles, - purgeCachedHookNamesMetadata, showTabBar = false, store, warnIfLegacyBackendDetected = false, @@ -199,21 +187,6 @@ export default function DevTools({ [enabledInspectedElementContextMenu, viewAttributeSourceFunction], ); - const hookNamesContext = useMemo( - () => ({ - fetchFileWithCaching: fetchFileWithCaching || null, - loadHookNames: loadHookNames || null, - prefetchSourceFiles: prefetchSourceFiles || null, - purgeCachedMetadata: purgeCachedHookNamesMetadata || null, - }), - [ - fetchFileWithCaching, - loadHookNames, - prefetchSourceFiles, - purgeCachedHookNamesMetadata, - ], - ); - const devToolsRef = useRef(null); useEffect(() => { @@ -270,51 +243,55 @@ export default function DevTools({ componentsPortalContainer={componentsPortalContainer} profilerPortalContainer={profilerPortalContainer}> - - - - - -
- {showTabBar && ( -
- - - {process.env.DEVTOOLS_VERSION} - -
- + + + + + +
+ {showTabBar && ( +
+ + + {process.env.DEVTOOLS_VERSION} + +
+ +
+ )} + + - )} - - -
- - - - - + + + + + + diff --git a/packages/react-devtools-shared/src/dynamicImportCache.js b/packages/react-devtools-shared/src/dynamicImportCache.js new file mode 100644 index 0000000000000..78045856abb88 --- /dev/null +++ b/packages/react-devtools-shared/src/dynamicImportCache.js @@ -0,0 +1,159 @@ +/** + * 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. + * + * @flow + */ + +import {__DEBUG__} from 'react-devtools-shared/src/constants'; + +import type {Thenable, Wakeable} from 'shared/ReactTypes'; + +const TIMEOUT = 30000; + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type PendingRecord = {| + status: 0, + value: Wakeable, +|}; + +type ResolvedRecord = {| + status: 1, + value: T, +|}; + +type RejectedRecord = {| + status: 2, + value: null, +|}; + +type Record = PendingRecord | ResolvedRecord | RejectedRecord; + +type Module = any; +type ModuleLoaderFunction = () => Thenable; + +// This is intentionally a module-level Map, rather than a React-managed one. +// Otherwise, refreshing the inspected element cache would also clear this cache. +// Modules are static anyway. +const moduleLoaderFunctionToModuleMap: Map< + ModuleLoaderFunction, + Module, +> = new Map(); + +function readRecord(record: Record): ResolvedRecord | RejectedRecord { + if (record.status === Resolved) { + // This is just a type refinement. + return record; + } else if (record.status === Rejected) { + // This is just a type refinement. + return record; + } else { + throw record.value; + } +} + +// TODO Flow type +export function loadModule(moduleLoaderFunction: ModuleLoaderFunction): Module { + let record = moduleLoaderFunctionToModuleMap.get(moduleLoaderFunction); + + if (__DEBUG__) { + console.log( + `[dynamicImportCache] loadModule("${moduleLoaderFunction.name}")`, + ); + } + + if (!record) { + const callbacks = new Set(); + const wakeable: Wakeable = { + then(callback) { + callbacks.add(callback); + }, + }; + + const wake = () => { + if (timeoutID) { + clearTimeout(timeoutID); + timeoutID = null; + } + + // This assumes they won't throw. + callbacks.forEach(callback => callback()); + callbacks.clear(); + }; + + const newRecord: Record = (record = { + status: Pending, + value: wakeable, + }); + + let didTimeout = false; + + moduleLoaderFunction().then( + module => { + if (__DEBUG__) { + console.log( + `[dynamicImportCache] loadModule("${moduleLoaderFunction.name}") then()`, + ); + } + + if (didTimeout) { + return; + } + + const resolvedRecord = ((newRecord: any): ResolvedRecord); + resolvedRecord.status = Resolved; + resolvedRecord.value = module; + + wake(); + }, + error => { + if (__DEBUG__) { + console.log( + `[dynamicImportCache] loadModule("${moduleLoaderFunction.name}") catch()`, + ); + } + + if (didTimeout) { + return; + } + + console.log(error); + + const thrownRecord = ((newRecord: any): RejectedRecord); + thrownRecord.status = Rejected; + thrownRecord.value = null; + + wake(); + }, + ); + + // Eventually timeout and stop trying to load the module. + let timeoutID = setTimeout(function onTimeout() { + if (__DEBUG__) { + console.log( + `[dynamicImportCache] loadModule("${moduleLoaderFunction.name}") onTimeout()`, + ); + } + + timeoutID = null; + + didTimeout = true; + + const timedoutRecord = ((newRecord: any): RejectedRecord); + timedoutRecord.status = Rejected; + timedoutRecord.value = null; + + wake(); + }, TIMEOUT); + + moduleLoaderFunctionToModuleMap.set(moduleLoaderFunction, record); + } + + const response = readRecord(record).value; + return response; +} diff --git a/packages/react-devtools-shared/src/hookNamesCache.js b/packages/react-devtools-shared/src/hookNamesCache.js index 3fceaa7d02477..104a1e3455eb8 100644 --- a/packages/react-devtools-shared/src/hookNamesCache.js +++ b/packages/react-devtools-shared/src/hookNamesCache.js @@ -17,7 +17,7 @@ import type { HookSourceLocationKey, } from 'react-devtools-shared/src/types'; import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks'; -import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; const TIMEOUT = 30000; diff --git a/packages/react-devtools-shared/src/hooks/parseHookNames/index.js b/packages/react-devtools-shared/src/hooks/parseHookNames/index.js index eae8440399c7b..a400a021a91fa 100644 --- a/packages/react-devtools-shared/src/hooks/parseHookNames/index.js +++ b/packages/react-devtools-shared/src/hooks/parseHookNames/index.js @@ -10,21 +10,15 @@ import type {HookSourceAndMetadata} from './loadSourceAndMetadata'; import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; import type {HookNames} from 'react-devtools-shared/src/types'; -import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks'; import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker'; import typeof * as ParseSourceAndMetadataModule from './parseSourceAndMetadata'; -import { - flattenHooksList, - loadSourceAndMetadata, - prefetchSourceFiles, -} from './loadSourceAndMetadata'; +import {flattenHooksList, loadSourceAndMetadata} from './loadSourceAndMetadata'; const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata(); -export {prefetchSourceFiles}; - export function parseSourceAndMetadata( hooksList: Array, locationKeyToHookSourceAndMetadata: Map, diff --git a/packages/react-devtools-shared/src/hooks/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-shared/src/hooks/parseHookNames/loadSourceAndMetadata.js index b72c2c68557b7..e8f043be24c19 100644 --- a/packages/react-devtools-shared/src/hooks/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-shared/src/hooks/parseHookNames/loadSourceAndMetadata.js @@ -45,7 +45,6 @@ // This is the fastest option since our custom metadata file is much smaller than a full source map, // and there is no need to convert runtime code to the original source. -import LRU from 'lru-cache'; import {__DEBUG__} from 'react-devtools-shared/src/constants'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; import {sourceMapIncludesSource} from '../SourceMapUtils'; @@ -55,14 +54,13 @@ import { withSyncPerformanceMark, } from 'react-devtools-shared/src/PerformanceMarks'; -import type {LRUCache} from 'react-devtools-shared/src/types'; import type { HooksNode, HookSource, HooksTree, } from 'react-debug-tools/src/ReactDebugHooks'; import type {MixedSourceMap} from '../SourceMapTypes'; -import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; // Prefer a cached albeit stale response to reduce download time. // We wouldn't want to load/parse a newer version of the source (even if one existed). @@ -70,14 +68,6 @@ const FETCH_OPTIONS = {cache: 'force-cache'}; const MAX_SOURCE_LENGTH = 100_000_000; -// Fetch requests originated from an extension might not have origin headers -// which may prevent subsequent requests from using cached responses -// if the server returns a Vary: 'Origin' header -// so this cache will temporarily store pre-fetches sources in memory. -const prefetchedSources: LRUCache = new LRU({ - max: 15, -}); - export type HookSourceAndMetadata = {| // Generated by react-debug-tools. hookSource: HookSource, @@ -477,109 +467,47 @@ function loadSourceFiles( locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { const {runtimeSourceURL} = hookSourceAndMetadata; - const prefetchedSourceCode = prefetchedSources.get(runtimeSourceURL); - if (prefetchedSourceCode != null) { - hookSourceAndMetadata.runtimeSourceCode = prefetchedSourceCode; - } else { - let fetchFileFunction = fetchFile; - if (fetchFileWithCaching != null) { - // If a helper function has been injected to fetch with caching, - // use it to fetch the (already loaded) source file. - fetchFileFunction = url => { - return withAsyncPerformanceMark( - `fetchFileWithCaching("${url}")`, - () => { - return ((fetchFileWithCaching: any): FetchFileWithCaching)(url); - }, - ); - }; - } + let fetchFileFunction = fetchFile; + if (fetchFileWithCaching != null) { + // If a helper function has been injected to fetch with caching, + // use it to fetch the (already loaded) source file. + fetchFileFunction = url => { + return withAsyncPerformanceMark( + `fetchFileWithCaching("${url}")`, + () => { + return ((fetchFileWithCaching: any): FetchFileWithCaching)(url); + }, + ); + }; + } - const fetchPromise = - dedupedFetchPromises.get(runtimeSourceURL) || - fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => { - // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, - // because then we need to parse the full source file as an AST. - if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { - throw Error('Source code too large to parse'); - } + const fetchPromise = + dedupedFetchPromises.get(runtimeSourceURL) || + fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => { + // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, + // because then we need to parse the full source file as an AST. + if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { + throw Error('Source code too large to parse'); + } - if (__DEBUG__) { - console.groupCollapsed( - `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, - ); - console.log(runtimeSourceCode); - console.groupEnd(); - } + if (__DEBUG__) { + console.groupCollapsed( + `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, + ); + console.log(runtimeSourceCode); + console.groupEnd(); + } - return runtimeSourceCode; - }); - dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); + return runtimeSourceCode; + }); + dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); - setterPromises.push( - fetchPromise.then(runtimeSourceCode => { - hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode; - }), - ); - } + setterPromises.push( + fetchPromise.then(runtimeSourceCode => { + hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode; + }), + ); }); return Promise.all(setterPromises); } - -export function prefetchSourceFiles( - hooksTree: HooksTree, - fetchFileWithCaching: FetchFileWithCaching | null, -): void { - // Deduplicate fetches, since there can be multiple location keys per source map. - const dedupedFetchPromises = new Set(); - - let fetchFileFunction = null; - if (fetchFileWithCaching != null) { - // If a helper function has been injected to fetch with caching, - // use it to fetch the (already loaded) source file. - fetchFileFunction = url => { - return withAsyncPerformanceMark( - `[pre] fetchFileWithCaching("${url}")`, - () => { - return ((fetchFileWithCaching: any): FetchFileWithCaching)(url); - }, - ); - }; - } else { - fetchFileFunction = url => fetchFile(url, '[pre] fetchFile'); - } - - const hooksQueue = Array.from(hooksTree); - - for (let i = 0; i < hooksQueue.length; i++) { - const hook = hooksQueue.pop(); - if (isUnnamedBuiltInHook(hook)) { - continue; - } - - const hookSource = hook.hookSource; - if (hookSource == null) { - continue; - } - - const runtimeSourceURL = ((hookSource.fileName: any): string); - - if (prefetchedSources.has(runtimeSourceURL)) { - // If we've already fetched this source, skip it. - continue; - } - - if (!dedupedFetchPromises.has(runtimeSourceURL)) { - dedupedFetchPromises.add(runtimeSourceURL); - - fetchFileFunction(runtimeSourceURL).then(text => { - prefetchedSources.set(runtimeSourceURL, text); - }); - } - - if (hook.subHooks.length > 0) { - hooksQueue.push(...hook.subHooks); - } - } -} diff --git a/packages/react-devtools-shell/src/devtools.js b/packages/react-devtools-shell/src/devtools.js index fda4c91b74ffe..7b4171851c1be 100644 --- a/packages/react-devtools-shell/src/devtools.js +++ b/packages/react-devtools-shell/src/devtools.js @@ -10,6 +10,11 @@ import { import {initialize as initializeFrontend} from 'react-devtools-inline/frontend'; import {initDevTools} from 'react-devtools-shared/src/devtools'; +// This is a pretty gross hack to make the runtime loaded named-hooks-code work. +// TODO (Webpack 5) Hoepfully we can remove this once we upgrade to Webpack 5. +// $FlowFixMe +__webpack_public_path__ = '/dist/'; // eslint-disable-line no-undef + const iframe = ((document.getElementById('target'): any): HTMLIFrameElement); const {contentDocument, contentWindow} = iframe; @@ -50,6 +55,11 @@ mountButton.addEventListener('click', function() { } }); +// TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. +function hookNamesModuleLoaderFunction() { + return import('react-devtools-inline/hookNames'); +} + inject('dist/app.js', () => { initDevTools({ connect(cb) { @@ -58,6 +68,7 @@ inject('dist/app.js', () => { createElement(DevTools, { browserTheme: 'light', enabledInspectedElementContextMenu: true, + hookNamesModuleLoaderFunction, showTabBar: true, warnIfLegacyBackendDetected: true, warnIfUnsupportedVersionDetected: true, diff --git a/packages/react-devtools-shell/webpack.config.js b/packages/react-devtools-shell/webpack.config.js index e60a004a3b391..029f9f5f7db40 100644 --- a/packages/react-devtools-shell/webpack.config.js +++ b/packages/react-devtools-shell/webpack.config.js @@ -32,7 +32,7 @@ const DEVTOOLS_VERSION = getVersionString(); const config = { mode: __DEV__ ? 'development' : 'production', - devtool: __DEV__ ? 'cheap-module-eval-source-map' : 'source-map', + devtool: __DEV__ ? 'cheap-source-map' : 'source-map', entry: { app: './src/app/index.js', devtools: './src/devtools.js', @@ -108,6 +108,7 @@ const config = { }; if (TARGET === 'local') { + // Local dev server build. config.devServer = { hot: true, port: 8080, @@ -116,6 +117,7 @@ if (TARGET === 'local') { stats: 'errors-only', }; } else { + // Static build to deploy somewhere else. config.output = { path: resolve(__dirname, 'dist'), filename: '[name].js',