diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx index 972b6a624..1062f93ea 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { Dispatch, ReactNode, SetStateAction } from "react"; import type { Icon } from "@tabler/icons-react"; import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; @@ -21,6 +21,7 @@ import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core"; import { useI18n } from "@homarr/translation/client"; import { useCustomWidgetFormDocumentDirty } from "./_custom-widget-form-state"; +import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality"; import { useCustomWidgetFormAnalysisField } from "./_use-custom-widget-form-analysis"; import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard"; import classes from "./_custom-widget-form.module.css"; @@ -179,8 +180,13 @@ export function CustomWidgetOptionsSnapshotSync({ setOptionsSnapshot: Dispatch>>; }) { const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions"); + const lastDefaults = useRef | null>(null); useEffect(() => { - if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data)); + if (!parsedOptions.success) return; + const defaults = getCustomWidgetDefaultOptions(parsedOptions.data); + if (lastDefaults.current && areCustomWidgetValuesEqual(lastDefaults.current, defaults)) return; + lastDefaults.current = defaults; + setOptionsSnapshot(defaults); }, [parsedOptions, setOptionsSnapshot]); return null; } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx index ccfe01c3f..f4ab3a043 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-state.tsx @@ -13,8 +13,11 @@ import { import type { ReactNode } from "react"; import type { UseFormReturnType } from "@mantine/form"; +import { customWidgetFormSchema } from "@homarr/custom-widgets/workbench"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; +import { areCustomWidgetValuesEqual } from "./_custom-widget-value-equality"; + export interface CustomWidgetFormDocumentStore { getValues(): CustomWidgetFormValues; setValues(values: CustomWidgetFormValues): void; @@ -23,28 +26,13 @@ export interface CustomWidgetFormDocumentStore { markSaved(values: CustomWidgetFormValues): void; } +const documentValueKeys = customWidgetFormSchema.keyof().options; + function areDocumentValuesEqual(left: CustomWidgetFormValues, right: CustomWidgetFormValues) { - const secretsEqual = - left.secrets.length === right.secrets.length && - left.secrets.every((secret, index) => { - const other = right.secrets[index]; - return ( - other !== undefined && - secret.sourceId === other.sourceId && - secret.kind === other.kind && - secret.value === other.value && - secret.hasValue === other.hasValue - ); - }); - return ( - left.name === right.name && - left.description === right.description && - left.iconUrl === right.iconUrl && - left.sources === right.sources && - left.requests === right.requests && - left.options === right.options && - left.template === right.template && - secretsEqual + if (Object.keys(left).length !== documentValueKeys.length) return false; + if (Object.keys(right).length !== documentValueKeys.length) return false; + return documentValueKeys.every( + (key) => Object.hasOwn(left, key) && Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key]), ); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-value-equality.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-value-equality.ts new file mode 100644 index 000000000..065056447 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-value-equality.ts @@ -0,0 +1,18 @@ +export function areCustomWidgetValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + + if (Array.isArray(left)) { + if (!Array.isArray(right) || left.length !== right.length) return false; + return left.every((value, index) => areCustomWidgetValuesEqual(value, right[index])); + } + + if (!isRecord(left) || !isRecord(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => Object.hasOwn(right, key) && areCustomWidgetValuesEqual(left[key], right[key])); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts index 6aa6e024a..bb72b4845 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts @@ -48,8 +48,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { const [previewPending, setPreviewPending] = useState(false); const setPreview = input.setPreview; const previewGeneration = useRef(0); + const activePreviewGeneration = useRef(null); const invalidatePreview = useCallback(() => { previewGeneration.current += 1; + if (activePreviewGeneration.current !== null) return; setPreview((preview) => { if ( preview.outcome === "idle" && @@ -60,7 +62,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { return preview; return { data: {}, status: {}, session: null, outcome: "idle" }; }); - setPreviewPending(false); }, [setPreview]); useLayoutEffect(() => { invalidatePreview(); @@ -69,6 +70,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { useEffect( () => () => { previewGeneration.current += 1; + activePreviewGeneration.current = null; }, [], ); @@ -133,7 +135,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { }); const runPreview = async () => { - if (previewPending) return; + if (activePreviewGeneration.current !== null) return; const valuesAtStart = input.documentStore.getValues(); const candidateAtStart = buildDefinition(valuesAtStart); if (!candidateAtStart.success) { @@ -156,6 +158,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { const secretsAtStart = getChangedSecrets(valuesAtStart); const generation = previewGeneration.current + 1; previewGeneration.current = generation; + activePreviewGeneration.current = generation; const isCurrent = () => previewGeneration.current === generation; input.setMobilePane("preview"); input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" }); @@ -198,8 +201,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { input.setPreview((current) => ({ ...current, outcome: "error" })); showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") }); } finally { - if (isCurrent()) { + if (activePreviewGeneration.current === generation) { + activePreviewGeneration.current = null; setPreviewPending(false); + if (!isCurrent()) setPreview({ data: {}, status: {}, session: null, outcome: "idle" }); } } }; @@ -211,7 +216,6 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { throw new Error(formatCustomWidgetImportIssues(result.issues)); } input.setOptionsSnapshot(getDefinitionDefaults(result.widget)); - input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" }); showSuccessNotification({ title: w("ai.response"), message: