From 8df1990617ceeea04caec9653cd11af9f78f0e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 5 Jul 2026 18:46:16 +0200 Subject: [PATCH] fix(appearance): support MediaQueryList change events on legacy Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- resources/js/hooks/use-appearance.tsx | 20 +++++++--- resources/js/hooks/use-mobile.tsx | 8 +++- resources/js/lib/media-query.test.ts | 54 +++++++++++++++++++++++++++ resources/js/lib/media-query.ts | 35 +++++++++++++++++ 4 files changed, 109 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..9d3f5762 --- /dev/null +++ b/resources/js/lib/media-query.test.ts @@ -0,0 +1,54 @@ +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); + }); +}); + +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..9a383532 --- /dev/null +++ b/resources/js/lib/media-query.ts @@ -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); +}