diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 9e86f427b..ce9737984 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -40,7 +40,13 @@ const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => ( ); -export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) => { +export const ClientBoard = ({ + initialTimestamp, + initialTimeZone, +}: { + initialTimestamp: number; + initialTimeZone: string; +}) => { const board = useRequiredBoard(); const t = useI18n("board.landmark"); const tPreview = useI18n("board.setting.section.layout.preview"); @@ -157,11 +163,15 @@ export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) ); if (representativeWidth === null) { - return {content}; + return ( + + {content} + + ); } return ( - + {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 689afb5bb..ccd61caca 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,7 +96,10 @@ export const createBoardContentPage = >( return ( - + ); diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx index 444e31d0e..faa44941b 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -4,6 +4,8 @@ import { Suspense, use, useMemo } from "react"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core"; import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { FallbackProps } from "react-error-boundary"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -73,9 +75,27 @@ export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetTool if (!completeArgs) return ; return ( - }> - - + + }> + + + + ); +}; + +const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => { + const t = useI18n("assistant.configureWidget"); + const actionT = useI18n("common.action"); + + return ( + }> + + {t("definitionLoadErrorDescription")} + + + ); }; 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 78cc82c04..7b38dd798 100644 --- a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts +++ b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { parse } from "@babel/parser"; import { describe, expect, test } from "vitest"; const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -13,18 +14,105 @@ const collectRuntimeSourceFiles = (directory: string): string[] => return [path]; }); +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, { + sourceFilename: fileName, + sourceType: "unambiguous", + plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"], + }); + let found = false; + + const visit = (node: AstNode) => { + if (found) return; + + 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; + } + } + + 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; +}; + describe("widget import boundary", () => { test("application runtime code uses narrow widget exports", () => { const offenders = collectRuntimeSourceFiles(sourceRoot).filter((path) => { const source = readFileSync(path, "utf8"); - const importStatements = source.match(/import[\s\S]*?;/g) ?? []; - return ( - importStatements.some( - (statement) => !/^import\s+type\b/.test(statement) && /\sfrom\s+["']@homarr\/widgets["']/.test(statement), - ) || /import\(["']@homarr\/widgets["']\)/.test(source) - ); + return hasRuntimeRootWidgetImport(source, path); }); expect(offenders).toEqual([]); }); + + 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 14efef71c..26cedeefb 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -108,7 +108,13 @@ describe("lazy widget application graph", () => { const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); const pageErrors: Error[] = []; + const hydrationErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error)); + page.on("console", (message) => { + if (message.type() === "error" && /hydration|did not match/i.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); let releaseRefresh: (() => void) | undefined; try { @@ -177,6 +183,7 @@ describe("lazy widget application graph", () => { await expect(downloadsWidget.locator('output[aria-live="polite"]')).toHaveCount(0); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); expect(pageErrors).toEqual([]); + expect(hydrationErrors).toEqual([]); const serviceWorkerContext = await browser.newContext(); const serviceWorkerPage = await serviceWorkerContext.newPage(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8849361b0..2652ca33d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -7771,6 +7771,8 @@ "ready": "Widget configuration ready", "cancelled": "Widget was not added", "automaticContinue": "Accepting the proposed widget configuration automatically", + "definitionLoadErrorTitle": "Widget settings could not be loaded", + "definitionLoadErrorDescription": "Homarr could not load this widget's settings. Try again.", "loadErrorTitle": "Integrations could not be loaded", "loadErrorDescription": "Homarr could not load the integrations available for this widget.", "noIntegrationTitle": "A compatible integration is required", diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 45180cff9..385f18262 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; +import { useWidgetLocalTimeZone } from "../common/widget-time-context"; import type { WidgetComponentProps } from "../definition"; import { AdvancedClockView } from "./advanced-view"; import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; @@ -17,7 +18,10 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid const isAdvanced = displayMode === "advanced"; const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); const time = useCurrentTime({ showSeconds }); - const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone(); + const initialLocalTimeZone = useWidgetLocalTimeZone(); + const requestedTimeZone = options.useCustomTimezone + ? options.timezone + : (initialLocalTimeZone ?? getResolvedLocalTimeZone()); 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 39fee29ee..4912dc706 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -6,9 +6,13 @@ import { renderToString } from "react-dom/server"; import { afterEach, describe, expect, test, vi } from "vitest"; import { useWidgetNow } from "./use-widget-now"; -import { WidgetTimeProvider } from "./widget-time-context"; +import { useWidgetLocalTimeZone, WidgetTimeProvider } from "./widget-time-context"; -const TimeProbe = () => ; +const TimeProbe = () => ( + +); afterEach(() => { vi.useRealTimers(); @@ -16,12 +20,13 @@ afterEach(() => { }); describe("WidgetTimeProvider", () => { - test("uses the same non-placeholder timestamp for server rendering and hydration", async () => { + test("hydrates with server time and timezone before switching to browser values", async () => { vi.useFakeTimers(); - const timestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); - vi.setSystemTime(timestamp); + const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); + const clientTimestamp = serverTimestamp + 60_000; + vi.setSystemTime(serverTimestamp); const content = ( - + ); @@ -29,11 +34,15 @@ 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"); + expect(host.textContent).toBe("2026-08-22T12:34:00.000Z|UTC"); const root = hydrateRoot(host, content); await act(async () => undefined); - expect(host.textContent).toBe("2026-08-22T12:34:00.000Z"); + expect(host.textContent).toBe("2026-08-22T12:35:00.000Z|Europe/Paris"); 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 2e09ae467..f46f58d7d 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -1,12 +1,46 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; -const WidgetTimeContext = createContext(null); +interface WidgetTimeContextValue { + initialTimestamp: number | null; + localTimeZone: string | null; +} -export const WidgetTimeProvider = ({ children, initialTimestamp }: PropsWithChildren<{ initialTimestamp: number }>) => ( - {children} -); +const WidgetTimeContext = createContext({ initialTimestamp: null, localTimeZone: null }); -export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext); +interface WidgetTimeProviderProps { + initialTimestamp: number; + initialTimeZone: string; +} + +export const WidgetTimeProvider = ({ + children, + initialTimestamp, + initialTimeZone, +}: PropsWithChildren) => { + const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone)); + + useEffect(() => { + setLocalTimeZone(getResolvedLocalTimeZone()); + }, []); + + const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]); + return {children}; +}; + +export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext).initialTimestamp; +export const useWidgetLocalTimeZone = () => useContext(WidgetTimeContext).localTimeZone; + +const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); + +const getSupportedTimeZone = (timeZone: string | undefined) => { + if (!timeZone) return "UTC"; + try { + new Intl.DateTimeFormat("en", { timeZone }).format(0); + return timeZone; + } catch { + return "UTC"; + } +}; diff --git a/packages/widgets/src/time.ts b/packages/widgets/src/time.ts index a294c53bb..07babe34b 100644 --- a/packages/widgets/src/time.ts +++ b/packages/widgets/src/time.ts @@ -1,3 +1,3 @@ -export { WidgetTimeProvider, useWidgetInitialTimestamp } from "./common/widget-time-context"; +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";