fix(custom-widgets): harden workbench state

This commit is contained in:
ajnart committed 2026-08-24 08:39:36 +02:00
1 parent d875fb9a75
commit 67774de3dd
4 files changed
+43 -27

No files matched your search

@@ -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<SetStateAction<Record<string, unknown>>>;
}) {
const parsedOptions = useCustomWidgetFormAnalysisField("parsedOptions");
const lastDefaults = useRef<Record<string, unknown> | 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;
}
@@ -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]),
);
}
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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<number | null>(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: