fix(appearance): support MediaQueryList change events on legacy Safari

Safari <14 (macOS 10.13/10.14) and other legacy browsers do not implement
MediaQueryList.addEventListener — the property is undefined. initializeTheme()
runs at app boot and called mediaQuery()?.addEventListener('change', ...); the
optional chaining guarded a null query but not the missing method, so it threw
'addEventListener is not a function' during module evaluation and white-screened
the whole app for those users (PHP-LARAVEL-41).

Add addMediaQueryListener/removeMediaQueryListener helpers that fall back to the
deprecated addListener/removeListener when the modern API is absent, and route
the prefers-color-scheme (use-appearance) and mobile-breakpoint (use-mobile)
subscriptions through them. Both call sites had the same latent crash.

Fixes PHP-LARAVEL-41
This commit is contained in:
Víctor Falcón 2026-07-05 18:46:16 +02:00
parent 05d4bae0af
commit 8df1990617
4 changed files with 109 additions and 8 deletions

View File

@ -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;

View File

@ -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() {

View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { addMediaQueryListener, removeMediaQueryListener } from './media-query';
function fakeMql(overrides: Partial<MediaQueryList>): 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);
});
});
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);
});
});

View File

@ -0,0 +1,35 @@
/**
* 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;
}
mql.addListener(handler);
}
export function removeMediaQueryListener(
mql: MediaQueryList,
handler: () => void,
): void {
if (typeof mql.removeEventListener === 'function') {
mql.removeEventListener('change', handler);
return;
}
mql.removeListener(handler);
}