mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix: complete dashboard hydration
This commit is contained in:
1 parent
03877a0971
commit
d07b9249c8
16 files changed
+468
-60
No files matched your search
@@ -39,21 +39,28 @@ const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
const mockAccount = () => ({
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
user: mockAccount(),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
// The card reads the mount-pinned accessor: it paints inside the dashboard's
|
||||
// streamed boundary, where reading the grants on the hydration render would
|
||||
// disagree with the server and cost the whole streamed tree. These cases are
|
||||
// about the MOUNTED render, so the accessor answers the account.
|
||||
useAccountOnceMounted: () => mockAccount(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HeartHandshake, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountOnceMounted } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
accountLabel,
|
||||
@@ -49,7 +49,12 @@ import {
|
||||
*/
|
||||
export function LookingAfterCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
// Withheld until mounted: this card sits inside the dashboard's streamed
|
||||
// boundary and renders nothing without grants. The shell has usually answered
|
||||
// `/api/auth/me` before the boundary hydrates, so reading the grants on the
|
||||
// hydration render paints a card where the server painted none — a mismatch
|
||||
// that discards the streamed dashboard. See `useAccountOnceMounted`.
|
||||
const user = useAccountOnceMounted();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
|
||||
@@ -66,7 +66,15 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
const { data, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
// optimistic mount fetch on the client's very first render and cannot on the
|
||||
// server, so an `isLoading` gate renders one thing in the streamed HTML and
|
||||
// another in the hydration render — React #418, and the whole streamed
|
||||
// dashboard is discarded. Gate on what both sides can see instead: whether
|
||||
// the cell holds an answer yet.
|
||||
const showLoading = !data && !isError;
|
||||
const workouts = data?.workouts ?? [];
|
||||
|
||||
return (
|
||||
@@ -95,7 +103,7 @@ export function RecentWorkoutsTile() {
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
{showLoading ? (
|
||||
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
|
||||
// (3 workouts × ~3 rem rows + spacing) so the surrounding
|
||||
// dashboard layout doesn't reflow once the data lands.
|
||||
|
||||
@@ -18,8 +18,18 @@ let mockUser: { modules?: Record<string, boolean> } | null = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: mockUser }),
|
||||
useAccountOnceMounted: () => mockUser,
|
||||
}));
|
||||
|
||||
// The card paints its skeleton until it is mounted, because the achievements
|
||||
// cell is shared with `<AchievementUnlockNotifier>` in the app shell: on a
|
||||
// cold load of the dashboard that cell is often already full by the time this
|
||||
// card hydrates, where the server had nothing, and a content branch there is a
|
||||
// hydration mismatch that costs the whole streamed page. These cases are about
|
||||
// the MOUNTED render, so the flag is stubbed to what it answers from the first
|
||||
// client re-render on.
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
|
||||
let mockData:
|
||||
| {
|
||||
summary: unknown;
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
@@ -83,7 +84,12 @@ const RECENT_LIMIT = 3;
|
||||
*/
|
||||
export function RecentAchievementsCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// Withheld until mounted for the same reason the skeleton gate below is:
|
||||
// this card paints inside the dashboard's streamed boundary, and a module
|
||||
// gate that reads the account on the hydration render disagrees with the
|
||||
// server, which had none. See `useAccountOnceMounted`.
|
||||
const user = useAccountOnceMounted();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the dashboard tile disappears entirely (the data
|
||||
@@ -102,6 +108,16 @@ export function RecentAchievementsCard() {
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
// The dashboard is a streamed Suspense boundary and hydrates after the app
|
||||
// shell. `<AchievementUnlockNotifier>` mounts with the shell and shares this
|
||||
// exact cache cell, so by the time this card replays its first render on the
|
||||
// client the payload is often already there — where the server had nothing
|
||||
// and painted the skeleton. Pinning the loading branch to the mount snapshot
|
||||
// keeps the hydration render identical to the server's; the unlocks land on
|
||||
// the re-render right after. See `useMounted`.
|
||||
const mounted = useMounted();
|
||||
const showSkeleton = !mounted || isPending;
|
||||
|
||||
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
|
||||
|
||||
// Module off ⇒ render nothing so the tile vanishes from the dashboard.
|
||||
@@ -132,7 +148,7 @@ export function RecentAchievementsCard() {
|
||||
}
|
||||
/>
|
||||
|
||||
{isPending ? (
|
||||
{showSkeleton ? (
|
||||
<ul
|
||||
data-slot="recent-achievements-skeleton"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -61,7 +61,15 @@ export function VorsorgeDashboardCard() {
|
||||
// the dashboard summary offering what the dedicated page withholds was the
|
||||
// tell that this row had never been asked.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders();
|
||||
const { data: reminders, isError } = useMeasurementReminders();
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
// optimistic mount fetch on the client's very first render and cannot on the
|
||||
// server, so an `isLoading` gate renders one thing in the streamed HTML and
|
||||
// another in the hydration render — React #418, and the whole streamed
|
||||
// dashboard is discarded. Gate on what both sides can see instead: whether
|
||||
// the cell holds an answer yet.
|
||||
const showLoading = !reminders && !isError;
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
@@ -102,7 +110,7 @@ export function VorsorgeDashboardCard() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading ? (
|
||||
{showLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountOnceMounted } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -149,7 +149,13 @@ function readExpanded(): boolean {
|
||||
* with status icon + label + CTA + dismiss-x.
|
||||
*/
|
||||
export function GettingStartedChecklist() {
|
||||
const { user } = useAuth();
|
||||
// Withheld until mounted: this card lives inside the dashboard's streamed
|
||||
// boundary and its whole render turns on `if (!user) return null` below. The
|
||||
// shell has usually answered `/api/auth/me` before the boundary hydrates, so
|
||||
// reading the payload on the hydration render would paint a card where the
|
||||
// server painted nothing — a mismatch that discards the streamed dashboard.
|
||||
// See `useAccountOnceMounted`.
|
||||
const user = useAccountOnceMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<ChecklistItemId>>(() =>
|
||||
|
||||
Reference in new issue
Block a user