Skip to content

Commit 98dd13d

Browse files
authored
fix: migrate to CSS-based Primer theme provider (#3394)
* fix: migrate to CSS-based Primer theme provider * test: keep focused theme provider coverage in unit tests
1 parent e348056 commit 98dd13d

5 files changed

Lines changed: 127 additions & 8 deletions

File tree

‎src/renderer/App.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useEffect } from 'react';
22
import { Navigate, Route, HashRouter as Router, Routes, useLocation } from 'react-router-dom';
33

4-
import { BaseStyles, ThemeProvider } from '@primer/react';
4+
import { BaseStyles } from '@primer/react';
5+
import { ThemeProvider } from '@primer/react/next';
56

67
import { QueryClientProvider } from '@tanstack/react-query';
78

‎src/renderer/__helpers__/visual-utils.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { render } from '@testing-library/react';
22
import type { ReactElement } from 'react';
33
import { type InitialEntry, MemoryRouter } from 'react-router-dom';
44

5-
import { BaseStyles, ThemeProvider } from '@primer/react';
5+
import { BaseStyles } from '@primer/react';
6+
import { ThemeProvider } from '@primer/react/next';
67

78
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
89

‎src/renderer/hooks/useAppearance.test.ts‎

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { renderHook, waitFor } from '@testing-library/react';
1+
import { act, cleanup, renderHook, screen, waitFor } from '@testing-library/react';
2+
import { createElement, type PropsWithChildren } from 'react';
3+
4+
import { BaseStyles } from '@primer/react';
5+
import { ThemeProvider, useTheme } from '@primer/react/next';
26

37
import { useSettingsStore } from '../stores';
48

@@ -13,9 +17,9 @@ const primerTheme = vi.hoisted(() => ({
1317
setNightScheme: vi.fn(),
1418
}));
1519

16-
vi.mock('@primer/react', async (importOriginal) => ({
17-
...(await importOriginal<typeof import('@primer/react')>()),
18-
useTheme: () => primerTheme,
20+
vi.mock('@primer/react/next', async (importOriginal) => ({
21+
...(await importOriginal<typeof import('@primer/react/next')>()),
22+
useTheme: vi.fn(() => primerTheme),
1923
}));
2024

2125
function mockPrefersContrast(matches: boolean) {
@@ -30,6 +34,10 @@ function mockPrefersContrast(matches: boolean) {
3034
}
3135

3236
describe('renderer/hooks/useAppearance.ts', () => {
37+
beforeEach(() => {
38+
vi.mocked(useTheme).mockImplementation(() => primerTheme);
39+
});
40+
3341
afterEach(() => {
3442
document.documentElement.removeAttribute('data-theme');
3543
document.documentElement.removeAttribute('data-glass-material');
@@ -211,4 +219,113 @@ describe('renderer/hooks/useAppearance.ts', () => {
211219

212220
expect(document.documentElement.classList.contains('gitify-colored-icons')).toBe(false);
213221
});
222+
223+
describe('with ThemeProvider', () => {
224+
let darkMedia: EventTarget & { matches: boolean };
225+
226+
beforeEach(async () => {
227+
const actual =
228+
await vi.importActual<typeof import('@primer/react/next')>('@primer/react/next');
229+
vi.mocked(useTheme).mockImplementation(actual.useTheme);
230+
231+
darkMedia = Object.assign(new EventTarget(), { matches: false });
232+
const otherMedia = Object.assign(new EventTarget(), { matches: false });
233+
vi.spyOn(window, 'matchMedia').mockImplementation(
234+
(query) =>
235+
(query === '(prefers-color-scheme: dark)' ? darkMedia : otherMedia) as MediaQueryList,
236+
);
237+
});
238+
239+
afterEach(() => {
240+
cleanup();
241+
vi.restoreAllMocks();
242+
vi.mocked(useTheme).mockImplementation(() => primerTheme);
243+
});
244+
245+
function renderWithTheme(theme: Theme) {
246+
useSettingsStore.setState({ theme });
247+
248+
return renderHook(
249+
() => {
250+
useAppearance();
251+
return useTheme();
252+
},
253+
{
254+
wrapper: ({ children }: PropsWithChildren) =>
255+
createElement(
256+
ThemeProvider,
257+
null,
258+
createElement(
259+
BaseStyles,
260+
null,
261+
createElement('div', { 'data-testid': 'theme-content' }, children),
262+
),
263+
),
264+
},
265+
);
266+
}
267+
268+
function getThemeWrapper() {
269+
const wrapper = screen.getByTestId('theme-content').closest('[data-color-mode]');
270+
expect(wrapper).toBeInTheDocument();
271+
return wrapper!;
272+
}
273+
274+
it('applies appearance to the real provider and preserves its descendant wrapper', () => {
275+
const { result } = renderWithTheme(Theme.LIGHT);
276+
277+
expect(result.current.colorMode).toBe('day');
278+
expect(result.current.colorScheme).toBe('light');
279+
const wrapper = getThemeWrapper();
280+
expect(wrapper).not.toBe(document.documentElement);
281+
expect(wrapper).toHaveAttribute('data-color-mode', 'light');
282+
expect(wrapper).toHaveAttribute('data-light-theme', 'light');
283+
expect(wrapper).toHaveAttribute('data-dark-theme', 'light');
284+
expect(wrapper.querySelector('[data-component="BaseStyles"]')).toContainElement(
285+
screen.getByTestId('theme-content'),
286+
);
287+
});
288+
289+
it('updates the real provider when settings change from light to dark', async () => {
290+
const { result } = renderWithTheme(Theme.LIGHT);
291+
292+
await act(async () => {
293+
useSettingsStore.setState({ theme: Theme.DARK });
294+
});
295+
296+
expect(result.current.colorMode).toBe('night');
297+
expect(result.current.colorScheme).toBe('dark');
298+
expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark');
299+
expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('dark');
300+
});
301+
302+
it('follows OS changes in System mode without changing the saved preference', async () => {
303+
const { result } = renderWithTheme(Theme.SYSTEM);
304+
expect(result.current.resolvedColorMode).toBe('day');
305+
306+
await act(async () => {
307+
darkMedia.matches = true;
308+
darkMedia.dispatchEvent(new Event('change'));
309+
});
310+
311+
expect(result.current.colorMode).toBe('auto');
312+
expect(result.current.resolvedColorMode).toBe('night');
313+
expect(result.current.colorScheme).toBe('dark');
314+
expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto');
315+
expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light');
316+
expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark');
317+
expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM);
318+
expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system');
319+
expect(window.gitify.setNativeTheme).not.toHaveBeenCalledWith('dark');
320+
});
321+
322+
it('applies an accessibility scheme through the real provider', () => {
323+
const { result } = renderWithTheme(Theme.DARK_COLORBLIND);
324+
325+
expect(result.current.colorMode).toBe('night');
326+
expect(result.current.colorScheme).toBe('dark_colorblind');
327+
expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark');
328+
expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark_colorblind');
329+
});
330+
});
214331
});

‎src/renderer/hooks/useAppearance.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
22

3-
import { useTheme } from '@primer/react';
3+
import { useTheme } from '@primer/react/next';
44

55
import type { NativeThemeSource } from '../../shared/events';
66

‎src/renderer/utils/ui/theme.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ThemeProviderProps } from '@primer/react';
1+
import type { ThemeProviderProps } from '@primer/react/next';
22

33
import { DesignLanguage, Theme } from '../../types';
44

0 commit comments

Comments
 (0)