Clear named hooks Suspense and AST cache after a Fast Refresh (#21891)
Brian Vaughn committed
Jul 15, 2021 at 23:39 UTC
e26cb8f86db0936e14682c39e2d26373fa6f433d
14 files changed
+109
-24
packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js
+5
-1
@@ -33,7 +33,7 @@ describe('parseHookNames', () => {
33
34
inspectHooks = require('react-debug-tools/src/ReactDebugHooks')
35
.inspectHooks;
36
- parseHookNames = require('../parseHookNames').default;
36
+ parseHookNames = require('../parseHookNames').parseHookNames;
37
38
// Jest (jest-runner?) configures Errors to automatically account for source maps.
39
// This changes behavior between our tests and the browser.
@@ -158,6 +158,10 @@ describe('parseHookNames', () => {
158
]);
159
});
160
161
+ // TODO Test that cache purge works
162
+
163
+ // TODO Test that cached metadata is purged when Fast Refresh scheduled
164
+
165
describe('inline, external and bundle source maps', () => {
166
it('should work for simple components', async () => {
167
async function test(path, name = 'Component') {
packages/react-devtools-extensions/src/main.js
+3
-2
@@ -12,7 +12,7 @@ import {
12
getSavedComponentFilters,
13
getShowInlineWarningsAndErrors,
14
} from 'react-devtools-shared/src/utils';
15
-import parseHookNames from './parseHookNames';
15
+import {parseHookNames, purgeCachedMetadata} from './parseHookNames';
16
import {
17
localStorageGetItem,
18
localStorageRemoveItem,
@@ -215,9 +215,10 @@ function createPanelIfReactLoaded() {
215
browserTheme: getBrowserTheme(),
216
componentsPortalContainer,
217
enabledInspectedElementContextMenu: true,
218
- loadHookNamesFunction: parseHookNames,
218
+ loadHookNames: parseHookNames,
219
overrideTab,
220
profilerPortalContainer,
221
+ purgeCachedHookNamesMetadata: purgeCachedMetadata,
222
showTabBar: false,
223
store,
224
warnIfUnsupportedVersionDetected: true,
packages/react-devtools-extensions/src/parseHookNames.js
+6
-1
@@ -102,7 +102,7 @@ const originalURLToMetadataCache: LRUCache<
102
},
103
});
104
105
-export default async function parseHookNames(
105
+export async function parseHookNames(
106
hooksTree: HooksTree,
107
): Thenable<HookNames | null> {
108
if (!enableHookNameParsing) {
@@ -623,3 +623,8 @@ function updateLruCache(
623
});
624
return Promise.resolve();
625
}
626
+
627
+export function purgeCachedMetadata(): void {
628
+ originalURLToMetadataCache.reset();
629
+ runtimeURLToMetadataCache.reset();
630
+}
packages/react-devtools-shared/src/backend/agent.js
+8
@@ -690,6 +690,14 @@ export default class Agent extends EventEmitter<{|
690
this.emit('traceUpdates', nodes);
691
};
692
693
+ onFastRefreshScheduled = () => {
694
+ if (__DEBUG__) {
695
+ debug('onFastRefreshScheduled');
696
+ }
697
+
698
+ this._bridge.send('fastRefreshScheduled');
699
+ };
700
+
701
onHookOperations = (operations: Array<number>) => {
702
if (__DEBUG__) {
703
debug(
packages/react-devtools-shared/src/backend/index.js
+1
@@ -48,6 +48,7 @@ export function initBackend(
48
agent.onUnsupportedRenderer(id);
49
}),
50
51
+ hook.sub('fastRefreshScheduled', agent.onFastRefreshScheduled),
52
hook.sub('operations', agent.onHookOperations),
53
hook.sub('traceUpdates', agent.onTraceUpdates),
54
packages/react-devtools-shared/src/backend/renderer.js
+17
@@ -568,6 +568,7 @@ export function attach(
568
overrideProps,
569
overridePropsDeletePath,
570
overridePropsRenamePath,
571
+ scheduleRefresh,
572
setErrorHandler,
573
setSuspenseHandler,
574
scheduleUpdate,
@@ -579,6 +580,22 @@ export function attach(
580
typeof setSuspenseHandler === 'function' &&
581
typeof scheduleUpdate === 'function';
582
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
+
599
// Tracks Fibers with recently changed number of error/warning messages.
600
// These collections store the Fiber rather than the ID,
601
// in order to avoid generating an ID for Fibers that never get mounted
packages/react-devtools-shared/src/backend/types.js
+2
@@ -144,6 +144,8 @@ export type ReactRenderer = {
144
Mount?: any,
145
// Only injected by React v17.0.3+ in DEV mode
146
setErrorHandler?: ?(shouldError: (fiber: Object) => ?boolean) => void,
147
+ // Intentionally opaque type to avoid coupling DevTools to different Fast Refresh versions.
148
+ scheduleRefresh?: Function,
149
...
150
};
151
packages/react-devtools-shared/src/bridge.js
+1
@@ -169,6 +169,7 @@ type UpdateConsolePatchSettingsParams = {|
169
export type BackendEvents = {|
170
bridgeProtocol: [BridgeProtocol],
171
extensionBackendInitialized: [],
172
+ fastRefreshScheduled: [],
173
inspectedElement: [InspectedElementPayload],
174
isBackendStorageAPISupported: [boolean],
175
isSynchronousXHRSupported: [boolean],
packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js
new
+20
@@ -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
@@ -26,10 +26,11 @@ import {
26
inspectElement,
27
} from 'react-devtools-shared/src/inspectedElementCache';
28
import {
29
+ clearHookNamesCache,
30
hasAlreadyLoadedHookNames,
31
loadHookNames,
32
} 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';
34
import {SettingsContext} from '../Settings/SettingsContext';
35
36
import type {HookNames} from 'react-devtools-shared/src/types';
@@ -63,7 +64,10 @@ export type Props = {|
64
65
export function InspectedElementContextController({children}: Props) {
66
const {selectedElementID} = useContext(TreeStateContext);
66
- const loadHookNamesFunction = useContext(LoadHookNamesFunctionContext);
67
+ const {
68
+ loadHookNames: loadHookNamesFunction,
69
+ purgeCachedMetadata,
70
+ } = useContext(HookNamesContext);
71
const bridge = useContext(BridgeContext);
72
const store = useContext(StoreContext);
73
const {parseHookNames: parseHookNamesByDefault} = useContext(SettingsContext);
@@ -150,6 +154,24 @@ export function InspectedElementContextController({children}: Props) {
154
[setState, state],
155
);
156
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
+
175
// Reset path now that we've asked the backend to hydrate it.
176
// The backend is stateful, so we don't need to remember this path the next time we inspect.
177
useEffect(() => {
packages/react-devtools-shared/src/devtools/views/Components/LoadHookNamesFunctionContext.js
deleted
-11
@@ -1,11 +0,0 @@
1
-// @flow
2
-
3
-import {createContext} from 'react';
4
-import type {LoadHookNamesFunction} from '../DevTools';
5
-
6
-export type Context = LoadHookNamesFunction | null;
7
-
8
-const LoadHookNamesFunctionContext = createContext<Context>(null);
9
-LoadHookNamesFunctionContext.displayName = 'LoadHookNamesFunctionContext';
10
-
11
-export default LoadHookNamesFunctionContext;
packages/react-devtools-shared/src/devtools/views/DevTools.js
+16
-6
@@ -22,7 +22,7 @@ import TabBar from './TabBar';
22
import {SettingsContextController} from './Settings/SettingsContext';
23
import {TreeContextController} from './Components/TreeContext';
24
import ViewElementSourceContext from './Components/ViewElementSourceContext';
25
-import LoadHookNamesFunctionContext from './Components/LoadHookNamesFunctionContext';
25
+import HookNamesContext from './Components/HookNamesContext';
26
import {ProfilerContextController} from './Profiler/ProfilerContext';
27
import {ModalDialogContextController} from './ModalDialog';
28
import ReactLogo from './ReactLogo';
@@ -51,6 +51,7 @@ export type ViewElementSource = (
51
export type LoadHookNamesFunction = (
52
hooksTree: HooksTree,
53
) => Thenable<HookNames>;
54
+export type PurgeCachedHookNamesMetadata = () => void;
55
export type ViewAttributeSource = (
56
id: number,
57
path: Array<string | number>,
@@ -87,7 +88,8 @@ export type Props = {|
88
// Loads and parses source maps for function components
89
// and extracts hook "names" based on the variables the hook return values get assigned to.
90
// Not every DevTools build can load source maps, so this property is optional.
90
- loadHookNamesFunction?: ?LoadHookNamesFunction,
91
+ loadHookNames?: ?LoadHookNamesFunction,
92
+ purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata,
93
|};
94
95
const componentsTab = {
@@ -112,9 +114,10 @@ export default function DevTools({
114
componentsPortalContainer,
115
defaultTab = 'components',
116
enabledInspectedElementContextMenu = false,
115
- loadHookNamesFunction,
117
+ loadHookNames,
118
overrideTab,
119
profilerPortalContainer,
120
+ purgeCachedHookNamesMetadata,
121
showTabBar = false,
122
store,
123
warnIfLegacyBackendDetected = false,
@@ -149,6 +152,14 @@ export default function DevTools({
152
[enabledInspectedElementContextMenu, viewAttributeSourceFunction],
153
);
154
155
+ const hookNamesContext = useMemo(
156
+ () => ({
157
+ loadHookNames: loadHookNames || null,
158
+ purgeCachedMetadata: purgeCachedHookNamesMetadata || null,
159
+ }),
160
+ [loadHookNames, purgeCachedHookNamesMetadata],
161
+ );
162
+
163
const devToolsRef = useRef<HTMLElement | null>(null);
164
165
useEffect(() => {
@@ -204,8 +215,7 @@ export default function DevTools({
215
componentsPortalContainer={componentsPortalContainer}
216
profilerPortalContainer={profilerPortalContainer}>
217
<ViewElementSourceContext.Provider value={viewElementSource}>
207
- <LoadHookNamesFunctionContext.Provider
208
- value={loadHookNamesFunction || null}>
218
+ <HookNamesContext.Provider value={hookNamesContext}>
219
<TreeContextController>
220
<ProfilerContextController>
221
<div className={styles.DevTools} ref={devToolsRef}>
@@ -240,7 +250,7 @@ export default function DevTools({
250
</div>
251
</ProfilerContextController>
252
</TreeContextController>
243
- </LoadHookNamesFunctionContext.Provider>
253
+ </HookNamesContext.Provider>
254
</ViewElementSourceContext.Provider>
255
</SettingsContextController>
256
<UnsupportedBridgeProtocolDialog />
packages/react-devtools-shared/src/hookNamesCache.js
+5
-1
@@ -58,7 +58,7 @@ function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
58
// Otherwise, refreshing the inspected element cache would also clear this cache.
59
// TODO Rethink this if the React API constraints change.
60
// 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();
62
63
export function hasAlreadyLoadedHookNames(element: Element): boolean {
64
const record = map.get(element);
@@ -181,3 +181,7 @@ export function getHookSourceLocationKey({
181
}
182
return `${fileName}:${lineNumber}:${columnNumber}`;
183
}
184
+
185
+export function clearHookNamesCache(): void {
186
+ map = new WeakMap();
187
+}
packages/react-devtools-shared/src/types.js
+1
@@ -87,5 +87,6 @@ export type HookNames = Map<HookSourceLocationKey, HookName>;
87
export type LRUCache<K, V> = {|
88
get: (key: K) => V,
89
has: (key: K) => boolean,
90
+ reset: () => void,
91
set: (key: K, value: V) => void,
92
|};