fix(17): WR-05 IN-03 IN-01 resize-aware useIsPhone hook; OidcRedirect side-effect in effect

This commit is contained in:
Lucas Berger
2026-06-18 13:40:00 -04:00
parent c2ceebf130
commit a4a7438641
6 changed files with 73 additions and 25 deletions
+8 -6
View File
@@ -44,7 +44,7 @@
* - /api/me query shared so AppNav has user data on all routes. * - /api/me query shared so AppNav has user data on all routes.
*/ */
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CalendarShell } from './components/CalendarShell.js'; import { CalendarShell } from './components/CalendarShell.js';
@@ -59,12 +59,9 @@ import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'; import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
import { SetupBanner } from './components/SetupBanner.js'; import { SetupBanner } from './components/SetupBanner.js';
import { SettingsSheet } from './components/SettingsSheet.js'; import { SettingsSheet } from './components/SettingsSheet.js';
import { useIsPhone } from './hooks/useIsPhone.js';
import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js'; import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
/** /**
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login. * OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
* *
@@ -75,13 +72,18 @@ function isPhone(): boolean {
* which browsers cannot follow as a fetch/XHR (T-07-04). * which browsers cannot follow as a fetch/XHR (T-07-04).
*/ */
function OidcRedirect() { function OidcRedirect() {
// IN-01: navigate from an effect, not during render. React may invoke a
// component body more than once (StrictMode double-invoke, concurrent
// re-renders); side effects belong in useEffect.
useEffect(() => {
window.location.replace('/api/login'); window.location.replace('/api/login');
}, []);
return <div aria-hidden="true" />; return <div aria-hidden="true" />;
} }
export default function App() { export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const phone = isPhone(); const phone = useIsPhone();
// Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit). // Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit).
// Must be fetched before rendering any authenticated route to gate the app on /setup. // Must be fetched before rendering any authenticated route to gate the app on /setup.
+2 -5
View File
@@ -55,6 +55,7 @@ import { SyncStateToast } from './SyncStateToast.js';
import { ColorLegend } from './ColorLegend.js'; import { ColorLegend } from './ColorLegend.js';
import { SkeletonCalendar } from './SkeletonCalendar.js'; import { SkeletonCalendar } from './SkeletonCalendar.js';
import { InstallPrompt } from './InstallPrompt.js'; import { InstallPrompt } from './InstallPrompt.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -71,10 +72,6 @@ function resolveDefaultView(persistedView: string): string {
return persistedView; return persistedView;
} }
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
// ── Component ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export function CalendarShell() { export function CalendarShell() {
@@ -249,7 +246,7 @@ export function CalendarShell() {
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data; const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data;
const isEventsError = eventsQuery.isError; const isEventsError = eventsQuery.isError;
const phone = isPhone(); const phone = useIsPhone();
// ── Auth splash (D-10) ──────────────────────────────────────────────────── // ── Auth splash (D-10) ────────────────────────────────────────────────────
// Gate the calendar render on auth state so no calendar shell, skeleton, or // Gate the calendar render on auth state so no calendar shell, skeleton, or
+2 -1
View File
@@ -31,6 +31,7 @@ import {
type SaveCredentialPayload, type SaveCredentialPayload,
type SaveMyCredentialPayload, type SaveMyCredentialPayload,
} from '../api/client.js'; } from '../api/client.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service'; export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
@@ -76,6 +77,7 @@ export function CredentialSheet({
triggerRef, triggerRef,
}: CredentialSheetProps) { }: CredentialSheetProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const phone = useIsPhone();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
@@ -153,7 +155,6 @@ export function CredentialSheet({
const heading = headingFor(mode); const heading = headingFor(mode);
const isPending = credentialMutation.isPending; const isPending = credentialMutation.isPending;
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0; const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
const phone = window.matchMedia('(max-width: 767px)').matches;
return ( return (
<> <>
+5 -6
View File
@@ -26,6 +26,7 @@ import { X, Bell, AlertCircle, Loader2, LogOut } from 'lucide-react';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { InstructionSheet } from './InstructionSheet.js'; import { InstructionSheet } from './InstructionSheet.js';
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js'; import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
@@ -55,6 +56,8 @@ interface SettingsSheetProps {
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
const navigate = useNavigate(); const navigate = useNavigate();
// WR-05: resize-aware so an iPad rotation across 767px reflows the sheet.
const phone = useIsPhone();
const [isTogglingOn, setIsTogglingOn] = useState(false); const [isTogglingOn, setIsTogglingOn] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false);
@@ -132,8 +135,6 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
if (!isOpen) return null; if (!isOpen) return null;
const phone = window.matchMedia('(max-width: 767px)').matches;
// D-07: fire-and-best-effort logout — navigate to /login regardless of API success/failure // D-07: fire-and-best-effort logout — navigate to /login regardless of API success/failure
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
@@ -589,6 +590,7 @@ interface ChangePasswordSheetProps {
} }
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) { function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
const phone = useIsPhone();
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -647,8 +649,6 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
if (!isOpen) return null; if (!isOpen) return null;
const phone = window.matchMedia('(max-width: 767px)').matches;
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
@@ -921,6 +921,7 @@ interface LinkOidcSheetProps {
} }
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) { function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
const phone = useIsPhone();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null); const headingRef = useRef<HTMLHeadingElement>(null);
@@ -959,8 +960,6 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
if (!isOpen) return null; if (!isOpen) return null;
const phone = window.matchMedia('(max-width: 767px)').matches;
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
+50
View File
@@ -0,0 +1,50 @@
/**
* useIsPhone / useMediaQuery — resize-aware breakpoint hooks (WR-05, IN-03).
*
* The previous pattern read `window.matchMedia('(max-width: 767px)').matches`
* synchronously at render time with no `change` listener, so rotating an iPad
* across the 767px breakpoint (or resizing a desktop window) left the layout
* stale until an unrelated re-render. These hooks subscribe to the media query
* via `matchMedia.addEventListener('change', …)` so components re-render on
* breakpoint crossings.
*
* The phone breakpoint is centralised here (PHONE_MAX_QUERY) so the JS constant
* stays in one place. It corresponds to one pixel below `--bp-tablet: 768px`
* in tokens.css.
*/
import { useEffect, useState } from 'react';
/** Phone breakpoint: ≤767px (one below --bp-tablet: 768px). */
export const PHONE_MAX_QUERY = '(max-width: 767px)';
/**
* useMediaQuery — subscribe to a media query and re-render on change.
*
* SSR-safe: returns `false` when `window`/`matchMedia` is unavailable.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mql = window.matchMedia(query);
// Sync immediately in case the query changed between render and effect.
setMatches(mql.matches);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
/**
* useIsPhone — true when the viewport is at or below the phone breakpoint.
* Re-renders on breakpoint crossings (resize / orientation change).
*/
export function useIsPhone(): boolean {
return useMediaQuery(PHONE_MAX_QUERY);
}
+5 -6
View File
@@ -38,6 +38,7 @@ import {
type AdminCalendar, type AdminCalendar,
} from '../api/client.js'; } from '../api/client.js';
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js'; import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
@@ -55,8 +56,8 @@ const sectionLabelStyle: React.CSSProperties = {
export function AdminPage() { export function AdminPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Phone detection for toast bottom offset // Phone detection for toast bottom offset (WR-05: resize-aware)
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; const phone = useIsPhone();
// Success toast state (D-08) // Success toast state (D-08)
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
@@ -1345,6 +1346,8 @@ interface ResetPasswordSheetProps {
} }
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) { function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
// WR-05: resize-aware phone detection.
const sheetPhone = useIsPhone();
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1374,10 +1377,6 @@ function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswor
onClose(); onClose();
} }
// Phone detection for desktop centering
const sheetPhone =
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (newPassword !== confirmPassword) throw new Error('mismatch'); if (newPassword !== confirmPassword) throw new Error('mismatch');