Compare commits

...

9 Commits

Author SHA1 Message Date
Gaurav Tewari
b02e17a0d4 chore: minor comments 2026-05-27 14:04:05 +05:30
Gaurav Tewari
637e194d1c chore: more changes 2026-05-27 13:12:31 +05:30
Gaurav Tewari
9eb8d6466c Merge remote-tracking branch 'origin' into feat/add-ripple-effect 2026-05-27 12:38:31 +05:30
Gaurav Tewari
0aec7a9fe8 fix: tests 2026-05-25 23:22:50 +05:30
Gaurav Tewari
bebe4ebb89 fix: update theme 2026-05-25 23:17:50 +05:30
Gaurav Tewari
2209708caa chore: more fixes 2026-05-25 22:53:36 +05:30
Gaurav Tewari
1f813ce21f refactor: update effect 2026-05-25 22:38:01 +05:30
Gaurav Tewari
f082821ac2 feat: add light and dark mode in command pallet 2026-05-25 13:30:44 +05:30
Gaurav Tewari
3c5bd81421 chore: add ripple effect 2026-05-23 22:15:00 +05:30
13 changed files with 632 additions and 48 deletions

View File

@@ -14,7 +14,9 @@ export function ShiftHoldOverlayController({
const actions = createShortcutActions({
navigate: noop,
handleThemeChange: noop,
// Overlay is read-only — actions never fire — so we only need a no-op
// that satisfies the typed signature.
handleThemeChange: (): void => undefined,
});
const visible = useShiftHoldOverlay({

View File

@@ -116,17 +116,35 @@ jest.mock('hooks/useNotifications', (): unknown => ({
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
//
// We spread jest.requireActual so additions to hooks/useDarkMode (new hooks,
// re-exports, contexts) keep working in this test without needing the mock to
// re-enumerate every export. We only override the hooks the palette actually
// calls.
jest.mock('hooks/useDarkMode', (): unknown => {
const actual = jest.requireActual('hooks/useDarkMode');
const useThemeModeMock = (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
toggleTheme: jest.Mock;
theme: string;
autoSwitch: boolean;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
toggleTheme: jest.fn(),
theme: 'dark',
}),
}));
autoSwitch: false,
});
return {
...actual,
__esModule: true,
default: useThemeModeMock,
useThemeMode: useThemeModeMock,
useIsDarkMode: (): boolean => true,
useSystemTheme: (): 'dark' | 'light' => 'dark',
};
});
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());

View File

@@ -20,6 +20,8 @@ import {
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { ThemeMode } from 'hooks/useDarkMode/constant';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -47,7 +49,8 @@ export function CmdKPalette({
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { theme } = useThemeMode();
const selectTheme = useThemeSelection();
const location = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const startNewConversation = useAIAssistantStore(
@@ -80,14 +83,12 @@ export function CmdKPalette({
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
function handleThemeChange(value: ThemeMode): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
// Close the palette inside the same flushSync batch as the theme change
// so its dismissal is part of the captured "new" frame of the wipe;
// otherwise the dialog would be visible in both snapshots and flicker.
selectTheme(value, () => setOpen(false));
}
function onClickHandler(key: string): void {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ROUTES from 'constants/routes';
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
import {
BarChart,
BellDot,
@@ -34,7 +34,7 @@ export type CmdAction = {
type ActionDeps = {
navigate: (path: string) => void;
handleThemeChange: (mode: string) => void;
handleThemeChange: (mode: ThemeMode) => void;
/**
* Provided only when the AI Assistant feature is available for the current
* tenant. When present, the palette surfaces an "Open AI Assistant" entry

View File

@@ -12,6 +12,8 @@ import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const setThemeFunction = jest.fn();
const setAutoSwitchFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
@@ -56,9 +58,11 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
theme: 'dark',
setTheme: setThemeFunction,
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: jest.fn(),
setAutoSwitch: setAutoSwitchFunction,
})),
}));
@@ -134,7 +138,8 @@ describe('MySettings Flows', () => {
fireEvent.click(lightOption);
await waitFor(() => {
expect(toggleThemeFunction).toHaveBeenCalled();
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
expect(setThemeFunction).toHaveBeenCalledWith('light');
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
@@ -142,6 +147,10 @@ describe('MySettings Flows', () => {
},
);
});
// Lock in that the new selectTheme flow does not call toggleTheme;
// otherwise we'd double-flip on top of the explicit setTheme call.
expect(toggleThemeFunction).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,8 @@ import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import { useNotifications } from 'hooks/useNotifications';
import { MonitorCog, Moon, Sun } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -23,9 +25,10 @@ import './MySettings.styles.scss';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const { autoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const selectTheme = useThemeSelection();
const [sideNavPinned, setSideNavPinned] = useState(false);
@@ -58,7 +61,7 @@ function MySettings(): JSX.Element {
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
</div>
),
value: 'dark',
value: THEME_MODE.DARK,
},
{
label: (
@@ -69,7 +72,7 @@ function MySettings(): JSX.Element {
</Tag>
</div>
),
value: 'light',
value: THEME_MODE.LIGHT,
},
{
label: (
@@ -77,46 +80,31 @@ function MySettings(): JSX.Element {
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
</div>
),
value: 'auto',
value: THEME_MODE.SYSTEM,
},
];
const [theme, setTheme] = useState(() => {
if (autoSwitch) {
return 'auto';
return THEME_MODE.SYSTEM;
}
return isDarkMode ? 'dark' : 'light';
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
});
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
toggleTheme();
}
}
const handleThemeChange = (event: RadioChangeEvent): void => {
// Radio options below are all THEME_MODE values, so antd's `any`-typed
// target.value is safe to narrow here.
const value = event.target.value as ThemeMode;
logEvent('Account Settings: Theme Changed', { theme: value });
selectTheme(value);
};
useEffect(() => {
if (autoSwitch) {
setTheme('auto');
setTheme(THEME_MODE.SYSTEM);
return;
}
if (isDarkMode) {
setTheme('dark');
} else {
setTheme('light');
}
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
}, [autoSwitch, isDarkMode]);
const handleSideNavPinnedChange = (checked: boolean): void => {

View File

@@ -0,0 +1,164 @@
import { act, renderHook } from '@testing-library/react';
import { useThemeSelection } from '../useThemeSelection';
const setThemeMock = jest.fn();
const setAutoSwitchMock = jest.fn();
let themeValue = 'dark';
let systemThemeValue: 'dark' | 'light' = 'light';
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
default: (): {
theme: string;
setTheme: jest.Mock;
setAutoSwitch: jest.Mock;
toggleTheme: jest.Mock;
autoSwitch: boolean;
} => ({
theme: themeValue,
setTheme: setThemeMock,
setAutoSwitch: setAutoSwitchMock,
toggleTheme: jest.fn(),
autoSwitch: false,
}),
useThemeMode: (): {
theme: string;
setTheme: jest.Mock;
setAutoSwitch: jest.Mock;
toggleTheme: jest.Mock;
autoSwitch: boolean;
} => ({
theme: themeValue,
setTheme: setThemeMock,
setAutoSwitch: setAutoSwitchMock,
toggleTheme: jest.fn(),
autoSwitch: false,
}),
useSystemTheme: (): 'dark' | 'light' => systemThemeValue,
useIsDarkMode: (): boolean => themeValue === 'dark',
}));
const canAnimateMock = jest.fn();
const runTransitionMock = jest.fn();
jest.mock('utils/themeTransition', () => ({
__esModule: true,
canAnimateThemeTransition: (): boolean => canAnimateMock(),
runThemeTransition: (cb: () => void): void => runTransitionMock(cb),
}));
describe('useThemeSelection', () => {
beforeEach(() => {
jest.clearAllMocks();
themeValue = 'dark';
systemThemeValue = 'light';
canAnimateMock.mockReturnValue(false);
// Default behaviour: invoke the applyChange callback synchronously.
runTransitionMock.mockImplementation((cb: () => void) => cb());
});
it('applies an explicit light theme without auto-switch', () => {
themeValue = 'dark';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('applies an explicit dark theme without auto-switch', () => {
themeValue = 'light';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('dark'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('SYSTEM with a light system preference resolves to setTheme("light") + auto on', () => {
themeValue = 'dark';
systemThemeValue = 'light';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
// Explicit resolved value is what keeps the wipe snapshot accurate;
// see the comment in useThemeSelection for the failure mode.
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('SYSTEM with a dark system preference resolves to setTheme("dark") + auto on', () => {
themeValue = 'light';
systemThemeValue = 'dark';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('invokes onApplied inside the same batch, after the state mutations', () => {
themeValue = 'dark';
const onApplied = jest.fn();
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light', onApplied));
expect(onApplied).toHaveBeenCalledTimes(1);
expect(setThemeMock.mock.invocationCallOrder[0]).toBeLessThan(
onApplied.mock.invocationCallOrder[0],
);
});
it('routes through runThemeTransition when the dark↔light state actually flips', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(runTransitionMock).toHaveBeenCalledTimes(1);
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('skips runThemeTransition when no dark↔light flip happens', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('dark'));
expect(runTransitionMock).not.toHaveBeenCalled();
// applyChange still ran inline.
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('skips runThemeTransition when SYSTEM resolves to the currently-rendered theme', () => {
themeValue = 'dark';
systemThemeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(runTransitionMock).not.toHaveBeenCalled();
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('skips runThemeTransition when capability check is false even if the theme flips', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(false);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(runTransitionMock).not.toHaveBeenCalled();
expect(setThemeMock).toHaveBeenCalledWith('light');
});
});

View File

@@ -2,4 +2,6 @@ export const THEME_MODE = {
LIGHT: 'light',
DARK: 'dark',
SYSTEM: 'auto',
};
} as const;
export type ThemeMode = typeof THEME_MODE[keyof typeof THEME_MODE];

View File

@@ -18,7 +18,13 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { THEME_MODE } from './constant';
export const ThemeContext = createContext({
export const ThemeContext = createContext<{
theme: string;
toggleTheme: () => void;
autoSwitch: boolean;
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
setTheme: Dispatch<SetStateAction<string>>;
}>({
theme: THEME_MODE.DARK,
toggleTheme: (): void => {},
autoSwitch: false,

View File

@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import {
canAnimateThemeTransition,
runThemeTransition,
} from 'utils/themeTransition';
import useThemeMode, { useSystemTheme } from './index';
import { THEME_MODE, ThemeMode } from './constant';
type SelectTheme = (value: ThemeMode, onApplied?: () => void) => void;
// Centralises the "apply a theme selection" flow used by MySettings and the
// command palette: figures out whether the visible (dark↔light) theme is
// actually flipping, applies the state change, and — when capable — wraps the
// change in a left→right view-transition wipe.
//
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
// same flushSync batch as the theme change (useful for, e.g., closing the
// command palette so its dismissal is part of the captured "new" snapshot).
export function useThemeSelection(): SelectTheme {
const { theme, setTheme, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
return useCallback<SelectTheme>(
(value, onApplied) => {
const currentIsDark = theme === THEME_MODE.DARK;
// When switching to SYSTEM, the visible theme flips iff the OS preference
// differs from what we're currently rendering. For explicit LIGHT/DARK,
// resolvedTargetIsDark is just (value === DARK).
const resolvedTargetIsDark =
value === THEME_MODE.SYSTEM
? systemTheme === THEME_MODE.DARK
: value === THEME_MODE.DARK;
const isSystem = value === THEME_MODE.SYSTEM;
// Always push the resolved LIGHT/DARK through setTheme synchronously so
// the View Transition snapshot reflects the new theme. If we relied on
// ThemeProvider's effect (setAutoSwitch → re-render → effect →
// setThemeState), the flip wouldn't be guaranteed to run inside this
// flushSync batch and the wipe would capture old → old, then snap.
const resolvedTheme = resolvedTargetIsDark
? THEME_MODE.DARK
: THEME_MODE.LIGHT;
// runThemeTransition needs a zero-arg callback, so this closure is
// unavoidable. It allocates once per selection — cheap enough that
// micro-optimising it would just obscure the flow.
const apply = (): void => {
setAutoSwitch(isSystem);
setTheme(resolvedTheme);
onApplied?.();
};
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
apply();
return;
}
runThemeTransition(apply);
},
[theme, systemTheme, setTheme, setAutoSwitch],
);
}
export default useThemeSelection;

View File

@@ -826,3 +826,22 @@ body.ai-assistant-panel-open {
:root {
--input-focus-outline-width: 0;
}
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
// these overrides don't leak into any unrelated view transitions added later.
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
// only visible effect, and stack the new snapshot above the old.
html.theme-wipe-active {
&::view-transition-old(root),
&::view-transition-new(root) {
animation: none;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 2;
}
}

View File

@@ -0,0 +1,193 @@
import {
canAnimateThemeTransition,
runThemeTransition,
THEME_WIPE_ACTIVE_CLASS,
} from '../themeTransition';
type StartVT = (cb: () => void) => {
ready: Promise<void>;
finished: Promise<void>;
};
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
const defaultImpl: StartVT = (cb) => {
cb();
return { ready: Promise.resolve(), finished: Promise.resolve() };
};
const fn = jest.fn(impl ?? defaultImpl);
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: fn,
});
return fn;
};
const removeStartViewTransition = (): void => {
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: undefined,
});
};
const setReducedMotion = (matches: boolean): void => {
(window.matchMedia as jest.Mock) = jest
.fn()
.mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
describe('canAnimateThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
});
it('returns false when document.startViewTransition is unavailable', () => {
removeStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns false when prefers-reduced-motion is reduce', () => {
installStartViewTransition();
setReducedMotion(true);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns true when API is supported and motion is allowed', () => {
installStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(true);
});
});
describe('runThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
});
it('falls back to running applyChange directly when API is missing', () => {
removeStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('invokes startViewTransition and runs applyChange inside its callback', () => {
const startVT = installStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(startVT).toHaveBeenCalledTimes(1);
expect(applyChange).toHaveBeenCalledTimes(1);
});
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
let resolveFinished: () => void = (): void => {};
installStartViewTransition((cb) => {
cb();
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveFinished = resolve;
}),
};
});
runThemeTransition(() => undefined);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
resolveFinished();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('keeps the wipe-active class through overlapping transitions', async () => {
let resolveA: () => void = (): void => {};
let resolveB: () => void = (): void => {};
let callIndex = 0;
installStartViewTransition((cb) => {
cb();
callIndex += 1;
if (callIndex === 1) {
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveA = resolve;
}),
};
}
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveB = resolve;
}),
};
});
runThemeTransition(() => undefined);
runThemeTransition(() => undefined);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
// First transition finishes — class must stay because B is still in flight.
resolveA();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
resolveB();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('falls back to applyChange and releases the class when startViewTransition throws before its callback runs', () => {
installStartViewTransition(() => {
throw new Error('boom');
});
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('does not double-invoke applyChange when startViewTransition throws after its callback runs', () => {
installStartViewTransition((cb) => {
cb();
throw new Error('post-cb');
});
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
});

View File

@@ -0,0 +1,115 @@
import { flushSync } from 'react-dom';
const WIPE_DURATION_MS = 400;
const WIPE_EASING = 'ease-out';
// Toggled on <html> for the duration of the wipe so the CSS overrides
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
// any future, unrelated view transitions in the app.
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
type ViewTransition = {
ready: Promise<void>;
finished: Promise<void>;
};
type DocumentWithVT = Document & {
startViewTransition?: (callback: () => void) => ViewTransition;
};
// Rapid theme switches cancel the in-flight transition and immediately start a
// new one; if we removed the class on the first transition's settled promise,
// we'd strip the CSS override mid-way through the next wipe and the user
// would briefly see the UA crossfade. Refcount so the class only comes off
// once every transition we started has settled.
let wipeActiveRefCount = 0;
const acquireWipeClass = (root: HTMLElement): void => {
wipeActiveRefCount += 1;
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
};
const releaseWipeClass = (root: HTMLElement): void => {
wipeActiveRefCount = Math.max(0, wipeActiveRefCount - 1);
if (wipeActiveRefCount === 0) {
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
}
};
// Identity of the transition we most recently started. Used to skip the
// .animate() call on a stale transition whose .ready resolved after a newer
// transition has already taken over the ::view-transition-new pseudo-element.
let currentTransition: ViewTransition | null = null;
export function canAnimateThemeTransition(): boolean {
const doc = document as DocumentWithVT;
if (typeof doc.startViewTransition !== 'function') {
return false;
}
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Runs `applyChange` inside a View Transition and wipes the new theme in from
// left to right via a polygon clip-path on ::view-transition-new(root).
// Callers should gate on canAnimateThemeTransition() first; this is a safe
// no-animation fallback otherwise.
export function runThemeTransition(applyChange: () => void): void {
const doc = document as DocumentWithVT;
if (!doc.startViewTransition) {
applyChange();
return;
}
const root = document.documentElement;
acquireWipeClass(root);
// Some Chromium versions throw if startViewTransition is called while
// another transition is in setup. Track whether the callback ran so we
// don't double-apply if the throw happens mid-callback.
let applied = false;
let transition: ViewTransition;
try {
transition = doc.startViewTransition(() => {
applied = true;
flushSync(applyChange);
});
} catch {
releaseWipeClass(root);
if (!applied) {
applyChange();
}
return;
}
currentTransition = transition;
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
transition.ready
.then(() => {
// If a newer transition has superseded this one between
// startViewTransition() and `ready` resolving, the browser has
// already cancelled our pseudo-element. Calling .animate() on it now
// would race with the newer transition's own animation.
if (currentTransition !== transition) {
return;
}
root.animate(
{ clipPath: [from, to] },
{
duration: WIPE_DURATION_MS,
easing: WIPE_EASING,
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => {
// Transition cancelled — applyChange has already run.
});
const cleanup = (): void => {
if (currentTransition === transition) {
currentTransition = null;
}
releaseWipeClass(root);
};
transition.finished.then(cleanup).catch(cleanup);
}