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:
parent
19d95a370f
commit
69678a9426
22 changed files with 264 additions and 84 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue