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:
parent
05d4bae0af
commit
465eb38dae
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue