diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index d51d074a9..bbce19bc2 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -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(() => 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(); }); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index 6293a8eac..76e798cc9 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -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(); let pendingClient: PersistedClient | undefined; let persistTimer: ReturnType | 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(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)); -}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e7656800..51aefdd43 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -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} > - - {children} - + {children} ); } @@ -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); - }} > {children} {process.env.NODE_ENV === "development" && } diff --git a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts index 7b38dd798..e4aa2a4ad 100644 --- a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts +++ b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts @@ -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; + if (node.type === "CallExpression") { + const callee = node.callee as Record | undefined; + const argument = Array.isArray(node.arguments) + ? (node.arguments[0] as Record | 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); }); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 26cedeefb..f3e6b1d03 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -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[] = []; diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 385f18262..ab0d5926b 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -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); diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx index 4912dc706..286c8f95f 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -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 = ( - + ); @@ -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()); diff --git a/packages/widgets/src/common/widget-time-context.tsx b/packages/widgets/src/common/widget-time-context.tsx index f46f58d7d..d344d5380 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -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({ initialTimestamp: null, localTimeZone: null }); +const WidgetTimestampContext = createContext(null); +const WidgetTimeZoneContext = createContext("UTC"); interface WidgetTimeProviderProps { initialTimestamp: number; @@ -19,19 +15,21 @@ export const WidgetTimeProvider = ({ children, initialTimestamp, initialTimeZone, -}: PropsWithChildren) => { - const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone)); +}: PropsWithChildren) => ( + + + {children} + + +); - useEffect(() => { - setLocalTimeZone(getResolvedLocalTimeZone()); - }, []); - - const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]); - return {children}; +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);