diff --git a/src/components/shared/chart/Canvas.tsx b/src/components/shared/chart/Canvas.tsx index c181d59158..444024b304 100644 --- a/src/components/shared/chart/Canvas.tsx +++ b/src/components/shared/chart/Canvas.tsx @@ -375,6 +375,18 @@ export class ChartCanvas extends React.Component< } } + override componentDidMount() { + window.addEventListener('profiler-theme-change', this._onThemeChange); + } + + override componentWillUnmount() { + window.removeEventListener('profiler-theme-change', this._onThemeChange); + } + + _onThemeChange = () => { + this._scheduleDraw(); + }; + override componentDidUpdate(prevProps: Props, prevState: State) { if (prevProps !== this.props) { if ( diff --git a/src/components/shared/thread/ActivityGraphCanvas.tsx b/src/components/shared/thread/ActivityGraphCanvas.tsx index 393812e217..d0d15cbb0e 100644 --- a/src/components/shared/thread/ActivityGraphCanvas.tsx +++ b/src/components/shared/thread/ActivityGraphCanvas.tsx @@ -105,8 +105,20 @@ export class ActivityGraphCanvas extends React.PureComponent { override componentDidMount() { this._renderCanvas(); + window.addEventListener('profiler-theme-change', this._onThemeChange); } + override componentWillUnmount() { + window.removeEventListener('profiler-theme-change', this._onThemeChange); + } + + _onThemeChange = () => { + // Invalidate the cached category draw styles, + // so they are recreated with the new theme colors. + this._categoryDrawStyles = null; + this._renderCanvas(); + }; + override componentDidUpdate() { this._renderCanvas(); } diff --git a/src/components/shared/thread/SampleGraph.tsx b/src/components/shared/thread/SampleGraph.tsx index 1ffd347fc9..2213224d83 100644 --- a/src/components/shared/thread/SampleGraph.tsx +++ b/src/components/shared/thread/SampleGraph.tsx @@ -119,8 +119,17 @@ class ThreadSampleGraphCanvas extends React.PureComponent { override componentDidMount() { this._renderCanvas(); + window.addEventListener('profiler-theme-change', this._onThemeChange); } + override componentWillUnmount() { + window.removeEventListener('profiler-theme-change', this._onThemeChange); + } + + _onThemeChange = () => { + this._renderCanvas(); + }; + override componentDidUpdate() { this._renderCanvas(); } diff --git a/src/components/timeline/Markers.tsx b/src/components/timeline/Markers.tsx index da95c8791e..5a9cd953f6 100644 --- a/src/components/timeline/Markers.tsx +++ b/src/components/timeline/Markers.tsx @@ -237,8 +237,17 @@ class TimelineMarkersCanvas extends React.PureComponent { override componentDidMount() { this._scheduleDraw(); + window.addEventListener('profiler-theme-change', this._onThemeChange); } + override componentWillUnmount() { + window.removeEventListener('profiler-theme-change', this._onThemeChange); + } + + _onThemeChange = () => { + this._scheduleDraw(); + }; + override componentDidUpdate() { this._scheduleDraw(); } diff --git a/src/test/components/FlameGraph.test.tsx b/src/test/components/FlameGraph.test.tsx index 88bb561d8f..3f3b28d2d6 100644 --- a/src/test/components/FlameGraph.test.tsx +++ b/src/test/components/FlameGraph.test.tsx @@ -66,6 +66,20 @@ describe('FlameGraph', function () { expect(drawCalls).toMatchSnapshot(); }); + it('redraws when the system theme changes', () => { + setupFlameGraph(); + // Flush the initial draw calls. + flushDrawLog(); + + // Simulate a theme change. + window.dispatchEvent(new CustomEvent('profiler-theme-change')); + + // drawCanvasAfterRaf={false} means the redraw is synchronous, so new draw + // calls should be available immediately without flushing rAF. + const drawCalls = flushDrawLog(); + expect(drawCalls.length).toBeGreaterThan(0); + }); + it('ignores invertCallstack and always displays non-inverted', () => { const { getState, dispatch } = setupFlameGraph(); expect(getInvertCallstack(getState())).toBe(false); diff --git a/src/test/components/SampleGraph.test.tsx b/src/test/components/SampleGraph.test.tsx index dd597212eb..a1ea0bfbeb 100644 --- a/src/test/components/SampleGraph.test.tsx +++ b/src/test/components/SampleGraph.test.tsx @@ -149,6 +149,21 @@ describe('SampleGraph', function () { }; } + it('redraws when the system theme changes', () => { + const { getContextDrawCalls } = setup(); + + // Flush the initial draw calls. + getContextDrawCalls(); + + // Simulate a theme change. + window.dispatchEvent(new CustomEvent('profiler-theme-change')); + + const drawCalls = getContextDrawCalls(); + expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe( + true + ); + }); + it('matches the component snapshot', () => { const { sampleGraphCanvas } = setup(); expect(sampleGraphCanvas).toMatchSnapshot(); diff --git a/src/test/components/ThreadActivityGraph.test.tsx b/src/test/components/ThreadActivityGraph.test.tsx index 48ceb4a170..2c16bd742f 100644 --- a/src/test/components/ThreadActivityGraph.test.tsx +++ b/src/test/components/ThreadActivityGraph.test.tsx @@ -170,6 +170,22 @@ describe('ThreadActivityGraph', function () { ); }); + it('redraws when the system theme changes', () => { + const { getContextDrawCalls } = setup(); + + // Flush out any existing draw calls. + getContextDrawCalls(); + expect(getContextDrawCalls().length).toEqual(0); + + // Simulate a theme change. + window.dispatchEvent(new CustomEvent('profiler-theme-change')); + + const drawCalls = getContextDrawCalls(); + expect(drawCalls.some(([operation]) => operation === 'beginPath')).toBe( + true + ); + }); + it('matches the 2d canvas draw snapshot with CPU values', () => { const profile = getSamplesProfile(); profile.meta.interval = 1; diff --git a/src/test/components/TimelineMarkers.test.tsx b/src/test/components/TimelineMarkers.test.tsx index 463dd63c07..3c1f84f89c 100644 --- a/src/test/components/TimelineMarkers.test.tsx +++ b/src/test/components/TimelineMarkers.test.tsx @@ -176,6 +176,25 @@ describe('TimelineMarkers', function () { beforeEach(addRootOverlayElement); afterEach(removeRootOverlayElement); + it('redraws when the system theme changes', () => { + const { flushRafCalls } = setupWithMarkers( + { rangeStart: 0, rangeEnd: 10 }, + [['DOMEvent', 0, 10]] + ); + + // Flush the initial draw calls. + flushRafCalls(); + flushDrawLog(); + + // Simulate a theme change. + window.dispatchEvent(new CustomEvent('profiler-theme-change')); + + // _scheduleDraw() uses RAF, so flush it before checking. + flushRafCalls(); + const drawCalls = flushDrawLog(); + expect(drawCalls.length).toBeGreaterThan(0); + }); + it('renders correctly overview markers', () => { window.devicePixelRatio = 1; diff --git a/src/test/unit/dark-mode.test.ts b/src/test/unit/dark-mode.test.ts index 7fb618aa54..fa6e695067 100644 --- a/src/test/unit/dark-mode.test.ts +++ b/src/test/unit/dark-mode.test.ts @@ -46,6 +46,50 @@ describe('isDarkMode', function () { }); }); +describe('profiler-theme-change event', function () { + it('is dispatched when the theme changes', function () { + resetForTest(); + // Initialize in light mode. + isDarkMode(); + + const listener = jest.fn(); + window.addEventListener('profiler-theme-change', listener); + + // Switch to dark via a storage event. + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => 'dark'); + window.dispatchEvent(new StorageEvent('storage', { key: 'theme' })); + + expect(listener).toHaveBeenCalledTimes(1); + window.removeEventListener('profiler-theme-change', listener); + }); + + it('is not dispatched during initialization', function () { + resetForTest(); + + const listener = jest.fn(); + window.addEventListener('profiler-theme-change', listener); + + isDarkMode(); // triggers setup + + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener('profiler-theme-change', listener); + }); + + it('is not dispatched when the theme stays the same', function () { + resetForTest(); + isDarkMode(); // initialize as light + + const listener = jest.fn(); + window.addEventListener('profiler-theme-change', listener); + + // Storage event fires but the resolved theme is still light. + window.dispatchEvent(new StorageEvent('storage', { key: 'theme' })); + + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener('profiler-theme-change', listener); + }); +}); + describe('initTheme', function () { it('sets the document element class', function () { resetForTest(); diff --git a/src/utils/dark-mode.ts b/src/utils/dark-mode.ts index 240bca12ea..8d0e00a62a 100644 --- a/src/utils/dark-mode.ts +++ b/src/utils/dark-mode.ts @@ -40,6 +40,7 @@ function _applyTheme(): void { shouldBeDark = getSystemTheme() === 'dark'; } + const changed = _isDarkModeSetup && _isDarkMode !== shouldBeDark; _isDarkMode = shouldBeDark; if (shouldBeDark) { @@ -47,6 +48,10 @@ function _applyTheme(): void { } else { document.documentElement.classList.remove('dark-mode'); } + + if (changed) { + window.dispatchEvent(new CustomEvent('profiler-theme-change')); + } } export function setThemePreference(pref: ThemePreference): void {