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

@@ -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)),