Skip to content

Commit f4e868a

Browse files
author
Brian Vaughn
committed
Clear named hooks Suspense and AST cache after a Fast Refresh
1 parent 682bbd0 commit f4e868a

File tree

14 files changed

+109
-24
lines changed

14 files changed

+109
-24
lines changed

packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('parseHookNames', () => {
3333

3434
inspectHooks = require('react-debug-tools/src/ReactDebugHooks')
3535
.inspectHooks;
36-
parseHookNames = require('../parseHookNames').default;
36+
parseHookNames = require('../parseHookNames').parseHookNames;
3737

3838
// Jest (jest-runner?) configures Errors to automatically account for source maps.
3939
// This changes behavior between our tests and the browser.
@@ -158,6 +158,10 @@ describe('parseHookNames', () => {
158158
]);
159159
});
160160

161+
// TODO Test that cache purge works
162+
163+
// TODO Test that cached metadata is purged when Fast Refresh scheduled
164+
161165
describe('inline, external and bundle source maps', () => {
162166
it('should work for simple components', async () => {
163167
async function test(path, name = 'Component') {

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
getSavedComponentFilters,
1313
getShowInlineWarningsAndErrors,
1414
} from 'react-devtools-shared/src/utils';
15-
import parseHookNames from './parseHookNames';
15+
import {parseHookNames, purgeCachedMetadata} from './parseHookNames';
1616
import {
1717
localStorageGetItem,
1818
localStorageRemoveItem,
@@ -215,9 +215,10 @@ function createPanelIfReactLoaded() {
215215
browserTheme: getBrowserTheme(),
216216
componentsPortalContainer,
217217
enabledInspectedElementContextMenu: true,
218-
loadHookNamesFunction: parseHookNames,
218+
loadHookNames: parseHookNames,
219219
overrideTab,
220220
profilerPortalContainer,
221+
purgeCachedHookNamesMetadata: purgeCachedMetadata,
221222
showTabBar: false,
222223
store,
223224
warnIfUnsupportedVersionDetected: true,

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const originalURLToMetadataCache: LRUCache<
102102
},
103103
});
104104

105-
export default async function parseHookNames(
105+
export async function parseHookNames(
106106
hooksTree: HooksTree,
107107
): Thenable<HookNames | null> {
108108
if (!enableHookNameParsing) {
@@ -623,3 +623,8 @@ function updateLruCache(
623623
});
624624
return Promise.resolve();
625625
}
626+
627+
export function purgeCachedMetadata(): void {
628+
originalURLToMetadataCache.reset();
629+
runtimeURLToMetadataCache.reset();
630+
}

packages/react-devtools-shared/src/backend/agent.js

+8
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,14 @@ export default class Agent extends EventEmitter<{|
690690
this.emit('traceUpdates', nodes);
691691
};
692692

693+
onFastRefreshScheduled = () => {
694+
if (__DEBUG__) {
695+
debug('onFastRefreshScheduled');
696+
}
697+
698+
this._bridge.send('fastRefreshScheduled');
699+
};
700+
693701
onHookOperations = (operations: Array<number>) => {
694702
if (__DEBUG__) {
695703
debug(

packages/react-devtools-shared/src/backend/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function initBackend(
4848
agent.onUnsupportedRenderer(id);
4949
}),
5050

51+
hook.sub('fastRefreshScheduled', agent.onFastRefreshScheduled),
5152
hook.sub('operations', agent.onHookOperations),
5253
hook.sub('traceUpdates', agent.onTraceUpdates),
5354

packages/react-devtools-shared/src/backend/renderer.js

+17
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,7 @@ export function attach(
568568
overrideProps,
569569
overridePropsDeletePath,
570570
overridePropsRenamePath,
571+
scheduleRefresh,
571572
setErrorHandler,
572573
setSuspenseHandler,
573574
scheduleUpdate,
@@ -579,6 +580,22 @@ export function attach(
579580
typeof setSuspenseHandler === 'function' &&
580581
typeof scheduleUpdate === 'function';
581582

583+
if (typeof scheduleRefresh === 'function') {
584+
// When Fast Refresh updates a component, the frontend may need to purge cached information.
585+
// For example, ASTs cached for the component (for named hooks) may no longer be valid.
586+
// Send a signal to the frontend to purge this cached information.
587+
// The "fastRefreshScheduled" dispatched is global (not Fiber or even Renderer specific).
588+
// This is less effecient since it means the front-end will need to purge the entire cache,
589+
// but this is probably an okay trade off in order to reduce coupling between the DevTools and Fast Refresh.
590+
renderer.scheduleRefresh = (...args) => {
591+
try {
592+
hook.emit('fastRefreshScheduled');
593+
} finally {
594+
return scheduleRefresh(...args);
595+
}
596+
};
597+
}
598+
582599
// Tracks Fibers with recently changed number of error/warning messages.
583600
// These collections store the Fiber rather than the ID,
584601
// in order to avoid generating an ID for Fibers that never get mounted

packages/react-devtools-shared/src/backend/types.js

+2
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export type ReactRenderer = {
144144
Mount?: any,
145145
// Only injected by React v17.0.3+ in DEV mode
146146
setErrorHandler?: ?(shouldError: (fiber: Object) => ?boolean) => void,
147+
// Intentionally opaque type to avoid coupling DevTools to different Fast Refresh versions.
148+
scheduleRefresh?: Function,
147149
...
148150
};
149151

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

+1
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ type UpdateConsolePatchSettingsParams = {|
169169
export type BackendEvents = {|
170170
bridgeProtocol: [BridgeProtocol],
171171
extensionBackendInitialized: [],
172+
fastRefreshScheduled: [],
172173
inspectedElement: [InspectedElementPayload],
173174
isBackendStorageAPISupported: [boolean],
174175
isSynchronousXHRSupported: [boolean],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// @flow
2+
3+
import {createContext} from 'react';
4+
import type {
5+
LoadHookNamesFunction,
6+
PurgeCachedHookNamesMetadata,
7+
} from '../DevTools';
8+
9+
export type Context = {
10+
loadHookNames: LoadHookNamesFunction | null,
11+
purgeCachedMetadata: PurgeCachedHookNamesMetadata | null,
12+
};
13+
14+
const HookNamesContext = createContext<Context>({
15+
loadHookNames: null,
16+
purgeCachedMetadata: null,
17+
});
18+
HookNamesContext.displayName = 'HookNamesContext';
19+
20+
export default HookNamesContext;

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

+24-2
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ import {
2626
inspectElement,
2727
} from 'react-devtools-shared/src/inspectedElementCache';
2828
import {
29+
clearHookNamesCache,
2930
hasAlreadyLoadedHookNames,
3031
loadHookNames,
3132
} from 'react-devtools-shared/src/hookNamesCache';
32-
import LoadHookNamesFunctionContext from 'react-devtools-shared/src/devtools/views/Components/LoadHookNamesFunctionContext';
33+
import HookNamesContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesContext';
3334
import {SettingsContext} from '../Settings/SettingsContext';
3435

3536
import type {HookNames} from 'react-devtools-shared/src/types';
@@ -63,7 +64,10 @@ export type Props = {|
6364

6465
export function InspectedElementContextController({children}: Props) {
6566
const {selectedElementID} = useContext(TreeStateContext);
66-
const loadHookNamesFunction = useContext(LoadHookNamesFunctionContext);
67+
const {
68+
loadHookNames: loadHookNamesFunction,
69+
purgeCachedMetadata,
70+
} = useContext(HookNamesContext);
6771
const bridge = useContext(BridgeContext);
6872
const store = useContext(StoreContext);
6973
const {parseHookNames: parseHookNamesByDefault} = useContext(SettingsContext);
@@ -150,6 +154,24 @@ export function InspectedElementContextController({children}: Props) {
150154
[setState, state],
151155
);
152156

157+
useEffect(() => {
158+
if (enableHookNameParsing) {
159+
if (typeof purgeCachedMetadata === 'function') {
160+
// When Fast Refresh updates a component, any cached AST metadata may be invalid.
161+
const fastRefreshScheduled = () => {
162+
startTransition(() => {
163+
clearHookNamesCache();
164+
purgeCachedMetadata();
165+
refresh();
166+
});
167+
};
168+
bridge.addListener('fastRefreshScheduled', fastRefreshScheduled);
169+
return () =>
170+
bridge.removeListener('fastRefreshScheduled', fastRefreshScheduled);
171+
}
172+
}
173+
}, [bridge]);
174+
153175
// Reset path now that we've asked the backend to hydrate it.
154176
// The backend is stateful, so we don't need to remember this path the next time we inspect.
155177
useEffect(() => {

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

-11
This file was deleted.

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

+16-6
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import TabBar from './TabBar';
2222
import {SettingsContextController} from './Settings/SettingsContext';
2323
import {TreeContextController} from './Components/TreeContext';
2424
import ViewElementSourceContext from './Components/ViewElementSourceContext';
25-
import LoadHookNamesFunctionContext from './Components/LoadHookNamesFunctionContext';
25+
import HookNamesContext from './Components/HookNamesContext';
2626
import {ProfilerContextController} from './Profiler/ProfilerContext';
2727
import {ModalDialogContextController} from './ModalDialog';
2828
import ReactLogo from './ReactLogo';
@@ -51,6 +51,7 @@ export type ViewElementSource = (
5151
export type LoadHookNamesFunction = (
5252
hooksTree: HooksTree,
5353
) => Thenable<HookNames>;
54+
export type PurgeCachedHookNamesMetadata = () => void;
5455
export type ViewAttributeSource = (
5556
id: number,
5657
path: Array<string | number>,
@@ -87,7 +88,8 @@ export type Props = {|
8788
// Loads and parses source maps for function components
8889
// and extracts hook "names" based on the variables the hook return values get assigned to.
8990
// Not every DevTools build can load source maps, so this property is optional.
90-
loadHookNamesFunction?: ?LoadHookNamesFunction,
91+
loadHookNames?: ?LoadHookNamesFunction,
92+
purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata,
9193
|};
9294

9395
const componentsTab = {
@@ -112,9 +114,10 @@ export default function DevTools({
112114
componentsPortalContainer,
113115
defaultTab = 'components',
114116
enabledInspectedElementContextMenu = false,
115-
loadHookNamesFunction,
117+
loadHookNames,
116118
overrideTab,
117119
profilerPortalContainer,
120+
purgeCachedHookNamesMetadata,
118121
showTabBar = false,
119122
store,
120123
warnIfLegacyBackendDetected = false,
@@ -149,6 +152,14 @@ export default function DevTools({
149152
[enabledInspectedElementContextMenu, viewAttributeSourceFunction],
150153
);
151154

155+
const hookNamesContext = useMemo(
156+
() => ({
157+
loadHookNames: loadHookNames || null,
158+
purgeCachedMetadata: purgeCachedHookNamesMetadata || null,
159+
}),
160+
[loadHookNames, purgeCachedHookNamesMetadata],
161+
);
162+
152163
const devToolsRef = useRef<HTMLElement | null>(null);
153164

154165
useEffect(() => {
@@ -204,8 +215,7 @@ export default function DevTools({
204215
componentsPortalContainer={componentsPortalContainer}
205216
profilerPortalContainer={profilerPortalContainer}>
206217
<ViewElementSourceContext.Provider value={viewElementSource}>
207-
<LoadHookNamesFunctionContext.Provider
208-
value={loadHookNamesFunction || null}>
218+
<HookNamesContext.Provider value={hookNamesContext}>
209219
<TreeContextController>
210220
<ProfilerContextController>
211221
<div className={styles.DevTools} ref={devToolsRef}>
@@ -240,7 +250,7 @@ export default function DevTools({
240250
</div>
241251
</ProfilerContextController>
242252
</TreeContextController>
243-
</LoadHookNamesFunctionContext.Provider>
253+
</HookNamesContext.Provider>
244254
</ViewElementSourceContext.Provider>
245255
</SettingsContextController>
246256
<UnsupportedBridgeProtocolDialog />

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
5858
// Otherwise, refreshing the inspected element cache would also clear this cache.
5959
// TODO Rethink this if the React API constraints change.
6060
// See https://github.com/reactwg/react-18/discussions/25#discussioncomment-980435
61-
const map: WeakMap<Element, Record<HookNames>> = new WeakMap();
61+
let map: WeakMap<Element, Record<HookNames>> = new WeakMap();
6262

6363
export function hasAlreadyLoadedHookNames(element: Element): boolean {
6464
const record = map.get(element);
@@ -181,3 +181,7 @@ export function getHookSourceLocationKey({
181181
}
182182
return `${fileName}:${lineNumber}:${columnNumber}`;
183183
}
184+
185+
export function clearHookNamesCache(): void {
186+
map = new WeakMap();
187+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,6 @@ export type HookNames = Map<HookSourceLocationKey, HookName>;
8787
export type LRUCache<K, V> = {|
8888
get: (key: K) => V,
8989
has: (key: K) => boolean,
90+
reset: () => void,
9091
set: (key: K, value: V) => void,
9192
|};

0 commit comments

Comments
 (0)