perf(v2): 8 optimizations — 24% smaller bundle, fewer re-renders

1. React.memo on WindowRenderer — prevents re-renders when parent
   state changes but no windows are affected

2. Coordinate display via direct DOM ref — no React state updates
   on mouse move (was triggering re-renders on every pixel)

3. useDeferredValue for sidebar vitals + player list — React
   prioritizes map interactions over stat text updates

4. Chat messages in ref — stores in useRef instead of useState,
   only bumps a version counter for re-render. Eliminates a
   new Map() allocation on every chat message.

5. Lazy-load 8 window components — InventoryWindow, CharacterWindow,
   RadarWindow, CombatStatsWindow, IssuesWindow, VitalSharingWindow,
   StatsWindow, CombatPickerWindow all loaded on first open.
   Main bundle dropped from 278KB to 211KB (24% reduction).

6. Preload critical assets — dereth.png, backpack icon, dungeon_tiles.json
   via <link rel="preload"> in index.html for instant map render.

7. Bundle splitting — React runtime extracted to separate 12KB chunk
   (cached independently). Window components split into 8 chunks.
   Total: 13 chunks vs previous 2.

8. Service worker — caches map images, icon sprites, and dungeon tiles.
   Icon images cached on first fetch. Repeat page loads serve from
   cache instantly. Auto-cleans old cache versions.

Net result:
- Initial load: 211KB main + 17KB CSS (was 278KB + 17KB)
- React cached separately: 12KB
- Windows load on demand: 1-15KB each
- Dashboard with Recharts: 425KB (unchanged, still lazy)
- Map images/icons: cached by service worker after first load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 12:11:08 +02:00
parent 19d95a370f
commit 69678a9426
22 changed files with 264 additions and 84 deletions

View file

@ -24,7 +24,7 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, sh
const groupRef = useRef<HTMLDivElement>(null);
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
const [tooltip, setTooltip] = useState<{ x: number; y: number; player: TelemetrySnapshot } | null>(null);
const [worldCoord, setWorldCoord] = useState<{ ns: number; ew: number } | null>(null);
const coordRef = useRef<HTMLDivElement>(null);
// Transform stored in ref, applied directly to DOM — no React re-render on pan/zoom
const txRef = useRef({ scale: 1, offX: 0, offY: 0 });
@ -83,12 +83,12 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, sh
txRef.current.offY = d.startOffY + (e.clientY - d.sy);
applyTransform();
}
// Coordinate display (throttled by React's batching)
if (containerRef.current && imgSize.w > 0) {
// Coordinate display — direct DOM write, no React state
if (containerRef.current && imgSize.w > 0 && coordRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const tx = txRef.current;
const coord = pxToWorld(e.clientX - rect.left, e.clientY - rect.top, tx.scale, tx.offX, tx.offY, imgSize.w, imgSize.h);
setWorldCoord(coord);
coordRef.current.textContent = formatCoord(coord.ns, coord.ew);
}
};
const onMouseUp = () => { dragRef.current.dragging = false; };
@ -155,9 +155,7 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, sh
</div>
)}
{worldCoord && (
<div className="ml-coords">{formatCoord(worldCoord.ns, worldCoord.ew)}</div>
)}
<div className="ml-coords" ref={coordRef} />
</div>
);
};

View file

@ -33,8 +33,9 @@ export const Sidebar: React.FC<Props> = ({
const isOnline = serverHealth?.status?.toLowerCase() === 'online' || serverHealth?.status?.toLowerCase() === 'up';
// Defer player list rendering — kill counters don't need 30fps updates
// Defer player list rendering — sidebar stats don't need real-time updates
const deferredPlayers = useDeferredValue(players);
const deferredVitals = useDeferredValue(vitals);
const sorted = useMemo(() => {
let list = [...deferredPlayers];
@ -110,7 +111,7 @@ export const Sidebar: React.FC<Props> = ({
<PlayerList
players={sorted}
vitals={vitals}
vitals={deferredVitals}
getColor={getColor}
onSelect={onSelectPlayer}
/>

View file

@ -1,14 +1,14 @@
import React, { useMemo } from 'react';
import React, { useMemo, lazy, Suspense } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { ChatWindow } from './ChatWindow';
import { StatsWindow } from './StatsWindow';
import { CharacterWindow } from './CharacterWindow';
import { InventoryWindow } from './InventoryWindow';
import { RadarWindow } from './RadarWindow';
import { CombatStatsWindow } from './CombatStatsWindow';
import { CombatPickerWindow } from './CombatPickerWindow';
import { IssuesWindow } from './IssuesWindow';
import { VitalSharingWindow } from './VitalSharingWindow';
import { ChatWindow } from './ChatWindow'; // Chat is always fast — keep eager
const StatsWindow = lazy(() => import('./StatsWindow').then(m => ({ default: m.StatsWindow })));
const CharacterWindow = lazy(() => import('./CharacterWindow').then(m => ({ default: m.CharacterWindow })));
const InventoryWindow = lazy(() => import('./InventoryWindow').then(m => ({ default: m.InventoryWindow })));
const RadarWindow = lazy(() => import('./RadarWindow').then(m => ({ default: m.RadarWindow })));
const CombatStatsWindow = lazy(() => import('./CombatStatsWindow').then(m => ({ default: m.CombatStatsWindow })));
const CombatPickerWindow = lazy(() => import('./CombatPickerWindow').then(m => ({ default: m.CombatPickerWindow })));
const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m.IssuesWindow })));
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
import type { CharacterState } from '../../types';
interface Props {
@ -18,11 +18,11 @@ interface Props {
socket: WebSocket | null;
}
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, nearbyObjects, socket }) => {
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, socket }) => {
const { windows } = useWindowManager();
return (
<>
<Suspense fallback={null}>
{windows.map(w => {
const charName = w.charName ?? '';
const prefix = w.id.split('-')[0];
@ -53,6 +53,8 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, near
return null;
}
})}
</>
</Suspense>
);
};
});
WindowRenderer.displayName = 'WindowRenderer';

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useWebSocket } from './useWebSocket';
import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints';
import type {
@ -23,7 +23,10 @@ export function useLiveData(): DashboardState {
const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
const [chatMessages, setChatMessages] = useState<Map<string, Array<{ text: string; color?: number; timestamp: string }>>>(new Map());
// Chat messages stored in ref to avoid re-renders on every message.
// A counter state triggers re-render only when needed.
const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>());
const [chatVersion, setChatVersion] = useState(0);
const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
const charsRef = useRef(characters);
charsRef.current = characters;
@ -77,13 +80,12 @@ export function useLiveData(): DashboardState {
}
} else if (msg.type === 'chat') {
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
setChatMessages(prev => {
const next = new Map(prev);
const arr = [...(next.get(m.character_name) ?? []), { text: m.text, color: m.color, timestamp: m.timestamp }];
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
next.set(m.character_name, arr);
return next;
});
const arr = chatMessagesRef.current.get(m.character_name) ?? [];
arr.push({ text: m.text, color: m.color, timestamp: m.timestamp });
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
chatMessagesRef.current.set(m.character_name, arr);
// Bump version to notify open chat windows (batched by React)
setChatVersion(v => v + 1);
}
}, [updateChar]);
@ -164,5 +166,8 @@ export function useLiveData(): DashboardState {
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const chatMessages = useMemo(() => chatMessagesRef.current, [chatVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, socketRef };
}

View file

@ -7,3 +7,8 @@ createRoot(document.getElementById('root')!).render(
<App />
</StrictMode>,
);
// Register service worker for asset caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/v2/sw.js').catch(() => {});
}