@samitouri / QOS-React-2 / commits / e26cb8f86d

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 |};