fix: harden widget query persistence and refresh indicator
Review follow-ups on the session query cache: - Cache the per-payload size verdict and reuse a single TextEncoder. The persist subscription re-dehydrates the whole cache on every cache event, so every widget payload was being superjson-serialized several times per refresh cycle just to measure it. - Skip the size measurement on the restore path. It runs before any query is allowed to fetch and the payload was already budgeted when it was written. - Reject stored queries whose dataUpdatedAt is not a positive finite number; `undefined <= 0` is false, so those were hydrating with a NaN timestamp. - Add `stop()` and tear the persister down before `queryClient.clear()`, so the clear cannot overwrite the saved snapshot with an empty cache. - Remove every other scope's cache when a persister is created: signing out navigates away before the scope guard can react, which left the previous account's dashboard data in the tab. - Remove the entry instead of storing an empty cache. Refresh indicator: - Make it decorative instead of an aria-live region; every widget refreshes on a timer, so it made assistive technology repeat "loading" forever. Also broadens the e2e hydration-error matcher to production React's minified error codes, hoists the single WidgetTimeProvider in ClientBoard, memoizes the resolved local time zone, and drops the exports this branch made dead.
This commit is contained in:
12 files changed
+208
-91
No files matched your search
@@ -61,11 +61,6 @@ export interface WorldClockTime {
|
||||
dayDifference: number | null;
|
||||
}
|
||||
|
||||
export const getResolvedLocalTimeZone = () => {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC";
|
||||
};
|
||||
|
||||
export const createWorldClockTime = ({
|
||||
city,
|
||||
now,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useWidgetLocalTimeZone, WidgetTimeProvider } from "./widget-time-contex
|
||||
|
||||
const TimeProbe = () => (
|
||||
<time>
|
||||
{useWidgetNow("minute")?.toISOString() ?? "missing"}|{useWidgetLocalTimeZone() ?? "missing"}
|
||||
{useWidgetNow("minute")?.toISOString() ?? "missing"}|{useWidgetLocalTimeZone()}
|
||||
</time>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
|
||||
const WidgetTimestampContext = createContext<number | null>(null);
|
||||
const WidgetTimeZoneContext = createContext("UTC");
|
||||
@@ -15,15 +15,22 @@ export const WidgetTimeProvider = ({
|
||||
children,
|
||||
initialTimestamp,
|
||||
initialTimeZone,
|
||||
}: PropsWithChildren<WidgetTimeProviderProps>) => (
|
||||
<WidgetTimestampContext.Provider value={initialTimestamp}>
|
||||
<WidgetTimeZoneContext.Provider value={getSupportedTimeZone(initialTimeZone)}>
|
||||
{children}
|
||||
</WidgetTimeZoneContext.Provider>
|
||||
</WidgetTimestampContext.Provider>
|
||||
);
|
||||
}: PropsWithChildren<WidgetTimeProviderProps>) => {
|
||||
const timeZone = useMemo(() => getSupportedTimeZone(initialTimeZone), [initialTimeZone]);
|
||||
|
||||
return (
|
||||
<WidgetTimestampContext.Provider value={initialTimestamp}>
|
||||
<WidgetTimeZoneContext.Provider value={timeZone}>{children}</WidgetTimeZoneContext.Provider>
|
||||
</WidgetTimestampContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext);
|
||||
|
||||
/**
|
||||
* Resolves to the browser time zone, but renders (and hydrates) with the time
|
||||
* zone the server used so widgets can paint a time before hydration finishes.
|
||||
*/
|
||||
export const useWidgetLocalTimeZone = () => {
|
||||
const initialTimeZone = useContext(WidgetTimeZoneContext);
|
||||
return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone);
|
||||
@@ -31,7 +38,15 @@ export const useWidgetLocalTimeZone = () => {
|
||||
|
||||
const subscribeToTimeZone = () => () => undefined;
|
||||
|
||||
const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
// React calls the snapshot getter on every render of every consumer, and
|
||||
// resolving a time zone builds `Intl.DateTimeFormat` instances, so keep the
|
||||
// answer around: it cannot change without a new document.
|
||||
let resolvedLocalTimeZone: string | undefined;
|
||||
|
||||
const getResolvedLocalTimeZone = () => {
|
||||
resolvedLocalTimeZone ??= getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
return resolvedLocalTimeZone;
|
||||
};
|
||||
|
||||
const getSupportedTimeZone = (timeZone: string | undefined) => {
|
||||
if (!timeZone) return "UTC";
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { Loader } from "next/dynamic";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Center, Loader as UiLoader } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
|
||||
import * as anchorNote from "./anchor-note";
|
||||
import * as audioStats from "./audio-stats";
|
||||
@@ -41,7 +39,6 @@ import * as notebook from "./notebook";
|
||||
import * as paperlessNgx from "./paperless-ngx";
|
||||
import * as patchmon from "./patchmon";
|
||||
import * as notifications from "./notifications";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
import * as releases from "./releases";
|
||||
import * as rssFeed from "./rssFeed";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
@@ -191,19 +188,3 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
||||
}
|
||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||
: never[])[number];
|
||||
|
||||
export const reduceWidgetOptionsWithDefaultValues = (
|
||||
kind: WidgetKind,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
currentValue: Record<string, unknown> = {},
|
||||
) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
[key]: currentValue[key] ?? value.defaultValue,
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1 @@
|
||||
export { WidgetTimeProvider, useWidgetInitialTimestamp, useWidgetLocalTimeZone } from "./common/widget-time-context";
|
||||
export { getNextWidgetTickDelay, useWidgetNow } from "./common/use-widget-now";
|
||||
export type { WidgetClockPrecision } from "./common/use-widget-now";
|
||||
export { WidgetTimeProvider } from "./common/widget-time-context";
|
||||
Reference in new issue
Block a user