refactor: simplify widget reload persistence

This commit is contained in:
Thomas Camlong committed 2026-08-23 09:00:33 +02:00
1 parent 65a96b6b14
commit 89e4cb843d
8 files changed
+79 -241

No files matched your search

@@ -8,11 +8,9 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersister,
getQueryPersistenceBuster,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
removeSessionQueryCache,
scheduleRestoredDashboardQueryRefresh,
shouldPersistDashboardQuery,
} from "./query-persistence";
@@ -71,13 +69,12 @@ describe("widget query persistence", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toContain("user-a");
const userAKey = getQueryPersistenceStorageKey("user-a");
const userBKey = getQueryPersistenceStorageKey("user-b");
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(userBKey, "b");
removeSessionQueryCache("user-a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(userBKey)).toBe("b");
});
@@ -88,12 +85,12 @@ describe("widget query persistence", () => {
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
await expect(createSessionQueryPersister("user-b").restoreClient()).resolves.toBeUndefined();
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
});
@@ -105,7 +102,7 @@ describe("widget query persistence", () => {
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries],
@@ -130,7 +127,7 @@ describe("widget query persistence", () => {
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
@@ -141,13 +138,11 @@ describe("widget query persistence", () => {
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
const restoredPredicate = persister.takeRestoredDashboardQueryPredicate();
expect(restoredPredicate?.(query)).toBe(true);
expect(persister.takeRestoredDashboardQueryPredicate()).toBeUndefined();
window.sessionStorage.setItem(key, "not-valid-superjson");
await expect(persister.restoreClient()).resolves.toBeUndefined();
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
@@ -165,7 +160,7 @@ describe("widget query persistence", () => {
key,
stringify({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
@@ -198,9 +193,9 @@ describe("widget query persistence", () => {
});
test.each([
["expired", 1, getQueryPersistenceBuster("user-a")],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, getQueryPersistenceBuster("user-a")],
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
@@ -217,7 +212,7 @@ describe("widget query persistence", () => {
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: getQueryPersistenceBuster(scope),
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
@@ -236,7 +231,7 @@ describe("widget query persistence", () => {
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
@@ -264,12 +259,12 @@ describe("widget query persistence", () => {
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
@@ -283,48 +278,6 @@ describe("widget query persistence", () => {
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("refreshes only restored cache data without awaiting the refresh", async () => {
vi.useFakeTimers();
const scope = "user-a";
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10);
const persister = createSessionQueryPersister(scope);
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
clientState: dehydrate(cached.queryClient),
});
persister.flush();
await persister.restoreClient();
const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20);
const neverSettles = new Promise<void>(() => undefined);
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries").mockReturnValue(neverSettles);
expect(scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister)).toBeUndefined();
expect(invalidateQueries).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
expect(invalidateQueries).toHaveBeenCalledOnce();
expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ type: "all", refetchType: "active" }));
const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate;
expect(predicate?.(cached.query)).toBe(true);
expect(predicate?.(fresh.query)).toBe(false);
});
test("does not invalidate fresh queries when no cache was restored", async () => {
vi.useFakeTimers();
const persister = createSessionQueryPersister("empty");
await persister.restoreClient();
const fresh = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries");
scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister);
await vi.advanceTimersByTimeAsync(0);
expect(invalidateQueries).not.toHaveBeenCalled();
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
@@ -340,17 +293,16 @@ describe("widget query persistence", () => {
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
await expect(persister.restoreClient()).resolves.toBeUndefined();
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("blocked"),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
@@ -359,7 +311,6 @@ describe("widget query persistence", () => {
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(() => removeSessionQueryCache("blocked-getter")).not.toThrow();
await expect(createSessionQueryPersister("blocked-getter").restoreClient()).resolves.toBeUndefined();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
@@ -7,40 +7,22 @@ import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/que
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
const queryPersistenceBuster = "v1-session-superjson";
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache:v1";
const hashScope = (scope: string) => {
let hash = 2_166_136_261;
for (let index = 0; index < scope.length; index += 1) {
hash ^= scope.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(36);
};
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
export const getQueryPersistenceStorageKey = (scope: string | null) =>
`${queryPersistenceStoragePrefix}:${hashScope(scope ?? "anonymous")}`;
export const getQueryPersistenceBuster = (scope: string | null) => `${queryPersistenceBuster}:${scope ?? "anonymous"}`;
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
interface PersistableQuery {
queryHash?: string;
queryKey: QueryKey;
state: {
data: unknown;
dataUpdatedAt: number;
status: string;
};
}
type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean;
export interface SessionQueryPersister extends Persister {
flush: () => void;
takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined;
}
export type SessionQueryPersister = Persister & { flush: () => void };
export const shouldPersistDashboardQuery = (query: PersistableQuery) => {
if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) {
@@ -66,6 +48,7 @@ const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
@@ -74,9 +57,6 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
// Keep this guard inside the persister as well as in the provider's
// dehydration options so a future caller cannot accidentally store an
// unrelated query payload.
queries: client.clientState.queries
.filter((query) => shouldPersistDashboardQuery(query as PersistableQuery))
.map(sanitizePersistedQuery),
@@ -120,9 +100,7 @@ const getSessionStorage = (): Storage | undefined => {
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
// Persistence is optional. Treat blocked or unavailable storage as an empty cache.
}
} catch {}
};
export const createSessionQueryPersister = (
@@ -130,8 +108,6 @@ export const createSessionQueryPersister = (
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
const buster = getQueryPersistenceBuster(scope);
let restoredQueries = new Map<string, number>();
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
@@ -175,15 +151,13 @@ export const createSessionQueryPersister = (
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
async restoreClient() {
restoredQueries = new Map();
restoreClient() {
try {
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
const age = client ? Date.now() - client.timestamp : Number.POSITIVE_INFINITY;
if (!client || age < 0 || age > queryPersistenceMaxAgeMs || client.buster !== buster) {
if (!client || client.timestamp > Date.now()) {
removeStorageItem(storage, key);
return undefined;
}
@@ -193,9 +167,6 @@ export const createSessionQueryPersister = (
return undefined;
}
restoredQueries = new Map(
client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]),
);
return client;
} catch {
removeStorageItem(storage, key);
@@ -205,34 +176,8 @@ export const createSessionQueryPersister = (
removeClient() {
clearPersistTimer();
pendingClient = undefined;
restoredQueries = new Map();
removeStorageItem(storage, key);
},
flush,
takeRestoredDashboardQueryPredicate() {
if (restoredQueries.size === 0) return undefined;
const restoredSnapshot = restoredQueries;
restoredQueries = new Map();
return (query) =>
query.queryHash !== undefined &&
restoredSnapshot.get(query.queryHash) === query.state.dataUpdatedAt &&
shouldPersistDashboardQuery(query);
},
};
};
export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient, persister: SessionQueryPersister) => {
const predicate = persister.takeRestoredDashboardQueryPredicate();
if (!predicate) return;
setTimeout(() => {
// Mark restored data stale even if its widget observer has not mounted yet.
// TanStack still refetches only active queries; a widget that mounts after
// this callback observes stale cached data and refreshes immediately.
void queryClient.invalidateQueries({ type: "all", refetchType: "active", predicate });
}, 0);
};
export const removeSessionQueryCache = (scope: string | null, storage: Storage | undefined = getSessionStorage()) => {
removeStorageItem(storage, getQueryPersistenceStorageKey(scope));
};
@@ -38,9 +38,8 @@ import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import {
createSessionQueryPersister,
getQueryPersistenceBuster,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
scheduleRestoredDashboardQueryRefresh,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
@@ -88,9 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider sessionQueryScope={initialSessionQueryScope} queryPersister={queryPersister}>
{children}
</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -99,9 +96,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
sessionQueryScope,
queryPersister,
}: PropsWithChildren<{ sessionQueryScope: string | null; queryPersister: SessionQueryPersister }>) => {
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -219,12 +215,9 @@ const ScopedTRPCReactProvider = ({
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: getQueryPersistenceBuster(sessionQueryScope),
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
onSuccess={() => {
scheduleRestoredDashboardQueryRefresh(queryClient, queryPersister);
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
@@ -16,79 +16,39 @@ const collectRuntimeSourceFiles = (directory: string): string[] =>
const rootWidgetsModule = "@homarr/widgets";
interface AstNode {
type: string;
[key: string]: unknown;
}
const isAstNode = (value: unknown): value is AstNode =>
typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
const getAstNode = (value: unknown) => (isAstNode(value) ? value : undefined);
const getAstNodes = (value: unknown) => (Array.isArray(value) ? value.filter(isAstNode) : []);
const isRootWidgetsModule = (node: unknown) => {
const astNode = getAstNode(node);
return astNode?.type === "StringLiteral" && astNode.value === rootWidgetsModule;
};
const hasRuntimeRootWidgetImport = (source: string, fileName: string): boolean => {
const sourceFile = parse(source, {
const { program } = parse(source, {
sourceFilename: fileName,
sourceType: "unambiguous",
plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"],
});
let found = false;
const hasStaticImport = program.body.some(
(node) =>
node.type === "ImportDeclaration" &&
node.source.value === rootWidgetsModule &&
node.importKind !== "type" &&
(node.specifiers.length === 0 ||
node.specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")),
);
return hasStaticImport || hasDynamicRootWidgetImport(program);
};
const visit = (node: AstNode) => {
if (found) return;
const hasDynamicRootWidgetImport = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(hasDynamicRootWidgetImport);
if (typeof value !== "object" || value === null) return false;
if (node.type === "ImportDeclaration" && isRootWidgetsModule(node.source)) {
const specifiers = getAstNodes(node.specifiers);
if (
node.importKind !== "type" &&
(specifiers.length === 0 ||
specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type"))
) {
found = true;
}
} else if (
(node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
isRootWidgetsModule(node.source)
) {
const specifiers = getAstNodes(node.specifiers);
if (
node.exportKind !== "type" &&
(node.type === "ExportAllDeclaration" ||
specifiers.length === 0 ||
specifiers.some((specifier) => specifier.exportKind !== "type"))
) {
found = true;
}
} else if (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") {
const moduleReference = getAstNode(node.moduleReference);
if (moduleReference?.type === "TSExternalModuleReference" && isRootWidgetsModule(moduleReference.expression)) {
found = true;
}
} else if (node.type === "CallExpression") {
const callee = getAstNode(node.callee);
const argument = getAstNodes(node.arguments)[0];
if (
isRootWidgetsModule(argument) &&
(callee?.type === "Import" || (callee?.type === "Identifier" && callee.name === "require"))
) {
found = true;
}
const node = value as Record<string, unknown>;
if (node.type === "CallExpression") {
const callee = node.callee as Record<string, unknown> | undefined;
const argument = Array.isArray(node.arguments)
? (node.arguments[0] as Record<string, unknown> | undefined)
: undefined;
if (callee?.type === "Import" && argument?.type === "StringLiteral" && argument.value === rootWidgetsModule) {
return true;
}
}
for (const value of Object.values(node)) {
if (isAstNode(value)) visit(value);
else if (Array.isArray(value)) value.filter(isAstNode).forEach(visit);
}
};
visit(sourceFile as unknown as AstNode);
return found;
return Object.values(node).some(hasDynamicRootWidgetImport);
};
describe("widget import boundary", () => {
@@ -104,14 +64,11 @@ describe("widget import boundary", () => {
test.each([
['import type { WidgetDefinition } from "@homarr/widgets";', false],
['import { type WidgetDefinition } from "@homarr/widgets";', false],
['export type { WidgetDefinition } from "@homarr/widgets";', false],
["const example = 'import { widgets } from \"@homarr/widgets\";';", false],
['// import { widgets } from "@homarr/widgets";', false],
['import { widgets } from "@homarr/widgets";', true],
['import { type WidgetDefinition, widgets } from "@homarr/widgets";', true],
['export * from "@homarr/widgets";', true],
['import /* chunk */ ( /* source */ "@homarr/widgets" );', true],
['require("@homarr/widgets");', true],
])("detects runtime root imports in %s", (source, expected) => {
expect(hasRuntimeRootWidgetImport(source, "fixture.ts")).toBe(expected);
});
-3
View File
@@ -102,9 +102,6 @@ describe("lazy widget application graph", () => {
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
// Playwright cannot route requests handled by a service worker. Keep it
// blocked in this context so the delayed refresh below is observable, then
// verify registration separately in an unblocked context.
const context = await browser.newContext({ serviceWorkers: "block" });
const page = await context.newPage();
const pageErrors: Error[] = [];
+3 -5
View File
@@ -10,7 +10,7 @@ import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
import { useCurrentTime } from "./use-current-time";
import { ClockWeatherSummary } from "./weather-summary";
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
import { isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat);
@@ -18,10 +18,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds });
const initialLocalTimeZone = useWidgetLocalTimeZone();
const requestedTimeZone = options.useCustomTimezone
? options.timezone
: (initialLocalTimeZone ?? getResolvedLocalTimeZone());
const localTimeZone = useWidgetLocalTimeZone();
const requestedTimeZone = options.useCustomTimezone ? options.timezone : localTimeZone;
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
@@ -24,9 +24,11 @@ describe("WidgetTimeProvider", () => {
vi.useFakeTimers();
const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime();
const clientTimestamp = serverTimestamp + 60_000;
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const serverTimeZone = browserTimeZone === "UTC" ? "Europe/Paris" : "UTC";
vi.setSystemTime(serverTimestamp);
const content = (
<WidgetTimeProvider initialTimestamp={serverTimestamp} initialTimeZone="UTC">
<WidgetTimeProvider initialTimestamp={serverTimestamp} initialTimeZone={serverTimeZone}>
<TimeProbe />
</WidgetTimeProvider>
);
@@ -34,15 +36,12 @@ describe("WidgetTimeProvider", () => {
const host = document.createElement("div");
host.innerHTML = html;
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
timeZone: "Europe/Paris",
} as Intl.ResolvedDateTimeFormatOptions);
vi.setSystemTime(clientTimestamp);
expect(host.textContent).toBe("2026-08-22T12:34:00.000Z|UTC");
expect(host.textContent).toBe(`2026-08-22T12:34:00.000Z|${serverTimeZone}`);
const root = hydrateRoot(host, content);
await act(async () => undefined);
expect(host.textContent).toBe("2026-08-22T12:35:00.000Z|Europe/Paris");
expect(host.textContent).toBe(`2026-08-22T12:35:00.000Z|${browserTimeZone}`);
expect(consoleError).not.toHaveBeenCalled();
await act(async () => root.unmount());
@@ -1,14 +1,10 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useContext, useSyncExternalStore } from "react";
interface WidgetTimeContextValue {
initialTimestamp: number | null;
localTimeZone: string | null;
}
const WidgetTimeContext = createContext<WidgetTimeContextValue>({ initialTimestamp: null, localTimeZone: null });
const WidgetTimestampContext = createContext<number | null>(null);
const WidgetTimeZoneContext = createContext("UTC");
interface WidgetTimeProviderProps {
initialTimestamp: number;
@@ -19,19 +15,21 @@ export const WidgetTimeProvider = ({
children,
initialTimestamp,
initialTimeZone,
}: PropsWithChildren<WidgetTimeProviderProps>) => {
const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone));
}: PropsWithChildren<WidgetTimeProviderProps>) => (
<WidgetTimestampContext.Provider value={initialTimestamp}>
<WidgetTimeZoneContext.Provider value={getSupportedTimeZone(initialTimeZone)}>
{children}
</WidgetTimeZoneContext.Provider>
</WidgetTimestampContext.Provider>
);
useEffect(() => {
setLocalTimeZone(getResolvedLocalTimeZone());
}, []);
const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]);
return <WidgetTimeContext.Provider value={value}>{children}</WidgetTimeContext.Provider>;
export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext);
export const useWidgetLocalTimeZone = () => {
const initialTimeZone = useContext(WidgetTimeZoneContext);
return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone);
};
export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext).initialTimestamp;
export const useWidgetLocalTimeZone = () => useContext(WidgetTimeContext).localTimeZone;
const subscribeToTimeZone = () => () => undefined;
const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);