fix: reconcile controlled composite input identities

This commit is contained in:
lda
2026-08-14 02:25:58 +07:00 Verified
parent 1e620b492c
commit 5f09765f46
2 changed files with 155 additions and 30 deletions
@@ -141,6 +141,130 @@ describe("InputExpressionControl", () => {
expect(screen.getByRole("textbox", { name: "Additional property name for items item 2" })).toHaveValue("kept");
});
it("follows logical item identity across an external remove", async () => {
const user = userEvent.setup();
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
const second = { kind: "object", fields: [] } satisfies ExpressionEditorState;
const Harness = () => {
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first, second] });
return (
<>
<button onClick={() => setState({ kind: "array", items: state.kind === "array" ? state.items.slice(1) : [] })} type="button">
External remove first
</button>
<InputExpressionControl
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
label="items"
onChange={setState}
state={state}
/>
</>
);
};
render(<Harness />);
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 2" }), "second-local");
await user.click(screen.getByRole("button", { name: "External remove first" }));
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local");
});
it("follows logical item identity across an external reorder", async () => {
const user = userEvent.setup();
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
const second = { kind: "object", fields: [] } satisfies ExpressionEditorState;
const Harness = () => {
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first, second] });
return (
<>
<button
onClick={() => setState({ kind: "array", items: state.kind === "array" ? [...state.items].reverse() : [] })}
type="button"
>
External reorder
</button>
<InputExpressionControl
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
label="items"
onChange={setState}
state={state}
/>
</>
);
};
render(<Harness />);
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 1" }), "first-local");
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 2" }), "second-local");
await user.click(screen.getByRole("button", { name: "External reorder" }));
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local");
expect(screen.getByRole("textbox", { name: "Additional property name for items item 2" })).toHaveValue("first-local");
});
it("allocates an identity for an externally added item without a missing key", async () => {
const user = userEvent.setup();
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
const Harness = () => {
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first] });
return (
<>
<button
onClick={() => setState({
kind: "array",
items: state.kind === "array" ? [...state.items, { kind: "object", fields: [] }] : [],
})}
type="button"
>
External add
</button>
<InputExpressionControl
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
label="items"
onChange={setState}
state={state}
/>
</>
);
};
render(<Harness />);
await user.click(screen.getByRole("button", { name: "External add" }));
expect(screen.getAllByRole("group", { name: "items item 2" })).not.toHaveLength(0);
expect(screen.getAllByRole("textbox", { name: /Additional property name for items item/ })).toHaveLength(2);
});
it("does not carry local state into a reconstructed semantically equal item", async () => {
const user = userEvent.setup();
const Harness = () => {
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [{ kind: "object", fields: [] }] });
return (
<>
<button
onClick={() => setState({ kind: "array", items: [{ kind: "object", fields: [] }] })}
type="button"
>
External replace with equal value
</button>
<InputExpressionControl
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
label="items"
onChange={setState}
state={state}
/>
</>
);
};
render(<Harness />);
const name = screen.getByRole("textbox", { name: "Additional property name for items item 1" });
await user.type(name, "stale-local");
await user.click(screen.getByRole("button", { name: "External replace with equal value" }));
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("");
});
it("gives repeated labels unique datalist and typed-leaf control ids", () => {
const field = normalizeSchema({ type: "string" });
render(
@@ -1,4 +1,4 @@
import { useId, useRef, useState } from "react";
import { useId, useState } from "react";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
import { rebaseSchemaField, type SchemaField } from "../schema-form/schema-field.js";
import type { FieldSources } from "../schema-form/schema-values.js";
@@ -170,13 +170,25 @@ export const InputExpressionControl = ({
showModeControl = true,
}: InputExpressionControlProps) => {
const controlId = `input-expression-${safeIdSuffix(useId())}`;
const nextItemId = useRef(state.kind === "array" ? state.items.length : 0);
const [itemIdentity] = useState(() => ({
byState: new WeakMap<ExpressionEditorState, string>(),
nextId: 0,
}));
const [additionalName, setAdditionalName] = useState("");
const [arrayItemIds, setArrayItemIds] = useState<ReadonlyArray<string>>(() =>
state.kind === "array"
? state.items.map((_, index) => `${controlId}-item-${index}`)
: [],
);
const identityForItem = (item: ExpressionEditorState): string => {
const existing = itemIdentity.byState.get(item);
if (existing !== undefined) return existing;
const id = `${controlId}-item-${itemIdentity.nextId}`;
itemIdentity.nextId += 1;
itemIdentity.byState.set(item, id);
return id;
};
const arrayItemIds = state.kind === "array"
? state.items.map(identityForItem)
: [];
const rememberItemIdentity = (item: ExpressionEditorState, id: string | undefined): void => {
if (id !== undefined) itemIdentity.byState.set(item, id);
};
const source = valueSourceFor(state);
const selectSource = (next: "path" | "literal" | "construct"): void =>
onChange(stateForSource(next, field, state));
@@ -200,10 +212,13 @@ export const InputExpressionControl = ({
<InputExpressionControl
field={itemField}
label={itemLabel}
onChange={(next) => onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
})}
onChange={(next) => {
rememberItemIdentity(next, itemId);
onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
});
}}
sourceSuggestions={sourceSuggestions}
state={item}
/>
@@ -213,13 +228,6 @@ export const InputExpressionControl = ({
className="schema-form__secondary-action"
disabled={index === 0}
onClick={() => {
setArrayItemIds((current) => current.map((candidate, candidateIndex) =>
candidateIndex === index - 1
? current[index]!
: candidateIndex === index
? current[index - 1]!
: candidate,
));
onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
@@ -240,13 +248,6 @@ export const InputExpressionControl = ({
className="schema-form__secondary-action"
disabled={index === state.items.length - 1}
onClick={() => {
setArrayItemIds((current) => current.map((candidate, candidateIndex) =>
candidateIndex === index
? current[index + 1]!
: candidateIndex === index + 1
? current[index]!
: candidate,
));
onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
@@ -266,7 +267,6 @@ export const InputExpressionControl = ({
aria-label={`Remove ${itemLabel}`}
className="schema-form__secondary-action"
onClick={() => {
setArrayItemIds((current) => current.filter((_, itemIndex) => itemIndex !== index));
onChange({ kind: "array", items: state.items.filter((_, itemIndex) => itemIndex !== index) });
}}
type="button"
@@ -280,10 +280,11 @@ export const InputExpressionControl = ({
<button
className="schema-form__secondary-action"
onClick={() => {
const itemId = `${controlId}-item-${nextItemId.current}`;
nextItemId.current += 1;
setArrayItemIds((current) => [...current, itemId]);
onChange({ kind: "array", items: [...state.items, defaultExpressionEditorState(field?.item ?? null)] });
const item = defaultExpressionEditorState(field?.item ?? null);
const itemId = `${controlId}-item-${itemIdentity.nextId}`;
itemIdentity.nextId += 1;
itemIdentity.byState.set(item, itemId);
onChange({ kind: "array", items: [...state.items, item] });
}}
type="button"
>