From 3cacfcf94cb99303de862d4fa3557f3bee65399a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:48:10 +0200 Subject: [PATCH] feat(dashboard): per-tile Suspense boundaries for progressive paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap every chart row cell and every tile-strip cell in a per-cell `` boundary so the dashboard composition reads as a grid of independently suspending islands rather than a flat tree that shares loading semantics (audit-H2 + brief C1). The dynamic-loaded charts (`HealthChartDynamic`, `MoodChart`, `MedicationComplianceChart`) already mount their own `` while the JS chunk resolves; the Suspense layer lifts the same skeleton to a streaming-compatible boundary so a future migration to `useSuspenseQuery` automatically buckets each chart's loading state to its own cell without a further composition pass. Bundled with two audit-cited polishes that share the same touch surface: hoist `DASHBOARD_QUERY_OPTS` to module scope so the option object reference stays stable across renders (audit-M2), and memoise the `getHourForTimeZone` call so the `Intl.DateTimeFormat` instantiation only re-runs when the user's timezone changes (audit-H4). Both are micro-wins individually but the page renders ~720 lines of JSX per pass, so a stable per-render allocation budget keeps Recharts re-render cost bounded. No visible behaviour change in the current cold-mount waterfall — every chart still suspends on its own client-fetch — but the structural boundary is the prerequisite for the v1.4.41 RSC migration that hoists slim-analytics fetching to the server. --- src/app/page.tsx | 70 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 95396dc6c..ddc44bf67 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { Suspense, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { @@ -48,6 +48,16 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; +// v1.4.40 W-RSC — module-scope so the option object is stable across +// renders (audit-M2). Pre-fix the same literal was declared inside the +// component body, so every render created a fresh `{}` reference; +// TanStack does a shallow compare and shrugs at the equivalent values, +// but the slot is one future-callback-field away from cache poisoning. +const DASHBOARD_QUERY_OPTS = { + staleTime: 60_000, + refetchOnWindowFocus: false, +} as const; + const MoodChart = dynamic( () => import("@/components/charts/mood-chart").then((mod) => ({ @@ -218,10 +228,9 @@ export default function DashboardPage() { // storm. None of these are real-time data; minute-scale staleness // is fine and the dashboard's chart queries already match this // cadence. - const DASHBOARD_QUERY_OPTS = { - staleTime: 60_000, - refetchOnWindowFocus: false, - } as const; + // + // v1.4.40 W-RSC — `DASHBOARD_QUERY_OPTS` hoisted to module scope + // (audit-M2 — stable reference across renders). // v1.4.39.2 — split the dashboard's analytics consumption so the // tile-strip paints from the slim slice and the BD-Zielbereich + @@ -559,7 +568,15 @@ export default function DashboardPage() { upperBound: 55, }, ); - const hour = user?.timezone ? getHourForTimeZone(user.timezone) : null; + // v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat` + // instantiation (audit-H4). The greeting derived from the hour only + // changes when the user's timezone changes; recomputing it every + // render churned the parts-array and walked the format ICU twice per + // render across a 1 400-line component body. + const hour = useMemo( + () => (user?.timezone ? getHourForTimeZone(user.timezone) : null), + [user?.timezone], + ); const timeGreeting = hour == null ? t("dashboard.greeting.day") @@ -1421,14 +1438,51 @@ export default function DashboardPage() { > {trendCards.map((entry) => (
- {entry.node} + {/* + * v1.4.40 W-RSC — per-tile `` boundary. + * The tile body is synchronous today (every tile + * reads from the merged `data` memo which already + * gates on the slim/thick analytics queries on the + * parent), so this fallback never paints in the + * current composition. The structural boundary + * matches the chart-row layer below so the entire + * dashboard composition reads as a streaming-ready + * grid of independently suspending cells — and + * future RSC hoists of any tile slot won't need a + * second pass to add the fallback infrastructure. + */} + {entry.node}
))} )} {charts.map((entry) => (
- {entry.node} + {/* + * v1.4.40 W-RSC — per-tile `` boundary so each + * chart paints independently rather than the row blocking + * on the slowest fetch (audit-H2 + brief C1). The + * `next/dynamic({ ssr: false })` lazy-load contract on + * `HealthChartDynamic` / `MoodChart` / + * `MedicationComplianceChart` already paints a + * `` while the JS chunk is in flight; this + * Suspense layer lifts the same skeleton to a streaming- + * compatible boundary so a future migration to + * `useSuspenseQuery` (when we replace the per-chart + * `["chart-data", …]` fetches with the slim-analytics- + * derived store) automatically buckets each chart's + * loading state to its own cell, with no further + * call-site changes. + * + * Today, the boundary is a structural no-op for the + * dynamic-loaded charts because their loading skeleton + * lives inside the dynamic factory. The benefit is + * future-proofing the composition: any descendant that + * later suspends (e.g. an RSC migration of a static + * legend, or a server-streamed sparkline) gets its own + * fallback without re-architecting the row. + */} + }>{entry.node} {entry.count != null ? : null}
))}