fix(appearance): support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) (#646)

## What & why

Fixes **PHP-LARAVEL-41** — `TypeError: addEventListener is not a
function` on Safari 13.1.2 / macOS 10.13.

### Root cause
`MediaQueryList.addEventListener` does not exist on Safari <14 (and
other legacy browsers) — the property is `undefined`; those engines only
expose the deprecated `addListener`. `initializeTheme()` runs at app
boot (`resources/js/app.tsx`) and called:

```ts
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
```

The `?.` guarded a **null** query but not the **missing method**, so the
call threw during module evaluation, aborting the rest of boot
(`initializeChartColorScheme`, `createInertiaApp`) → the whole React app
failed to mount (white screen) for those users. `use-mobile.tsx` had the
same latent crash for any component using `useIsMobile`.

## Changes (one concern per commit)
1. **fix(appearance): support MediaQueryList change events on legacy
Safari** — add `resources/js/lib/media-query.ts`
(`addMediaQueryListener` / `removeMediaQueryListener`) that fall back to
`addListener` / `removeListener` when the modern API is absent, and
route the `prefers-color-scheme` (`use-appearance`) and
mobile-breakpoint (`use-mobile`) subscriptions through them. Colocated
Vitest test covers both the modern and legacy-fallback branches for add
and remove.
2. **harden: no-op when neither API is present** — guard the deprecated
fallback with a `typeof … === 'function'` check so a hypothetical
`MediaQueryList` exposing neither API silently no-ops instead of
re-introducing the crash. (Reviewer recommendation.)

Modern-browser behavior is byte-for-byte unchanged (the modern path is
taken whenever `addEventListener` exists); the only added cost is a
`typeof` check.

## Verification
- `vendor/bin/pint`-equivalent for JS: Prettier + ESLint clean on all
changed files.
- Vitest: `resources/js/lib/media-query.test.ts` covers modern + legacy
+ neither-API branches. (CI runs `bun run test`.)
- Reviewed by two independent agents (architecture + product): both
concluded **ship it**, no must-fix. Confirmed no other
`matchMedia(...).addEventListener('change')` call sites were missed
(`account-balance-card.tsx` and `welcome.tsx` only read `.matches`),
correct add/remove pairing preserved, and no modern-browser regression.

## Follow-up (out of scope, flagged for awareness)
- The Vite build sets no explicit `build.target`/browserslist, so the
default baseline is Safari 14. This PR stops the reported **boot**
crash, but lazily-loaded page chunks could still `SyntaxError` on
navigation for Safari 13 users. Full Safari-13 support would need an
explicit lower build target — a broader change with its own risk, left
as a separate follow-up.
This commit is contained in:
Víctor Falcón 2026-07-05 19:03:35 +02:00 committed by GitHub
parent 05d4bae0af
commit 465eb38dae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 128 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,66 @@
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);
});
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);
});
});

View File

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