diff --git a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js
index dd500bbfb2eaa..842cb78c05728 100644
--- a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js
+++ b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js
@@ -25,6 +25,23 @@ function requireText(path, encoding) {
   }
 }
 
+function initFetchMock() {
+  const fetchMock = require('jest-fetch-mock');
+  fetchMock.enableMocks();
+  fetchMock.mockIf(/.+$/, request => {
+    const url = request.url;
+    const isLoadingExternalSourceMap = /external\/.*\.map/.test(url);
+    if (isLoadingExternalSourceMap) {
+      // Assert that url contains correct query params
+      expect(url.includes('?foo=bar&param=some_value')).toBe(true);
+      const fileSystemPath = url.split('?')[0];
+      return requireText(fileSystemPath, 'utf8');
+    }
+    return requireText(url, 'utf8');
+  });
+  return fetchMock;
+}
+
 describe('parseHookNames', () => {
   let fetchMock;
   let inspectHooks;
@@ -37,12 +54,32 @@ describe('parseHookNames', () => {
       console.trace('source-map-support');
     });
 
-    fetchMock = require('jest-fetch-mock');
-    fetchMock.enableMocks();
+    fetchMock = initFetchMock();
 
     inspectHooks = require('react-debug-tools/src/ReactDebugHooks')
       .inspectHooks;
-    parseHookNames = require('../parseHookNames/parseHookNames').parseHookNames;
+
+    // Jest can't run the workerized version of this module.
+    const {
+      flattenHooksList,
+      loadSourceAndMetadata,
+    } = require('../parseHookNames/loadSourceAndMetadata');
+    const parseSourceAndMetadata = require('../parseHookNames/parseSourceAndMetadata')
+      .parseSourceAndMetadata;
+    parseHookNames = async hooksTree => {
+      const hooksList = flattenHooksList(hooksTree);
+
+      // Runs in the UI thread so it can share Network cache:
+      const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata(
+        hooksList,
+      );
+
+      // Runs in a Worker because it's CPU intensive:
+      return parseSourceAndMetadata(
+        hooksList,
+        locationKeyToHookSourceAndMetadata,
+      );
+    };
 
     // Jest (jest-runner?) configures Errors to automatically account for source maps.
     // This changes behavior between our tests and the browser.
@@ -55,18 +92,6 @@ describe('parseHookNames', () => {
     Error.prepareStackTrace = (error, trace) => {
       return error.stack;
     };
-
-    fetchMock.mockIf(/.+$/, request => {
-      const url = request.url;
-      const isLoadingExternalSourceMap = /external\/.*\.map/.test(url);
-      if (isLoadingExternalSourceMap) {
-        // Assert that url contains correct query params
-        expect(url.includes('?foo=bar&param=some_value')).toBe(true);
-        const fileSystemPath = url.split('?')[0];
-        return requireText(fileSystemPath, 'utf8');
-      }
-      return requireText(url, 'utf8');
-    });
   });
 
   afterEach(() => {
@@ -880,18 +905,20 @@ describe('parseHookNames', () => {
 describe('parseHookNames worker', () => {
   let inspectHooks;
   let parseHookNames;
-  let workerizedParseHookNamesMock;
+  let workerizedParseSourceAndMetadataMock;
 
   beforeEach(() => {
     window.Worker = undefined;
 
-    workerizedParseHookNamesMock = jest.fn();
+    workerizedParseSourceAndMetadataMock = jest.fn();
 
-    jest.mock('../parseHookNames/parseHookNames.worker.js', () => {
+    initFetchMock();
+
+    jest.mock('../parseHookNames/parseSourceAndMetadata.worker.js', () => {
       return {
         __esModule: true,
         default: () => ({
-          parseHookNames: workerizedParseHookNamesMock,
+          parseSourceAndMetadata: workerizedParseSourceAndMetadataMock,
         }),
       };
     });
@@ -912,11 +939,12 @@ describe('parseHookNames worker', () => {
       .Component;
 
     window.Worker = true;
-    // resets module so mocked worker instance can be updated
+
+    // Reset module so mocked worker instance can be updated.
     jest.resetModules();
     parseHookNames = require('../parseHookNames').parseHookNames;
 
     await getHookNamesForComponent(Component);
-    expect(workerizedParseHookNamesMock).toHaveBeenCalledTimes(1);
+    expect(workerizedParseSourceAndMetadataMock).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js
index cfe5e3c7a9738..9e09513b78fb4 100644
--- a/packages/react-devtools-extensions/src/background.js
+++ b/packages/react-devtools-extensions/src/background.js
@@ -117,14 +117,27 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
 });
 
 chrome.runtime.onMessage.addListener((request, sender) => {
-  if (sender.tab) {
+  const tab = sender.tab;
+  if (tab) {
+    const id = tab.id;
     // This is sent from the hook content script.
     // It tells us a renderer has attached.
     if (request.hasDetectedReact) {
       // We use browserAction instead of pageAction because this lets us
       // display a custom default popup when React is *not* detected.
       // It is specified in the manifest.
-      setIconAndPopup(request.reactBuildType, sender.tab.id);
+      setIconAndPopup(request.reactBuildType, id);
+    } else {
+      switch (request.payload?.type) {
+        case 'fetch-file-with-cache-complete':
+        case 'fetch-file-with-cache-error':
+          // Forward the result of fetch-in-page requests back to the extension.
+          const devtools = ports[id]?.devtools;
+          if (devtools) {
+            devtools.postMessage(request);
+          }
+          break;
+      }
     }
   }
 });
diff --git a/packages/react-devtools-extensions/src/injectGlobalHook.js b/packages/react-devtools-extensions/src/injectGlobalHook.js
index e34197af35499..701d4927487d9 100644
--- a/packages/react-devtools-extensions/src/injectGlobalHook.js
+++ b/packages/react-devtools-extensions/src/injectGlobalHook.js
@@ -23,21 +23,66 @@ let lastDetectionResult;
 // (it will be injected directly into the page).
 // So instead, the hook will use postMessage() to pass message to us here.
 // And when this happens, we'll send a message to the "background page".
-window.addEventListener('message', function(evt) {
-  if (evt.source !== window || !evt.data) {
+window.addEventListener('message', function onMessage({data, source}) {
+  if (source !== window || !data) {
     return;
   }
-  if (evt.data.source === 'react-devtools-detector') {
-    lastDetectionResult = {
-      hasDetectedReact: true,
-      reactBuildType: evt.data.reactBuildType,
-    };
-    chrome.runtime.sendMessage(lastDetectionResult);
-  } else if (evt.data.source === 'react-devtools-inject-backend') {
-    const script = document.createElement('script');
-    script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
-    document.documentElement.appendChild(script);
-    script.parentNode.removeChild(script);
+
+  switch (data.source) {
+    case 'react-devtools-detector':
+      lastDetectionResult = {
+        hasDetectedReact: true,
+        reactBuildType: data.reactBuildType,
+      };
+      chrome.runtime.sendMessage(lastDetectionResult);
+      break;
+    case 'react-devtools-extension':
+      if (data.payload?.type === 'fetch-file-with-cache') {
+        const url = data.payload.url;
+
+        const reject = value => {
+          chrome.runtime.sendMessage({
+            source: 'react-devtools-content-script',
+            payload: {
+              type: 'fetch-file-with-cache-error',
+              url,
+              value,
+            },
+          });
+        };
+
+        const resolve = value => {
+          chrome.runtime.sendMessage({
+            source: 'react-devtools-content-script',
+            payload: {
+              type: 'fetch-file-with-cache-complete',
+              url,
+              value,
+            },
+          });
+        };
+
+        fetch(url, {cache: 'force-cache'}).then(
+          response => {
+            if (response.ok) {
+              response
+                .text()
+                .then(text => resolve(text))
+                .catch(error => reject(null));
+            } else {
+              reject(null);
+            }
+          },
+          error => reject(null),
+        );
+      }
+      break;
+    case 'react-devtools-inject-backend':
+      const script = document.createElement('script');
+      script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
+      document.documentElement.appendChild(script);
+      script.parentNode.removeChild(script);
+      break;
   }
 });
 
@@ -45,18 +90,18 @@ window.addEventListener('message', function(evt) {
 // while navigating the history to a document that has not been destroyed yet,
 // replay the last detection result if the content script is active and the
 // document has been hidden and shown again.
-window.addEventListener('pageshow', function(evt) {
-  if (!lastDetectionResult || evt.target !== window.document) {
+window.addEventListener('pageshow', function({target}) {
+  if (!lastDetectionResult || target !== window.document) {
     return;
   }
   chrome.runtime.sendMessage(lastDetectionResult);
 });
 
 const detectReact = `
-window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function(evt) {
+window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) {
   window.postMessage({
     source: 'react-devtools-detector',
-    reactBuildType: evt.reactBuildType,
+    reactBuildType,
   }, '*');
 });
 `;
diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js
index 06c227d7e4cbe..43ef1a1b26864 100644
--- a/packages/react-devtools-extensions/src/main.js
+++ b/packages/react-devtools-extensions/src/main.js
@@ -25,6 +25,27 @@ const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
 
 const isChrome = getBrowserName() === 'Chrome';
 
+const cachedNetworkEvents = new Map();
+
+// Cache JavaScript resources as the page loads them.
+// This helps avoid unnecessary duplicate requests when hook names are parsed.
+// Responses with a Vary: 'Origin' might not match future requests.
+// This lets us avoid a possible (expensive) cache miss.
+// For more info see: github.com/facebook/react/pull/22198
+chrome.devtools.network.onRequestFinished.addListener(
+  function onRequestFinished(event) {
+    if (event.request.method === 'GET') {
+      switch (event.response.content.mimeType) {
+        case 'application/javascript':
+        case 'application/x-javascript':
+        case 'text/javascript':
+          cachedNetworkEvents.set(event.request.url, event);
+          break;
+      }
+    }
+  },
+);
+
 let panelCreated = false;
 
 // The renderer interface can't read saved component filters directly,
@@ -212,20 +233,76 @@ function createPanelIfReactLoaded() {
           }
         };
 
+        // For some reason in Firefox, chrome.runtime.sendMessage() from a content script
+        // never reaches the chrome.runtime.onMessage event listener.
+        let fetchFileWithCaching = null;
+        if (isChrome) {
+          // Fetching files from the extension won't make use of the network cache
+          // for resources that have already been loaded by the page.
+          // This helper function allows the extension to request files to be fetched
+          // by the content script (running in the page) to increase the likelihood of a cache hit.
+          fetchFileWithCaching = url => {
+            const event = cachedNetworkEvents.get(url);
+            if (event != null) {
+              // If this resource has already been cached locally,
+              // skip the network queue (which might not be a cache hit anyway)
+              // and just use the cached response.
+              return new Promise(resolve => {
+                event.getContent(content => resolve(content));
+              });
+            }
+
+            // If DevTools was opened after the page started loading,
+            // we may have missed some requests.
+            // So fall back to a fetch() and hope we get a cached response.
+
+            return new Promise((resolve, reject) => {
+              function onPortMessage({payload, source}) {
+                if (source === 'react-devtools-content-script') {
+                  switch (payload?.type) {
+                    case 'fetch-file-with-cache-complete':
+                      chrome.runtime.onMessage.removeListener(onPortMessage);
+                      resolve(payload.value);
+                      break;
+                    case 'fetch-file-with-cache-error':
+                      chrome.runtime.onMessage.removeListener(onPortMessage);
+                      reject(payload.value);
+                      break;
+                  }
+                }
+              }
+
+              chrome.runtime.onMessage.addListener(onPortMessage);
+
+              chrome.devtools.inspectedWindow.eval(`
+                window.postMessage({
+                  source: 'react-devtools-extension',
+                  payload: {
+                    type: 'fetch-file-with-cache',
+                    url: "${url}",
+                  },
+                });
+              `);
+            });
+          };
+        }
+
         root = createRoot(document.createElement('div'));
 
         render = (overrideTab = mostRecentOverrideTab) => {
           mostRecentOverrideTab = overrideTab;
           import('./parseHookNames').then(
-            ({parseHookNames, purgeCachedMetadata}) => {
+            ({parseHookNames, prefetchSourceFiles, purgeCachedMetadata}) => {
               root.render(
                 createElement(DevTools, {
                   bridge,
                   browserTheme: getBrowserTheme(),
                   componentsPortalContainer,
                   enabledInspectedElementContextMenu: true,
+                  fetchFileWithCaching,
                   loadHookNames: parseHookNames,
                   overrideTab,
+                  prefetchSourceFiles,
                   profilerPortalContainer,
                   purgeCachedHookNamesMetadata: purgeCachedMetadata,
                   showTabBar: false,
@@ -366,6 +443,9 @@ function createPanelIfReactLoaded() {
 
       // Re-initialize DevTools panel when a new page is loaded.
       chrome.devtools.network.onNavigated.addListener(function onNavigated() {
+        // Clear cached requests when a new page is opened.
+        cachedNetworkEvents.clear();
+
         // Re-initialize saved filters on navigation,
         // since global values stored on window get reset in this case.
         syncSavedPreferences();
@@ -382,6 +462,9 @@ function createPanelIfReactLoaded() {
 
 // Load (or reload) the DevTools extension when the user navigates to a new page.
 function checkPageForReact() {
+  // Clear cached requests when a new page is opened.
+  cachedNetworkEvents.clear();
+
   syncSavedPreferences();
   createPanelIfReactLoaded();
 }
diff --git a/packages/react-devtools-extensions/src/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js
index 643655ae757e0..eae8440399c7b 100644
--- a/packages/react-devtools-extensions/src/parseHookNames/index.js
+++ b/packages/react-devtools-extensions/src/parseHookNames/index.js
@@ -7,17 +7,59 @@
  * @flow
  */
 
-// This file uses workerize to load ./parseHookNames.worker as a webworker and instanciates it,
-// exposing flow typed functions that can be used on other files.
+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 WorkerizedParseHookNames from './parseHookNames.worker';
-import typeof * as ParseHookNamesModule from './parseHookNames';
+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';
 
-const workerizedParseHookNames: ParseHookNamesModule = WorkerizedParseHookNames();
+const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata();
 
-type ParseHookNames = $PropertyType<ParseHookNamesModule, 'parseHookNames'>;
+export {prefetchSourceFiles};
 
-export const parseHookNames: ParseHookNames = hooksTree =>
-  workerizedParseHookNames.parseHookNames(hooksTree);
+export function parseSourceAndMetadata(
+  hooksList: Array<HooksNode>,
+  locationKeyToHookSourceAndMetadata: Map<string, HookSourceAndMetadata>,
+): Promise<HookNames | null> {
+  return workerizedParseHookNames.parseSourceAndMetadata(
+    hooksList,
+    locationKeyToHookSourceAndMetadata,
+  );
+}
 
 export const purgeCachedMetadata = workerizedParseHookNames.purgeCachedMetadata;
+
+const EMPTY_MAP = new Map();
+
+export async function parseHookNames(
+  hooksTree: HooksTree,
+  fetchFileWithCaching: FetchFileWithCaching | null,
+): Promise<HookNames | null> {
+  return withAsyncPerformanceMark('parseHookNames', async () => {
+    const hooksList = flattenHooksList(hooksTree);
+    if (hooksList.length === 0) {
+      // This component tree contains no named hooks.
+      return EMPTY_MAP;
+    }
+
+    // Runs on the main/UI thread so it can reuse Network cache:
+    const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata(
+      hooksList,
+      fetchFileWithCaching,
+    );
+
+    // Runs in a Worker because it's CPU intensive:
+    return parseSourceAndMetadata(
+      hooksList,
+      locationKeyToHookSourceAndMetadata,
+    );
+  });
+}
diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js
new file mode 100644
index 0000000000000..dad3b864636b8
--- /dev/null
+++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js
@@ -0,0 +1,570 @@
+/**
+ * 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
+ */
+
+// Parsing source and source maps is done in a Web Worker
+// because parsing is CPU intensive and should not block the UI thread.
+//
+// Fetching source and source map files is intentionally done on the UI thread
+// so that loaded source files can reuse the browser's Network cache.
+// Requests made from within an extension do not share the page's Network cache,
+// but messages can be sent from the UI thread to the content script
+// which can make a request from the page's context (with caching).
+//
+// Some overhead may be incurred sharing (serializing) the loaded data between contexts,
+// but less than fetching the file to begin with,
+// and in some cases we can avoid serializing the source code at all
+// (e.g. when we are in an environment that supports our custom metadata format).
+//
+// The overall flow of this file is such:
+// 1. Find the Set of source files defining the hooks and load them all.
+//    Then for each source file, do the following:
+//
+//    a. Search loaded source file to see if a source map is available.
+//       If so, load that file and pass it to a Worker for parsing.
+//       The source map is used to retrieve the original source,
+//       which is then also parsed in the Worker to infer hook names.
+//       This is less ideal because parsing a full source map is slower,
+//       since we need to evaluate the mappings in order to map the runtime code to the original source,
+//       but at least the eventual source that we parse to an AST is small/fast.
+//
+//    b. If no source map, pass the full source to a Worker for parsing.
+//       Use the source to infer hook names.
+//       This is the least optimal route as parsing the full source is very CPU intensive.
+//
+// In the future, we may add an additional optimization the above sequence.
+// This check would come before the source map check:
+//
+//    a. Search loaded source file to see if a custom React metadata file is available.
+//       If so, load that file and pass it to a Worker for parsing and extracting.
+//       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';
+import {
+  withAsyncPerformanceMark,
+  withCallbackPerformanceMark,
+  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 'react-devtools-extensions/src/SourceMapTypes';
+import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
+
+// 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).
+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<string, string> = new LRU({
+  max: 15,
+});
+
+export type HookSourceAndMetadata = {|
+  // Generated by react-debug-tools.
+  hookSource: HookSource,
+
+  // Compiled code (React components or custom hooks) containing primitive hook calls.
+  runtimeSourceCode: string | null,
+
+  // Same as hookSource.fileName but guaranteed to be non-null.
+  runtimeSourceURL: string,
+
+  // Raw source map JSON.
+  // Either decoded from an inline source map or loaded from an externa source map file.
+  // Sources without source maps won't have this.
+  sourceMapJSON: MixedSourceMap | null,
+
+  // External URL of source map.
+  // Sources without source maps (or with inline source maps) won't have this.
+  sourceMapURL: string | null,
+|};
+
+export type LocationKeyToHookSourceAndMetadata = Map<
+  string,
+  HookSourceAndMetadata,
+>;
+export type HooksList = Array<HooksNode>;
+
+export async function loadSourceAndMetadata(
+  hooksList: HooksList,
+  fetchFileWithCaching: FetchFileWithCaching | null,
+): Promise<LocationKeyToHookSourceAndMetadata> {
+  return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => {
+    const locationKeyToHookSourceAndMetadata = withSyncPerformanceMark(
+      'initializeHookSourceAndMetadata',
+      () => initializeHookSourceAndMetadata(hooksList),
+    );
+
+    await withAsyncPerformanceMark('loadSourceFiles()', () =>
+      loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching),
+    );
+
+    await withAsyncPerformanceMark('extractAndLoadSourceMapJSON()', () =>
+      extractAndLoadSourceMapJSON(locationKeyToHookSourceAndMetadata),
+    );
+
+    // At this point, we've loaded JS source (text) and source map (JSON).
+    // The remaining works (parsing these) is CPU intensive and should be done in a worker.
+    return locationKeyToHookSourceAndMetadata;
+  });
+}
+
+function decodeBase64String(encoded: string): Object {
+  if (typeof atob === 'function') {
+    return atob(encoded);
+  } else if (
+    typeof Buffer !== 'undefined' &&
+    Buffer !== null &&
+    typeof Buffer.from === 'function'
+  ) {
+    return Buffer.from(encoded, 'base64');
+  } else {
+    throw Error('Cannot decode base64 string');
+  }
+}
+
+function extractAndLoadSourceMapJSON(
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+): Promise<*> {
+  // Deduplicate fetches, since there can be multiple location keys per source map.
+  const dedupedFetchPromises = new Map();
+
+  const setterPromises = [];
+  locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => {
+    const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm;
+    const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string);
+
+    // TODO (named hooks) Search for our custom metadata first.
+    // If it's found, we should use it rather than source maps.
+
+    // TODO (named hooks) If this RegExp search is slow, we could try breaking it up
+    // first using an indexOf(' sourceMappingURL=') to find the start of the comment
+    // (probably at the end of the file) and then running the RegExp on the remaining substring.
+    let sourceMappingURLMatch = withSyncPerformanceMark(
+      'sourceMapRegex.exec(runtimeSourceCode)',
+      () => sourceMapRegex.exec(runtimeSourceCode),
+    );
+
+    if (sourceMappingURLMatch == null) {
+      if (__DEBUG__) {
+        console.log('extractAndLoadSourceMapJSON() No source map found');
+      }
+
+      // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST().
+    } else {
+      const externalSourceMapURLs = [];
+      while (sourceMappingURLMatch != null) {
+        const {runtimeSourceURL} = hookSourceAndMetadata;
+        const sourceMappingURL = sourceMappingURLMatch[1];
+        const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0;
+        if (hasInlineSourceMap) {
+          // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
+          // since there can be multiple location keys per source map.
+
+          // Web apps like Code Sandbox embed multiple inline source maps.
+          // In this case, we need to loop through and find the right one.
+          // We may also need to trim any part of this string that isn't based64 encoded data.
+          const trimmed = ((sourceMappingURL.match(
+            /base64,([a-zA-Z0-9+\/=]+)/,
+          ): any): Array<string>)[1];
+          const decoded = withSyncPerformanceMark('decodeBase64String()', () =>
+            decodeBase64String(trimmed),
+          );
+
+          const sourceMapJSON = withSyncPerformanceMark(
+            'JSON.parse(decoded)',
+            () => JSON.parse(decoded),
+          );
+
+          if (__DEBUG__) {
+            console.groupCollapsed(
+              'extractAndLoadSourceMapJSON() Inline source map',
+            );
+            console.log(sourceMapJSON);
+            console.groupEnd();
+          }
+
+          // Hook source might be a URL like "https://4syus.csb.app/src/App.js"
+          // Parsed source map might be a partial path like "src/App.js"
+          if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) {
+            hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
+
+            // OPTIMIZATION If we've located a source map for this source,
+            // we'll use it to retrieve the original source (to extract hook names).
+            // We only fall back to parsing the full source code is when there's no source map.
+            // The source is (potentially) very large,
+            // So we can avoid the overhead of serializing it unnecessarily.
+            hookSourceAndMetadata.runtimeSourceCode = null;
+
+            break;
+          }
+        } else {
+          externalSourceMapURLs.push(sourceMappingURL);
+        }
+
+        // If the first source map we found wasn't a match, check for more.
+        sourceMappingURLMatch = withSyncPerformanceMark(
+          'sourceMapRegex.exec(runtimeSourceCode)',
+          () => sourceMapRegex.exec(runtimeSourceCode),
+        );
+      }
+
+      if (hookSourceAndMetadata.sourceMapJSON === null) {
+        externalSourceMapURLs.forEach((sourceMappingURL, index) => {
+          if (index !== externalSourceMapURLs.length - 1) {
+            // Files with external source maps should only have a single source map.
+            // More than one result might indicate an edge case,
+            // like a string in the source code that matched our "sourceMappingURL" regex.
+            // We should just skip over cases like this.
+            console.warn(
+              `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`,
+            );
+            return;
+          }
+
+          const {runtimeSourceURL} = hookSourceAndMetadata;
+          let url = sourceMappingURL;
+          if (!url.startsWith('http') && !url.startsWith('/')) {
+            // Resolve paths relative to the location of the file name
+            const lastSlashIdx = runtimeSourceURL.lastIndexOf('/');
+            if (lastSlashIdx !== -1) {
+              const baseURL = runtimeSourceURL.slice(
+                0,
+                runtimeSourceURL.lastIndexOf('/'),
+              );
+              url = `${baseURL}/${url}`;
+            }
+          }
+
+          hookSourceAndMetadata.sourceMapURL = url;
+
+          const fetchPromise =
+            dedupedFetchPromises.get(url) ||
+            fetchFile(url).then(
+              sourceMapContents => {
+                const sourceMapJSON = withSyncPerformanceMark(
+                  'JSON.parse(sourceMapContents)',
+                  () => JSON.parse(sourceMapContents),
+                );
+
+                return sourceMapJSON;
+              },
+
+              // In this case, we fall back to the assumption that the source has no source map.
+              // This might indicate an (unlikely) edge case that had no source map,
+              // but contained the string "sourceMappingURL".
+              error => null,
+            );
+
+          if (__DEBUG__) {
+            if (!dedupedFetchPromises.has(url)) {
+              console.log(
+                `extractAndLoadSourceMapJSON() External source map "${url}"`,
+              );
+            }
+          }
+
+          dedupedFetchPromises.set(url, fetchPromise);
+
+          setterPromises.push(
+            fetchPromise.then(sourceMapJSON => {
+              if (sourceMapJSON !== null) {
+                hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
+
+                // OPTIMIZATION If we've located a source map for this source,
+                // we'll use it to retrieve the original source (to extract hook names).
+                // We only fall back to parsing the full source code is when there's no source map.
+                // The source is (potentially) very large,
+                // So we can avoid the overhead of serializing it unnecessarily.
+                hookSourceAndMetadata.runtimeSourceCode = null;
+              }
+            }),
+          );
+        });
+      }
+    }
+  });
+
+  return Promise.all(setterPromises);
+}
+
+function fetchFile(
+  url: string,
+  markName?: string = 'fetchFile',
+): Promise<string> {
+  return withCallbackPerformanceMark(`${markName}("${url}")`, done => {
+    return new Promise((resolve, reject) => {
+      fetch(url, FETCH_OPTIONS).then(
+        response => {
+          if (response.ok) {
+            response
+              .text()
+              .then(text => {
+                done();
+                resolve(text);
+              })
+              .catch(error => {
+                if (__DEBUG__) {
+                  console.log(
+                    `${markName}() Could not read text for url "${url}"`,
+                  );
+                }
+                done();
+                reject(null);
+              });
+          } else {
+            if (__DEBUG__) {
+              console.log(`${markName}() Got bad response for url "${url}"`);
+            }
+            done();
+            reject(null);
+          }
+        },
+        error => {
+          if (__DEBUG__) {
+            console.log(`${markName}() Could not fetch file: ${error.message}`);
+          }
+          done();
+          reject(null);
+        },
+      );
+    });
+  });
+}
+
+export function hasNamedHooks(hooksTree: HooksTree): boolean {
+  for (let i = 0; i < hooksTree.length; i++) {
+    const hook = hooksTree[i];
+
+    if (!isUnnamedBuiltInHook(hook)) {
+      return true;
+    }
+
+    if (hook.subHooks.length > 0) {
+      if (hasNamedHooks(hook.subHooks)) {
+        return true;
+      }
+    }
+  }
+
+  return false;
+}
+
+export function flattenHooksList(hooksTree: HooksTree): HooksList {
+  const hooksList: HooksList = [];
+  withSyncPerformanceMark('flattenHooksList()', () => {
+    flattenHooksListImpl(hooksTree, hooksList);
+  });
+
+  if (__DEBUG__) {
+    console.log('flattenHooksList() hooksList:', hooksList);
+  }
+
+  return hooksList;
+}
+
+function flattenHooksListImpl(
+  hooksTree: HooksTree,
+  hooksList: Array<HooksNode>,
+): void {
+  for (let i = 0; i < hooksTree.length; i++) {
+    const hook = hooksTree[i];
+
+    if (isUnnamedBuiltInHook(hook)) {
+      // No need to load source code or do any parsing for unnamed hooks.
+      if (__DEBUG__) {
+        console.log('flattenHooksListImpl() Skipping unnamed hook', hook);
+      }
+
+      continue;
+    }
+
+    hooksList.push(hook);
+
+    if (hook.subHooks.length > 0) {
+      flattenHooksListImpl(hook.subHooks, hooksList);
+    }
+  }
+}
+
+function initializeHookSourceAndMetadata(
+  hooksList: Array<HooksNode>,
+): LocationKeyToHookSourceAndMetadata {
+  // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
+  const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata = new Map();
+  for (let i = 0; i < hooksList.length; i++) {
+    const hook = hooksList[i];
+
+    const hookSource = hook.hookSource;
+    if (hookSource == null) {
+      // Older versions of react-debug-tools don't include this information.
+      // In this case, we can't continue.
+      throw Error('Hook source code location not found.');
+    }
+
+    const locationKey = getHookSourceLocationKey(hookSource);
+    if (!locationKeyToHookSourceAndMetadata.has(locationKey)) {
+      // Can't be null because getHookSourceLocationKey() would have thrown
+      const runtimeSourceURL = ((hookSource.fileName: any): string);
+
+      const hookSourceAndMetadata: HookSourceAndMetadata = {
+        hookSource,
+        runtimeSourceCode: null,
+        runtimeSourceURL,
+        sourceMapJSON: null,
+        sourceMapURL: null,
+      };
+
+      locationKeyToHookSourceAndMetadata.set(
+        locationKey,
+        hookSourceAndMetadata,
+      );
+    }
+  }
+
+  return locationKeyToHookSourceAndMetadata;
+}
+
+// Determines whether incoming hook is a primitive hook that gets assigned to variables.
+function isUnnamedBuiltInHook(hook: HooksNode) {
+  return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes(
+    hook.name,
+  );
+}
+
+function loadSourceFiles(
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+  fetchFileWithCaching: FetchFileWithCaching | null,
+): Promise<*> {
+  // Deduplicate fetches, since there can be multiple location keys per file.
+  const dedupedFetchPromises = new Map();
+
+  const setterPromises = [];
+  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);
+            },
+          );
+        };
+      }
+
+      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();
+          }
+
+          return runtimeSourceCode;
+        });
+      dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
+
+      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-extensions/src/parseHookNames/parseHookNames.js b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js
deleted file mode 100644
index 9afd3dea034a5..0000000000000
--- a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js
+++ /dev/null
@@ -1,750 +0,0 @@
-/**
- * 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 {parse} from '@babel/parser';
-import LRU from 'lru-cache';
-import {SourceMapConsumer} from 'source-map-js';
-import {getHookName} from '../astUtils';
-import {areSourceMapsAppliedToErrors} from '../ErrorTester';
-import {__DEBUG__} from 'react-devtools-shared/src/constants';
-import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
-import {sourceMapIncludesSource} from '../SourceMapUtils';
-import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer';
-import {
-  withAsyncPerformanceMark,
-  withCallbackPerformanceMark,
-  withSyncPerformanceMark,
-} from 'react-devtools-shared/src/PerformanceMarks';
-
-import type {
-  HooksNode,
-  HookSource,
-  HooksTree,
-} from 'react-debug-tools/src/ReactDebugHooks';
-import type {HookNames, LRUCache} from 'react-devtools-shared/src/types';
-import type {SourceConsumer} from '../astUtils';
-
-const MAX_SOURCE_LENGTH = 100_000_000;
-
-type AST = mixed;
-
-type HookSourceData = {|
-  // Generated by react-debug-tools.
-  hookSource: HookSource,
-
-  // API for consuming metadfata present in extended source map.
-  metadataConsumer: SourceMapMetadataConsumer | null,
-
-  // AST for original source code; typically comes from a consumed source map.
-  originalSourceAST: AST | null,
-
-  // Source code (React components or custom hooks) containing primitive hook calls.
-  // If no source map has been provided, this code will be the same as runtimeSourceCode.
-  originalSourceCode: string | null,
-
-  // Original source URL if there is a source map, or the same as runtimeSourceURL.
-  originalSourceURL: string | null,
-
-  // Compiled code (React components or custom hooks) containing primitive hook calls.
-  runtimeSourceCode: string | null,
-
-  // Same as hookSource.fileName but guaranteed to be non-null.
-  runtimeSourceURL: string,
-
-  // APIs from source-map for parsing source maps (if detected).
-  sourceConsumer: SourceConsumer | null,
-
-  // External URL of source map.
-  // Sources without source maps (or with inline source maps) won't have this.
-  sourceMapURL: string | null,
-|};
-
-type CachedRuntimeCodeMetadata = {|
-  sourceConsumer: SourceConsumer | null,
-  metadataConsumer: SourceMapMetadataConsumer | null,
-|};
-
-const runtimeURLToMetadataCache: LRUCache<
-  string,
-  CachedRuntimeCodeMetadata,
-> = new LRU({
-  max: 50,
-  dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
-    if (__DEBUG__) {
-      console.log(
-        `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
-      );
-    }
-
-    const sourceConsumer = metadata.sourceConsumer;
-    if (sourceConsumer !== null) {
-      sourceConsumer.destroy();
-    }
-  },
-});
-
-type CachedSourceCodeMetadata = {|
-  originalSourceAST: AST,
-  originalSourceCode: string,
-|};
-
-const originalURLToMetadataCache: LRUCache<
-  string,
-  CachedSourceCodeMetadata,
-> = new LRU({
-  max: 50,
-  dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => {
-    if (__DEBUG__) {
-      console.log(
-        `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
-      );
-    }
-  },
-});
-
-export async function parseHookNames(
-  hooksTree: HooksTree,
-): Promise<HookNames | null> {
-  const hooksList: Array<HooksNode> = [];
-  withSyncPerformanceMark('flattenHooksList()', () => {
-    flattenHooksList(hooksTree, hooksList);
-  });
-
-  return withAsyncPerformanceMark('parseHookNames()', () =>
-    parseHookNamesImpl(hooksList),
-  );
-}
-
-async function parseHookNamesImpl(
-  hooksList: HooksNode[],
-): Promise<HookNames | null> {
-  if (__DEBUG__) {
-    console.log('parseHookNames() hooksList:', hooksList);
-  }
-
-  // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
-  const locationKeyToHookSourceData: Map<string, HookSourceData> = new Map();
-  for (let i = 0; i < hooksList.length; i++) {
-    const hook = hooksList[i];
-
-    const hookSource = hook.hookSource;
-    if (hookSource == null) {
-      // Older versions of react-debug-tools don't include this information.
-      // In this case, we can't continue.
-      throw Error('Hook source code location not found.');
-    }
-
-    const locationKey = getHookSourceLocationKey(hookSource);
-    if (!locationKeyToHookSourceData.has(locationKey)) {
-      // Can't be null because getHookSourceLocationKey() would have thrown
-      const runtimeSourceURL = ((hookSource.fileName: any): string);
-
-      const hookSourceData: HookSourceData = {
-        hookSource,
-        metadataConsumer: null,
-        originalSourceAST: null,
-        originalSourceCode: null,
-        originalSourceURL: null,
-        runtimeSourceCode: null,
-        runtimeSourceURL,
-        sourceConsumer: null,
-        sourceMapURL: null,
-      };
-
-      // If we've already loaded the source map info for this file,
-      // we can skip reloading it (and more importantly, re-parsing it).
-      const runtimeMetadata = runtimeURLToMetadataCache.get(
-        hookSourceData.runtimeSourceURL,
-      );
-      if (runtimeMetadata != null) {
-        if (__DEBUG__) {
-          console.groupCollapsed(
-            `parseHookNames() Found cached runtime metadata for file "${hookSourceData.runtimeSourceURL}"`,
-          );
-          console.log(runtimeMetadata);
-          console.groupEnd();
-        }
-        hookSourceData.sourceConsumer = runtimeMetadata.sourceConsumer;
-        hookSourceData.metadataConsumer = runtimeMetadata.metadataConsumer;
-      }
-
-      locationKeyToHookSourceData.set(locationKey, hookSourceData);
-    }
-  }
-
-  await withAsyncPerformanceMark('loadSourceFiles()', () =>
-    loadSourceFiles(locationKeyToHookSourceData),
-  );
-
-  await withAsyncPerformanceMark('extractAndLoadSourceMaps()', () =>
-    extractAndLoadSourceMaps(locationKeyToHookSourceData),
-  );
-
-  withSyncPerformanceMark('parseSourceAST()', () =>
-    parseSourceAST(locationKeyToHookSourceData),
-  );
-
-  withSyncPerformanceMark('updateLruCache()', () =>
-    updateLruCache(locationKeyToHookSourceData),
-  );
-
-  return withSyncPerformanceMark('findHookNames()', () =>
-    findHookNames(hooksList, locationKeyToHookSourceData),
-  );
-}
-
-function decodeBase64String(encoded: string): Object {
-  if (typeof atob === 'function') {
-    return atob(encoded);
-  } else if (
-    typeof Buffer !== 'undefined' &&
-    Buffer !== null &&
-    typeof Buffer.from === 'function'
-  ) {
-    return Buffer.from(encoded, 'base64');
-  } else {
-    throw Error('Cannot decode base64 string');
-  }
-}
-
-function extractAndLoadSourceMaps(
-  locationKeyToHookSourceData: Map<string, HookSourceData>,
-): Promise<*> {
-  // SourceMapConsumer.initialize() does nothing when running in Node (aka Jest)
-  // because the wasm file is automatically read from the file system
-  // so we can avoid triggering a warning message about this.
-  if (!__TEST__) {
-    if (__DEBUG__) {
-      console.log(
-        'extractAndLoadSourceMaps() Initializing source-map library ...',
-      );
-    }
-  }
-
-  // Deduplicate fetches, since there can be multiple location keys per source map.
-  const fetchPromises = new Map();
-
-  const setPromises = [];
-  locationKeyToHookSourceData.forEach(hookSourceData => {
-    if (
-      hookSourceData.sourceConsumer != null &&
-      hookSourceData.metadataConsumer != null
-    ) {
-      // Use cached source map and metadata consumers.
-      return;
-    }
-
-    const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm;
-    const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
-
-    let sourceMappingURLMatch = withSyncPerformanceMark(
-      'sourceMapRegex.exec(runtimeSourceCode)',
-      () => sourceMapRegex.exec(runtimeSourceCode),
-    );
-
-    if (sourceMappingURLMatch == null) {
-      // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST().
-
-      if (__DEBUG__) {
-        console.log('extractAndLoadSourceMaps() No source map found');
-      }
-    } else {
-      const externalSourceMapURLs = [];
-      while (sourceMappingURLMatch != null) {
-        const {runtimeSourceURL} = hookSourceData;
-        const sourceMappingURL = sourceMappingURLMatch[1];
-        const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0;
-        if (hasInlineSourceMap) {
-          // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
-          // since there can be multiple location keys per source map.
-
-          // Web apps like Code Sandbox embed multiple inline source maps.
-          // In this case, we need to loop through and find the right one.
-          // We may also need to trim any part of this string that isn't based64 encoded data.
-          const trimmed = ((sourceMappingURL.match(
-            /base64,([a-zA-Z0-9+\/=]+)/,
-          ): any): Array<string>)[1];
-          const decoded = withSyncPerformanceMark('decodeBase64String()', () =>
-            decodeBase64String(trimmed),
-          );
-
-          const parsed = withSyncPerformanceMark('JSON.parse(decoded)', () =>
-            JSON.parse(decoded),
-          );
-
-          if (__DEBUG__) {
-            console.groupCollapsed(
-              'extractAndLoadSourceMaps() Inline source map',
-            );
-            console.log(parsed);
-            console.groupEnd();
-          }
-
-          // Hook source might be a URL like "https://4syus.csb.app/src/App.js"
-          // Parsed source map might be a partial path like "src/App.js"
-          if (sourceMapIncludesSource(parsed, runtimeSourceURL)) {
-            hookSourceData.metadataConsumer = withSyncPerformanceMark(
-              'new SourceMapMetadataConsumer(parsed)',
-              () => new SourceMapMetadataConsumer(parsed),
-            );
-            hookSourceData.sourceConsumer = withSyncPerformanceMark(
-              'new SourceMapConsumer(parsed)',
-              () => new SourceMapConsumer(parsed),
-            );
-            break;
-          }
-        } else {
-          externalSourceMapURLs.push(sourceMappingURL);
-        }
-
-        sourceMappingURLMatch = withSyncPerformanceMark(
-          'sourceMapRegex.exec(runtimeSourceCode)',
-          () => sourceMapRegex.exec(runtimeSourceCode),
-        );
-      }
-
-      const foundInlineSourceMap =
-        hookSourceData.sourceConsumer != null &&
-        hookSourceData.metadataConsumer != null;
-      if (!foundInlineSourceMap) {
-        externalSourceMapURLs.forEach((sourceMappingURL, index) => {
-          if (index !== externalSourceMapURLs.length - 1) {
-            // Files with external source maps should only have a single source map.
-            // More than one result might indicate an edge case,
-            // like a string in the source code that matched our "sourceMappingURL" regex.
-            // We should just skip over cases like this.
-            console.warn(
-              `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`,
-            );
-            return;
-          }
-
-          const {runtimeSourceURL} = hookSourceData;
-          let url = sourceMappingURL;
-          if (!url.startsWith('http') && !url.startsWith('/')) {
-            // Resolve paths relative to the location of the file name
-            const lastSlashIdx = runtimeSourceURL.lastIndexOf('/');
-            if (lastSlashIdx !== -1) {
-              const baseURL = runtimeSourceURL.slice(
-                0,
-                runtimeSourceURL.lastIndexOf('/'),
-              );
-              url = `${baseURL}/${url}`;
-            }
-          }
-
-          hookSourceData.sourceMapURL = url;
-
-          const fetchPromise =
-            fetchPromises.get(url) ||
-            fetchFile(url).then(
-              sourceMapContents => {
-                const parsed = withSyncPerformanceMark(
-                  'JSON.parse(sourceMapContents)',
-                  () => JSON.parse(sourceMapContents),
-                );
-
-                const sourceConsumer = withSyncPerformanceMark(
-                  'new SourceMapConsumer(parsed)',
-                  () => new SourceMapConsumer(parsed),
-                );
-
-                const metadataConsumer = withSyncPerformanceMark(
-                  'new SourceMapMetadataConsumer(parsed)',
-                  () => new SourceMapMetadataConsumer(parsed),
-                );
-
-                return {sourceConsumer, metadataConsumer};
-              },
-              // In this case, we fall back to the assumption that the source has no source map.
-              // This might indicate an (unlikely) edge case that had no source map,
-              // but contained the string "sourceMappingURL".
-              error => null,
-            );
-
-          if (__DEBUG__) {
-            if (!fetchPromises.has(url)) {
-              console.log(
-                `extractAndLoadSourceMaps() External source map "${url}"`,
-              );
-            }
-          }
-
-          fetchPromises.set(url, fetchPromise);
-          setPromises.push(
-            fetchPromise.then(result => {
-              hookSourceData.metadataConsumer =
-                result?.metadataConsumer ?? null;
-              hookSourceData.sourceConsumer = result?.sourceConsumer ?? null;
-            }),
-          );
-        });
-      }
-    }
-  });
-  return Promise.all(setPromises);
-}
-
-function fetchFile(url: string): Promise<string> {
-  return withCallbackPerformanceMark('fetchFile("' + url + '")', done => {
-    return new Promise((resolve, reject) => {
-      fetch(url).then(
-        response => {
-          if (response.ok) {
-            response
-              .text()
-              .then(text => {
-                done();
-                resolve(text);
-              })
-              .catch(error => {
-                if (__DEBUG__) {
-                  console.log(
-                    `fetchFile() Could not read text for url "${url}"`,
-                  );
-                }
-                done();
-                reject(null);
-              });
-          } else {
-            if (__DEBUG__) {
-              console.log(`fetchFile() Got bad response for url "${url}"`);
-            }
-            done();
-            reject(null);
-          }
-        },
-        error => {
-          if (__DEBUG__) {
-            console.log(`fetchFile() Could not fetch file: ${error.message}`);
-          }
-          done();
-          reject(null);
-        },
-      );
-    });
-  });
-}
-
-function findHookNames(
-  hooksList: Array<HooksNode>,
-  locationKeyToHookSourceData: Map<string, HookSourceData>,
-): HookNames {
-  const map: HookNames = new Map();
-
-  hooksList.map(hook => {
-    // We already guard against a null HookSource in parseHookNames()
-    const hookSource = ((hook.hookSource: any): HookSource);
-    const fileName = hookSource.fileName;
-    if (!fileName) {
-      return null; // Should not be reachable.
-    }
-
-    const locationKey = getHookSourceLocationKey(hookSource);
-    const hookSourceData = locationKeyToHookSourceData.get(locationKey);
-    if (!hookSourceData) {
-      return null; // Should not be reachable.
-    }
-
-    const {lineNumber, columnNumber} = hookSource;
-    if (!lineNumber || !columnNumber) {
-      return null; // Should not be reachable.
-    }
-
-    const {originalSourceURL, sourceConsumer} = hookSourceData;
-
-    let originalSourceColumnNumber;
-    let originalSourceLineNumber;
-    if (areSourceMapsAppliedToErrors() || !sourceConsumer) {
-      // Either the current environment automatically applies source maps to errors,
-      // or the current code had no source map to begin with.
-      // Either way, we don't need to convert the Error stack frame locations.
-      originalSourceColumnNumber = columnNumber;
-      originalSourceLineNumber = lineNumber;
-    } else {
-      const position = withSyncPerformanceMark(
-        'sourceConsumer.originalPositionFor()',
-        () =>
-          sourceConsumer.originalPositionFor({
-            line: lineNumber,
-
-            // Column numbers are represented differently between tools/engines.
-            // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
-            // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
-            column: columnNumber - 1,
-          }),
-      );
-
-      originalSourceColumnNumber = position.column;
-      originalSourceLineNumber = position.line;
-    }
-
-    if (__DEBUG__) {
-      console.log(
-        `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`,
-      );
-    }
-
-    if (
-      originalSourceLineNumber == null ||
-      originalSourceColumnNumber == null ||
-      originalSourceURL == null
-    ) {
-      return null;
-    }
-
-    let name;
-    const {metadataConsumer} = hookSourceData;
-    if (metadataConsumer != null) {
-      name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () =>
-        metadataConsumer.hookNameFor({
-          line: originalSourceLineNumber,
-          column: originalSourceColumnNumber,
-          source: originalSourceURL,
-        }),
-      );
-    }
-
-    if (name == null) {
-      name = withSyncPerformanceMark('getHookName()', () =>
-        getHookName(
-          hook,
-          hookSourceData.originalSourceAST,
-          ((hookSourceData.originalSourceCode: any): string),
-          ((originalSourceLineNumber: any): number),
-          originalSourceColumnNumber,
-        ),
-      );
-    }
-
-    if (__DEBUG__) {
-      console.log(`findHookNames() Found name "${name || '-'}"`);
-    }
-
-    const key = getHookSourceLocationKey(hookSource);
-    map.set(key, name);
-  });
-
-  return map;
-}
-
-function loadSourceFiles(
-  locationKeyToHookSourceData: Map<string, HookSourceData>,
-): Promise<*> {
-  // Deduplicate fetches, since there can be multiple location keys per file.
-  const fetchPromises = new Map();
-
-  const setPromises = [];
-  locationKeyToHookSourceData.forEach(hookSourceData => {
-    const {runtimeSourceURL} = hookSourceData;
-    const fetchPromise =
-      fetchPromises.get(runtimeSourceURL) ||
-      fetchFile(runtimeSourceURL).then(runtimeSourceCode => {
-        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();
-        }
-        return runtimeSourceCode;
-      });
-    fetchPromises.set(runtimeSourceURL, fetchPromise);
-    setPromises.push(
-      fetchPromise.then(runtimeSourceCode => {
-        hookSourceData.runtimeSourceCode = runtimeSourceCode;
-      }),
-    );
-  });
-  return Promise.all(setPromises);
-}
-
-function parseSourceAST(
-  locationKeyToHookSourceData: Map<string, HookSourceData>,
-): void {
-  locationKeyToHookSourceData.forEach(hookSourceData => {
-    if (hookSourceData.originalSourceAST !== null) {
-      // Use cached metadata.
-      return;
-    }
-
-    const {metadataConsumer, sourceConsumer} = hookSourceData;
-    const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
-    let hasHookMap = false;
-    let originalSourceURL;
-    let originalSourceCode;
-    if (sourceConsumer !== null) {
-      // Parse and extract the AST from the source map.
-      const {lineNumber, columnNumber} = hookSourceData.hookSource;
-      if (lineNumber == null || columnNumber == null) {
-        throw Error('Hook source code location not found.');
-      }
-      // Now that the source map has been loaded,
-      // extract the original source for later.
-      const {source} = withSyncPerformanceMark(
-        'sourceConsumer.originalPositionFor()',
-        () =>
-          sourceConsumer.originalPositionFor({
-            line: lineNumber,
-
-            // Column numbers are represented differently between tools/engines.
-            // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
-            // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
-            column: columnNumber - 1,
-          }),
-      );
-
-      if (source == null) {
-        // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
-        throw new Error(
-          'Could not map hook runtime location to original source location',
-        );
-      }
-
-      // TODO (named hooks) maybe canonicalize this URL somehow?
-      // It can be relative if the source map specifies it that way,
-      // but we use it as a cache key across different source maps and there can be collisions.
-      originalSourceURL = (source: string);
-      originalSourceCode = withSyncPerformanceMark(
-        'sourceConsumer.sourceContentFor()',
-        () => (sourceConsumer.sourceContentFor(source, true): string),
-      );
-
-      if (__DEBUG__) {
-        console.groupCollapsed(
-          'parseSourceAST() Extracted source code from source map',
-        );
-        console.log(originalSourceCode);
-        console.groupEnd();
-      }
-
-      if (
-        metadataConsumer != null &&
-        metadataConsumer.hasHookMap(originalSourceURL)
-      ) {
-        hasHookMap = true;
-      }
-    } else {
-      // There's no source map to parse here so we can just parse the original source itself.
-      originalSourceCode = runtimeSourceCode;
-      // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
-      // Namespace them?
-      originalSourceURL = hookSourceData.runtimeSourceURL;
-    }
-
-    hookSourceData.originalSourceCode = originalSourceCode;
-    hookSourceData.originalSourceURL = originalSourceURL;
-
-    if (hasHookMap) {
-      // If there's a hook map present from an extended sourcemap then
-      // we don't need to parse the source files and instead can use the
-      // hook map to extract hook names.
-      return;
-    }
-
-    // The cache also serves to deduplicate parsing by URL in our loop over
-    // location keys. This may need to change if we switch to async parsing.
-    const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
-    if (sourceMetadata != null) {
-      if (__DEBUG__) {
-        console.groupCollapsed(
-          `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
-        );
-        console.log(sourceMetadata);
-        console.groupEnd();
-      }
-      hookSourceData.originalSourceAST = sourceMetadata.originalSourceAST;
-      hookSourceData.originalSourceCode = sourceMetadata.originalSourceCode;
-    } else {
-      // TypeScript is the most commonly used typed JS variant so let's default to it
-      // unless we detect explicit Flow usage via the "@flow" pragma.
-      const plugin =
-        originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript';
-
-      // TODO (named hooks) Parsing should ideally be done off of the main thread.
-      const originalSourceAST = withSyncPerformanceMark(
-        '[@babel/parser] parse(originalSourceCode)',
-        () =>
-          parse(originalSourceCode, {
-            sourceType: 'unambiguous',
-            plugins: ['jsx', plugin],
-          }),
-      );
-      hookSourceData.originalSourceAST = originalSourceAST;
-      if (__DEBUG__) {
-        console.log(
-          `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
-        );
-      }
-      originalURLToMetadataCache.set(originalSourceURL, {
-        originalSourceAST,
-        originalSourceCode,
-      });
-    }
-  });
-}
-
-function flattenHooksList(
-  hooksTree: HooksTree,
-  hooksList: Array<HooksNode>,
-): void {
-  for (let i = 0; i < hooksTree.length; i++) {
-    const hook = hooksTree[i];
-
-    if (isUnnamedBuiltInHook(hook)) {
-      // No need to load source code or do any parsing for unnamed hooks.
-      if (__DEBUG__) {
-        console.log('flattenHooksList() Skipping unnamed hook', hook);
-      }
-      continue;
-    }
-
-    hooksList.push(hook);
-    if (hook.subHooks.length > 0) {
-      flattenHooksList(hook.subHooks, hooksList);
-    }
-  }
-}
-
-// Determines whether incoming hook is a primitive hook that gets assigned to variables.
-function isUnnamedBuiltInHook(hook: HooksNode) {
-  return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes(
-    hook.name,
-  );
-}
-
-function updateLruCache(
-  locationKeyToHookSourceData: Map<string, HookSourceData>,
-): void {
-  locationKeyToHookSourceData.forEach(
-    ({metadataConsumer, sourceConsumer, runtimeSourceURL}) => {
-      // Only set once to avoid triggering eviction/cleanup code.
-      if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
-        if (__DEBUG__) {
-          console.log(
-            `updateLruCache() Caching runtime metadata for "${runtimeSourceURL}"`,
-          );
-        }
-
-        runtimeURLToMetadataCache.set(runtimeSourceURL, {
-          metadataConsumer,
-          sourceConsumer,
-        });
-      }
-    },
-  );
-}
-
-export function purgeCachedMetadata(): void {
-  originalURLToMetadataCache.reset();
-  runtimeURLToMetadataCache.reset();
-}
diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js
deleted file mode 100644
index c89f11a55db28..0000000000000
--- a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * 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.
- */
-
-import * as parseHookNamesModule from './parseHookNames';
-
-export const parseHookNames = parseHookNamesModule.parseHookNames;
-export const purgeCachedMetadata = parseHookNamesModule.purgeCachedMetadata;
diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js
new file mode 100644
index 0000000000000..143498c7e54c2
--- /dev/null
+++ b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js
@@ -0,0 +1,458 @@
+/**
+ * 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
+ */
+
+// For an overview of why the code in this file is structured this way,
+// refer to header comments in loadSourceAndMetadata.
+
+import {parse} from '@babel/parser';
+import LRU from 'lru-cache';
+import {SourceMapConsumer} from 'source-map-js';
+import {getHookName} from '../astUtils';
+import {areSourceMapsAppliedToErrors} from '../ErrorTester';
+import {__DEBUG__} from 'react-devtools-shared/src/constants';
+import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
+import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer';
+import {
+  withAsyncPerformanceMark,
+  withSyncPerformanceMark,
+} from 'react-devtools-shared/src/PerformanceMarks';
+
+import type {
+  HooksList,
+  LocationKeyToHookSourceAndMetadata,
+} from './loadSourceAndMetadata';
+import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
+import type {HookNames, LRUCache} from 'react-devtools-shared/src/types';
+import type {SourceConsumer} from '../astUtils';
+
+type AST = mixed;
+
+type HookParsedMetadata = {|
+  // API for consuming metadfata present in extended source map.
+  metadataConsumer: SourceMapMetadataConsumer | null,
+
+  // AST for original source code; typically comes from a consumed source map.
+  originalSourceAST: AST | null,
+
+  // Source code (React components or custom hooks) containing primitive hook calls.
+  // If no source map has been provided, this code will be the same as runtimeSourceCode.
+  originalSourceCode: string | null,
+
+  // Original source URL if there is a source map, or the same as runtimeSourceURL.
+  originalSourceURL: string | null,
+
+  // APIs from source-map for parsing source maps (if detected).
+  sourceConsumer: SourceConsumer | null,
+|};
+
+type LocationKeyToHookParsedMetadata = Map<string, HookParsedMetadata>;
+
+type CachedRuntimeCodeMetadata = {|
+  sourceConsumer: SourceConsumer | null,
+  metadataConsumer: SourceMapMetadataConsumer | null,
+|};
+
+const runtimeURLToMetadataCache: LRUCache<
+  string,
+  CachedRuntimeCodeMetadata,
+> = new LRU({
+  max: 50,
+  dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
+    if (__DEBUG__) {
+      console.log(
+        `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
+      );
+    }
+
+    const sourceConsumer = metadata.sourceConsumer;
+    if (sourceConsumer !== null) {
+      sourceConsumer.destroy();
+    }
+  },
+});
+
+type CachedSourceCodeMetadata = {|
+  originalSourceAST: AST,
+  originalSourceCode: string,
+|};
+
+const originalURLToMetadataCache: LRUCache<
+  string,
+  CachedSourceCodeMetadata,
+> = new LRU({
+  max: 50,
+  dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => {
+    if (__DEBUG__) {
+      console.log(
+        `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
+      );
+    }
+  },
+});
+
+export async function parseSourceAndMetadata(
+  hooksList: HooksList,
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+): Promise<HookNames | null> {
+  return withAsyncPerformanceMark('parseSourceAndMetadata()', async () => {
+    const locationKeyToHookParsedMetadata = withSyncPerformanceMark(
+      'initializeHookParsedMetadata',
+      () => initializeHookParsedMetadata(locationKeyToHookSourceAndMetadata),
+    );
+
+    withSyncPerformanceMark('parseSourceMaps', () =>
+      parseSourceMaps(
+        locationKeyToHookSourceAndMetadata,
+        locationKeyToHookParsedMetadata,
+      ),
+    );
+
+    withSyncPerformanceMark('parseSourceAST()', () =>
+      parseSourceAST(
+        locationKeyToHookSourceAndMetadata,
+        locationKeyToHookParsedMetadata,
+      ),
+    );
+
+    return withSyncPerformanceMark('findHookNames()', () =>
+      findHookNames(hooksList, locationKeyToHookParsedMetadata),
+    );
+  });
+}
+
+function findHookNames(
+  hooksList: HooksList,
+  locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
+): HookNames {
+  const map: HookNames = new Map();
+
+  hooksList.map(hook => {
+    // We already guard against a null HookSource in parseHookNames()
+    const hookSource = ((hook.hookSource: any): HookSource);
+    const fileName = hookSource.fileName;
+    if (!fileName) {
+      return null; // Should not be reachable.
+    }
+
+    const locationKey = getHookSourceLocationKey(hookSource);
+    const hookParsedMetadata = locationKeyToHookParsedMetadata.get(locationKey);
+    if (!hookParsedMetadata) {
+      return null; // Should not be reachable.
+    }
+
+    const {lineNumber, columnNumber} = hookSource;
+    if (!lineNumber || !columnNumber) {
+      return null; // Should not be reachable.
+    }
+
+    const {originalSourceURL, sourceConsumer} = hookParsedMetadata;
+
+    let originalSourceColumnNumber;
+    let originalSourceLineNumber;
+    if (areSourceMapsAppliedToErrors() || !sourceConsumer) {
+      // Either the current environment automatically applies source maps to errors,
+      // or the current code had no source map to begin with.
+      // Either way, we don't need to convert the Error stack frame locations.
+      originalSourceColumnNumber = columnNumber;
+      originalSourceLineNumber = lineNumber;
+    } else {
+      // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181
+      const position = withSyncPerformanceMark(
+        'sourceConsumer.originalPositionFor()',
+        () =>
+          sourceConsumer.originalPositionFor({
+            line: lineNumber,
+
+            // Column numbers are represented differently between tools/engines.
+            // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
+            // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
+            column: columnNumber - 1,
+          }),
+      );
+
+      originalSourceColumnNumber = position.column;
+      originalSourceLineNumber = position.line;
+    }
+
+    if (__DEBUG__) {
+      console.log(
+        `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`,
+      );
+    }
+
+    if (
+      originalSourceLineNumber == null ||
+      originalSourceColumnNumber == null ||
+      originalSourceURL == null
+    ) {
+      return null;
+    }
+
+    let name;
+    const {metadataConsumer} = hookParsedMetadata;
+    if (metadataConsumer != null) {
+      name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () =>
+        metadataConsumer.hookNameFor({
+          line: originalSourceLineNumber,
+          column: originalSourceColumnNumber,
+          source: originalSourceURL,
+        }),
+      );
+    }
+
+    if (name == null) {
+      name = withSyncPerformanceMark('getHookName()', () =>
+        getHookName(
+          hook,
+          hookParsedMetadata.originalSourceAST,
+          ((hookParsedMetadata.originalSourceCode: any): string),
+          ((originalSourceLineNumber: any): number),
+          originalSourceColumnNumber,
+        ),
+      );
+    }
+
+    if (__DEBUG__) {
+      console.log(`findHookNames() Found name "${name || '-'}"`);
+    }
+
+    const key = getHookSourceLocationKey(hookSource);
+    map.set(key, name);
+  });
+
+  return map;
+}
+
+function initializeHookParsedMetadata(
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+) {
+  // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
+  const locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata = new Map();
+  locationKeyToHookSourceAndMetadata.forEach(
+    (hookSourceAndMetadata, locationKey) => {
+      const hookParsedMetadata: HookParsedMetadata = {
+        metadataConsumer: null,
+        originalSourceAST: null,
+        originalSourceCode: null,
+        originalSourceURL: null,
+        sourceConsumer: null,
+      };
+
+      locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata);
+
+      const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
+
+      // If we've already loaded the source map info for this file,
+      // we can skip reloading it (and more importantly, re-parsing it).
+      const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL);
+      if (runtimeMetadata != null) {
+        if (__DEBUG__) {
+          console.groupCollapsed(
+            `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`,
+          );
+          console.log(runtimeMetadata);
+          console.groupEnd();
+        }
+        hookParsedMetadata.sourceConsumer = runtimeMetadata.sourceConsumer;
+        hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer;
+      }
+    },
+  );
+
+  return locationKeyToHookParsedMetadata;
+}
+
+function parseSourceAST(
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+  locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
+): void {
+  locationKeyToHookSourceAndMetadata.forEach(
+    (hookSourceAndMetadata, locationKey) => {
+      const hookParsedMetadata = locationKeyToHookParsedMetadata.get(
+        locationKey,
+      );
+      if (hookParsedMetadata == null) {
+        throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
+      }
+
+      if (hookParsedMetadata.originalSourceAST !== null) {
+        // Use cached metadata.
+        return;
+      }
+
+      const {metadataConsumer, sourceConsumer} = hookParsedMetadata;
+      const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string);
+      let hasHookMap = false;
+      let originalSourceURL;
+      let originalSourceCode;
+      if (sourceConsumer !== null) {
+        // Parse and extract the AST from the source map.
+        const {lineNumber, columnNumber} = hookSourceAndMetadata.hookSource;
+        if (lineNumber == null || columnNumber == null) {
+          throw Error('Hook source code location not found.');
+        }
+        // Now that the source map has been loaded,
+        // extract the original source for later.
+        // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181
+        const {source} = withSyncPerformanceMark(
+          'sourceConsumer.originalPositionFor()',
+          () =>
+            sourceConsumer.originalPositionFor({
+              line: lineNumber,
+
+              // Column numbers are represented differently between tools/engines.
+              // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
+              // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
+              column: columnNumber - 1,
+            }),
+        );
+
+        if (source == null) {
+          // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
+          throw new Error(
+            'Could not map hook runtime location to original source location',
+          );
+        }
+
+        // TODO (named hooks) maybe canonicalize this URL somehow?
+        // It can be relative if the source map specifies it that way,
+        // but we use it as a cache key across different source maps and there can be collisions.
+        originalSourceURL = (source: string);
+        originalSourceCode = withSyncPerformanceMark(
+          'sourceConsumer.sourceContentFor()',
+          () => (sourceConsumer.sourceContentFor(source, true): string),
+        );
+
+        if (__DEBUG__) {
+          console.groupCollapsed(
+            'parseSourceAST() Extracted source code from source map',
+          );
+          console.log(originalSourceCode);
+          console.groupEnd();
+        }
+
+        if (
+          metadataConsumer != null &&
+          metadataConsumer.hasHookMap(originalSourceURL)
+        ) {
+          hasHookMap = true;
+        }
+      } else {
+        // There's no source map to parse here so we can just parse the original source itself.
+        originalSourceCode = runtimeSourceCode;
+        // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
+        // Namespace them?
+        originalSourceURL = hookSourceAndMetadata.runtimeSourceURL;
+      }
+
+      hookParsedMetadata.originalSourceCode = originalSourceCode;
+      hookParsedMetadata.originalSourceURL = originalSourceURL;
+
+      if (hasHookMap) {
+        // If there's a hook map present from an extended sourcemap then
+        // we don't need to parse the source files and instead can use the
+        // hook map to extract hook names.
+        return;
+      }
+
+      // The cache also serves to deduplicate parsing by URL in our loop over location keys.
+      // This may need to change if we switch to async parsing.
+      const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
+      if (sourceMetadata != null) {
+        if (__DEBUG__) {
+          console.groupCollapsed(
+            `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
+          );
+          console.log(sourceMetadata);
+          console.groupEnd();
+        }
+        hookParsedMetadata.originalSourceAST = sourceMetadata.originalSourceAST;
+        hookParsedMetadata.originalSourceCode =
+          sourceMetadata.originalSourceCode;
+      } else {
+        // TypeScript is the most commonly used typed JS variant so let's default to it
+        // unless we detect explicit Flow usage via the "@flow" pragma.
+        const plugin =
+          originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript';
+
+        // TODO (named hooks) This is probably where we should check max source length,
+        // rather than in loadSourceAndMetatada -> loadSourceFiles().
+        const originalSourceAST = withSyncPerformanceMark(
+          '[@babel/parser] parse(originalSourceCode)',
+          () =>
+            parse(originalSourceCode, {
+              sourceType: 'unambiguous',
+              plugins: ['jsx', plugin],
+            }),
+        );
+        hookParsedMetadata.originalSourceAST = originalSourceAST;
+
+        if (__DEBUG__) {
+          console.log(
+            `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
+          );
+        }
+
+        originalURLToMetadataCache.set(originalSourceURL, {
+          originalSourceAST,
+          originalSourceCode,
+        });
+      }
+    },
+  );
+}
+
+function parseSourceMaps(
+  locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
+  locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
+) {
+  locationKeyToHookSourceAndMetadata.forEach(
+    (hookSourceAndMetadata, locationKey) => {
+      const hookParsedMetadata = locationKeyToHookParsedMetadata.get(
+        locationKey,
+      );
+      if (hookParsedMetadata == null) {
+        throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
+      }
+
+      const sourceMapJSON = hookSourceAndMetadata.sourceMapJSON;
+      if (sourceMapJSON != null) {
+        hookParsedMetadata.metadataConsumer = withSyncPerformanceMark(
+          'new SourceMapMetadataConsumer(sourceMapJSON)',
+          () => new SourceMapMetadataConsumer(sourceMapJSON),
+        );
+        hookParsedMetadata.sourceConsumer = withSyncPerformanceMark(
+          'new SourceMapConsumer(sourceMapJSON)',
+          () => new SourceMapConsumer(sourceMapJSON),
+        );
+
+        const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
+
+        // Only set once to avoid triggering eviction/cleanup code.
+        if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
+          if (__DEBUG__) {
+            console.log(
+              `parseSourceMaps() Caching runtime metadata for "${runtimeSourceURL}"`,
+            );
+          }
+
+          runtimeURLToMetadataCache.set(runtimeSourceURL, {
+            metadataConsumer: hookParsedMetadata.metadataConsumer,
+            sourceConsumer: hookParsedMetadata.sourceConsumer,
+          });
+        }
+      }
+    },
+  );
+}
+
+export function purgeCachedMetadata(): void {
+  originalURLToMetadataCache.reset();
+  runtimeURLToMetadataCache.reset();
+}
diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js
new file mode 100644
index 0000000000000..b2e0f21425844
--- /dev/null
+++ b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js
@@ -0,0 +1,13 @@
+/**
+ * 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.
+ */
+
+import * as parseSourceAndMetadataModule from './parseSourceAndMetadata';
+
+export const parseSourceAndMetadata =
+  parseSourceAndMetadataModule.parseSourceAndMetadata;
+export const purgeCachedMetadata =
+  parseSourceAndMetadataModule.purgeCachedMetadata;
diff --git a/packages/react-devtools-shared/src/PerformanceMarks.js b/packages/react-devtools-shared/src/PerformanceMarks.js
index 0c94b9d4f2593..e702d3923f1df 100644
--- a/packages/react-devtools-shared/src/PerformanceMarks.js
+++ b/packages/react-devtools-shared/src/PerformanceMarks.js
@@ -9,13 +9,22 @@
 
 import {__PERFORMANCE_PROFILE__} from './constants';
 
+const supportsUserTiming =
+  typeof performance !== 'undefined' &&
+  typeof performance.mark === 'function' &&
+  typeof performance.clearMarks === 'function';
+
 function mark(markName: string): void {
-  performance.mark(markName + '-start');
+  if (supportsUserTiming) {
+    performance.mark(markName + '-start');
+  }
 }
 
 function measure(markName: string): void {
-  performance.mark(markName + '-end');
-  performance.measure(markName, markName + '-start', markName + '-end');
+  if (supportsUserTiming) {
+    performance.mark(markName + '-end');
+    performance.measure(markName, markName + '-start', markName + '-end');
+  }
 }
 
 export async function withAsyncPerformanceMark<TReturn>(
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js
index bcd0f940751b8..f9f295c7eb43d 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js
@@ -2,17 +2,23 @@
 
 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<Context>({
+  fetchFileWithCaching: null,
   loadHookNames: null,
+  prefetchSourceFiles: null,
   purgeCachedMetadata: null,
 });
 HookNamesContext.displayName = 'HookNamesContext';
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 e7b15b3f3dc5e..967196a3b9052 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
@@ -16,6 +16,7 @@ import {
   useContext,
   useEffect,
   useMemo,
+  useRef,
   useState,
 } from 'react';
 import {TreeStateContext} from './TreeContext';
@@ -64,7 +65,9 @@ export type Props = {|
 export function InspectedElementContextController({children}: Props) {
   const {selectedElementID} = useContext(TreeStateContext);
   const {
+    fetchFileWithCaching,
     loadHookNames: loadHookNamesFunction,
+    prefetchSourceFiles,
     purgeCachedMetadata,
   } = useContext(HookNamesContext);
   const bridge = useContext(BridgeContext);
@@ -126,6 +129,7 @@ export function InspectedElementContextController({children}: Props) {
           element,
           inspectedElement.hooks,
           loadHookNamesFunction,
+          fetchFileWithCaching,
         );
       }
     }
@@ -151,6 +155,21 @@ export function InspectedElementContextController({children}: Props) {
     [setState, state],
   );
 
+  const inspectedElementRef = useRef(null);
+  useEffect(() => {
+    if (
+      inspectedElement !== null &&
+      inspectedElement.hooks !== null &&
+      inspectedElementRef.current !== inspectedElement
+    ) {
+      inspectedElementRef.current = inspectedElement;
+
+      if (typeof prefetchSourceFiles === 'function') {
+        prefetchSourceFiles(inspectedElement.hooks, fetchFileWithCaching);
+      }
+    }
+  }, [inspectedElement, prefetchSourceFiles]);
+
   useEffect(() => {
     if (typeof purgeCachedMetadata === 'function') {
       // When Fast Refresh updates a component, any cached AST metadata may be invalid.
diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js
index 17a88d44cb5b9..a1280d14bd224 100644
--- a/packages/react-devtools-shared/src/devtools/views/DevTools.js
+++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js
@@ -51,6 +51,11 @@ import type {Thenable} from '../cache';
 export type BrowserTheme = 'dark' | 'light';
 export type TabID = 'components' | 'profiler';
 
+export type FetchFileWithCaching = (url: string) => Promise<string>;
+export type PrefetchSourceFiles = (
+  hooksTree: HooksTree,
+  fetchFileWithCaching: FetchFileWithCaching | null,
+) => void;
 export type ViewElementSource = (
   id: number,
   inspectedElement: InspectedElement,
@@ -101,7 +106,9 @@ export type Props = {|
   // Loads and parses source maps for function components
   // 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,
 |};
 
@@ -127,9 +134,11 @@ export default function DevTools({
   componentsPortalContainer,
   defaultTab = 'components',
   enabledInspectedElementContextMenu = false,
+  fetchFileWithCaching,
   loadHookNames,
   overrideTab,
   profilerPortalContainer,
+  prefetchSourceFiles,
   purgeCachedHookNamesMetadata,
   showTabBar = false,
   store,
@@ -192,10 +201,17 @@ export default function DevTools({
 
   const hookNamesContext = useMemo(
     () => ({
+      fetchFileWithCaching: fetchFileWithCaching || null,
       loadHookNames: loadHookNames || null,
+      prefetchSourceFiles: prefetchSourceFiles || null,
       purgeCachedMetadata: purgeCachedHookNamesMetadata || null,
     }),
-    [loadHookNames, purgeCachedHookNamesMetadata],
+    [
+      fetchFileWithCaching,
+      loadHookNames,
+      prefetchSourceFiles,
+      purgeCachedHookNamesMetadata,
+    ],
   );
 
   const devToolsRef = useRef<HTMLElement | null>(null);
diff --git a/packages/react-devtools-shared/src/hookNamesCache.js b/packages/react-devtools-shared/src/hookNamesCache.js
index 070dafeb1d8eb..6edbc0e30a12e 100644
--- a/packages/react-devtools-shared/src/hookNamesCache.js
+++ b/packages/react-devtools-shared/src/hookNamesCache.js
@@ -17,6 +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';
 
 const TIMEOUT = 30000;
 
@@ -53,6 +54,11 @@ function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
   }
 }
 
+type LoadHookNamesFunction = (
+  hookLog: HooksTree,
+  fetchFileWithCaching: FetchFileWithCaching | null,
+) => Thenable<HookNames>;
+
 // This is intentionally a module-level Map, rather than a React-managed one.
 // Otherwise, refreshing the inspected element cache would also clear this cache.
 // TODO Rethink this if the React API constraints change.
@@ -67,7 +73,8 @@ export function hasAlreadyLoadedHookNames(element: Element): boolean {
 export function loadHookNames(
   element: Element,
   hooksTree: HooksTree,
-  loadHookNamesFunction: (hookLog: HooksTree) => Thenable<HookNames>,
+  loadHookNamesFunction: LoadHookNamesFunction,
+  fetchFileWithCaching: FetchFileWithCaching | null,
 ): HookNames | null {
   let record = map.get(element);
 
@@ -103,7 +110,7 @@ export function loadHookNames(
 
     let didTimeout = false;
 
-    loadHookNamesFunction(hooksTree).then(
+    loadHookNamesFunction(hooksTree, fetchFileWithCaching).then(
       function onSuccess(hookNames) {
         if (didTimeout) {
           return;