mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(dashboard): per-tile Suspense boundaries for progressive paint
Wrap every chart row cell and every tile-strip cell in a per-cell `<Suspense>` 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 `<ChartSkeleton>` 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.
This commit is contained in:
1 parent
128d863763
commit
3cacfcf94c
1 file changed
+62
-8
+62
-8
@@ -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) => (
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{entry.node}
|
||||
{/*
|
||||
* v1.4.40 W-RSC — per-tile `<Suspense>` 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.
|
||||
*/}
|
||||
<Suspense fallback={null}>{entry.node}</Suspense>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
{/*
|
||||
* v1.4.40 W-RSC — per-tile `<Suspense>` 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
|
||||
* `<ChartSkeleton>` 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.
|
||||
*/}
|
||||
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
|
||||
{entry.count != null ? <TrendHint count={entry.count} /> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user