mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target version instead of disarming for the whole session, so a second deploy re-arms the self-heal and a stale shell can no longer strand with 404ing chunks. Lazy chart imports retry once and mount inside a per-card error boundary with a reload affordance — a failed chunk degrades to one card instead of replacing the page. The JSON API client attaches a fifteen-second timeout unless the caller brings its own signal (the raw streaming path is exempt), chart queries surface errors with a retry instead of rendering as an empty period, per-type fetch failures reject instead of caching an empty series as fresh, and the session and snapshot queries retry once on transient network or server errors.
This commit is contained in:
1 parent
b346e78cc7
commit
b5e799c2d6
27 files changed
+892
-89
No files matched your search
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
|
||||
"noDataInRangeTitle": "Keine Daten in diesem Zeitraum",
|
||||
"noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.",
|
||||
"errorTitle": "Daten konnten nicht geladen werden",
|
||||
"loadErrorTitle": "Diagramm konnte nicht geladen werden",
|
||||
"loadErrorReload": "Neu laden",
|
||||
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
|
||||
"personalBaseline": "Dein Mittel",
|
||||
"deltaVsBaseline": "{delta} vs. dein Mittel",
|
||||
|
||||
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
|
||||
"noDataInRangeTitle": "No data in this range",
|
||||
"noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.",
|
||||
"errorTitle": "Data could not be loaded",
|
||||
"loadErrorTitle": "Chart could not be loaded",
|
||||
"loadErrorReload": "Reload",
|
||||
"sparseDataCaption": "More days will fill out the trend.",
|
||||
"personalBaseline": "Your normal",
|
||||
"deltaVsBaseline": "{delta} vs. your normal",
|
||||
|
||||
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
|
||||
"noDataInRangeTitle": "Sin datos en este rango",
|
||||
"noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.",
|
||||
"errorTitle": "No se pudieron cargar los datos",
|
||||
"loadErrorTitle": "No se pudo cargar el gráfico",
|
||||
"loadErrorReload": "Recargar",
|
||||
"sparseDataCaption": "Más días completarán la tendencia.",
|
||||
"personalBaseline": "Tu media",
|
||||
"deltaVsBaseline": "{delta} vs. tu media",
|
||||
|
||||
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
|
||||
"noDataInRangeTitle": "Aucune donnée sur cette période",
|
||||
"noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.",
|
||||
"errorTitle": "Impossible de charger les données",
|
||||
"loadErrorTitle": "Impossible de charger le graphique",
|
||||
"loadErrorReload": "Recharger",
|
||||
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
|
||||
"personalBaseline": "Ta moyenne",
|
||||
"deltaVsBaseline": "{delta} vs. ta moyenne",
|
||||
|
||||
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
|
||||
"noDataInRangeTitle": "Nessun dato in questo intervallo",
|
||||
"noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.",
|
||||
"errorTitle": "Impossibile caricare i dati",
|
||||
"loadErrorTitle": "Impossibile caricare il grafico",
|
||||
"loadErrorReload": "Ricarica",
|
||||
"sparseDataCaption": "Più giorni completeranno il trend.",
|
||||
"personalBaseline": "La tua media",
|
||||
"deltaVsBaseline": "{delta} vs. la tua media",
|
||||
|
||||
@@ -1740,6 +1740,9 @@
|
||||
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
|
||||
"noDataInRangeTitle": "Brak danych w tym zakresie",
|
||||
"noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.",
|
||||
"errorTitle": "Nie udało się załadować danych",
|
||||
"loadErrorTitle": "Nie udało się załadować wykresu",
|
||||
"loadErrorReload": "Załaduj ponownie",
|
||||
"sparseDataCaption": "Więcej dni dopełni trend.",
|
||||
"personalBaseline": "Twoja średnia",
|
||||
"deltaVsBaseline": "{delta} vs. twoja średnia",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
|
||||
*
|
||||
* The guard is keyed on the running shell's build version: one reload
|
||||
* attempt per BROKEN SHELL, not one per session. After a successful
|
||||
* heal the reloaded page carries the new version and the guard re-arms
|
||||
* by construction; the pre-v1.16.8 once-per-session key exhausted on
|
||||
* multi-deploy days and stranded the user on the error page.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
|
||||
|
||||
describe("shouldAttemptChunkReload", () => {
|
||||
it("reloads when no attempt is recorded", () => {
|
||||
expect(shouldAttemptChunkReload(null, "1.16.8")).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a second reload for the SAME shell version", () => {
|
||||
const stored = chunkReloadGuardValue("1.16.8");
|
||||
expect(shouldAttemptChunkReload(stored, "1.16.8")).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms after a deploy — the reloaded shell carries a NEW version", () => {
|
||||
const stored = chunkReloadGuardValue("1.16.8");
|
||||
expect(shouldAttemptChunkReload(stored, "1.16.9")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a legacy timestamp guard value as no attempt", () => {
|
||||
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
|
||||
// a guard value, so a stranded session heals on its next chunk error.
|
||||
expect(shouldAttemptChunkReload("1765400000000", "1.16.8")).toBe(true);
|
||||
});
|
||||
|
||||
it("degrades to once-per-shell-lifetime when no version is injected", () => {
|
||||
expect(chunkReloadGuardValue("")).toBe("unversioned");
|
||||
expect(shouldAttemptChunkReload(null, "")).toBe(true);
|
||||
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
+39
-5
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { ErrorDetails } from "@/components/error-details";
|
||||
|
||||
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
function isChunkLoadError(err: Error & { digest?: string }): boolean {
|
||||
if (err.name === "ChunkLoadError") return true;
|
||||
@@ -16,6 +17,33 @@ function isChunkLoadError(err: Error & { digest?: string }): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard value recorded per auto-reload attempt — the running shell's
|
||||
* build version, so the suppression is scoped to ONE shell. After a
|
||||
* successful heal the reloaded page carries the NEW version and the
|
||||
* guard re-arms by construction; only a shell that reloaded and is
|
||||
* STILL broken (the new build is also missing the chunk) stays
|
||||
* suppressed. Builds without an injected version degrade to the legacy
|
||||
* once-per-session behaviour via the "unversioned" sentinel.
|
||||
*/
|
||||
export function chunkReloadGuardValue(shellVersion: string): string {
|
||||
return shellVersion || "unversioned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `stored` is the sessionStorage value from the last attempt
|
||||
* (`null` when none happened); legacy guard values (the pre-v1.16.8
|
||||
* timestamp) never match a version string, so a session stuck from
|
||||
* before the change heals on its next chunk error.
|
||||
*/
|
||||
export function shouldAttemptChunkReload(
|
||||
stored: string | null,
|
||||
shellVersion: string,
|
||||
): boolean {
|
||||
return stored !== chunkReloadGuardValue(shellVersion);
|
||||
}
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
@@ -36,13 +64,19 @@ export default function AppError({
|
||||
// After a deploy the cached shell still references old chunk
|
||||
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
|
||||
// A single hard reload fetches the new shell + chunk graph.
|
||||
// sessionStorage gates the auto-reload so an error we can't recover
|
||||
// from (e.g. the new build is also missing the chunk) doesn't loop.
|
||||
// The sessionStorage guard is keyed on the shell version (see
|
||||
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
|
||||
// broken shell but re-arms after every deploy — the pre-v1.16.8
|
||||
// once-per-session guard exhausted on multi-deploy days and left
|
||||
// the user on the error page with no automatic way out.
|
||||
if (typeof window !== "undefined" && isChunkLoadError(error)) {
|
||||
try {
|
||||
const already = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (!already) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now()));
|
||||
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
|
||||
window.sessionStorage.setItem(
|
||||
CHUNK_RELOAD_KEY,
|
||||
chunkReloadGuardValue(SHELL_VERSION),
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
|
||||
+27
-8
@@ -28,8 +28,10 @@ import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import {
|
||||
DashboardChartCell,
|
||||
useDashboardChartReveal,
|
||||
@@ -64,20 +66,37 @@ const DASHBOARD_QUERY_OPTS = {
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
const MoodChart = dynamic(
|
||||
// v1.16.8 — the loaders retry a rejected chunk import once (a lazy
|
||||
// import caches its rejection permanently, so a transient 404 from a
|
||||
// stale shell used to brick the card for the session) and each mount
|
||||
// wraps in `<ChartErrorBoundary>` so a chunk that still fails degrades
|
||||
// to ONE error card instead of bubbling to the route-level `error.tsx`.
|
||||
const MoodChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/mood-chart")).then(
|
||||
(mod) => ({ default: mod.MoodChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const MedicationComplianceChart = dynamic(
|
||||
const MoodChart = (props: React.ComponentProps<typeof MoodChartLazy>) => (
|
||||
<ChartErrorBoundary>
|
||||
<MoodChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
const MedicationComplianceChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/medication-compliance-chart").then((mod) => ({
|
||||
default: mod.MedicationComplianceChart,
|
||||
})),
|
||||
importWithRetry(
|
||||
() => import("@/components/charts/medication-compliance-chart"),
|
||||
).then((mod) => ({ default: mod.MedicationComplianceChart })),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const MedicationComplianceChart = (
|
||||
props: React.ComponentProps<typeof MedicationComplianceChartLazy>,
|
||||
) => (
|
||||
<ChartErrorBoundary>
|
||||
<MedicationComplianceChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Unit tests for the version-poller reload decision.
|
||||
*
|
||||
* The guard is keyed on the TARGET (live) version: a repeat mismatch
|
||||
* against the SAME version is suppressed (no reload loop on a misset
|
||||
* server), but a SECOND deploy in one session moves the live version
|
||||
* past the recorded one and the heal flow re-arms. The pre-v1.16.8
|
||||
* guard disabled all polling after one attempt, which stranded stale
|
||||
* shells on multi-deploy days.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveVersionPollDecision } from "../version-poller";
|
||||
|
||||
describe("resolveVersionPollDecision", () => {
|
||||
it("is up-to-date when live matches the shell", () => {
|
||||
expect(resolveVersionPollDecision("1.16.8", "1.16.8", null)).toBe(
|
||||
"up-to-date",
|
||||
);
|
||||
});
|
||||
|
||||
it("is up-to-date when the live version cannot be read", () => {
|
||||
expect(resolveVersionPollDecision(null, "1.16.8", null)).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("reloads on a mismatch with no prior attempt", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", null)).toBe("reload");
|
||||
});
|
||||
|
||||
it("suppresses a repeat reload for the SAME target version", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", "1.16.9")).toBe(
|
||||
"already-attempted",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-arms when a SECOND deploy moves the live version past the recorded one", () => {
|
||||
expect(resolveVersionPollDecision("1.16.10", "1.16.8", "1.16.9")).toBe(
|
||||
"reload",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a legacy timestamp guard value as no attempt", () => {
|
||||
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
|
||||
// a real version string, so a stranded session heals on its next poll.
|
||||
expect(
|
||||
resolveVersionPollDecision("1.16.9", "1.16.8", "1765400000000"),
|
||||
).toBe("reload");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
// vi.doMock below invalidates the module registry, so the re-imported
|
||||
// provider gets a fresh (unprimed) locale cache — pass the DE bundle
|
||||
// explicitly instead of relying on the vitest.setup.ts seeding.
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
/**
|
||||
* v1.16.8 — failed chart queries must look like ERRORS, not "no data".
|
||||
*
|
||||
* Pre-fix all three dashboard charts destructured only
|
||||
* `{ data, isLoading }`: a failed query fell into the empty-state copy
|
||||
* ("Keine Daten in diesem Zeitraum" / "Keine Daten verfügbar") — and
|
||||
* the mood chart unmounted its card entirely. The fix surfaces
|
||||
* `isError` as a `<ChartErrorState>` card with a retry affordance in
|
||||
* each chart, keeping the card chrome mounted so the dashboard
|
||||
* composition stays intact.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockQueryError() {
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch: () => Promise.resolve(),
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("chart isError rendering — error state, not empty state", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("<HealthChart> paints the error card with retry, keeps the card chrome", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
// Card chrome must stay mounted.
|
||||
expect(html).toContain("Pulse");
|
||||
// The empty-state copy must NOT paint — an outage is not "no data".
|
||||
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MoodChart> keeps the card mounted and paints the error card", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Pre-fix the component returned null here — html was empty.
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MedicationComplianceChart> paints the error card, not the no-data hint", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
expect(html).not.toContain("Keine Daten im gewählten Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactElement, type ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Error-state card + per-chart error boundary.
|
||||
*
|
||||
* Two failure classes used to look like something else:
|
||||
* - A failed data query fell into the "no data in this range" empty
|
||||
* state (the queries swallowed per-type failures as empty success),
|
||||
* so an outage read as "you have no measurements".
|
||||
* - A rejected lazy chunk import (stale shell after a deploy) bubbled
|
||||
* to the route-level `error.tsx` and replaced the WHOLE page.
|
||||
*
|
||||
* `<ChartErrorState>` is the shared presentational card — same dashed
|
||||
* footprint as `<ChartEmptyState>` so the dashboard layout never
|
||||
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
|
||||
* each lazy chart mount so a failed chunk degrades to one card with a
|
||||
* reload affordance instead of taking the page down.
|
||||
*/
|
||||
|
||||
export interface ChartErrorStateProps {
|
||||
/** Translated headline, e.g. "Data could not be loaded". */
|
||||
title: string;
|
||||
/** Translated action-button label, e.g. "Retry". */
|
||||
actionLabel: string;
|
||||
/** Action handler — query `refetch` or a full page reload. */
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<ChartEmptyState>`).
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
height = 240,
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryProps {
|
||||
fallback: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): BoundaryState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Surface to the client-side error tracker the same way the
|
||||
// route-level boundary does — the card degrades quietly but the
|
||||
// failure still lands in monitoring.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card boundary for lazy chart mounts. A chunk that still rejects
|
||||
* after the `importWithRetry` attempt (see `src/lib/retry-import.ts`)
|
||||
* renders this card instead of bubbling to the route boundary; the
|
||||
* reload affordance fetches the fresh shell + chunk graph (a rejected
|
||||
* lazy import is cached, so a remount alone cannot recover).
|
||||
*/
|
||||
export function ChartErrorBoundary({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Boundary
|
||||
fallback={
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentProps, ReactElement } from "react";
|
||||
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
/**
|
||||
* v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the
|
||||
@@ -17,17 +20,33 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
*
|
||||
* Re-exporting through this module gives every consumer the same
|
||||
* pre-configured `<ChartSkeleton>`-backed lazy boundary. Consumers
|
||||
* still forward the same props they did before — the type contract
|
||||
* mirrors `<HealthChart>` exactly via `dynamic()`'s own inference.
|
||||
* still forward the same props they did before.
|
||||
*
|
||||
* v1.16.8 — the loader retries a rejected chunk import once (a lazy
|
||||
* import caches its rejection permanently, so a transient 404 from a
|
||||
* stale shell used to brick the card for the session) and every mount
|
||||
* wraps in `<ChartErrorBoundary>` so a chunk that still fails degrades
|
||||
* to ONE error card with a reload affordance instead of bubbling to the
|
||||
* route-level `error.tsx`.
|
||||
*/
|
||||
export const HealthChartDynamic = dynamic(
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamic(
|
||||
props: ComponentProps<typeof HealthChartLazy>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
|
||||
* underlying `<HealthChart>` import, but the loading shell renders the
|
||||
@@ -38,10 +57,20 @@ export const HealthChartDynamic = dynamic(
|
||||
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
|
||||
* ensures the next/dynamic loader paints the matching skeleton.
|
||||
*/
|
||||
export const HealthChartDynamicMini = dynamic(
|
||||
const HealthChartLazyMini = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamicMini(
|
||||
props: ComponentProps<typeof HealthChartLazyMini>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazyMini {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
@@ -631,7 +632,7 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
|
||||
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
|
||||
// and a fresh measurement evicts every per-chart daily cache in one
|
||||
@@ -708,16 +709,14 @@ export function HealthChart({
|
||||
"days",
|
||||
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
|
||||
);
|
||||
let points: Array<{ at: string; value: number }>;
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
points = data?.points ?? [];
|
||||
} catch {
|
||||
// Sleep overlay is additive — skip it on failure, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed fetch rejects the whole query instead of
|
||||
// silently resolving an empty series: the swallowed-catch
|
||||
// variant cached the empty result as fresh success, so an
|
||||
// outage painted "no data in this range" with no retry path.
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
const points = data?.points ?? [];
|
||||
for (const point of points) {
|
||||
const value = point.value * valueScale;
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
@@ -773,16 +772,16 @@ export function HealthChart({
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
|
||||
let page: MeasurementApiRow[];
|
||||
try {
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
page = data?.measurements ?? [];
|
||||
} catch {
|
||||
// Per-type fetch failure degrades that series only, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed per-type fetch rejects the whole query
|
||||
// instead of silently dropping the series: the swallowed-catch
|
||||
// variant cached the partial/empty result as fresh success, so
|
||||
// an outage painted the empty state (or a half chart) with no
|
||||
// retry path. Rejecting lets TanStack's retry semantics and the
|
||||
// in-card error state below do their jobs.
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
const page = data?.measurements ?? [];
|
||||
|
||||
for (const measurement of page) {
|
||||
// v1.7.0 — fold the display-time scale into the raw value at
|
||||
@@ -1545,6 +1544,16 @@ export function HealthChart({
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data in this range" empty state.
|
||||
// Pre-fix `isError` fell through to the empty branch and an
|
||||
// outage read as "you have no measurements".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
|
||||
@@ -56,6 +56,7 @@ import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { ChartOverlayControls } from "./chart-overlay-controls";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
@@ -242,7 +243,7 @@ export function MedicationComplianceChart({
|
||||
// `["dashboard-medication-compliance"]` lands in
|
||||
// `medicationDependentKeys` and an intake POST refreshes the chart
|
||||
// immediately rather than waiting for `staleTime` (audit L4).
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.dashboardMedicationCompliance(days),
|
||||
queryFn: async (): Promise<DailyCompliancePoint[]> => {
|
||||
return apiGet<DailyCompliancePoint[]>(`/api/medications/intake?scope=compliance&days=${days}`);
|
||||
@@ -415,6 +416,16 @@ export function MedicationComplianceChart({
|
||||
// `h-48` spinner box; matches the painted chart body below so
|
||||
// the card never jumps when the data lands.
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data" hint. Pre-fix `isError` fell
|
||||
// through to the empty branch (data undefined → hasData false)
|
||||
// and an outage read as "no doses tracked".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !hasData ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type {
|
||||
@@ -346,7 +347,7 @@ export function MoodChart({
|
||||
// cache fresh; the chart's own subscription becomes the cache writer
|
||||
// for any insights / mood sub-page that mounts the chart without the
|
||||
// dashboard parent having fetched first.
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async (): Promise<MoodAnalyticsData> => {
|
||||
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
|
||||
@@ -580,7 +581,11 @@ export function MoodChart({
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
|
||||
// the mount on `hasMood` anyway). A failed query must keep the card
|
||||
// mounted so the error state + retry affordance below can paint;
|
||||
// pre-fix the `return null` made an outage erase the widget entirely.
|
||||
if (!isLoading && !isError && !data?.entries?.length) return null;
|
||||
|
||||
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
|
||||
// chart body render inside the exact same flat shell `<HealthChart
|
||||
@@ -602,6 +607,15 @@ export function MoodChart({
|
||||
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
|
||||
}
|
||||
/>
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance instead of unmounting the card (see the gate above).
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -30,15 +30,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
* 3. `window.location.reload()` — fetches the fresh shell + new
|
||||
* chunk graph.
|
||||
*
|
||||
* `sessionStorage` gates the reload to once per session so a misset
|
||||
* version (server briefly serves a stale image, e.g. mid-deploy
|
||||
* webhook race) cannot loop.
|
||||
* The reload guard is keyed on the TARGET version: `sessionStorage`
|
||||
* records which live version we last reloaded for, and only a repeat
|
||||
* mismatch against that SAME version is suppressed (a misset version —
|
||||
* server briefly serves a stale image mid-deploy — cannot loop).
|
||||
* Polling itself never stops, so on a multi-deploy day a SECOND
|
||||
* release moves the live version past the recorded one and the heal
|
||||
* flow re-arms. The pre-v1.16.8 guard disabled all polling for the
|
||||
* rest of the session after one attempt, which left a stale shell
|
||||
* with 404ing chunks stuck forever once the guard was spent.
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `lastReloadedFor` is the live version recorded by the most
|
||||
* recent heal attempt in this session (`null` when none happened);
|
||||
* legacy guard values (the pre-v1.16.8 timestamp) never match a real
|
||||
* version string, so a stuck session from before the change heals on
|
||||
* its next mismatch.
|
||||
*/
|
||||
export function resolveVersionPollDecision(
|
||||
liveVersion: string | null,
|
||||
shellVersion: string,
|
||||
lastReloadedFor: string | null,
|
||||
): VersionPollDecision {
|
||||
if (!liveVersion || liveVersion === shellVersion) return "up-to-date";
|
||||
if (lastReloadedFor === liveVersion) return "already-attempted";
|
||||
return "reload";
|
||||
}
|
||||
|
||||
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
try {
|
||||
const data = await apiGet<{ version?: string } | undefined>(
|
||||
@@ -51,9 +77,9 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function evictAndReload(): Promise<void> {
|
||||
async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, String(Date.now()));
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict-privacy modes; fall
|
||||
// through to the reload anyway — worst case the next deploy
|
||||
@@ -85,19 +111,24 @@ export function VersionPoller(): null {
|
||||
useEffect(() => {
|
||||
if (!SHELL_VERSION) return;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (sessionStorage.getItem(SESSION_GUARD_KEY)) return;
|
||||
} catch {
|
||||
/* fall through — we'll try the reload anyway if mismatch hits */
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function checkOnce(): Promise<void> {
|
||||
const live = await fetchLiveVersion(controller.signal);
|
||||
if (!live) return;
|
||||
if (live === SHELL_VERSION) return;
|
||||
await evictAndReload();
|
||||
let lastReloadedFor: string | null = null;
|
||||
try {
|
||||
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
|
||||
} catch {
|
||||
/* fall through — treat as "no attempt recorded" */
|
||||
}
|
||||
const decision = resolveVersionPollDecision(
|
||||
live,
|
||||
SHELL_VERSION,
|
||||
lastReloadedFor,
|
||||
);
|
||||
if (decision !== "reload") return;
|
||||
await evictAndReload(live as string);
|
||||
}
|
||||
|
||||
// First check 5 s after mount — gives the app a chance to settle
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* v1.16.8 — the auth cell retries ONCE on transient failures.
|
||||
*
|
||||
* `useAuth` used to pin `retry: false`, so one transient network blip
|
||||
* on `/api/auth/me` flipped `isAuthenticated` false and sent the shell
|
||||
* to the redirect spinner mid-session. The cell now uses the shared
|
||||
* transient-retry predicate: one retry on network errors / 5xx, never
|
||||
* on 401/403 (those are real answers).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const useQueryMock = vi.fn<(opts: Record<string, unknown>) => { data: undefined }>(
|
||||
() => ({ data: undefined }),
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: Record<string, unknown>) => useQueryMock(opts),
|
||||
useQueryClient: () => ({}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: () => undefined }),
|
||||
}));
|
||||
|
||||
import { useAuth } from "../use-auth";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
afterEach(() => {
|
||||
useQueryMock.mockClear();
|
||||
});
|
||||
|
||||
describe("useAuth — transient-retry softening", () => {
|
||||
it("routes retry through the shared transient predicate", () => {
|
||||
useAuth();
|
||||
expect(useQueryMock).toHaveBeenCalledTimes(1);
|
||||
const opts = useQueryMock.mock.calls[0]![0];
|
||||
expect(opts.retry).toBe(retryOnceOnTransientError);
|
||||
expect(opts.queryKey).toEqual(queryKeys.authMe());
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TimeFormatPreference } from "@/lib/format-locale";
|
||||
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
||||
@@ -127,7 +128,10 @@ export function useAuth() {
|
||||
// `queryKeys.auth()` invalidations.
|
||||
queryKey: queryKeys.authMe(),
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
|
||||
// single transient failure used to flip `isAuthenticated` false and
|
||||
// send the shell to the redirect spinner mid-session.
|
||||
retry: retryOnceOnTransientError,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Unit tests for the lazy-chunk import retry wrapper.
|
||||
*
|
||||
* A rejected `import()` inside `React.lazy` / `next/dynamic` caches its
|
||||
* rejection permanently, so the wrapper must absorb ONE transient
|
||||
* failure (stale shell right after a deploy, flaky network) before the
|
||||
* rejection reaches the boundary.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IMPORT_RETRY_DELAY_MS, importWithRetry } from "../retry-import";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("importWithRetry", () => {
|
||||
it("resolves immediately when the first attempt succeeds", async () => {
|
||||
const importer = vi.fn().mockResolvedValue({ default: "ok" });
|
||||
|
||||
await expect(importWithRetry(importer)).resolves.toEqual({
|
||||
default: "ok",
|
||||
});
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries once after the delay when the first attempt rejects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const importer = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Loading chunk 42 failed"))
|
||||
.mockResolvedValueOnce({ default: "ok" });
|
||||
|
||||
const promise = importWithRetry(importer);
|
||||
// The retry must not fire before the delay elapses.
|
||||
await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS - 1);
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(promise).resolves.toEqual({ default: "ok" });
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects with the original error once the retry budget is spent", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chunkError = new Error("Loading chunk 42 failed");
|
||||
const importer = vi.fn().mockRejectedValue(chunkError);
|
||||
|
||||
const promise = importWithRetry(importer);
|
||||
promise.catch(() => undefined); // pre-attach so the rejection is handled
|
||||
await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS);
|
||||
|
||||
await expect(promise).rejects.toBe(chunkError);
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,9 @@ describe("apiFetch", () => {
|
||||
const data = await apiFetch<{ id: string }>("/api/measurements/m1");
|
||||
|
||||
expect(data).toEqual({ id: "m1" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes init through to fetch (signal, cache, headers)", async () => {
|
||||
@@ -171,6 +173,67 @@ describe("apiFetchRaw", () => {
|
||||
|
||||
await expect(apiFetchRaw("/api/foo")).resolves.toBe(res);
|
||||
});
|
||||
|
||||
it("applies NO default timeout — SSE streams outlive any fixed window", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("stream", { status: 200 }));
|
||||
|
||||
await apiFetchRaw("/api/insights/chat", { method: "POST" });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("default timeout", () => {
|
||||
it("attaches a default AbortSignal when the caller passes no signal", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
await apiFetch("/api/foo", { cache: "no-store" });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.cache).toBe("no-store");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("a caller-supplied signal replaces the default", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
const controller = new AbortController();
|
||||
|
||||
await apiFetch("/api/foo", { signal: controller.signal });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("signal: null opts out of the default entirely", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
await apiFetch("/api/foo", { signal: null });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBeNull();
|
||||
});
|
||||
|
||||
it("apiFetchEnvelope carries the same default", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ data: [], error: null, meta: {} }),
|
||||
);
|
||||
|
||||
await apiFetchEnvelope("/api/items");
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("the default signal carries through the verb helpers' body merge", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
await apiPost("/api/foo", { a: 1 });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verb helpers", () => {
|
||||
@@ -179,7 +242,10 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiGet("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "GET" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -206,7 +272,10 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiPost("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", { method: "POST" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("merges caller headers with the JSON Content-Type", async () => {
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
* callers can branch on `err.status === 429` for cooldown UX.
|
||||
* - No toast / no global side effect ever originates here. Surfacing
|
||||
* errors stays the call site's responsibility.
|
||||
* - Every request carries a default 15 s `AbortSignal.timeout` so a
|
||||
* stalled response can never hold a query in `isLoading` forever.
|
||||
* Passing `init.signal` (any value, including `null` to opt out)
|
||||
* replaces the default entirely — callers that need a longer
|
||||
* window or their own cancellation own the signal then.
|
||||
*
|
||||
* Verb helpers `apiGet` / `apiPost` / `apiPut` / `apiPatch` /
|
||||
* `apiDelete` JSON-encode an optional body and set `Content-Type:
|
||||
@@ -39,7 +44,9 @@
|
||||
* few callers that read success-side `meta` (pagination totals).
|
||||
* - `apiFetchRaw` is a plain same-origin `fetch` passthrough for call
|
||||
* sites that need the `Response` itself — blob downloads, SSE
|
||||
* streams, manual status branching. No `.ok` check, no unwrap.
|
||||
* streams, manual status branching. No `.ok` check, no unwrap, and
|
||||
* deliberately NO default timeout: the Coach chat stream and other
|
||||
* long-lived responses ride this path with their own signals.
|
||||
*/
|
||||
|
||||
import { readError } from "./read-error";
|
||||
@@ -91,6 +98,17 @@ async function parseEnvelope<T, M>(
|
||||
return JSON.parse(text) as Envelope<T, M>;
|
||||
}
|
||||
|
||||
/** Default per-request timeout for the envelope helpers. */
|
||||
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
function withDefaultTimeout(init?: RequestInit): RequestInit {
|
||||
// An explicit caller signal wins — including `null`, which opts out
|
||||
// of any timeout. Only the absent case gets the default so a stalled
|
||||
// response cannot park a TanStack query in `isLoading` forever.
|
||||
if (init?.signal !== undefined) return init;
|
||||
return { ...init, signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin API fetch — resolves with the unwrapped `data` payload,
|
||||
* throws {@link ApiError} on a non-OK response.
|
||||
@@ -99,7 +117,7 @@ export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, init);
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, unknown>(res);
|
||||
return (envelope === undefined ? undefined : envelope.data) as T;
|
||||
@@ -113,7 +131,7 @@ export async function apiFetchEnvelope<
|
||||
T = unknown,
|
||||
M = Record<string, unknown>,
|
||||
>(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> {
|
||||
const res = await fetch(path, init);
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, M>(res);
|
||||
return {
|
||||
@@ -125,7 +143,8 @@ export async function apiFetchEnvelope<
|
||||
/**
|
||||
* Plain same-origin `fetch` passthrough for call sites that need the raw
|
||||
* `Response` — blob downloads, SSE streams, manual status branching.
|
||||
* Performs no `.ok` check and no envelope unwrap.
|
||||
* Performs no `.ok` check, no envelope unwrap, and applies no default
|
||||
* timeout (SSE streams legitimately outlive any fixed window).
|
||||
*/
|
||||
export function apiFetchRaw(
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Unit tests for the shared shell-cell retry predicate.
|
||||
*
|
||||
* `useAuth` / `useDashboardSnapshot` used to pin `retry: false`, so one
|
||||
* transient blip flipped the shell to the redirect spinner / flashed
|
||||
* the full-dashboard empty state. The predicate grants exactly one
|
||||
* retry on transport failures and 5xx — never on 4xx, where the server
|
||||
* gave a real answer.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { retryOnceOnTransientError } from "../retry-transient";
|
||||
|
||||
describe("retryOnceOnTransientError", () => {
|
||||
it("retries a network-level failure once", () => {
|
||||
const netErr = new TypeError("fetch failed");
|
||||
expect(retryOnceOnTransientError(0, netErr)).toBe(true);
|
||||
expect(retryOnceOnTransientError(1, netErr)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries a timeout abort once", () => {
|
||||
const timeoutErr = new DOMException("signal timed out", "TimeoutError");
|
||||
expect(retryOnceOnTransientError(0, timeoutErr)).toBe(true);
|
||||
expect(retryOnceOnTransientError(1, timeoutErr)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries a 5xx once", () => {
|
||||
const serverErr = new ApiError("upstream", 503);
|
||||
expect(retryOnceOnTransientError(0, serverErr)).toBe(true);
|
||||
expect(retryOnceOnTransientError(1, serverErr)).toBe(false);
|
||||
});
|
||||
|
||||
it("never retries 401/403 — the server answered", () => {
|
||||
expect(retryOnceOnTransientError(0, new ApiError("Unauthorized", 401))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(retryOnceOnTransientError(0, new ApiError("Forbidden", 403))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("never retries other 4xx", () => {
|
||||
expect(retryOnceOnTransientError(0, new ApiError("Not found", 404))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(retryOnceOnTransientError(0, new ApiError("Too many", 429))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,18 @@ import {
|
||||
prefetchDashboardSnapshot,
|
||||
_resetDashboardSnapshotPreloadForTests,
|
||||
} from "../use-dashboard-snapshot";
|
||||
import { retryOnceOnTransientError } from "../retry-transient";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */
|
||||
function envelopeResponse(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ data, error: null }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useQueryMock.mockClear();
|
||||
getQueryDataMock.mockReset();
|
||||
@@ -70,14 +79,17 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => {
|
||||
expect(opts.queryKey).toEqual(queryKeys.dashboardSnapshot());
|
||||
});
|
||||
|
||||
it("retries once on transient failures only (network / 5xx, never 4xx)", () => {
|
||||
useDashboardSnapshot();
|
||||
const opts = lastOpts();
|
||||
expect(opts.retry).toBe(retryOnceOnTransientError);
|
||||
});
|
||||
|
||||
it("seeds the widgets cache from the snapshot layout when the slot is empty", async () => {
|
||||
const layout = { widgets: [] };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { layout, tiles: {} } }),
|
||||
}),
|
||||
vi.fn().mockResolvedValue(envelopeResponse({ layout, tiles: {} })),
|
||||
);
|
||||
getQueryDataMock.mockReturnValue(undefined);
|
||||
useDashboardSnapshot();
|
||||
@@ -92,10 +104,9 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => {
|
||||
it("never clobbers an already-populated widgets cache", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { layout: { widgets: [] }, tiles: {} } }),
|
||||
}),
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(envelopeResponse({ layout: { widgets: [] }, tiles: {} })),
|
||||
);
|
||||
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
|
||||
useDashboardSnapshot();
|
||||
@@ -117,10 +128,9 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
|
||||
}
|
||||
|
||||
function stubFetch(payload: unknown = { layout: { widgets: [] }, tiles: {} }) {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: payload }),
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(envelopeResponse(payload)));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
@@ -161,10 +171,7 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("HTTP 401"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { layout, tiles: {} } }),
|
||||
});
|
||||
.mockResolvedValueOnce(envelopeResponse({ layout, tiles: {} }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Shared TanStack `retry` predicate for the shell-critical cells
|
||||
* (`useAuth`, `useDashboardSnapshot`): ONE retry on transport-level
|
||||
* failures (network `TypeError`, abort/timeout `DOMException`) and on
|
||||
* 5xx, never on a 4xx. A 401/403 is a real answer — retrying it only
|
||||
* delays the redirect / empty-state decision — while a single transient
|
||||
* blip used to flip the shell straight to the redirect spinner or flash
|
||||
* the full-dashboard empty state under the former `retry: false`.
|
||||
*/
|
||||
export function retryOnceOnTransientError(
|
||||
failureCount: number,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (failureCount >= 1) return false;
|
||||
if (error instanceof ApiError) return error.status >= 500;
|
||||
return true;
|
||||
}
|
||||
@@ -33,15 +33,18 @@ import {
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
|
||||
async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
|
||||
const res = await fetch("/api/dashboard/snapshot");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
return json.data as DashboardSnapshot;
|
||||
// Routed through the typed wrapper so a failure carries its HTTP
|
||||
// status (`ApiError`) for the transient-retry predicate, and the
|
||||
// default 15 s timeout keeps a stalled response from pinning the
|
||||
// whole dashboard on its skeleton.
|
||||
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot");
|
||||
}
|
||||
|
||||
/** Handoff freshness window — mirrors the hook's `staleTime`. */
|
||||
@@ -162,6 +165,9 @@ export function useDashboardSnapshot(enabled = true) {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: false,
|
||||
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
|
||||
// single transient failure used to flash the full-dashboard empty
|
||||
// state under the former `retry: false`.
|
||||
retry: retryOnceOnTransientError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Retrying wrapper for lazy chunk imports.
|
||||
*
|
||||
* A rejected `import()` inside `React.lazy` / `next/dynamic` caches its
|
||||
* rejection on the component instance, so one transient chunk 404 (a
|
||||
* stale shell right after a deploy, a flaky connection) bricks the lazy
|
||||
* boundary for the rest of the session — remounting never re-attempts
|
||||
* the import. Wrapping the importer here absorbs the transient class:
|
||||
* the import retries once after a short delay before the rejection is
|
||||
* allowed to surface to the boundary's error handling.
|
||||
*/
|
||||
|
||||
export const IMPORT_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Run `importer`, retrying `retries` more times (default once) after
|
||||
* `delayMs` when it rejects. The final rejection propagates unchanged
|
||||
* so the caller's error boundary sees the original chunk error.
|
||||
*/
|
||||
export async function importWithRetry<T>(
|
||||
importer: () => Promise<T>,
|
||||
retries = 1,
|
||||
delayMs = IMPORT_RETRY_DELAY_MS,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await importer();
|
||||
} catch (err) {
|
||||
if (retries <= 0) throw err;
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
return importWithRetry(importer, retries - 1, delayMs);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user