From c062854a908ad83c5d9ccc54c8156f80c5f3d61c Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Fri, 14 Aug 2026 12:27:49 -0700 Subject: [PATCH] Add idle lobby performance diagnostics --- apps/client/src/App.test.tsx | 126 ++++++++++++++ apps/client/src/App.tsx | 34 ++++ apps/client/src/components/LetterGrid.tsx | 2 + .../PerformanceDiagnostics.test.tsx | 73 ++++++++ .../src/components/PerformanceDiagnostics.tsx | 150 +++++++++++++++++ .../components/RoomLobby.performance.test.tsx | 34 ++++ apps/client/src/components/RoomLobby.tsx | 2 + apps/client/src/lobby-client.test.ts | 148 +++++++++++++++++ apps/client/src/lobby-client.ts | 156 +++++++++++++++++- .../src/performance-diagnostics.test.ts | 53 ++++++ apps/client/src/performance-diagnostics.ts | 78 +++++++++ apps/client/src/styles.css | 42 +++++ docs/PERFORMANCE.md | 55 ++++++ 13 files changed, 947 insertions(+), 6 deletions(-) create mode 100644 apps/client/src/components/PerformanceDiagnostics.test.tsx create mode 100644 apps/client/src/components/PerformanceDiagnostics.tsx create mode 100644 apps/client/src/lobby-client.test.ts create mode 100644 apps/client/src/performance-diagnostics.test.ts create mode 100644 apps/client/src/performance-diagnostics.ts diff --git a/apps/client/src/App.test.tsx b/apps/client/src/App.test.tsx index 970e3c1..125c36f 100644 --- a/apps/client/src/App.test.tsx +++ b/apps/client/src/App.test.tsx @@ -19,6 +19,7 @@ import { import { App } from './App'; import type { LobbyClient } from './lobby-client'; +import { resetPerformanceDiagnosticsForTests } from './performance-diagnostics'; import type { LobbySessionStore, StoredLobbySession } from './session-store'; const controllerPlayer: PlayerState = { @@ -352,6 +353,7 @@ async function chooseTap(user: ReturnType) { describe('Stage 4B display and player room routes', () => { beforeEach(() => { window.history.replaceState({}, '', '/'); + resetPerformanceDiagnosticsForTests(); }); it('automatically creates one passive display room at the root', async () => { @@ -595,6 +597,69 @@ describe('Stage 4B display and player room routes', () => { expect(client.createDisplay).not.toHaveBeenCalled(); }); + it('keeps opt-in diagnostics through join navigation and hides them otherwise', async () => { + const user = userEvent.setup(); + const stopDiagnostics = vi.fn(); + const enableDiagnostics = vi.fn(() => stopDiagnostics); + const client = createFakeClient({ + joinPlayer: vi.fn( + async (): Promise => controllerSuccess, + ), + enablePerformanceDiagnostics: enableDiagnostics, + getPerformanceDiagnostics: () => ({ + connected: true, + transport: 'websocket', + connections: 1, + reconnects: 0, + connectionStatusTransitions: 1, + transportUpgrades: 1, + roomStatesReceived: 1, + roomErrorsReceived: 0, + enginePacketsReceived: 3, + enginePacketsSent: 2, + }), + }); + window.history.replaceState({}, '', '/join/ABC234?perf=1'); + + const view = render( + , + ); + await user.type( + screen.getByRole('textbox', { name: 'Display name' }), + 'Silver Owl', + ); + await user.click(screen.getByRole('button', { name: 'Join Room' })); + + expect( + await screen.findByRole('heading', { name: 'Performance Diagnostics' }), + ).toBeVisible(); + expect(enableDiagnostics).toHaveBeenCalledTimes(1); + expect(window.location.pathname).toBe('/room/ABC234'); + expect(window.location.search).toBe(''); + view.unmount(); + expect(stopDiagnostics).toHaveBeenCalledTimes(1); + + resetPerformanceDiagnosticsForTests(); + window.history.replaceState({}, '', '/join/ABC234'); + const normalClient = createFakeClient({ + joinPlayer: vi.fn( + async (): Promise => controllerSuccess, + ), + }); + render( + , + ); + await user.type( + screen.getByRole('textbox', { name: 'Display name' }), + 'Silver Owl', + ); + await user.click(screen.getByRole('button', { name: 'Join Room' })); + await screen.findByRole('region', { name: 'Puzzle' }); + expect( + screen.queryByRole('heading', { name: 'Performance Diagnostics' }), + ).toBeNull(); + }); + it('shows the first joining phone player as the game host', async () => { const user = userEvent.setup(); const client = createFakeClient({ @@ -2191,6 +2256,67 @@ describe('Stage 4B display and player room routes', () => { expect(commits).toBe(commitsBeforeDuplicate); }); + it('counts accepted, duplicate, and rejected snapshots without changing acceptance', async () => { + let reportRoomState: ((room: RoomState) => void) | undefined; + window.history.replaceState({}, '', '/room/ABC234?perf=1'); + const client = createFakeClient({ + reconnectPlayer: vi.fn( + async (): Promise => controllerWithPlayersSuccess, + ), + onRoomState: (listener) => { + reportRoomState = listener; + return () => undefined; + }, + enablePerformanceDiagnostics: () => () => undefined, + }); + render( + , + ); + await screen.findByRole('button', { name: 'Start Round' }); + + act(() => reportRoomState?.(controllerWithPlayersSuccess.room)); + act(() => + reportRoomState?.({ + ...controllerWithPlayersSuccess.room, + stateVersion: 0, + }), + ); + act(() => + reportRoomState?.({ + ...controllerWithPlayersSuccess.room, + stateVersion: 2, + settings: { + ...controllerWithPlayersSuccess.room.settings, + gridSize: 6, + }, + }), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Refresh Diagnostics' }), + ); + + const snapshot = screen.getByLabelText('Diagnostic snapshot'); + expect(snapshot).toHaveTextContent('Room states accepted: 2'); + expect(snapshot).toHaveTextContent('Duplicates ignored: 1'); + expect(snapshot).toHaveTextContent( + 'Stale/conflicting snapshots rejected: 1', + ); + expect(screen.getByRole('button', { name: '6 × 6' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + it('still accepts a meaningful same-version snapshot with the same server time', async () => { let reportRoomState: ((room: RoomState) => void) | undefined; const client = createFakeClient({ diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e417964..b2c20f8 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -22,6 +22,7 @@ import { AppShell } from './components/AppShell'; import { JoinRoomForm } from './components/JoinRoomForm'; import { LobbyError } from './components/LobbyError'; import { NotFound } from './components/NotFound'; +import { PerformanceDiagnostics } from './components/PerformanceDiagnostics'; import { PlayerPrototype } from './components/PlayerPrototype'; import { RoomLobby } from './components/RoomLobby'; import { useDisplayAudio } from './hooks/useDisplayAudio'; @@ -34,6 +35,10 @@ import { type LobbySessionStore, type StoredLobbySession, } from './session-store'; +import { + incrementPerformanceCounter, + initializePerformanceDiagnostics, +} from './performance-diagnostics'; type AppProps = { routePath?: string; @@ -285,6 +290,8 @@ export function App({ client = defaultLobbyClient, sessionStore = defaultSessionStore, }: AppProps) { + const diagnosticsEnabled = initializePerformanceDiagnostics(); + incrementPerformanceCounter('appRenders'); const [currentPath, setCurrentPath] = useState( canonicalPath(routePath ?? window.location.pathname), ); @@ -311,6 +318,13 @@ export function App({ const displayRecoveryTimerRef = useRef(null); const attemptedRoomCodeRef = useRef(null); + useEffect(() => { + if (!diagnosticsEnabled) { + return; + } + return client.enablePerformanceDiagnostics?.(); + }, [client, diagnosticsEnabled]); + const cancelDisplayRecovery = useCallback(() => { if (displayRecoveryTimerRef.current !== null) { window.clearTimeout(displayRecoveryTimerRef.current); @@ -332,6 +346,7 @@ export function App({ const currentRoom = roomRef.current; if (currentRoom?.code === nextRoom.code) { if (nextRoom.stateVersion < currentRoom.stateVersion) { + incrementPerformanceCounter('roomSnapshotsRejected'); return; } if (nextRoom.stateVersion === currentRoom.stateVersion) { @@ -341,6 +356,7 @@ export function App({ nextServerTime < currentServerTime || !finalizedResultsMatch(currentRoom, nextRoom) ) { + incrementPerformanceCounter('roomSnapshotsRejected'); return; } // Action acknowledgements can be followed by the exact same @@ -350,6 +366,7 @@ export function App({ nextServerTime === currentServerTime && roomSnapshotsMatch(currentRoom, nextRoom) ) { + incrementPerformanceCounter('roomSnapshotDuplicatesIgnored'); return; } } @@ -357,6 +374,7 @@ export function App({ const renderRoom = shareStableRoundReferences(currentRoom, nextRoom); roomRef.current = renderRoom; + incrementPerformanceCounter('roomSnapshotsAccepted'); setRoom(renderRoom); if (currentSession.role === 'player' && nextRoom.round) { @@ -402,6 +420,7 @@ export function App({ sessionRef.current = nextSession; setSession(nextSession); roomRef.current = nextRoom; + incrementPerformanceCounter('roomSnapshotsAccepted'); setRoom(nextRoom); if (nextSession.role === 'player' && nextRoom.round) { const isParticipant = nextRoom.round.participants.some( @@ -985,6 +1004,19 @@ export function App({ let pageClassName: string; const routeRoomCode = roomCodeFromPath(currentPath); const routeJoinRoomCode = joinRoomCodeFromPath(currentPath); + const diagnosticsPanel = + diagnosticsEnabled && room && session ? ( + + ) : null; if (currentPath === '/') { if (room && session?.role === 'display') { @@ -1004,6 +1036,7 @@ export function App({ submissionState={null} onSubmitWord={submitWord} /> + {diagnosticsPanel} ); } else if (displayStarting || !roomError) { @@ -1070,6 +1103,7 @@ export function App({ submissionState={submissionState} onSubmitWord={submitWord} /> + {diagnosticsPanel} ); } else if (reconnecting) { diff --git a/apps/client/src/components/LetterGrid.tsx b/apps/client/src/components/LetterGrid.tsx index c52ca4d..a3e4158 100644 --- a/apps/client/src/components/LetterGrid.tsx +++ b/apps/client/src/components/LetterGrid.tsx @@ -9,6 +9,7 @@ import { } from 'react'; import type { WordEntryMode } from '../utils/word-entry'; +import { incrementPerformanceCounter } from '../performance-diagnostics'; import { resolveTraceSegment, type TracePoint, @@ -52,6 +53,7 @@ export const LetterGrid = memo(function LetterGrid({ onTraceEnd, onTraceCancel, }: LetterGridProps) { + incrementPerformanceCounter('letterGridRenders'); const gridRef = useRef(null); const tileElementsRef = useRef>([]); const activePointerIdRef = useRef(null); diff --git a/apps/client/src/components/PerformanceDiagnostics.test.tsx b/apps/client/src/components/PerformanceDiagnostics.test.tsx new file mode 100644 index 0000000..2604c3c --- /dev/null +++ b/apps/client/src/components/PerformanceDiagnostics.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { LobbyClient } from '../lobby-client'; +import { + enablePerformanceDiagnosticsForTests, + incrementPerformanceCounter, + resetPerformanceDiagnosticsForTests, +} from '../performance-diagnostics'; +import { PerformanceDiagnostics } from './PerformanceDiagnostics'; + +let now: number; + +beforeEach(() => { + now = 1_000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + enablePerformanceDiagnosticsForTests(now); +}); + +afterEach(() => { + resetPerformanceDiagnosticsForTests(); + vi.restoreAllMocks(); +}); + +describe('PerformanceDiagnostics', () => { + it('stays static until explicitly refreshed and exposes no sensitive data', () => { + const interval = vi.spyOn(window, 'setInterval'); + const animationFrame = vi.spyOn(window, 'requestAnimationFrame'); + const client = { + getPerformanceDiagnostics: () => ({ + connected: true, + transport: 'polling', + connections: 1, + reconnects: 0, + connectionStatusTransitions: 1, + transportUpgrades: 0, + roomStatesReceived: 2, + roomErrorsReceived: 0, + enginePacketsReceived: 7, + enginePacketsSent: 4, + }), + } as LobbyClient; + incrementPerformanceCounter('appRenders'); + + render( + , + ); + + const snapshot = screen.getByLabelText('Diagnostic snapshot'); + expect(snapshot).toHaveTextContent('Elapsed: 0m 00s'); + expect(snapshot).toHaveTextContent('Transport: polling'); + expect(snapshot).toHaveTextContent('Controller: Yes'); + expect(snapshot).not.toHaveTextContent('ABC234'); + expect(snapshot).not.toHaveTextContent('token'); + + now += 600_000; + incrementPerformanceCounter('roomLobbyRenders'); + expect(snapshot).toHaveTextContent('Elapsed: 0m 00s'); + fireEvent.click( + screen.getByRole('button', { name: 'Refresh Diagnostics' }), + ); + expect(snapshot).toHaveTextContent('Elapsed: 10m 00s'); + expect(snapshot).toHaveTextContent('RoomLobby renders: 1'); + expect(interval).not.toHaveBeenCalled(); + expect(animationFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/components/PerformanceDiagnostics.tsx b/apps/client/src/components/PerformanceDiagnostics.tsx new file mode 100644 index 0000000..a0830bd --- /dev/null +++ b/apps/client/src/components/PerformanceDiagnostics.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import type { ConnectionStatus, RoomState } from '@words/shared'; + +import type { + LobbyClient, + SocketPerformanceDiagnostics, +} from '../lobby-client'; +import { + readPerformanceCounters, + type PerformanceCounterSnapshot, +} from '../performance-diagnostics'; + +type PerformanceDiagnosticsProps = { + role: 'display' | 'player'; + phase: RoomState['phase']; + isController: boolean; + connectionStatus: ConnectionStatus; + client: LobbyClient; +}; + +type DiagnosticSnapshot = { + capturedAt: string; + role: 'display' | 'player'; + phase: RoomState['phase']; + isController: boolean; + visibility: DocumentVisibilityState; + connectionStatus: ConnectionStatus; + counters: PerformanceCounterSnapshot; + socket: SocketPerformanceDiagnostics; +}; + +const emptySocketDiagnostics = (): SocketPerformanceDiagnostics => ({ + connected: false, + transport: 'unknown', + connections: 0, + reconnects: 0, + connectionStatusTransitions: 0, + transportUpgrades: 0, + roomStatesReceived: 0, + roomErrorsReceived: 0, + enginePacketsReceived: 0, + enginePacketsSent: 0, +}); + +function formatElapsed(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1_000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; +} + +function formatSnapshot(snapshot: DiagnosticSnapshot): string { + const { counters, socket } = snapshot; + return [ + 'Performance Diagnostics', + `Captured: ${snapshot.capturedAt}`, + `Elapsed: ${formatElapsed(counters.elapsedMilliseconds)}`, + `Role: ${snapshot.role === 'player' ? 'Player' : 'Display'}`, + `Phase: ${snapshot.phase}`, + `Controller: ${snapshot.isController ? 'Yes' : 'No'}`, + `Visibility: ${snapshot.visibility}`, + `Connection: ${snapshot.connectionStatus}`, + `Socket connected: ${socket.connected ? 'Yes' : 'No'}`, + `Transport: ${socket.transport}`, + `Connections: ${socket.connections}`, + `Reconnects: ${socket.reconnects}`, + `Connection-status transitions: ${socket.connectionStatusTransitions}`, + `Transport upgrades: ${socket.transportUpgrades}`, + `Room states received: ${socket.roomStatesReceived}`, + `Room errors received: ${socket.roomErrorsReceived}`, + `Room states accepted: ${counters.roomSnapshotsAccepted}`, + `Duplicates ignored: ${counters.roomSnapshotDuplicatesIgnored}`, + `Stale/conflicting snapshots rejected: ${counters.roomSnapshotsRejected}`, + `App renders: ${counters.appRenders}`, + `RoomLobby renders: ${counters.roomLobbyRenders}`, + `LetterGrid renders: ${counters.letterGridRenders}`, + `Engine packets received: ${socket.enginePacketsReceived}`, + `Engine packets sent: ${socket.enginePacketsSent}`, + ].join('\n'); +} + +export function PerformanceDiagnostics({ + role, + phase, + isController, + connectionStatus, + client, +}: PerformanceDiagnosticsProps) { + const capture = (): DiagnosticSnapshot => ({ + capturedAt: new Date().toISOString(), + role, + phase, + isController, + visibility: document.visibilityState, + connectionStatus, + counters: readPerformanceCounters(), + socket: client.getPerformanceDiagnostics?.() ?? emptySocketDiagnostics(), + }); + const [snapshot, setSnapshot] = useState(capture); + const [copyStatus, setCopyStatus] = useState(''); + + const refresh = () => { + setSnapshot(capture()); + setCopyStatus(''); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(formatSnapshot(snapshot)); + setCopyStatus('Copied.'); + } catch { + setCopyStatus('Copy failed. Select the diagnostic text instead.'); + } + }; + + return ( +
+
+
+ Opt-in measurement +

Performance Diagnostics

+
+
+ + +
+
+
{formatSnapshot(snapshot)}
+

+ {copyStatus} +

+
+ ); +} diff --git a/apps/client/src/components/RoomLobby.performance.test.tsx b/apps/client/src/components/RoomLobby.performance.test.tsx index a4c80fc..c68c48c 100644 --- a/apps/client/src/components/RoomLobby.performance.test.tsx +++ b/apps/client/src/components/RoomLobby.performance.test.tsx @@ -7,6 +7,12 @@ import { type SubmitWordResponse, } from '@words/shared'; +import { + enablePerformanceDiagnosticsForTests, + readPerformanceCounters, + resetPerformanceDiagnosticsForTests, +} from '../performance-diagnostics'; + const renderMetrics = vi.hoisted(() => ({ letterGrid: 0 })); vi.mock('./LetterGrid', async (importOriginal) => { @@ -110,6 +116,7 @@ beforeEach(() => { vi.useFakeTimers(); monotonicTime = 0; vi.spyOn(performance, 'now').mockImplementation(() => monotonicTime); + enablePerformanceDiagnosticsForTests(monotonicTime); renderMetrics.letterGrid = 0; const slot = document.createElement('div'); slot.id = 'phone-entry-mode-slot'; @@ -120,9 +127,36 @@ afterEach(() => { document.getElementById('phone-entry-mode-slot')?.remove(); vi.useRealTimers(); vi.restoreAllMocks(); + resetPerformanceDiagnosticsForTests(); }); describe('RoomLobby runtime isolation', () => { + it('does no recurring application work over ten idle lobby minutes', () => { + const timeout = vi.spyOn(window, 'setTimeout'); + const interval = vi.spyOn(window, 'setInterval'); + const animationFrame = vi.spyOn(window, 'requestAnimationFrame'); + render( + , + ); + const initialCounters = readPerformanceCounters(); + const timeoutCalls = timeout.mock.calls.length; + + expect(vi.getTimerCount()).toBe(0); + act(() => { + monotonicTime += 600_000; + vi.advanceTimersByTime(600_000); + }); + + expect(readPerformanceCounters()).toMatchObject({ + roomLobbyRenders: initialCounters.roomLobbyRenders, + letterGridRenders: initialCounters.letterGridRenders, + }); + expect(timeout).toHaveBeenCalledTimes(timeoutCalls); + expect(interval).not.toHaveBeenCalled(); + expect(animationFrame).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + it('does not rerender LetterGrid for visible countdown changes', () => { render(); const initialGridRenders = renderMetrics.letterGrid; diff --git a/apps/client/src/components/RoomLobby.tsx b/apps/client/src/components/RoomLobby.tsx index 3f3e369..27e7e25 100644 --- a/apps/client/src/components/RoomLobby.tsx +++ b/apps/client/src/components/RoomLobby.tsx @@ -14,6 +14,7 @@ import { } from '@words/shared'; import { useRoundDeadlineReached } from '../useRoundCountdown'; +import { incrementPerformanceCounter } from '../performance-diagnostics'; import { createDemoBoard } from '../utils/demoBoard'; import { isExpectedSubmissionRejection, @@ -62,6 +63,7 @@ export function RoomLobby({ onReturnToLobby, onSubmitWord, }: RoomLobbyProps) { + incrementPerformanceCounter('roomLobbyRenders'); const [actionPending, setActionPending] = useState(false); const [actionError, setActionError] = useState(null); const [selectedPath, setSelectedPath] = useState([]); diff --git a/apps/client/src/lobby-client.test.ts b/apps/client/src/lobby-client.test.ts new file mode 100644 index 0000000..9aebd21 --- /dev/null +++ b/apps/client/src/lobby-client.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Socket } from 'socket.io-client'; + +import type { ClientToServerEvents, ServerToClientEvents } from '@words/shared'; + +import { SocketLobbyClient } from './lobby-client'; +import { + enablePerformanceDiagnosticsForTests, + resetPerformanceDiagnosticsForTests, +} from './performance-diagnostics'; + +type Listener = (...arguments_: unknown[]) => void; + +class FakeEmitter { + private listeners = new Map>(); + + on(event: string, listener: Listener) { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + return this; + } + + off(event: string, listener: Listener) { + this.listeners.get(event)?.delete(listener); + return this; + } + + trigger(event: string, ...arguments_: unknown[]) { + for (const listener of this.listeners.get(event) ?? []) { + listener(...arguments_); + } + } + + listenerCount(event: string) { + return this.listeners.get(event)?.size ?? 0; + } +} + +class FakeEngine extends FakeEmitter { + transport = { name: 'polling' }; +} + +class FakeManager extends FakeEmitter { + engine = new FakeEngine(); +} + +class FakeSocket extends FakeEmitter { + connected = false; + active = false; + io = new FakeManager(); +} + +function createClient() { + const socket = new FakeSocket(); + const client = new SocketLobbyClient( + socket as unknown as Socket, + ); + return { client, socket }; +} + +beforeEach(() => { + resetPerformanceDiagnosticsForTests(); + enablePerformanceDiagnosticsForTests(); +}); + +describe('SocketLobbyClient performance diagnostics', () => { + it('reports polling and observes a transport upgrade and Engine.IO packets', () => { + const { client, socket } = createClient(); + const cleanup = client.enablePerformanceDiagnostics(); + socket.io.trigger('open'); + + expect(client.getPerformanceDiagnostics().transport).toBe('polling'); + socket.io.engine.trigger('packet', { type: 'ping' }); + socket.io.engine.trigger('packetCreate', { type: 'pong' }); + socket.io.engine.transport = { name: 'websocket' }; + socket.io.engine.trigger('upgrade', socket.io.engine.transport); + + expect(client.getPerformanceDiagnostics()).toMatchObject({ + transport: 'websocket', + transportUpgrades: 1, + enginePacketsReceived: 1, + enginePacketsSent: 1, + }); + cleanup(); + }); + + it('counts connections, reconnects, status transitions, and room events', () => { + const { client, socket } = createClient(); + const cleanup = client.enablePerformanceDiagnostics(); + + socket.connected = true; + socket.trigger('connect'); + socket.trigger('room:state', {}); + socket.trigger('room:error', {}); + socket.connected = false; + socket.trigger('disconnect'); + socket.io.trigger('reconnect', 1); + socket.connected = true; + socket.trigger('connect'); + + expect(client.getPerformanceDiagnostics()).toMatchObject({ + connected: true, + connections: 2, + reconnects: 1, + connectionStatusTransitions: 3, + roomStatesReceived: 1, + roomErrorsReceived: 1, + }); + cleanup(); + }); + + it('removes every diagnostic listener without changing normal socket state', () => { + const { client, socket } = createClient(); + const cleanup = client.enablePerformanceDiagnostics(); + socket.io.trigger('open'); + cleanup(); + const before = client.getPerformanceDiagnostics(); + + socket.trigger('connect'); + socket.trigger('room:state', {}); + socket.io.trigger('reconnect', 1); + socket.io.engine.trigger('packet', {}); + socket.io.engine.trigger('upgrade', { name: 'websocket' }); + + expect(client.getConnectionStatus()).toBe('disconnected'); + expect(client.getPerformanceDiagnostics()).toEqual(before); + expect(socket.listenerCount('connect')).toBe(0); + expect(socket.listenerCount('room:state')).toBe(0); + expect(socket.io.listenerCount('open')).toBe(0); + expect(socket.io.listenerCount('reconnect')).toBe(0); + expect(socket.io.engine.listenerCount('packet')).toBe(0); + expect(socket.io.engine.listenerCount('packetCreate')).toBe(0); + expect(socket.io.engine.listenerCount('upgrade')).toBe(0); + }); + + it('adds no diagnostic listeners when perf mode is disabled', () => { + resetPerformanceDiagnosticsForTests(); + const { client, socket } = createClient(); + + client.enablePerformanceDiagnostics(); + + expect(socket.listenerCount('connect')).toBe(0); + expect(socket.listenerCount('room:state')).toBe(0); + expect(socket.io.listenerCount('open')).toBe(0); + expect(socket.io.engine.listenerCount('packet')).toBe(0); + }); +}); diff --git a/apps/client/src/lobby-client.ts b/apps/client/src/lobby-client.ts index 15c2e20..4852299 100644 --- a/apps/client/src/lobby-client.ts +++ b/apps/client/src/lobby-client.ts @@ -27,6 +27,21 @@ import { type UpdateRoomSettingsInput, } from '@words/shared'; +import { performanceDiagnosticsEnabled } from './performance-diagnostics'; + +export type SocketPerformanceDiagnostics = { + connected: boolean; + transport: string; + connections: number; + reconnects: number; + connectionStatusTransitions: number; + transportUpgrades: number; + roomStatesReceived: number; + roomErrorsReceived: number; + enginePacketsReceived: number; + enginePacketsSent: number; +}; + export type LobbyClient = { getConnectionStatus: () => ConnectionStatus; createDisplay: (input: CreateDisplayInput) => Promise; @@ -53,6 +68,8 @@ export type LobbyClient = { onConnectionStatus: ( listener: (status: ConnectionStatus) => void, ) => () => void; + enablePerformanceDiagnostics?: () => () => void; + getPerformanceDiagnostics?: () => SocketPerformanceDiagnostics; }; const connectionError: RoomError = { @@ -92,12 +109,33 @@ const submissionConnectionFailure: SubmitWordResponse = { export class SocketLobbyClient implements LobbyClient { private readonly socket: Socket; private connectionPromise: Promise | null = null; - - constructor() { - this.socket = io({ - autoConnect: false, - timeout: 5_000, - }); + private performanceCleanup: (() => void) | null = null; + private enginePerformanceCleanup: (() => void) | null = null; + private lastDiagnosticConnectionStatus: ConnectionStatus = 'disconnected'; + private lastDiagnosticTransport = 'unknown'; + private performanceDiagnostics: Omit< + SocketPerformanceDiagnostics, + 'connected' | 'transport' + > = { + connections: 0, + reconnects: 0, + connectionStatusTransitions: 0, + transportUpgrades: 0, + roomStatesReceived: 0, + roomErrorsReceived: 0, + enginePacketsReceived: 0, + enginePacketsSent: 0, + }; + + constructor( + socket: Socket | null = null, + ) { + this.socket = + socket ?? + io({ + autoConnect: false, + timeout: 5_000, + }); } getConnectionStatus(): ConnectionStatus { @@ -351,6 +389,112 @@ export class SocketLobbyClient implements LobbyClient { }; } + enablePerformanceDiagnostics(): () => void { + if (!performanceDiagnosticsEnabled() || this.performanceCleanup) { + return () => undefined; + } + + this.lastDiagnosticConnectionStatus = this.getConnectionStatus(); + this.lastDiagnosticTransport = this.currentTransport(); + if (this.socket.connected) { + this.performanceDiagnostics.connections = 1; + } + + const recordConnectionStatus = (status: ConnectionStatus) => { + if (status !== this.lastDiagnosticConnectionStatus) { + this.performanceDiagnostics.connectionStatusTransitions += 1; + this.lastDiagnosticConnectionStatus = status; + } + }; + const onConnect = () => { + this.performanceDiagnostics.connections += 1; + this.lastDiagnosticTransport = this.currentTransport(); + recordConnectionStatus('connected'); + }; + const onDisconnect = () => recordConnectionStatus('disconnected'); + const onConnectError = () => recordConnectionStatus('disconnected'); + const onReconnect = () => { + this.performanceDiagnostics.reconnects += 1; + }; + const onRoomState = () => { + this.performanceDiagnostics.roomStatesReceived += 1; + }; + const onRoomError = () => { + this.performanceDiagnostics.roomErrorsReceived += 1; + }; + const onManagerOpen = () => this.attachEnginePerformanceDiagnostics(); + + this.socket.on('connect', onConnect); + this.socket.on('disconnect', onDisconnect); + this.socket.on('connect_error', onConnectError); + this.socket.on('room:state', onRoomState); + this.socket.on('room:error', onRoomError); + this.socket.io.on('open', onManagerOpen); + this.socket.io.on('reconnect', onReconnect); + this.attachEnginePerformanceDiagnostics(); + + const cleanup = () => { + if (this.performanceCleanup !== cleanup) { + return; + } + this.socket.off('connect', onConnect); + this.socket.off('disconnect', onDisconnect); + this.socket.off('connect_error', onConnectError); + this.socket.off('room:state', onRoomState); + this.socket.off('room:error', onRoomError); + this.socket.io.off('open', onManagerOpen); + this.socket.io.off('reconnect', onReconnect); + this.enginePerformanceCleanup?.(); + this.enginePerformanceCleanup = null; + this.performanceCleanup = null; + }; + this.performanceCleanup = cleanup; + return cleanup; + } + + getPerformanceDiagnostics(): SocketPerformanceDiagnostics { + const transport = this.currentTransport(); + if (transport !== 'unknown') { + this.lastDiagnosticTransport = transport; + } + return { + connected: this.socket.connected, + transport: this.lastDiagnosticTransport, + ...this.performanceDiagnostics, + }; + } + + private currentTransport(): string { + return this.socket.io.engine?.transport?.name ?? 'unknown'; + } + + private attachEnginePerformanceDiagnostics(): void { + const engine = this.socket.io.engine; + if (!engine) { + return; + } + this.enginePerformanceCleanup?.(); + const onPacket = () => { + this.performanceDiagnostics.enginePacketsReceived += 1; + }; + const onPacketCreate = () => { + this.performanceDiagnostics.enginePacketsSent += 1; + }; + const onUpgrade = (transport: { name: string }) => { + this.performanceDiagnostics.transportUpgrades += 1; + this.lastDiagnosticTransport = transport.name; + }; + engine.on('packet', onPacket); + engine.on('packetCreate', onPacketCreate); + engine.on('upgrade', onUpgrade); + this.lastDiagnosticTransport = engine.transport?.name ?? 'unknown'; + this.enginePerformanceCleanup = () => { + engine.off('packet', onPacket); + engine.off('packetCreate', onPacketCreate); + engine.off('upgrade', onUpgrade); + }; + } + private ensureConnected(): Promise { if (this.socket.connected) { return Promise.resolve(true); diff --git a/apps/client/src/performance-diagnostics.test.ts b/apps/client/src/performance-diagnostics.test.ts new file mode 100644 index 0000000..ce07acf --- /dev/null +++ b/apps/client/src/performance-diagnostics.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + enablePerformanceDiagnosticsForTests, + incrementPerformanceCounter, + initializePerformanceDiagnostics, + performanceDiagnosticsEnabled, + readPerformanceCounters, + resetPerformanceDiagnosticsForTests, +} from './performance-diagnostics'; + +beforeEach(() => { + resetPerformanceDiagnosticsForTests(); + vi.restoreAllMocks(); +}); + +describe('performance diagnostic counters', () => { + it('activates only for the initial perf=1 query and survives later navigation', () => { + expect(initializePerformanceDiagnostics('?perf=1')).toBe(true); + expect(initializePerformanceDiagnostics('')).toBe(true); + expect(performanceDiagnosticsEnabled()).toBe(true); + }); + + it('is a no-op when diagnostics are disabled', () => { + initializePerformanceDiagnostics(''); + incrementPerformanceCounter('appRenders'); + expect(readPerformanceCounters().appRenders).toBe(0); + }); + + it('increments without scheduling refresh work', () => { + let now = 100; + vi.spyOn(performance, 'now').mockImplementation(() => now); + const timeout = vi.spyOn(window, 'setTimeout'); + const interval = vi.spyOn(window, 'setInterval'); + const animationFrame = vi.spyOn(window, 'requestAnimationFrame'); + enablePerformanceDiagnosticsForTests(now); + + incrementPerformanceCounter('appRenders'); + incrementPerformanceCounter('roomLobbyRenders'); + incrementPerformanceCounter('letterGridRenders'); + now += 600_000; + + expect(readPerformanceCounters()).toMatchObject({ + elapsedMilliseconds: 600_000, + appRenders: 1, + roomLobbyRenders: 1, + letterGridRenders: 1, + }); + expect(timeout).not.toHaveBeenCalled(); + expect(interval).not.toHaveBeenCalled(); + expect(animationFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/client/src/performance-diagnostics.ts b/apps/client/src/performance-diagnostics.ts new file mode 100644 index 0000000..fa914bb --- /dev/null +++ b/apps/client/src/performance-diagnostics.ts @@ -0,0 +1,78 @@ +export type PerformanceCounterName = + | 'appRenders' + | 'roomLobbyRenders' + | 'letterGridRenders' + | 'roomSnapshotsAccepted' + | 'roomSnapshotDuplicatesIgnored' + | 'roomSnapshotsRejected'; + +export type PerformanceCounterSnapshot = Record< + PerformanceCounterName, + number +> & { + elapsedMilliseconds: number; +}; + +const emptyCounters = (): Record => ({ + appRenders: 0, + roomLobbyRenders: 0, + letterGridRenders: 0, + roomSnapshotsAccepted: 0, + roomSnapshotDuplicatesIgnored: 0, + roomSnapshotsRejected: 0, +}); + +let initialized = false; +let enabled = false; +let startedAt = 0; +let counters = emptyCounters(); + +function monotonicNow(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +export function initializePerformanceDiagnostics( + search = window.location.search, +): boolean { + if (!initialized) { + initialized = true; + enabled = new URLSearchParams(search).get('perf') === '1'; + startedAt = monotonicNow(); + } + return enabled; +} + +export function performanceDiagnosticsEnabled(): boolean { + return enabled; +} + +export function incrementPerformanceCounter( + counter: PerformanceCounterName, +): void { + if (enabled) { + counters[counter] += 1; + } +} + +export function readPerformanceCounters(): PerformanceCounterSnapshot { + return { + ...counters, + elapsedMilliseconds: enabled ? Math.max(0, monotonicNow() - startedAt) : 0, + }; +} + +export function resetPerformanceDiagnosticsForTests(): void { + initialized = false; + enabled = false; + startedAt = 0; + counters = emptyCounters(); +} + +export function enablePerformanceDiagnosticsForTests( + startTime = monotonicNow(), +): void { + initialized = true; + enabled = true; + startedAt = startTime; + counters = emptyCounters(); +} diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 9ceaa5c..f773595 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -2008,6 +2008,48 @@ button:disabled { color: var(--coral); } +.performance-diagnostics { + width: min(100% - 1rem, 44rem); + margin: 1rem auto; +} + +.performance-diagnostics__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.performance-diagnostics__actions .button { + min-height: 2.5rem; + padding: 0.55rem 0.75rem; +} + +.performance-diagnostics pre { + max-height: 28rem; + margin: 1rem 0 0; + padding: 0.8rem; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: rgba(6, 16, 27, 0.52); + color: var(--paper-muted); + font: + 0.78rem/1.5 ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + monospace; + white-space: pre-wrap; +} + +.performance-diagnostics__status { + min-height: 1.5em; + margin: 0.5rem 0 0; + color: var(--paper-muted); + font-size: 0.8rem; +} + .lobby-toolbar { display: flex; align-items: center; diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 195c7da..6b4e84a 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -184,6 +184,61 @@ reduces resolver work to 31 calls over one simulated second, but this work-count reduction is not proof of a battery-percentage improvement; real-device battery testing remains required. +## Idle lobby battery investigation + +Physical iPhone testing reported approximately four battery-percentage points +of use during ten minutes in an idle player lobby with the screen on at roughly +medium brightness. This is a field observation, not a controlled electrical +measurement: radio conditions, battery health, temperature, display power, and +browser behavior were not isolated. + +The idle player path has no active round countdown or deadline timeout, no idle +Trace sample timeout, no application `requestAnimationFrame` loop, and no +application polling interval. The server lifecycle sweep does not intentionally +broadcast room snapshots every 250 ms. An idle-lobby CSS audit found no +continuous player animation, `backdrop-filter`, or filter effect. The fixed +skip link is normally translated off-screen; player shadows are static, the +viewport-unit rules are not themselves animated, and transitions are limited +to interaction states. Those static design properties are not evidence of +continuous invalidation, so this diagnostic pass does not alter them. + +Pages initially loaded with `?perf=1` now enable an in-memory diagnostic +registry for that page lifetime, including navigation from `/join/...` to +`/room/...`. Integer counters advance only when the measured socket event, +snapshot decision, or component render already occurs. The visible snapshot is +static until the tester presses **Refresh Diagnostics**; there is no diagnostic +polling timer or animation-frame sampler. The panel reports connection and +Engine.IO transport state, connections and reconnects, transport upgrades, +room state/error events, packet counts, accepted/duplicate/rejected snapshot +decisions, and App/RoomLobby/LetterGrid renders. It omits names, room codes, +credentials, packet bodies, and reconnect tokens. + +`SocketLobbyClient` still calls `io({ autoConnect: false, timeout: 5_000 })` +without a `transports` override. Socket.IO therefore retains its normal +Engine.IO transport negotiation and fallback behavior. HTTP polling or a failed +WebSocket upgrade is a hypothesis for physical testing, not a conclusion, and +this pass does not force WebSocket or change heartbeat/reconnect behavior. + +Run the first diagnostic comparison for ten idle minutes under approximately +the reported conditions: + +1. Load the player join URL with `?perf=1` and join the room. +2. Record starting battery percentage, whether the phone feels cool or warm, + and whether the player is the Game Host or an ordinary player. +3. Leave the screen on at approximately medium brightness and do not interact + for ten minutes while the room remains in `LOBBY`. +4. Record ending battery percentage and phone temperature. +5. Press **Refresh Diagnostics**, then **Copy Diagnostics**, and retain the text + or a screenshot. +6. Repeat for both Game Host and ordinary-player lobby roles when practical. + +The key interpretation inputs are transport, reconnect count, room states +received/accepted, App/RoomLobby/LetterGrid renders, and Engine.IO packet +counts. Polling points to transport-upgrade investigation; flat WebSocket, +snapshot, and render counters shift attention toward iOS paint/compositing or +host-only controls; frequent snapshots or renders should be traced to their +specific event or local-state source before any optimization is attempted. + ## Final practical phone validation Run one normal 30–60 minute two-player session under conditions similar to the