fix(custom-widgets): address review and CI

This commit is contained in:
ajnart committed 2026-08-23 22:57:17 +02:00
1 parent 2153069a2f
commit da57a937ed
10 files changed
+49 -46

No files matched your search

+2 -2
View File
@@ -1400,8 +1400,8 @@ This is the shortest useful map from the report back to the current code. Line n
| Renderer session and synchronous render | [`custom-jsx-renderer.tsx`](packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx) | Stable session/binding reconciliation is delivered; interpretation and binding hashing still belong in a shared compile artifact. |
| Existing editor undo/redo | [`code-editor.tsx`](packages/custom-widgets/src/workbench/code-editor.tsx) and [`direct-code-mirror.tsx`](packages/custom-widgets/src/workbench/direct-code-mirror.tsx) | CodeMirror history, depth counters, toolbar commands, and `basicSetup` already exist; the missing layer is coherent workbench-wide undo. |
| Interpreter parse/render entry | [`interpreter.tsx`](packages/custom-widgets/src/jsx/interpreter.tsx) and [`interpreter-parser.ts`](packages/custom-widgets/src/jsx/interpreter-parser.ts) | Candidate for a shared generation-tagged compile artifact. |
| Runtime display bindings | [`custom-jsx-display.tsx`](packages/widgets/src/custom-api/custom-jsx-display.tsx) | Template change state clearing and fresh data/status wrapper objects. |
| Prompt and channel contract | [`ai-prompt.ts`](packages/custom-widgets/src/core/ai-prompt.ts), [`_copy-ai-prompt-button.tsx`](apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx), and [`assistant-tool-policy.ts`](apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts) | Clipboard final-output instruction conflicts with the in-product tool lifecycle. |
| Runtime display bindings | [`custom-jsx-display.tsx`](packages/widgets/src/custom-api/custom-jsx-display.tsx) | Published/query-cache state is now definition-scoped while template-only edits preserve compatible state; shared compile/binding artifacts remain future work. |
| Prompt and channel contract | [`ai-prompt.ts`](packages/custom-widgets/src/core/ai-prompt.ts), [`_copy-ai-prompt-button.tsx`](apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx), and [`assistant-tool-policy.ts`](apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts) | Clipboard, Assistant, and MCP contracts are now separate and trust-bounded; authoritative session/fingerprint/evidence context remains the next seam. |
| Assistant dynamic context | [`custom-widget-authoring-context.ts`](apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts) | Adapter point for session/fingerprint/diagnostic/evidence context. |
| Exact-tested creation | [`creation-procedures.ts`](packages/api/src/router/custom-widget/creation-procedures.ts), especially 53–109 | Existing invariant to preserve and generalize into evidence-bound workbench save/update. |
| Preview session authority | [`preview-sessions.ts`](packages/custom-widgets/src/server/preview-sessions.ts) | Existing short-lived preview state; its internal update protection should not become a local authoring/version model. |
@@ -84,13 +84,6 @@ describe("Custom Widget workbench preview options", () => {
it("normalizes only changed secrets", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
@@ -108,13 +108,17 @@ function useBoundProps(componentName: string, props: Record<string, unknown>): R
const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
const registerInput = context?.registerInput;
useEffect(() => {
if (!isBound || !binding || !registerInput || !inputType) return;
if (!isBound || !binding || !registerInput || !inputType || serializedInitialValue === undefined) return;
return registerInput(binding, inputType, JSON.parse(serializedInitialValue) as WidgetInputValue);
}, [binding, inputType, isBound, registerInput, serializedInitialValue]);
if (!isBound || !binding || !context || !inputType) return sanitized;
delete sanitized.defaultValue;
delete sanitized.defaultChecked;
const currentValue = context.inputs[binding] ?? initialValue;
let currentValue = initialValue;
if (context.inputTypes[binding] === inputType && Object.hasOwn(context.inputs, binding)) {
const storedValue = context.inputs[binding];
if (storedValue !== undefined) currentValue = storedValue;
}
const update = (value: unknown, checked = false) => {
const extracted = extractEventValue(value, checked);
@@ -24,6 +24,7 @@ const permissions = new Set(["view", "modify", "full"]);
export interface CustomJsxRendererMessages {
noTemplate: string;
templateWarnings(count: number): string;
bindingTypeConflict(name: string, firstType: WidgetInputType, secondType: WidgetInputType): string;
}
export interface CustomJsxRendererProps {
@@ -146,6 +147,7 @@ function CustomJsxRendererSession({
const registrations = useRef(new Map<symbol, InputRegistration>());
const registrationVersion = useRef(0);
const mounted = useRef(true);
const bindingTypeConflict = messages.bindingTypeConflict;
useEffect(() => {
mounted.current = true;
return () => {
@@ -163,7 +165,7 @@ function CustomJsxRendererSession({
continue;
}
if (existing.type === registration.type) continue;
const message = `BINDING_TYPE_CONFLICT: '${registration.name}' is bound as both ${existing.type} and ${registration.type}`;
const message = bindingTypeConflict(registration.name, existing.type, registration.type);
if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message);
}
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
@@ -172,15 +174,19 @@ function CustomJsxRendererSession({
for (const [name, registration] of active) nextTypes[name] = registration.type;
const nextValues: Record<string, WidgetInputValue> = {};
for (const [name, registration] of active) {
nextValues[name] =
current.types[name] === registration.type && Object.hasOwn(current.values, name)
? current.values[name]
: registration.initialValue;
if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) {
const previousValue = current.values[name];
if (previousValue !== undefined) {
nextValues[name] = previousValue;
continue;
}
}
nextValues[name] = registration.initialValue;
}
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
return { values: nextValues, types: nextTypes };
});
}, []);
}, [bindingTypeConflict]);
const registerInput = useCallback(
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
const id = Symbol(name);
@@ -12,6 +12,7 @@ import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch,
import { createCustomJsxComponents } from "../jsx";
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
import type {
CustomJsxRendererMessages,
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
CustomWidgetRequestResult,
@@ -36,6 +37,13 @@ const messages: CustomWidgetRuntimeMessages = {
refresh: "Refresh",
};
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
bindingTypeConflict: (name: string, firstType: string, secondType: string) =>
`Input ${name} conflicts between ${firstType} and ${secondType}`,
} satisfies CustomJsxRendererMessages;
let root: Root;
let host: HTMLDivElement;
@@ -125,7 +133,7 @@ describe("Custom Widget runtime ports", () => {
createBindings={() => {
throw new RangeError("Response data exceeded the depth limit");
}}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
messages={rendererMessages}
/>,
createPort(),
);
@@ -147,7 +155,7 @@ describe("Custom Widget runtime ports", () => {
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
messages={rendererMessages}
/>,
createPort(),
);
@@ -159,11 +167,6 @@ describe("Custom Widget runtime ports", () => {
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<CustomJsxRenderer
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
@@ -183,11 +186,6 @@ describe("Custom Widget runtime ports", () => {
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<>
<input aria-label="Outside" name="shared" type="radio" />
@@ -217,10 +215,6 @@ describe("Custom Widget runtime ports", () => {
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
@@ -252,7 +246,7 @@ describe("Custom Widget runtime ports", () => {
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
messages={rendererMessages}
/>,
createPort(),
);
@@ -273,10 +267,6 @@ describe("Custom Widget runtime ports", () => {
});
it("resets temporary bindings when the template changes", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
@@ -313,10 +303,6 @@ describe("Custom Widget runtime ports", () => {
});
it("preserves temporary bindings when data changes without changing the template", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
@@ -28,7 +28,7 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
}
.accessibleMuted[data-invalid="true"] {
color: var(--mantine-color-red-6);
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-4));
}
.viewport {
max-height: min(62vh, 680px);
@@ -104,6 +104,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const isCharacterLimitExceeded = props.maxLength !== undefined && props.value.length > props.maxLength;
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value);
@@ -175,6 +176,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
label={props.label}
placeholder={props.placeholder}
theme={colorScheme}
invalid={isCharacterLimitExceeded}
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
readOnly={props.readOnly ?? false}
diagnosticMessage={props.messages.diagnostic}
@@ -211,9 +213,11 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
</Text>
</Group>
<Text
component="output"
size="xs"
className={classes.accessibleMuted}
data-invalid={Boolean(props.maxLength && props.value.length > props.maxLength)}
data-invalid={isCharacterLimitExceeded}
aria-live="polite"
>
{props.messages.characters(props.value.length, props.maxLength)}
</Text>
@@ -29,6 +29,7 @@ interface DirectCodeMirrorProps {
placeholder?: string;
height: string;
theme: "light" | "dark";
invalid: boolean;
readOnly: boolean;
diagnosticMessage(diagnostic: EditorDiagnostic): string;
onChange(value: string): void;
@@ -124,9 +125,10 @@ export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
tabindex: "0",
"aria-label": props.label,
"aria-labelledby": props.labelledBy,
"aria-invalid": props.invalid.toString(),
}),
);
}, [compartments.accessibility, instanceId, props.id, props.label, props.labelledBy]);
}, [compartments.accessibility, instanceId, props.id, props.invalid, props.label, props.labelledBy]);
useEffect(() => {
reconfigure(instanceId, compartments.editability, createEditabilityExtension(props.readOnly));
}, [compartments.editability, instanceId, props.readOnly]);
@@ -166,6 +168,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps, comp
tabindex: "0",
"aria-label": props.label,
"aria-labelledby": props.labelledBy,
"aria-invalid": props.invalid.toString(),
}),
),
compartments.editability.of(createEditabilityExtension(props.readOnly)),
+1
View File
@@ -851,6 +851,7 @@
"line": "Line {line, number}",
"ast": "{value}",
"bindingTypeConflict": "The input \"{value}\" is shared by controls with incompatible value types.",
"runtimeBindingTypeConflict": "The input \"{value}\" is bound as both {firstType} and {secondType}.",
"dynamicRequestId": "A literal requestId is required. Dynamic request IDs are not accepted.",
"dynamicInputBinding": "A literal input name is required for bind.",
"inlineRequestProps": "Network components accept requestId and params; inline URL, method, header, and body props are blocked.",
@@ -3,7 +3,7 @@
import { useCallback, useMemo, useState } from "react";
import { useI18n } from "@homarr/translation/client";
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import type { CustomJsxRendererMessages, CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
@@ -21,6 +21,7 @@ interface ScopedQueryState {
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
const diagnosticsT = useI18n("customWidget.editor.diagnostics");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const copyLabel = actionT("copy");
const copiedLabel = t("copied");
@@ -28,6 +29,10 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
() => createCustomWidgetComponents({ copy: copyLabel, copied: copiedLabel }),
[copiedLabel, copyLabel],
);
const bindingTypeConflict = useCallback<CustomJsxRendererMessages["bindingTypeConflict"]>(
(name, firstType, secondType) => diagnosticsT("runtimeBindingTypeConflict", { value: name, firstType, secondType }),
[diagnosticsT],
);
const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
@@ -77,6 +82,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
messages={{
noTemplate: t("noTemplate"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
bindingTypeConflict,
}}
/>
);