diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index c4270d92..0c832d2e 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -1,3 +1,7 @@ +import { + addMediaQueryListener, + removeMediaQueryListener, +} from '@/lib/media-query'; import { useCallback, useEffect, useState } from 'react'; export type Appearance = 'light' | 'dark' | 'system'; @@ -53,7 +57,10 @@ export function initializeTheme() { applyTheme(savedAppearance); // Add the event listener for system theme changes... - mediaQuery()?.addEventListener('change', handleSystemThemeChange); + const mql = mediaQuery(); + if (mql) { + addMediaQueryListener(mql, handleSystemThemeChange); + } } export function useAppearance() { @@ -78,11 +85,12 @@ export function useAppearance() { updateAppearance(savedAppearance || 'system'); - return () => - mediaQuery()?.removeEventListener( - 'change', - handleSystemThemeChange, - ); + return () => { + const mql = mediaQuery(); + if (mql) { + removeMediaQueryListener(mql, handleSystemThemeChange); + } + }; }, [updateAppearance]); return { appearance, updateAppearance } as const; diff --git a/resources/js/hooks/use-mobile.tsx b/resources/js/hooks/use-mobile.tsx index 05890898..273463cc 100644 --- a/resources/js/hooks/use-mobile.tsx +++ b/resources/js/hooks/use-mobile.tsx @@ -1,3 +1,7 @@ +import { + addMediaQueryListener, + removeMediaQueryListener, +} from '@/lib/media-query'; import { useSyncExternalStore } from 'react'; const MOBILE_BREAKPOINT = 768; @@ -11,8 +15,8 @@ function subscribe(callback: () => void) { const mql = getMediaQueryList(); if (!mql) return () => {}; - mql.addEventListener('change', callback); - return () => mql.removeEventListener('change', callback); + addMediaQueryListener(mql, callback); + return () => removeMediaQueryListener(mql, callback); } function getSnapshot() { diff --git a/resources/js/lib/media-query.test.ts b/resources/js/lib/media-query.test.ts new file mode 100644 index 00000000..770b14b0 --- /dev/null +++ b/resources/js/lib/media-query.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { addMediaQueryListener, removeMediaQueryListener } from './media-query'; + +function fakeMql(overrides: Partial): MediaQueryList { + return { matches: false, media: '', ...overrides } as MediaQueryList; +} + +describe('addMediaQueryListener', () => { + it('uses the modern addEventListener when available', () => { + const addEventListener = vi.fn(); + const handler = () => {}; + + addMediaQueryListener(fakeMql({ addEventListener }), handler); + + expect(addEventListener).toHaveBeenCalledWith('change', handler); + }); + + it('falls back to the deprecated addListener on Safari <14', () => { + const addListener = vi.fn(); + const handler = () => {}; + + // addEventListener undefined mirrors old Safari, where the modern API is + // missing and the previous code threw at boot. + addMediaQueryListener( + fakeMql({ addEventListener: undefined, addListener }), + handler, + ); + + expect(addListener).toHaveBeenCalledWith(handler); + }); + + it('does not throw when neither API is present', () => { + expect(() => + addMediaQueryListener( + fakeMql({ + addEventListener: undefined, + addListener: undefined, + }), + () => {}, + ), + ).not.toThrow(); + }); +}); + +describe('removeMediaQueryListener', () => { + it('uses the modern removeEventListener when available', () => { + const removeEventListener = vi.fn(); + const handler = () => {}; + + removeMediaQueryListener(fakeMql({ removeEventListener }), handler); + + expect(removeEventListener).toHaveBeenCalledWith('change', handler); + }); + + it('falls back to the deprecated removeListener on Safari <14', () => { + const removeListener = vi.fn(); + const handler = () => {}; + + removeMediaQueryListener( + fakeMql({ removeEventListener: undefined, removeListener }), + handler, + ); + + expect(removeListener).toHaveBeenCalledWith(handler); + }); +}); diff --git a/resources/js/lib/media-query.ts b/resources/js/lib/media-query.ts new file mode 100644 index 00000000..93557b16 --- /dev/null +++ b/resources/js/lib/media-query.ts @@ -0,0 +1,42 @@ +/** + * Cross-browser MediaQueryList change subscription. + * + * Safari <14 and other legacy browsers do not implement + * MediaQueryList.addEventListener / removeEventListener — the property is + * undefined, so calling it throws "addEventListener is not a function". At app + * boot (initializeTheme) that aborts the whole mount and white-screens the app + * for those users. Such browsers only expose the deprecated addListener / + * removeListener, so fall back to them when the modern API is missing. + */ +export function addMediaQueryListener( + mql: MediaQueryList, + handler: () => void, +): void { + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + + return; + } + + // Deprecated fallback for Safari <14 / legacy browsers. Guarded so a + // hypothetical MediaQueryList exposing neither API no-ops instead of + // re-introducing the crash this helper exists to prevent. + if (typeof mql.addListener === 'function') { + mql.addListener(handler); + } +} + +export function removeMediaQueryListener( + mql: MediaQueryList, + handler: () => void, +): void { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', handler); + + return; + } + + if (typeof mql.removeListener === 'function') { + mql.removeListener(handler); + } +}