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
|
|
@ -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
72
frontend/public/sw.js
Normal 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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1
static/v2/assets/CharacterWindow-C26CdTBY.js
Normal file
1
static/v2/assets/CharacterWindow-C26CdTBY.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/CombatPickerWindow-BOlAFaCG.js
Normal file
1
static/v2/assets/CombatPickerWindow-BOlAFaCG.js
Normal 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};
|
||||
1
static/v2/assets/CombatStatsWindow-Ct0adxan.js
Normal file
1
static/v2/assets/CombatStatsWindow-Ct0adxan.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/v2/assets/InventoryWindow-DLq3agcE.js
Normal file
1
static/v2/assets/InventoryWindow-DLq3agcE.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/IssuesWindow-CSupRDWU.js
Normal file
1
static/v2/assets/IssuesWindow-CSupRDWU.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/RadarWindow-T2z8FzRN.js
Normal file
1
static/v2/assets/RadarWindow-T2z8FzRN.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/StatsWindow-BT7jJ8yH.js
Normal file
1
static/v2/assets/StatsWindow-BT7jJ8yH.js
Normal 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};
|
||||
1
static/v2/assets/VitalSharingWindow-CwE3JOVC.js
Normal file
1
static/v2/assets/VitalSharingWindow-CwE3JOVC.js
Normal 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};
|
||||
34
static/v2/assets/index-BfJ04YaG.js
Normal file
34
static/v2/assets/index-BfJ04YaG.js
Normal file
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
17
static/v2/assets/react-DlyoauG8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
72
static/v2/sw.js
Normal 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;
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue