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" />
<title>Mosswart Overlord v2</title>
<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>
<body>
<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 [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(() => {});
}

View file

@ -7,6 +7,14 @@ export default defineConfig({
build: {
outDir: '../static/v2',
emptyOutDir: true,
chunkSizeWarningLimit: 300,
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
},
},
},
},
server: {
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" />
<title>Mosswart Overlord v2</title>
<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">
</head>
<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;
}
});