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

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title> <title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" /> <link rel="icon" type="image/png" href="/icons/7735.png" />
<link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

72
frontend/public/sw.js Normal file
View file

@ -0,0 +1,72 @@
// Service worker for MosswartOverlord v2 — caches static assets for instant repeat loads
const CACHE_NAME = 'mo-v2-cache-v1';
const STATIC_ASSETS = [
'/dereth.png',
'/dereth_highres.png',
'/prismatic-taper-icon.png',
'/icons/0600127E.png',
'/icons/06000133.png',
'/icons/06001080.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache icon images on first fetch
if (url.pathname.startsWith('/icons/') && event.request.method === 'GET') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache dungeon_tiles.json (large, rarely changes)
if (url.pathname === '/dungeon_tiles.json') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache static assets (map images etc)
if (STATIC_ASSETS.some(a => url.pathname === a)) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
return;
}
});

View file

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

View file

@ -33,8 +33,9 @@ export const Sidebar: React.FC<Props> = ({
const isOnline = serverHealth?.status?.toLowerCase() === 'online' || serverHealth?.status?.toLowerCase() === 'up'; 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 deferredPlayers = useDeferredValue(players);
const deferredVitals = useDeferredValue(vitals);
const sorted = useMemo(() => { const sorted = useMemo(() => {
let list = [...deferredPlayers]; let list = [...deferredPlayers];
@ -110,7 +111,7 @@ export const Sidebar: React.FC<Props> = ({
<PlayerList <PlayerList
players={sorted} players={sorted}
vitals={vitals} vitals={deferredVitals}
getColor={getColor} getColor={getColor}
onSelect={onSelectPlayer} 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 { useWindowManager } from '../../contexts/WindowManagerContext';
import { ChatWindow } from './ChatWindow'; import { ChatWindow } from './ChatWindow'; // Chat is always fast — keep eager
import { StatsWindow } from './StatsWindow'; const StatsWindow = lazy(() => import('./StatsWindow').then(m => ({ default: m.StatsWindow })));
import { CharacterWindow } from './CharacterWindow'; const CharacterWindow = lazy(() => import('./CharacterWindow').then(m => ({ default: m.CharacterWindow })));
import { InventoryWindow } from './InventoryWindow'; const InventoryWindow = lazy(() => import('./InventoryWindow').then(m => ({ default: m.InventoryWindow })));
import { RadarWindow } from './RadarWindow'; const RadarWindow = lazy(() => import('./RadarWindow').then(m => ({ default: m.RadarWindow })));
import { CombatStatsWindow } from './CombatStatsWindow'; const CombatStatsWindow = lazy(() => import('./CombatStatsWindow').then(m => ({ default: m.CombatStatsWindow })));
import { CombatPickerWindow } from './CombatPickerWindow'; const CombatPickerWindow = lazy(() => import('./CombatPickerWindow').then(m => ({ default: m.CombatPickerWindow })));
import { IssuesWindow } from './IssuesWindow'; const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m.IssuesWindow })));
import { VitalSharingWindow } from './VitalSharingWindow'; const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
import type { CharacterState } from '../../types'; import type { CharacterState } from '../../types';
interface Props { interface Props {
@ -18,11 +18,11 @@ interface Props {
socket: WebSocket | null; 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(); const { windows } = useWindowManager();
return ( return (
<> <Suspense fallback={null}>
{windows.map(w => { {windows.map(w => {
const charName = w.charName ?? ''; const charName = w.charName ?? '';
const prefix = w.id.split('-')[0]; const prefix = w.id.split('-')[0];
@ -53,6 +53,8 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, near
return null; 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 { useWebSocket } from './useWebSocket';
import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints'; import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints';
import type { import type {
@ -23,7 +23,10 @@ export function useLiveData(): DashboardState {
const [totalRares, setTotalRares] = useState(0); const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0); const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]); 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 [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
const charsRef = useRef(characters); const charsRef = useRef(characters);
charsRef.current = characters; charsRef.current = characters;
@ -77,13 +80,12 @@ export function useLiveData(): DashboardState {
} }
} else if (msg.type === 'chat') { } else if (msg.type === 'chat') {
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string }; const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
setChatMessages(prev => { const arr = chatMessagesRef.current.get(m.character_name) ?? [];
const next = new Map(prev); arr.push({ text: m.text, color: m.color, timestamp: m.timestamp });
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);
if (arr.length > 1000) arr.splice(0, arr.length - 1000); chatMessagesRef.current.set(m.character_name, arr);
next.set(m.character_name, arr); // Bump version to notify open chat windows (batched by React)
return next; setChatVersion(v => v + 1);
});
} }
}, [updateChar]); }, [updateChar]);
@ -164,5 +166,8 @@ export function useLiveData(): DashboardState {
return () => clearInterval(id); 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 }; return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, socketRef };
} }

View file

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

View file

@ -7,6 +7,14 @@ export default defineConfig({
build: { build: {
outDir: '../static/v2', outDir: '../static/v2',
emptyOutDir: true, emptyOutDir: true,
chunkSizeWarningLimit: 300,
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
},
},
},
}, },
server: { server: {
port: 5173, port: 5173,

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{u as c,j as r,D as d}from"./index-BfJ04YaG.js";import"./react-DlyoauG8.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{r as o,j as t,D as d}from"./index-BfJ04YaG.js";import"./react-DlyoauG8.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -0,0 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-BfJ04YaG.js";import"./react-DlyoauG8.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

17
static/v2/assets/react-DlyoauG8.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title> <title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" /> <link rel="icon" type="image/png" href="/icons/7735.png" />
<script type="module" crossorigin src="/v2/assets/index-DaZ3M_iW.js"></script> <link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/v2/assets/index-BfJ04YaG.js"></script>
<link rel="modulepreload" crossorigin href="/v2/assets/react-DlyoauG8.js">
<link rel="stylesheet" crossorigin href="/v2/assets/index-CyLyPOVJ.css"> <link rel="stylesheet" crossorigin href="/v2/assets/index-CyLyPOVJ.css">
</head> </head>
<body> <body>

72
static/v2/sw.js Normal file
View file

@ -0,0 +1,72 @@
// Service worker for MosswartOverlord v2 — caches static assets for instant repeat loads
const CACHE_NAME = 'mo-v2-cache-v1';
const STATIC_ASSETS = [
'/dereth.png',
'/dereth_highres.png',
'/prismatic-taper-icon.png',
'/icons/0600127E.png',
'/icons/06000133.png',
'/icons/06001080.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache icon images on first fetch
if (url.pathname.startsWith('/icons/') && event.request.method === 'GET') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache dungeon_tiles.json (large, rarely changes)
if (url.pathname === '/dungeon_tiles.json') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
});
})
);
return;
}
// Cache static assets (map images etc)
if (STATIC_ASSETS.some(a => url.pathname === a)) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
return;
}
});