From 465eb38dae1e856f0017f7f0e33bbad31b1d2c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sun, 5 Jul 2026 19:03:35 +0200 Subject: [PATCH] fix(appearance): support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- resources/js/hooks/use-appearance.tsx | 20 +++++--- resources/js/hooks/use-mobile.tsx | 8 +++- resources/js/lib/media-query.test.ts | 66 +++++++++++++++++++++++++++ resources/js/lib/media-query.ts | 42 +++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 resources/js/lib/media-query.test.ts create mode 100644 resources/js/lib/media-query.ts 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); + } +}