perf: dynamic-import chart.js in FitnessChart

Chart.js (~244 KB) was a top-level import, so every route that referenced
FitnessChart.svelte transitively pulled it. Defer it to an async block
inside onMount so non-stats fitness routes (workout, check-in, nutrition,
history list) no longer ship chart.js.

- `ChartCtor` holds the async-loaded constructor
- `disposed` guard handles unmount during the import
- theme MutationObserver / matchMedia wiring moved inside the async
  block so it only attaches once the chart actually exists
This commit is contained in:
2026-04-23 14:56:19 +02:00
parent cf3fe84d95
commit 3b4318206d
3 changed files with 37 additions and 30 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
Order = impact. Font items + app.html preload intentionally skipped.
- [x] 1. Lucide subpath imports — convert `from '@lucide/svelte'` barrel imports to `@lucide/svelte/icons/<kebab-name>` so Vite tree-shakes per-icon (current 748 KB shared chunk)
- [ ] 2. Chart.js dynamic import in `FitnessChart.svelte` (drop 244 KB from non-stats fitness routes)
- [x] 2. Chart.js dynamic import in `FitnessChart.svelte` (drop 244 KB from non-stats fitness routes)
- [ ] 3. Recipe `all_brief` endpoint — drop `JSON.parse(JSON.stringify(...))`, move shuffle client-side, enable caching
- [ ] 4. Favorites page — drop unnecessary `all_brief` fetch (verify consumer first)
- [ ] 5. Replace redundant `locals.auth()` with `locals.session` across recipe/calendar/fitness loaders
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.46.13",
"version": "1.46.14",
"private": true,
"type": "module",
"scripts": {
+35 -28
View File
@@ -1,7 +1,5 @@
<script>
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
import 'chartjs-adapter-date-fns';
/**
* @type {{
@@ -20,9 +18,10 @@
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
/** @type {Chart | null} */
/** @type {import('chart.js').Chart | null} */
let chart = $state(null);
let registered = false;
/** @type {typeof import('chart.js').Chart | null} */
let ChartCtor = null;
const nordColors = [
'#88C0D0', '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A',
@@ -37,11 +36,7 @@
}
function createChart() {
if (!canvas || !data?.datasets) return;
if (!registered) {
Chart.register(...registerables);
registered = true;
}
if (!canvas || !data?.datasets || !ChartCtor) return;
if (chart) chart.destroy();
const ctx = canvas.getContext('2d');
@@ -104,7 +99,7 @@
});
}
chart = new Chart(ctx, /** @type {any} */ ({
chart = new ChartCtor(ctx, /** @type {any} */ ({
type,
data: { labels: plainLabels, datasets: plainDatasets },
plugins,
@@ -182,30 +177,42 @@
}
onMount(() => {
createChart();
requestAnimationFrame(() => {
if (chart) {
chart.options.animation = { duration: 300 };
chart.options.transitions = {
active: { animation: { duration: 200 } }
};
}
});
let disposed = false;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onTheme = () => setTimeout(createChart, 100);
mq.addEventListener('change', onTheme);
/** @type {MutationObserver | undefined} */
let obs;
const obs = new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === 'data-theme') onTheme();
}
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
(async () => {
const [{ Chart, registerables }] = await Promise.all([
import('chart.js'),
import('chartjs-adapter-date-fns')
]);
if (disposed) return;
Chart.register(...registerables);
ChartCtor = Chart;
createChart();
requestAnimationFrame(() => {
if (chart) {
chart.options.animation = { duration: 300 };
chart.options.transitions = {
active: { animation: { duration: 200 } }
};
}
});
mq.addEventListener('change', onTheme);
obs = new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === 'data-theme') onTheme();
}
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
})();
return () => {
disposed = true;
mq.removeEventListener('change', onTheme);
obs.disconnect();
obs?.disconnect();
if (chart) chart.destroy();
};
});