Skip to content

Commit a5ca3b6

Browse files
robertiseleclaude
andcommitted
Fix intermittent loss of validation error highlighting in CodeAutocompleteField
The suggestion-highlight lifecycle removed all marks including the validation error marker: resetting the highlight cleared the whole document, and the effect cleanup removed marks at line-local positions although they were placed at offset-corrected positions. Mark removal is now scoped to the mark class, the error marker is re-applied from the latest validation result, and the initial validation effect depends on the editor instance instead of a ref read during render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c74f47f commit a5ca3b6

5 files changed

Lines changed: 92 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
1010

1111
- `<Tooltip />`:
1212
- Code markup inside tooltips was hardly readable because of low contrast.
13+
- `<CodeAutocompleteField />`:
14+
- Validation error highlighting was intermittently removed and not re-rendered: resetting the suggestion highlighting cleared all marks including the error marker, and its cleanup removed marks at wrong positions on multi-line content. Mark removal is now scoped to the mark class.
1315

1416
## [26.0.0] - 2026-07-08
1517

src/components/AutoSuggestion/AutoSuggestion.tsx

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ export const CodeAutocompleteField = ({
206206
const suggestionRequestData = React.useRef<RequestMetaData>({ requestId: undefined });
207207
const [pathValidationPending, setPathValidationPending] = React.useState(false);
208208
const validationRequestData = React.useRef<RequestMetaData>({ requestId: undefined });
209-
const errorMarkers = React.useRef<any[]>([]);
210209
const [validationResponse, setValidationResponse] = useState<CodeAutocompleteFieldValidationResult | undefined>(
211210
undefined,
212211
);
@@ -246,11 +245,11 @@ export const CodeAutocompleteField = ({
246245
}, [initialValue, reInitOnInitialValueChange]);
247246

248247
React.useEffect(() => {
249-
if (currentCm.current) {
248+
if (cm) {
250249
// Validate initial value
251250
checkValuePathValidity(initialValue);
252251
}
253-
}, [!!currentCm.current]);
252+
}, [cm]);
254253

255254
const setCurrentIndex = (newIndex: number) => {
256255
editorState.index = newIndex;
@@ -272,6 +271,13 @@ export const CodeAutocompleteField = ({
272271

273272
// Handle replacement highlighting
274273
useEffect(() => {
274+
const highlightClassName = `${eccgui}-autosuggestion__text--highlighted`;
275+
// Only removes the replacement highlighting: other marks, e.g. the error highlighting, must survive
276+
const removeHighlight = () => {
277+
if (cm) {
278+
removeMarkFromText({ view: cm, from: 0, to: cm.state?.doc.length, className: highlightClassName });
279+
}
280+
};
275281
if (highlightedElement && cm) {
276282
const { from, length } = highlightedElement;
277283
if (length > 0 && selectedTextRanges.current.length === 0) {
@@ -281,14 +287,12 @@ export const CodeAutocompleteField = ({
281287
view: cm,
282288
from: fromOffset,
283289
to: toOffset,
284-
className: `${eccgui}-autosuggestion__text--highlighted`,
290+
className: highlightClassName,
285291
});
286-
return () => removeMarkFromText({ view: cm, from, to });
292+
return removeHighlight;
287293
}
288294
} else {
289-
if (cm) {
290-
removeMarkFromText({ view: cm, from: 0, to: cm.state?.doc.length });
291-
}
295+
removeHighlight();
292296
}
293297
return;
294298
}, [highlightedElement, selectedTextRanges, cm]);
@@ -297,26 +301,18 @@ export const CodeAutocompleteField = ({
297301
React.useEffect(() => {
298302
const parseError = validationResponse?.parseError;
299303
if (cm) {
300-
const clearCurrentErrorMarker = () => {
301-
if (errorMarkers.current.length) {
302-
const [from, to] = errorMarkers.current;
303-
removeMarkFromText({ view: cm, from, to });
304-
errorMarkers.current = [];
305-
}
306-
};
304+
const errorClassName = `${eccgui}-autosuggestion__text--highlighted-error`;
305+
// Remove a previous error marker first, so at most one error is marked at any time
306+
removeMarkFromText({ view: cm, from: 0, to: cm.state?.doc.length, className: errorClassName });
307307
if (parseError) {
308308
const { message, start, end } = parseError;
309-
clearCurrentErrorMarker();
310-
const { from, to } = markText({
309+
markText({
311310
view: cm,
312311
from: start,
313312
to: end,
314-
className: `${eccgui}-autosuggestion__text--highlighted-error`,
313+
className: errorClassName,
315314
title: message,
316315
});
317-
errorMarkers.current = [from, to];
318-
} else {
319-
clearCurrentErrorMarker();
320316
}
321317
}
322318

src/components/AutoSuggestion/extensions/markText.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { StateEffect, StateField } from "@codemirror/state";
2-
import { Decoration, EditorView } from "@codemirror/view";
1+
import { Range, StateEffect, StateField } from "@codemirror/state";
2+
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
33

4-
const addMarks = StateEffect?.define(),
5-
filterMarks = StateEffect?.define();
4+
const addMarks = StateEffect.define<Range<Decoration>[]>(),
5+
filterMarks = StateEffect.define<(from: number, to: number, value: Decoration) => boolean>();
66

77
// This value must be added to the set of extensions to enable this
8-
export const markField = StateField?.define({
8+
export const markField = StateField.define<DecorationSet>({
99
// Start with an empty set of decorations
1010
create() {
1111
return Decoration.none;
1212
},
1313
// This is called whenever the editor updates—it computes the new set
14-
update(value: any, tr) {
14+
update(value: DecorationSet, tr) {
1515
// Move the decorations to account for document changes
1616
value = value.map(tr.changes);
1717
// If this transaction adds or removes decorations, apply those changes
@@ -44,7 +44,7 @@ export const markText = (config: marksConfig) => {
4444
const stopRange = Math.min(config.to, docLength);
4545
if (!docLength || config.from === stopRange) return { from: 0, to: 0 };
4646
config.view.dispatch({
47-
effects: addMarks.of([strikeMark.range(config.from, stopRange)] as any),
47+
effects: addMarks.of([strikeMark.range(config.from, stopRange)]),
4848
});
4949
return { from: config.from, to: stopRange };
5050
};
@@ -55,8 +55,12 @@ export const removeMarkFromText = (config: marksConfig) => {
5555
) as EditorView["dispatch"];
5656

5757
dispatch({
58-
effects: filterMarks?.of(
59-
((from: number, to: number) => to <= config.from || from >= config.to) as unknown as null,
58+
effects: filterMarks.of(
59+
(from, to, value) =>
60+
to <= config.from ||
61+
from >= config.to ||
62+
// If a class name is given, only marks of that class are removed
63+
(!!config.className && value.spec.class !== config.className),
6064
),
6165
});
6266
};

src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render } from "@testing-library/react";
2+
import { render, waitFor } from "@testing-library/react";
33

44
import "@testing-library/jest-dom";
55

@@ -27,15 +27,15 @@ describe("AutoSuggestion", () => {
2727
props = {
2828
label: "test value path",
2929
initialValue: "",
30-
onChange: jest.fn((value) => {}),
31-
fetchSuggestions: jest.fn((inputString, cursorPosition) => undefined),
32-
checkInput: jest.fn((inputString) => ({
30+
onChange: jest.fn(),
31+
fetchSuggestions: jest.fn(() => undefined),
32+
checkInput: jest.fn(() => ({
3333
valid: true,
3434
})),
35-
onInputChecked: jest.fn((validInput) => {}),
35+
onInputChecked: jest.fn(),
3636
validationErrorText: "",
3737
clearIconText: "",
38-
onFocusChange: jest.fn((hasFocus) => {}),
38+
onFocusChange: jest.fn(),
3939
id: "test-auto-suggestion",
4040
};
4141
});
@@ -49,4 +49,23 @@ describe("AutoSuggestion", () => {
4949
const { getByText } = render(<AutoSuggestion {...props} />);
5050
expect(getByText(props.label!)).toBeTruthy();
5151
});
52+
53+
it("should render the validation error highlighting for the reported range", async () => {
54+
const { container } = render(
55+
<AutoSuggestion
56+
{...props}
57+
initialValue={"{{project.testx}}"}
58+
validationRequestDelay={1}
59+
checkInput={() => ({
60+
valid: false,
61+
parseError: { message: "'project.testx' is not defined.", start: 2, end: 15 },
62+
})}
63+
/>,
64+
);
65+
await waitFor(() => {
66+
const errorMark = container.querySelector(".eccgui-autosuggestion__text--highlighted-error");
67+
expect(errorMark).toBeTruthy();
68+
expect(errorMark!.textContent).toBe("project.testx");
69+
});
70+
});
5271
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { EditorState } from "@codemirror/state";
2+
import { EditorView } from "@codemirror/view";
3+
4+
import { markField, markText, removeMarkFromText } from "../extensions/markText";
5+
6+
describe("markText extensions", () => {
7+
const createView = (doc: string) => new EditorView({ state: EditorState.create({ doc, extensions: [markField] }) });
8+
9+
const markCount = (view: EditorView, className: string) => {
10+
let count = 0;
11+
view.state.field(markField).between(0, view.state.doc.length, (from, to, value) => {
12+
if (value.spec.class === className) count += 1;
13+
});
14+
return count;
15+
};
16+
17+
it("should remove all marks in the range if no class name is given", () => {
18+
const view = createView("{{project.testx}} and more");
19+
markText({ view, from: 2, to: 15, className: "error-mark" });
20+
markText({ view, from: 4, to: 10, className: "highlight-mark" });
21+
removeMarkFromText({ view, from: 0, to: view.state.doc.length });
22+
expect(markCount(view, "error-mark")).toBe(0);
23+
expect(markCount(view, "highlight-mark")).toBe(0);
24+
});
25+
26+
it("should only remove marks of the given class if one is given", () => {
27+
const view = createView("{{project.testx}} and more");
28+
markText({ view, from: 2, to: 15, className: "error-mark" });
29+
markText({ view, from: 4, to: 10, className: "highlight-mark" });
30+
removeMarkFromText({ view, from: 0, to: view.state.doc.length, className: "highlight-mark" });
31+
expect(markCount(view, "highlight-mark")).toBe(0);
32+
// The error mark survives, e.g. the validation error marker when suggestion highlighting is removed
33+
expect(markCount(view, "error-mark")).toBe(1);
34+
});
35+
});

0 commit comments

Comments
 (0)