Compare commits

...
Author SHA1 Message Date
ajnart cef3f42a0b fix(widgets): prevent stale cache from serving errored data
Add two layers on top of PR #6399's error boundary recovery:

Layer 1 — QueryCache subscriber (query-cache-error-cleanup.ts):
Subscribe to QueryCache events. When a persistable query transitions
from success to error, resetQueries clears stale data so widgets show
error/loading UI instead of rendering incompatible cached values.
Uses resetQueries (not removeQueries) so active observers get a
refetch triggered rather than being orphaned with a destroyed query.

Layer 2 — Rehydration invalidation (trpc.tsx):
Add onSuccess callback to PersistQueryClientProvider that invalidates
all persistable queries after rehydration. Combined with staleTime: 0,
this ensures rehydrated persisted data is immediately marked stale and
refetches on mount rather than being served as fresh.

Closes #6398
2026-07-21 16:39:54 +02:00
Clarkar00 319a083c6d fix(widgets): refetch after render errors 2026-07-21 16:39:11 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
9 changed files with 224 additions and 5 deletions

No files matched your search

@@ -0,0 +1,29 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => {
const wasSuccessful = new Set<string>();
return queryClient.getQueryCache().subscribe((event) => {
const query = event.query;
const key = query.queryHash;
if (event.type === "removed") {
wasSuccessful.delete(key);
return;
}
if (event.type !== "updated") return;
if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) {
wasSuccessful.add(key);
return;
}
if (query.state.status === "error" && wasSuccessful.has(key)) {
queryClient.resetQueries({ queryKey: query.queryKey, exact: true });
wasSuccessful.delete(key);
}
});
};
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
@@ -36,6 +36,7 @@ import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
@@ -102,6 +103,10 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return client;
});
useEffect(() => {
return createQueryCacheErrorCleanup(queryClient);
}, [queryClient]);
const [trpcClient] = useState(() => {
return clientApi.createClient({
links: [
@@ -162,6 +167,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
onSuccess={() => {
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
@@ -19,6 +19,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -112,7 +113,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
{({ reset }) => (
<ErrorBoundary
onReset={() => {
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
removePersistedWidgetQueries(queryClient);
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -0,0 +1,157 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
describe("removePersistedWidgetQueries", () => {
test("removes stale widget queries and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("removes persisted queries outside the widget router", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(dockerQueryKey, "stale");
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("removes persisted widget queries when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
});
describe("query cache error cleanup", () => {
test("resets data when a persistable query transitions from success to error", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { old: true });
queryClient.setQueryData(userKey, { user: true });
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
expect(queryClient.getQueryData(userKey)).toEqual({ user: true });
unsubscribe();
});
test("preserves stale data for non-persistable query errors", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const boardKey = [["board", "getBoardByName"], { input: {} }];
queryClient.setQueryData(boardKey, { boards: [] });
await queryClient
.fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] });
unsubscribe();
});
test("handles success→error→success→error cycling without getting stuck", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "first-success");
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("second-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("third-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
unsubscribe();
});
test("unsubscribe stops processing further events", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "stale");
unsubscribe();
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBe("stale");
});
});
describe("rehydration marks persistable data as stale", () => {
test("rehydrated queries are invalidated so they refetch on mount", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0 } },
});
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { rehydrated: true });
queryClient.setQueryData(userKey, { rehydrated: true });
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false);
});
});
@@ -0,0 +1,7 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
};
+4 -1
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
@@ -27,6 +27,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
@@ -48,6 +49,7 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}),
});
@@ -66,5 +68,6 @@ export interface Weather {
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
humidity: number | undefined;
}[];
}
+2 -1
View File
@@ -2257,7 +2257,8 @@
"sunrise": "Sunrise",
"sunset": "Sunset",
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}",
"humidity": "Humidity: {humidity}%"
},
"kind": {
"clear": "Clear",
+8 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -87,6 +87,12 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text>
</Group>
)}
{weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size={16} />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group>
)}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<IconArrowUpRight size={16} />
@@ -204,6 +210,7 @@ function Forecast({ weather, options }: WeatherProps) {
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
maxWindSpeed={dayWeather.maxWindSpeed}
maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/>
</HoverCard.Dropdown>
</HoverCard>
+6
View File
@@ -5,6 +5,7 @@ import {
IconCloudRain,
IconCloudSnow,
IconCloudStorm,
IconDroplets,
IconMoon,
IconQuestionMark,
IconSnowflake,
@@ -51,6 +52,7 @@ interface WeatherDescriptionProps {
sunset?: string;
maxWindSpeed?: number;
maxWindGusts?: number;
humidity?: number;
}
/**
@@ -78,6 +80,7 @@ export const WeatherDescription = ({
sunset,
maxWindSpeed,
maxWindGusts,
humidity,
}: WeatherDescriptionProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
@@ -97,6 +100,9 @@ export const WeatherDescription = ({
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && (
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
)}
{maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindSpeed", {