Skip to content

Commit e3917e8

Browse files
author
Brian Vaughn
committed
Move source file fetching into content script for better cache utilization
Network requests made from an extension do not reuse the page's Network cache. To work around that, this commit adds a helper function to the content script (which runs in the page's context) that the extension can communicate with via postMessage. The content script can then fetch cached files for the extension.
1 parent f3b406f commit e3917e8

File tree

9 files changed

+160
-28
lines changed

9 files changed

+160
-28
lines changed

packages/react-devtools-extensions/src/background.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,27 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
117117
});
118118

119119
chrome.runtime.onMessage.addListener((request, sender) => {
120-
if (sender.tab) {
120+
const tab = sender.tab;
121+
if (tab) {
122+
const id = tab.id;
121123
// This is sent from the hook content script.
122124
// It tells us a renderer has attached.
123125
if (request.hasDetectedReact) {
124126
// We use browserAction instead of pageAction because this lets us
125127
// display a custom default popup when React is *not* detected.
126128
// It is specified in the manifest.
127-
setIconAndPopup(request.reactBuildType, sender.tab.id);
129+
setIconAndPopup(request.reactBuildType, id);
130+
} else {
131+
switch (request.payload?.type) {
132+
case 'fetch-file-with-cache-complete':
133+
case 'fetch-file-with-cache-error':
134+
// Forward the result of fetch-in-page requests back to the extension.
135+
const devtools = ports[id]?.devtools;
136+
if (devtools) {
137+
devtools.postMessage(request);
138+
}
139+
break;
140+
}
128141
}
129142
}
130143
});

packages/react-devtools-extensions/src/injectGlobalHook.js

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,85 @@ let lastDetectionResult;
2323
// (it will be injected directly into the page).
2424
// So instead, the hook will use postMessage() to pass message to us here.
2525
// And when this happens, we'll send a message to the "background page".
26-
window.addEventListener('message', function(evt) {
27-
if (evt.source !== window || !evt.data) {
26+
window.addEventListener('message', function onMessage({data, source}) {
27+
if (source !== window || !data) {
2828
return;
2929
}
30-
if (evt.data.source === 'react-devtools-detector') {
31-
lastDetectionResult = {
32-
hasDetectedReact: true,
33-
reactBuildType: evt.data.reactBuildType,
34-
};
35-
chrome.runtime.sendMessage(lastDetectionResult);
36-
} else if (evt.data.source === 'react-devtools-inject-backend') {
37-
const script = document.createElement('script');
38-
script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
39-
document.documentElement.appendChild(script);
40-
script.parentNode.removeChild(script);
30+
31+
switch (data.source) {
32+
case 'react-devtools-detector':
33+
lastDetectionResult = {
34+
hasDetectedReact: true,
35+
reactBuildType: data.reactBuildType,
36+
};
37+
chrome.runtime.sendMessage(lastDetectionResult);
38+
break;
39+
case 'react-devtools-extension':
40+
if (data.payload?.type === 'fetch-file-with-cache') {
41+
const url = data.payload.url;
42+
43+
const reject = value => {
44+
chrome.runtime.sendMessage({
45+
source: 'react-devtools-content-script',
46+
payload: {
47+
type: 'fetch-file-with-cache-error',
48+
url,
49+
value,
50+
},
51+
});
52+
};
53+
54+
const resolve = value => {
55+
chrome.runtime.sendMessage({
56+
source: 'react-devtools-content-script',
57+
payload: {
58+
type: 'fetch-file-with-cache-complete',
59+
url,
60+
value,
61+
},
62+
});
63+
};
64+
65+
fetch(url, {cache: 'force-cache'}).then(
66+
response => {
67+
if (response.ok) {
68+
response
69+
.text()
70+
.then(text => resolve(text))
71+
.catch(error => reject(null));
72+
} else {
73+
reject(null);
74+
}
75+
},
76+
error => reject(null),
77+
);
78+
}
79+
break;
80+
case 'react-devtools-inject-backend':
81+
const script = document.createElement('script');
82+
script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
83+
document.documentElement.appendChild(script);
84+
script.parentNode.removeChild(script);
85+
break;
4186
}
4287
});
4388

4489
// NOTE: Firefox WebExtensions content scripts are still alive and not re-injected
4590
// while navigating the history to a document that has not been destroyed yet,
4691
// replay the last detection result if the content script is active and the
4792
// document has been hidden and shown again.
48-
window.addEventListener('pageshow', function(evt) {
49-
if (!lastDetectionResult || evt.target !== window.document) {
93+
window.addEventListener('pageshow', function({target}) {
94+
if (!lastDetectionResult || target !== window.document) {
5095
return;
5196
}
5297
chrome.runtime.sendMessage(lastDetectionResult);
5398
});
5499

55100
const detectReact = `
56-
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function(evt) {
101+
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) {
57102
window.postMessage({
58103
source: 'react-devtools-detector',
59-
reactBuildType: evt.reactBuildType,
104+
reactBuildType,
60105
}, '*');
61106
});
62107
`;

packages/react-devtools-extensions/src/main.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,41 @@ function createPanelIfReactLoaded() {
212212
}
213213
};
214214

215+
// Fetching files from the extension won't make use of the network cache
216+
// for resources that have already been loaded by the page.
217+
// This helper function allows the extension to request files to be fetched
218+
// by the content script (running in the page) to increase the likelihood of a cache hit.
219+
const fetchFileWithCaching = url => {
220+
return new Promise((resolve, reject) => {
221+
function onPortMessage({payload, source}) {
222+
if (source === 'react-devtools-content-script') {
223+
switch (payload?.type) {
224+
case 'fetch-file-with-cache-complete':
225+
chrome.runtime.onMessage.removeListener(onPortMessage);
226+
resolve(payload.value);
227+
break;
228+
case 'fetch-file-with-cache-error':
229+
chrome.runtime.onMessage.removeListener(onPortMessage);
230+
reject(payload.value);
231+
break;
232+
}
233+
}
234+
}
235+
236+
chrome.runtime.onMessage.addListener(onPortMessage);
237+
238+
chrome.devtools.inspectedWindow.eval(`
239+
window.postMessage({
240+
source: 'react-devtools-extension',
241+
payload: {
242+
type: 'fetch-file-with-cache',
243+
url: "${url}",
244+
},
245+
});
246+
`);
247+
});
248+
};
249+
215250
root = createRoot(document.createElement('div'));
216251

217252
render = (overrideTab = mostRecentOverrideTab) => {
@@ -224,6 +259,7 @@ function createPanelIfReactLoaded() {
224259
browserTheme: getBrowserTheme(),
225260
componentsPortalContainer,
226261
enabledInspectedElementContextMenu: true,
262+
fetchFileWithCaching,
227263
loadHookNames: parseHookNames,
228264
overrideTab,
229265
profilerPortalContainer,

packages/react-devtools-extensions/src/parseHookNames/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {HookSourceAndMetadata} from './loadSourceAndMetadata';
1111
import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
1212
import type {HookNames} from 'react-devtools-shared/src/types';
13+
import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
1314

1415
import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks';
1516
import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker';
@@ -32,13 +33,14 @@ export const purgeCachedMetadata = workerizedParseHookNames.purgeCachedMetadata;
3233

3334
export async function parseHookNames(
3435
hooksTree: HooksTree,
36+
fetchFileWithCaching: FetchFileWithCaching | null,
3537
): Promise<HookNames | null> {
3638
return withAsyncPerformanceMark('parseHookNames', async () => {
3739
// Runs on the main/UI thread so it can reuse Network cache:
3840
const [
3941
hooksList,
4042
locationKeyToHookSourceAndMetadata,
41-
] = await loadSourceAndMetadata(hooksTree);
43+
] = await loadSourceAndMetadata(hooksTree, fetchFileWithCaching);
4244

4345
// Runs in a Worker because it's CPU intensive:
4446
return parseSourceAndMetadata(

packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
// because parsing is CPU intensive and should not block the UI thread.
1212
//
1313
// Fetching source and source map files is intentionally done on the UI thread
14-
// so that loaded source files can always reuse the browser's Network cache.
15-
// Requests made from within a Worker might not reuse this cache (at least in Chrome 92).
14+
// so that loaded source files can reuse the browser's Network cache.
15+
// Requests made from within an extension do not share the page's Network cache,
16+
// but messages can be sent from the UI thread to the content script
17+
// which can make a request from the page's context (with caching).
1618
//
17-
// Some overhead may be incurred sharing the source data with the Worker,
19+
// Some overhead may be incurred sharing (serializing) the loaded data between contexts,
1820
// but less than fetching the file to begin with,
1921
// and in some cases we can avoid serializing the source code at all
2022
// (e.g. when we are in an environment that supports our custom metadata format).
@@ -55,6 +57,7 @@ import type {
5557
HooksTree,
5658
} from 'react-debug-tools/src/ReactDebugHooks';
5759
import type {MixedSourceMap} from 'react-devtools-extensions/src/SourceMapTypes';
60+
import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
5861

5962
// Prefer a cached albeit stale response to reduce download time.
6063
// We wouldn't want to load/parse a newer version of the source (even if one existed).
@@ -90,6 +93,7 @@ export type HooksList = Array<HooksNode>;
9093

9194
export default async function loadSourceAndMetadata(
9295
hooksTree: HooksTree,
96+
fetchFileWithCaching: FetchFileWithCaching | null,
9397
): Promise<[HooksList, LocationKeyToHookSourceAndMetadata]> {
9498
return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => {
9599
const hooksList: HooksList = [];
@@ -107,7 +111,7 @@ export default async function loadSourceAndMetadata(
107111
);
108112

109113
await withAsyncPerformanceMark('loadSourceFiles()', () =>
110-
loadSourceFiles(locationKeyToHookSourceAndMetadata),
114+
loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching),
111115
);
112116

113117
await withAsyncPerformanceMark('extractAndLoadSourceMapJSON()', () =>
@@ -409,16 +413,32 @@ function isUnnamedBuiltInHook(hook: HooksNode) {
409413

410414
function loadSourceFiles(
411415
locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
416+
fetchFileWithCaching: FetchFileWithCaching | null,
412417
): Promise<*> {
413418
// Deduplicate fetches, since there can be multiple location keys per file.
414419
const dedupedFetchPromises = new Map();
415420

416421
const setterPromises = [];
417422
locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => {
418423
const {runtimeSourceURL} = hookSourceAndMetadata;
424+
425+
let fetchFileFunction = fetchFile;
426+
if (fetchFileWithCaching != null) {
427+
// If a helper function has been injected to fetch with caching,
428+
// use it to fetch the (already loaded) source file.
429+
fetchFileFunction = url => {
430+
return withAsyncPerformanceMark(
431+
`fetchFileWithCaching("${url}")`,
432+
() => {
433+
return ((fetchFileWithCaching: any): FetchFileWithCaching)(url);
434+
},
435+
);
436+
};
437+
}
438+
419439
const fetchPromise =
420440
dedupedFetchPromises.get(runtimeSourceURL) ||
421-
fetchFile(runtimeSourceURL).then(runtimeSourceCode => {
441+
fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
422442
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
423443
// because then we need to parse the full source file as an AST.
424444
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {

packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
import {createContext} from 'react';
44
import type {
5+
FetchFileWithCaching,
56
LoadHookNamesFunction,
67
PurgeCachedHookNamesMetadata,
78
} from '../DevTools';
89

910
export type Context = {
11+
fetchFileWithCaching: FetchFileWithCaching | null,
1012
loadHookNames: LoadHookNamesFunction | null,
1113
purgeCachedMetadata: PurgeCachedHookNamesMetadata | null,
1214
};
1315

1416
const HookNamesContext = createContext<Context>({
17+
fetchFileWithCaching: null,
1518
loadHookNames: null,
1619
purgeCachedMetadata: null,
1720
});

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type Props = {|
6464
export function InspectedElementContextController({children}: Props) {
6565
const {selectedElementID} = useContext(TreeStateContext);
6666
const {
67+
fetchFileWithCaching,
6768
loadHookNames: loadHookNamesFunction,
6869
purgeCachedMetadata,
6970
} = useContext(HookNamesContext);
@@ -126,6 +127,7 @@ export function InspectedElementContextController({children}: Props) {
126127
element,
127128
inspectedElement.hooks,
128129
loadHookNamesFunction,
130+
fetchFileWithCaching,
129131
);
130132
}
131133
}

packages/react-devtools-shared/src/devtools/views/DevTools.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {Thenable} from '../cache';
5151
export type BrowserTheme = 'dark' | 'light';
5252
export type TabID = 'components' | 'profiler';
5353

54+
export type FetchFileWithCaching = (url: string) => Promise<string>;
5455
export type ViewElementSource = (
5556
id: number,
5657
inspectedElement: InspectedElement,
@@ -101,6 +102,7 @@ export type Props = {|
101102
// Loads and parses source maps for function components
102103
// and extracts hook "names" based on the variables the hook return values get assigned to.
103104
// Not every DevTools build can load source maps, so this property is optional.
105+
fetchFileWithCaching?: ?FetchFileWithCaching,
104106
loadHookNames?: ?LoadHookNamesFunction,
105107
purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata,
106108
|};
@@ -127,6 +129,7 @@ export default function DevTools({
127129
componentsPortalContainer,
128130
defaultTab = 'components',
129131
enabledInspectedElementContextMenu = false,
132+
fetchFileWithCaching,
130133
loadHookNames,
131134
overrideTab,
132135
profilerPortalContainer,
@@ -192,10 +195,11 @@ export default function DevTools({
192195

193196
const hookNamesContext = useMemo(
194197
() => ({
198+
fetchFileWithCaching: fetchFileWithCaching || null,
195199
loadHookNames: loadHookNames || null,
196200
purgeCachedMetadata: purgeCachedHookNamesMetadata || null,
197201
}),
198-
[loadHookNames, purgeCachedHookNamesMetadata],
202+
[fetchFileWithCaching, loadHookNames, purgeCachedHookNamesMetadata],
199203
);
200204

201205
const devToolsRef = useRef<HTMLElement | null>(null);

packages/react-devtools-shared/src/hookNamesCache.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
HookSourceLocationKey,
1818
} from 'react-devtools-shared/src/types';
1919
import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
20+
import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
2021

2122
const TIMEOUT = 30000;
2223

@@ -53,6 +54,11 @@ function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
5354
}
5455
}
5556

57+
type LoadHookNamesFunction = (
58+
hookLog: HooksTree,
59+
fetchFileWithCaching: FetchFileWithCaching | null,
60+
) => Thenable<HookNames>;
61+
5662
// This is intentionally a module-level Map, rather than a React-managed one.
5763
// Otherwise, refreshing the inspected element cache would also clear this cache.
5864
// TODO Rethink this if the React API constraints change.
@@ -67,7 +73,8 @@ export function hasAlreadyLoadedHookNames(element: Element): boolean {
6773
export function loadHookNames(
6874
element: Element,
6975
hooksTree: HooksTree,
70-
loadHookNamesFunction: (hookLog: HooksTree) => Thenable<HookNames>,
76+
loadHookNamesFunction: LoadHookNamesFunction,
77+
fetchFileWithCaching: FetchFileWithCaching | null,
7178
): HookNames | null {
7279
let record = map.get(element);
7380

@@ -103,7 +110,7 @@ export function loadHookNames(
103110

104111
let didTimeout = false;
105112

106-
loadHookNamesFunction(hooksTree).then(
113+
loadHookNamesFunction(hooksTree, fetchFileWithCaching).then(
107114
function onSuccess(hookNames) {
108115
if (didTimeout) {
109116
return;

0 commit comments

Comments
 (0)