From cfcdf2dea9d6bd8ec65cb88c97487f00ac72db32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 21:56:19 +0000 Subject: [PATCH] fix(ui): align PWA display-mode listeners --- .../StandaloneBrowserControls.test.tsx | 97 +++++++++++++++++++ .../components/StandaloneBrowserControls.tsx | 14 +-- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/ui/src/components/StandaloneBrowserControls.test.tsx b/ui/src/components/StandaloneBrowserControls.test.tsx index 1eff16b7..0da94ca2 100644 --- a/ui/src/components/StandaloneBrowserControls.test.tsx +++ b/ui/src/components/StandaloneBrowserControls.test.tsx @@ -25,8 +25,47 @@ async function flushReact() { }); } +function installMatchMedia(initialMatches: Record = {}) { + type Listener = (event: MediaQueryListEvent) => void; + const queries = new Map }>(); + + function getQuery(query: string) { + let entry = queries.get(query); + if (!entry) { + entry = { matches: initialMatches[query] ?? false, listeners: new Set() }; + queries.set(query, entry); + } + + return { + get matches() { + return entry.matches; + }, + media: query, + addEventListener: (_type: "change", listener: Listener) => entry.listeners.add(listener), + removeEventListener: (_type: "change", listener: Listener) => entry.listeners.delete(listener), + addListener: (listener: Listener) => entry.listeners.add(listener), + removeListener: (listener: Listener) => entry.listeners.delete(listener), + } as MediaQueryList; + } + + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => getQuery(query), + }); + + return { + setMatches(query: string, matches: boolean) { + const entry = queries.get(query) ?? { matches: false, listeners: new Set() }; + entry.matches = matches; + queries.set(query, entry); + entry.listeners.forEach((listener) => listener({ matches, media: query } as MediaQueryListEvent)); + }, + }; +} + describe("StandaloneBrowserControls", () => { let container: HTMLDivElement; + const originalMatchMedia = window.matchMedia; beforeEach(() => { container = document.createElement("div"); @@ -36,6 +75,11 @@ describe("StandaloneBrowserControls", () => { afterEach(() => { delete (navigator as Navigator & { standalone?: boolean }).standalone; + if (originalMatchMedia) { + Object.defineProperty(window, "matchMedia", { configurable: true, value: originalMatchMedia }); + } else { + delete (window as Window & { matchMedia?: Window["matchMedia"] }).matchMedia; + } container.remove(); document.body.innerHTML = ""; }); @@ -62,4 +106,57 @@ describe("StandaloneBrowserControls", () => { root.unmount(); }); }); + + it("hides controls in normal mobile browser mode", async () => { + Object.defineProperty(navigator, "standalone", { configurable: true, value: false }); + installMatchMedia(); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + expect(container.querySelector('[aria-label="Refresh"]')).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it("responds to all chromeless display-mode media query changes", async () => { + Object.defineProperty(navigator, "standalone", { configurable: true, value: false }); + const media = installMatchMedia(); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + + expect(container.querySelector('[aria-label="Refresh"]')).toBeNull(); + + await act(() => { + media.setMatches("(display-mode: fullscreen)", true); + }); + await flushReact(); + + expect(container.querySelector('[aria-label="Refresh"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/StandaloneBrowserControls.tsx b/ui/src/components/StandaloneBrowserControls.tsx index 2b0f1e36..6b6fe783 100644 --- a/ui/src/components/StandaloneBrowserControls.tsx +++ b/ui/src/components/StandaloneBrowserControls.tsx @@ -3,7 +3,7 @@ import { ExternalLink, RefreshCw, Share2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useOptionalToastActions } from "../context/ToastContext"; -import { isChromelessDisplayMode } from "../lib/pwa-display-mode"; +import { CHROMELESS_DISPLAY_MODES, isChromelessDisplayMode } from "../lib/pwa-display-mode"; function ControlButton({ label, @@ -48,14 +48,14 @@ export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) { update(); if (typeof window.matchMedia !== "function") return; - const media = window.matchMedia("(display-mode: standalone)"); - if (typeof media.addEventListener === "function") { - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); + const mediaQueries = CHROMELESS_DISPLAY_MODES.map((mode) => window.matchMedia(`(display-mode: ${mode})`)); + if (mediaQueries.every((media) => typeof media.addEventListener === "function")) { + mediaQueries.forEach((media) => media.addEventListener("change", update)); + return () => mediaQueries.forEach((media) => media.removeEventListener("change", update)); } - media.addListener(update); - return () => media.removeListener(update); + mediaQueries.forEach((media) => media.addListener(update)); + return () => mediaQueries.forEach((media) => media.removeListener(update)); }, [mobile]); const refresh = useCallback(() => {