Skip to content

Commit 569763d

Browse files
authored
perf(header): optimize performance and fix memory leaks (#154)
1 parent b031784 commit 569763d

File tree

1 file changed

+147
-74
lines changed
  • packages/scan/src/core/web/components/widget

1 file changed

+147
-74
lines changed

packages/scan/src/core/web/components/widget/header.tsx

Lines changed: 147 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,60 @@ import { Store } from "../../../..";
44
import { getCompositeComponentFromElement, getOverrideMethods } from "../../inspect-element/utils";
55
import { replayComponent } from "../../inspect-element/view-state";
66
import { Icon } from "../icon";
7-
import { debounce } from "../../utils/helpers";
7+
import type { States } from "../../inspect-element/inspect-state-machine";
8+
9+
const THROTTLE_MS = 32;
10+
const REPLAY_DELAY_MS = 300;
811

912
const BtnReplay = () => {
10-
const refBtnReplay = useRef<HTMLButtonElement>(null);
11-
const refIsReplaying = useRef(false);
12-
const timeoutRef = useRef<number>();
13+
const replayState = useRef({
14+
isReplaying: false,
15+
timeoutId: undefined as TTimer,
16+
toggleDisabled(disabled: boolean, button: HTMLElement) {
17+
button.classList[disabled ? 'add' : 'remove']('disabled');
18+
}
19+
});
1320

1421
const { overrideProps, overrideHookState } = getOverrideMethods();
15-
const canEdit = !overrideProps;
22+
const canEdit = !!overrideProps;
1623

17-
const handleReplay = useCallback((e: MouseEvent) => {
24+
const handleReplay = (e: MouseEvent) => {
1825
e.stopPropagation();
26+
const state = replayState.current;
27+
const button = e.currentTarget as HTMLElement;
1928

2029
const inspectState = Store.inspectState.value;
21-
if (refIsReplaying.current || inspectState.kind !== 'focused') return;
30+
if (state.isReplaying || inspectState.kind !== 'focused') return;
2231

2332
const { parentCompositeFiber } = getCompositeComponentFromElement(inspectState.focusedDomElement);
2433
if (!parentCompositeFiber || !overrideProps || !overrideHookState) return;
2534

26-
refIsReplaying.current = true;
27-
refBtnReplay.current?.classList.add('disabled');
28-
29-
void replayComponent(parentCompositeFiber).finally(() => {
30-
if (timeoutRef.current) {
31-
clearTimeout(timeoutRef.current);
32-
}
33-
34-
const cleanup = () => {
35-
refIsReplaying.current = false;
36-
refBtnReplay.current?.classList.remove('disabled');
37-
};
38-
39-
if (document.hidden) {
40-
cleanup();
41-
} else {
42-
timeoutRef.current = window.setTimeout(cleanup, 300);
43-
}
44-
});
45-
}, []);
46-
47-
useEffect(() => {
48-
return () => {
49-
if (timeoutRef.current) {
50-
clearTimeout(timeoutRef.current);
51-
}
52-
};
53-
}, []);
35+
state.isReplaying = true;
36+
state.toggleDisabled(true, button);
37+
38+
void replayComponent(parentCompositeFiber)
39+
.catch(() => void 0)
40+
.finally(() => {
41+
if (state.timeoutId) {
42+
clearTimeout(state.timeoutId);
43+
state.timeoutId = undefined;
44+
}
45+
if (document.hidden) {
46+
state.isReplaying = false;
47+
state.toggleDisabled(false, button);
48+
} else {
49+
state.timeoutId = setTimeout(() => {
50+
state.isReplaying = false;
51+
state.toggleDisabled(false, button);
52+
}, REPLAY_DELAY_MS);
53+
}
54+
});
55+
};
5456

5557
if (!canEdit) return null;
5658

5759
return (
5860
<button
59-
ref={refBtnReplay}
6061
title="Replay component"
6162
className="react-scan-replay-button"
6263
onClick={handleReplay}
@@ -67,85 +68,157 @@ const BtnReplay = () => {
6768
};
6869

6970
export const Header = () => {
70-
const refComponentName = useRef<HTMLSpanElement>(null);
71-
const refMetrics = useRef<HTMLSpanElement>(null);
72-
73-
const handleClose = useCallback(() => {
71+
const headerState = useRef({
72+
refs: {
73+
componentName: null as HTMLSpanElement | null,
74+
metrics: null as HTMLSpanElement | null,
75+
},
76+
timers: {
77+
update: undefined as TTimer,
78+
raf: 0 as number
79+
},
80+
values: {
81+
componentName: '',
82+
metrics: '',
83+
lastUpdate: 0,
84+
pendingUpdate: false,
85+
fiber: null as any
86+
},
87+
mounted: true
88+
});
89+
90+
const handleClose = () => {
7491
if (Store.inspectState.value.propContainer) {
7592
Store.inspectState.value = {
7693
kind: 'inspect-off',
7794
propContainer: Store.inspectState.value.propContainer,
7895
};
7996
}
80-
}, []);
97+
};
98+
99+
const formatMetrics = (count: number, time?: number) =>
100+
`${count} renders${time ? ` • ${time.toFixed(2)}ms` : ''}`;
81101

82102
const updateHeaderContent = useCallback(() => {
103+
const state = headerState.current;
104+
if (!state.mounted) return;
105+
83106
const inspectState = Store.inspectState.value;
84107
if (inspectState.kind !== 'focused') return;
85108

86109
const focusedDomElement = inspectState.focusedDomElement;
87-
if (!refComponentName.current || !refMetrics.current || !focusedDomElement) return;
110+
if (!focusedDomElement || !state.refs.componentName || !state.refs.metrics) return;
88111

89112
const { parentCompositeFiber } = getCompositeComponentFromElement(focusedDomElement);
90113
if (!parentCompositeFiber) return;
91114

92-
const currentComponentName = refComponentName.current.dataset.text;
93-
const currentMetrics = refMetrics.current.dataset.text;
94-
95115
const fiber = parentCompositeFiber.alternate ?? parentCompositeFiber;
96-
const reportData = Store.reportData.get(fiber);
97-
const componentName = getDisplayName(parentCompositeFiber.type) ?? 'Unknown';
98116

99-
if (componentName === currentComponentName && reportData?.count === 0) {
100-
return;
117+
if (fiber !== state.values.fiber) {
118+
state.values.fiber = fiber;
119+
state.values.componentName = getDisplayName(parentCompositeFiber.type) ?? 'Unknown';
101120
}
102121

103-
const renderCount = reportData?.count ?? 0;
104-
const renderTime = reportData?.time ?? 0;
105-
const newMetrics = renderCount > 0
106-
? `${renderCount} renders${renderTime > 0 ? ` • ${renderTime.toFixed(2)}ms` : ''}`
107-
: '';
108-
109-
if (componentName !== currentComponentName || newMetrics !== currentMetrics) {
110-
requestAnimationFrame(() => {
111-
if (!refComponentName.current || !refMetrics.current) return;
112-
refComponentName.current.dataset.text = componentName;
113-
refMetrics.current.dataset.text = newMetrics;
122+
const reportData = Store.reportData.get(fiber);
123+
124+
if (!reportData?.count) return;
125+
const newMetrics = formatMetrics(reportData.count, reportData.time);
126+
if (newMetrics === state.values.metrics && !state.values.pendingUpdate) return;
127+
128+
if (!state.values.pendingUpdate) {
129+
state.values.pendingUpdate = true;
130+
cancelAnimationFrame(state.timers.raf);
131+
state.timers.raf = requestAnimationFrame(() => {
132+
if (state.refs.componentName && state.refs.metrics) {
133+
state.refs.componentName.dataset.text = state.values.componentName;
134+
state.refs.metrics.dataset.text = newMetrics;
135+
state.values.metrics = newMetrics;
136+
state.values.lastUpdate = Date.now();
137+
state.values.pendingUpdate = false;
138+
state.timers.raf = 0;
139+
}
114140
});
115141
}
116142
}, []);
117143

144+
const scheduleUpdate = useCallback(() => {
145+
const state = headerState.current;
146+
const now = Date.now();
147+
const timeSinceLastUpdate = now - state.values.lastUpdate;
148+
149+
if (timeSinceLastUpdate < THROTTLE_MS) return;
150+
151+
if (state.timers.update) {
152+
clearTimeout(state.timers.update);
153+
state.timers.update = undefined;
154+
}
155+
156+
state.timers.update = setTimeout(updateHeaderContent, THROTTLE_MS);
157+
}, [updateHeaderContent]);
158+
159+
const handleInspectStateChange = useCallback((newState: States) => {
160+
const state = headerState.current;
161+
if (!state.mounted) return;
162+
163+
if (state.timers.update) {
164+
clearTimeout(state.timers.update);
165+
state.timers.update = undefined;
166+
}
167+
if (state.timers.raf) {
168+
cancelAnimationFrame(state.timers.raf);
169+
state.timers.raf = 0;
170+
}
171+
state.values.pendingUpdate = false;
172+
173+
if (newState.kind === 'focused') {
174+
updateHeaderContent();
175+
}
176+
}, [updateHeaderContent]);
177+
118178
useEffect(() => {
119-
const unsubscribeLastReportTime = Store.lastReportTime.subscribe(updateHeaderContent);
120-
const unsubscribeStoreInspectState = Store.inspectState.subscribe(state => {
121-
if (state.kind === 'focused') {
122-
updateHeaderContent();
123-
}
124-
});
179+
const state = headerState.current;
180+
181+
Store.lastReportTime.subscribe(scheduleUpdate);
182+
Store.inspectState.subscribe(handleInspectStateChange);
125183

126184
return () => {
127-
unsubscribeLastReportTime();
128-
unsubscribeStoreInspectState();
185+
state.mounted = false;
186+
if (state.timers.update) {
187+
clearTimeout(state.timers.update);
188+
state.timers.update = undefined;
189+
}
190+
if (state.timers.raf) {
191+
cancelAnimationFrame(state.timers.raf);
192+
state.timers.raf = 0;
193+
}
194+
state.values.pendingUpdate = false;
129195
};
130-
}, [updateHeaderContent]);
196+
}, [scheduleUpdate, handleInspectStateChange]);
197+
198+
const setComponentNameRef = useCallback((node: HTMLSpanElement | null) => {
199+
headerState.current.refs.componentName = node;
200+
}, []);
201+
202+
const setMetricsRef = useCallback((node: HTMLSpanElement | null) => {
203+
headerState.current.refs.metrics = node;
204+
}, []);
131205

132206
return (
133207
<div className="react-scan-header">
134208
<span
135-
ref={refComponentName}
209+
ref={setComponentNameRef}
136210
className="with-data-text"
137211
/>
138212
<span
139-
ref={refMetrics}
213+
ref={setMetricsRef}
140214
className="with-data-text mr-auto !overflow-visible text-xs text-[#888]"
141215
/>
142216

143-
{/* fixme: render replay button causes large amounts of cpu usage when idle */}
144-
{/* <BtnReplay /> */}
217+
<BtnReplay />
145218

146219
<button
147220
title="Close"
148-
class="react-scan-close-button ml-auto"
221+
class="react-scan-close-button"
149222
onClick={handleClose}
150223
>
151224
<Icon name="icon-close" />

0 commit comments

Comments
 (0)