perf(charts): one shared async boundary for the whole recharts surface

Every next/dynamic chart import used to point at its own module, so
Turbopack emitted a separate chunk group per boundary — the production
build carried EIGHT ~312 KB copies of the recharts module graph, and
/insights (the worst-LCP route) downloaded two of them (~92 KB gz of
pure duplication, one fully unused at load).

All chart loaders now resolve the single chart-runtime barrel
(src/components/charts/chart-runtime.ts), which owns the only path to
recharts: one shared chunk group, recharts exists exactly once in the
build (verified: 8 -> 1 recharts-fingerprinted chunks), cached across
routes. Charts stay recharts and stay pixel-identical; every call site
keeps its own matching skeleton and ssr:false flag.

Riders of the same rule:
- the mood-trio barrel (mood-charts.ts, the v1.16.7 precedent this
  generalises) folds into the runtime and is removed
- the vitals sparkline body extracts to delta-sparkline.tsx and loads
  through the runtime; the tile keeps the fixed 40 px band, so the
  async gap is an identically-sized empty box
- /cycle's BBT chart (the last static recharts import on a live route)
  defers through the runtime with a card-matching skeleton

H3
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:32:43 +02:00
1 parent 788aac5837
commit e260b2d5c4
20 files changed
+165 -74

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ import { apiGet } from "@/lib/api/api-fetch";
*/
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
+2 -2
View File
@@ -81,7 +81,7 @@ const BATCH_COVERAGE_DAYS = 31;
// to ONE error card instead of bubbling to the route-level `error.tsx`.
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/mood-chart")).then(
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
@@ -94,7 +94,7 @@ const MoodChart = (props: React.ComponentProps<typeof MoodChartLazy>) => (
const MedicationComplianceChartLazy = dynamic(
() =>
importWithRetry(
() => import("@/components/charts/medication-compliance-chart"),
() => import("@/components/charts/chart-runtime"),
).then((mod) => ({ default: mod.MedicationComplianceChart })),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
@@ -32,7 +32,7 @@ import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
// working — same pattern the insights page uses for the scatter chart.
const HostMetricsChart = dynamic(
() =>
import("./host-metrics-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.HostMetricsChart,
})),
{
+43
View File
@@ -0,0 +1,43 @@
/**
* The ONE async boundary for every recharts-rendering component.
*
* Every `next/dynamic` chart import in the tree points at THIS module
* (`import("@/components/charts/chart-runtime")`), never at the individual
* chart files. Pointing N dynamic boundaries at N different modules made
* Turbopack emit a separate chunk group per boundary, each carrying its own
* full copy of the recharts module graph — the production build shipped
* EIGHT ~312 KB copies of the library, and /insights alone downloaded two
* (~92 KB gz of pure duplication on the worst-LCP route). One shared import
* target ⇒ one shared chunk group ⇒ recharts exists exactly once, cached
* across every route.
*
* Extends the v1.16.7 `mood-charts.ts` precedent (which did this for the
* mood trio) to the whole chart surface; that scoped barrel is folded in
* here.
*
* Rules:
* - A new recharts consumer is added HERE and loaded through
* `dynamic(() => import("@/components/charts/chart-runtime").then(...))`
* at its call site — with the call site's own matching skeleton.
* - No component outside this module's static import graph may import
* "recharts" directly.
* - Types stay importable from the component files directly (type-only
* imports are value-free and don't drag the chunk in).
*/
export { HealthChart } from "./health-chart";
export { MoodChart } from "./mood-chart";
export { MedicationComplianceChart } from "./medication-compliance-chart";
export { ScatterCorrelationChart } from "./scatter-correlation-chart";
export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
export { BbtChart } from "@/components/cycle/bbt-chart";
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked-bar";
export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart";
export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
export { DrugLevelChart } from "@/components/medications/drug-level-chart";
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
@@ -14,7 +14,7 @@ import { importWithRetry } from "@/lib/retry-import";
* Pre-fix, eight call sites (`src/app/page.tsx`, five insights
* sub-pages, `sleep-duration-chart.tsx`, `trends-row.tsx`, and
* `vo2-max-chart-row.tsx`) duplicated the same
* `dynamic(() => import("@/components/charts/health-chart"), { ... })`
* `dynamic(() => import("@/components/charts/chart-runtime"), { ... })`
* incantation. The duplication meant the loading skeleton + `ssr: false`
* flag had to be re-wired on every caller.
*
@@ -31,7 +31,7 @@ import { importWithRetry } from "@/lib/retry-import";
*/
const HealthChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/health-chart")).then(
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
@@ -59,7 +59,7 @@ export function HealthChartDynamic(
*/
const HealthChartLazyMini = dynamic(
() =>
importWithRetry(() => import("@/components/charts/health-chart")).then(
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
@@ -42,7 +42,7 @@ import type {
// off the detail page's first-load JS; the skeleton shell holds the layout.
const CustomMetricChartLazy = dynamic(
() =>
importWithRetry(() => import("./custom-metric-chart")).then((mod) => ({
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
default: mod.CustomMetricChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
+29 -1
View File
@@ -1,17 +1,18 @@
"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
@@ -54,6 +55,33 @@ function shiftToday(days: number): string {
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
// The BBT chart is the only recharts consumer on /cycle — load it through
// the shared chart-runtime boundary so the route stops carrying its own
// copy of the recharts module graph. The loading shell mirrors the card
// the chart paints (header row + caption + 210 px chart band inside the
// standard card chrome) so the tab layout holds still while the shared
// chunk streams.
const BbtChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.BbtChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border rounded-xl border py-6">
<div className="px-6">
<Skeleton className="h-5 w-44" />
</div>
<div className="mt-6 px-6">
<Skeleton className="mb-2 h-4 w-64" />
<Skeleton className="h-[210px] w-full rounded-md" />
</div>
</div>
),
},
);
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
type CycleTab = (typeof CYCLE_TABS)[number];
@@ -47,10 +47,12 @@ vi.mock("next/dynamic", async () => {
const mood = await import("@/components/charts/mood-chart");
return {
default: (loader: unknown) => {
// Identify which chart the loader targets by its source text.
// Identify which chart the loader targets by its source text. Every
// loader now resolves the shared chart-runtime boundary and picks a
// named export off it, so the export name is the discriminator.
const src = String(loader);
const Comp = (
src.includes("mood-chart") ? mood.MoodChart : health.HealthChart
src.includes("MoodChart") ? mood.MoodChart : health.HealthChart
) as React.ComponentType<Record<string, unknown>>;
return (props: Record<string, unknown>) => <Comp {...props} />;
},
+1 -1
View File
@@ -29,7 +29,7 @@ import { AskCoachAction } from "./ask-coach-action";
const ScatterCorrelationChart = dynamic(
() =>
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{
@@ -0,0 +1,49 @@
"use client";
import { useId } from "react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
/**
* The recharts body of the `<SparklineDeltaTile>` inline sparkline,
* extracted so the tile itself carries no static recharts import. The tile
* loads this through the shared chart-runtime boundary
* (`@/components/charts/chart-runtime`); its fixed 40 px container is owned
* by the tile, so the async gap paints an empty band of identical size —
* zero layout shift, pixel-identical once mounted.
*/
export function DeltaSparkline({
data,
strokeVar,
}: {
/** `{ i, v }` points, already length-guarded (≥2) by the tile. */
data: Array<{ i: number; v: number }>;
/** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */
strokeVar: string;
}) {
// A stable per-instance id for the gradient <defs>. Deriving it from a
// label slug collides when two tiles share a localized label (the gradient
// fill on the second tile would not resolve); useId() is collision-free.
const fillId = useId();
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Area
type="monotone"
dataKey="v"
stroke={strokeVar}
strokeWidth={1.5}
fill={`url(#${fillId})`}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -1,8 +1,7 @@
"use client";
import { useId } from "react";
import dynamic from "next/dynamic";
import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
@@ -28,6 +27,19 @@ import {
* Fixed footprint so a tile never reflows its neighbours.
*/
// The recharts sparkline body loads through the shared chart-runtime
// boundary so this tile (statically imported by the vitals dashboard)
// carries no recharts in its own chunk group. The tile owns the fixed
// 40 px container, so the async gap is an identically-sized empty band —
// no layout shift, no skeleton needed.
const DeltaSparkline = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DeltaSparkline,
})),
{ ssr: false, loading: () => null },
);
export interface SparklineDeltaTileProps {
label: string;
/** Latest reading; `null` renders the empty state. */
@@ -88,10 +100,6 @@ export function SparklineDeltaTile({
}: SparklineDeltaTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// A stable per-instance id for the gradient <defs>. Deriving it from the
// label slug collides when two tiles share a localized label (the gradient
// fill on the second tile would not resolve); useId() is collision-free.
const fillId = useId();
const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment);
const trendColor = sentimentColorClass(arrowSentiment);
@@ -190,29 +198,7 @@ export function SparklineDeltaTile({
className="mt-3 h-10 w-full"
data-slot="sparkline-delta-tile-spark"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={sparkData}
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
>
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Area
type="monotone"
dataKey="v"
stroke={strokeVar}
strokeWidth={1.5}
fill={`url(#${fillId})`}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
<DeltaSparkline data={sparkData} strokeVar={strokeVar} />
</div>
) : null}
@@ -1,18 +0,0 @@
/**
* v1.16.7 — single async boundary for the three mood mini-charts.
*
* The distribution / weekday / time-of-day charts were each deferred
* through their own `next/dynamic` import, which produced three separate
* chunk groups: the cards popped in one after another as each chunk
* landed (incoherent reveal on the mood insights page), and each group
* carried its own copy of the Recharts module graph. Funnelling all
* three through this one barrel gives them a single shared chunk — they
* arrive (and reveal) together, and Recharts is bundled once for the
* trio.
*
* Consumers keep importing the chart types directly from the component
* files (type-only imports are value-free and don't drag the chunk in).
*/
export { MoodDistributionChart } from "./mood-distribution-chart";
export { MoodWeekdayChart } from "./mood-weekday-chart";
export { MoodTimeOfDayChart } from "./mood-time-of-day-chart";
@@ -25,7 +25,7 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
const ScatterCorrelationChart = dynamic(
() =>
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{
@@ -36,12 +36,13 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 — all three loaders resolve the ONE `./mood-charts` barrel so
// they share a single chunk: the cards reveal together instead of
// popping in chunk-by-chunk, and Recharts ships once for the trio.
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDistributionChart = dynamic(
() =>
import("./mood-charts").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
@@ -53,7 +54,7 @@ const MoodDistributionChart = dynamic(
);
const MoodWeekdayChart = dynamic(
() =>
import("./mood-charts").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
@@ -65,7 +66,7 @@ const MoodWeekdayChart = dynamic(
);
const MoodTimeOfDayChart = dynamic(
() =>
import("./mood-charts").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
@@ -35,7 +35,7 @@ import { ConfidenceMeter } from "./confidence-meter";
*/
const HealthChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/health-chart")).then(
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
@@ -48,7 +48,7 @@ const HealthChart = (props: ComponentProps<typeof HealthChartLazy>) => (
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/mood-chart")).then(
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
+1 -1
View File
@@ -25,7 +25,7 @@ import { SleepDurationChart } from "./sleep-duration-chart";
// matches the card the bar paints so the layout stays stable.
const SleepStageStackedBarLazy = dynamic(
() =>
importWithRetry(() => import("./sleep-stage-stacked-bar")).then((mod) => ({
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
default: mod.SleepStageStackedBar,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
+1 -1
View File
@@ -58,7 +58,7 @@ import {
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
+1 -1
View File
@@ -51,7 +51,7 @@ import type {
// loading shell matches the in-card chart footprint so the layout is stable.
const LabBiomarkerChartLazy = dynamic(
() =>
importWithRetry(() => import("./lab-biomarker-chart")).then((mod) => ({
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
default: mod.LabBiomarkerChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
@@ -89,14 +89,14 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
// shared chart skeleton standing in while the chunk streams.
const DrugLevelChart = dynamic(
() =>
import("@/components/medications/drug-level-chart").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const DoseStrengthCurve = dynamic(
() =>
import("@/components/medications/dose-strength-curve").then((mod) => ({
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DoseStrengthCurve,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
@@ -38,7 +38,7 @@ import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
// holds the layout.
const AssessmentHistoryChartLazy = dynamic(
() =>
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
default: mod.AssessmentHistoryChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },