Skip to content

Commit e3b7d88

Browse files
authored
fix: prevent custom widget builder crash on large syntax errors (#42198)
1 parent 9755a46 commit e3b7d88

7 files changed

Lines changed: 362 additions & 39 deletions

File tree

app/client/src/components/editorComponents/CodeEditor/index.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,11 +1523,14 @@ class CodeEditor extends Component<Props, State> {
15231523
return;
15241524
}
15251525

1526-
const lintErrors = this.props.lintErrors;
1527-
1528-
if (this.props.customErrors?.length) {
1529-
lintErrors.push(...this.props.customErrors);
1530-
}
1526+
// Copy before merging — getEntityLintErrors may return a shared emptyLint
1527+
// singleton. Mutating it with push() permanently pollutes that array, so
1528+
// custom widget compile errors survive after the syntax is fixed and can
1529+
// underline the wrong line once the source shifts.
1530+
const lintErrors = [
1531+
...this.props.lintErrors,
1532+
...(this.props.customErrors ?? []),
1533+
];
15311534

15321535
this.annotations = getLintAnnotations(editor.getValue(), lintErrors, {
15331536
isJSObject,

app/client/src/components/editorComponents/CodeEditor/lintHelpers.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE,
1515
INVALID_JSOBJECT_START_STATEMENT,
1616
INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
17+
LINT_BINDING_LITERAL_MATCH_ERROR_CODE,
1718
} from "plugins/Linting/constants";
1819
export const getIndexOfRegex = (
1920
str: string,
@@ -74,11 +75,33 @@ export const getAllWordOccurrences = (str: string, key: string) => {
7475
return indices;
7576
};
7677

78+
/** indexOf-based search — no word-boundary regex (safe for mid-word prefixes). */
79+
export const getAllLiteralOccurrences = (str: string, key: string) => {
80+
if (!key) {
81+
return [];
82+
}
83+
84+
const indices = [];
85+
let startIndex = 0;
86+
let index = str.indexOf(key, startIndex);
87+
88+
while (index > -1) {
89+
indices.push(index);
90+
startIndex = index + key.length;
91+
index = str.indexOf(key, startIndex);
92+
}
93+
94+
return indices;
95+
};
96+
7797
export const getKeyPositionInString = (
7898
str: string,
7999
key: string,
100+
options?: { literal?: boolean },
80101
): Position[] => {
81-
const indices = getAllWordOccurrences(str, key);
102+
const indices = options?.literal
103+
? getAllLiteralOccurrences(str, key)
104+
: getAllWordOccurrences(str, key);
82105
let positions: Position[] = [];
83106

84107
if (str.includes("\n")) {
@@ -178,7 +201,9 @@ export const getLintAnnotations = (
178201

179202
const bindingPositions = isJSObject
180203
? [VALID_JS_OBJECT_BINDING_POSITION]
181-
: getKeyPositionInString(value, originalBinding);
204+
: getKeyPositionInString(value, originalBinding, {
205+
literal: code === LINT_BINDING_LITERAL_MATCH_ERROR_CODE,
206+
});
182207

183208
if (isNumber(line) && isNumber(ch)) {
184209
for (const bindingLocation of bindingPositions) {

app/client/src/pages/Editor/CustomWidgetBuilder/Editor/CodeEditors/JSEditor.tsx

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,10 @@ import {
1111
import { CustomWidgetBuilderContext } from "../..";
1212
import LazyCodeEditor from "components/editorComponents/LazyCodeEditor";
1313
import { getAppsmithScriptSchema } from "widgets/CustomWidget/component/constants";
14-
// import { DebuggerLogType } from "../../types";
15-
// import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
16-
// import { Severity } from "entities/AppsmithConsole";
1714
import CodemirrorTernService from "utils/autocomplete/CodemirrorTernService";
1815
import { Spinner } from "@appsmith/ads";
1916
import { CUSTOM_WIDGET_FEATURE, createMessage } from "ee/constants/messages";
20-
import { DebuggerLogType } from "../../types";
21-
import type { LintError } from "utils/DynamicBindingUtils";
22-
import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
23-
import { Severity } from "entities/AppsmithConsole";
24-
import { isUndefined } from "lodash";
17+
import { mapCompileErrorsToLintErrors } from "./mapCompileErrorsToLintErrors";
2518

2619
export default function JSEditor(props: ContentProps) {
2720
const [loading, setLoading] = useState(true);
@@ -36,30 +29,11 @@ export default function JSEditor(props: ContentProps) {
3629

3730
const { height } = props;
3831

39-
const errors: LintError[] = useMemo(() => {
40-
return debuggerLogs
41-
? debuggerLogs
42-
.filter((d) => d.type === DebuggerLogType.ERROR)
43-
.map((d) => d.args)
44-
.flat()
45-
.filter((d) => !isUndefined(d.line) && !isUndefined(d.column))
46-
.map((d) => ({
47-
errorType: PropertyEvaluationErrorType.LINT,
48-
raw: uncompiledSrcDoc?.js || "",
49-
severity: Severity.ERROR,
50-
errorMessage: {
51-
name: "LintingError",
52-
message: d.message as string,
53-
},
54-
errorSegment: uncompiledSrcDoc?.js || "",
55-
originalBinding: uncompiledSrcDoc?.js || "",
56-
variables: [],
57-
code: "",
58-
line: d.line ? d.line - 1 : 1,
59-
ch: d.column ? d.column + 2 : 1,
60-
}))
61-
: [];
62-
}, [debuggerLogs]);
32+
const errors = useMemo(
33+
() =>
34+
mapCompileErrorsToLintErrors(debuggerLogs, uncompiledSrcDoc?.js || ""),
35+
[debuggerLogs, uncompiledSrcDoc?.js],
36+
);
6337

6438
useEffect(() => {
6539
["LIB/node-forge", "LIB/moment", "base64-js", "LIB/lodash"].forEach((d) => {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { getLintAnnotations } from "components/editorComponents/CodeEditor/lintHelpers";
2+
import { LINT_BINDING_LITERAL_MATCH_ERROR_CODE } from "plugins/Linting/constants";
3+
import { DebuggerLogType } from "../../types";
4+
import {
5+
getLintBindingPrefix,
6+
LINT_BINDING_PREFIX_LENGTH,
7+
mapCompileErrorsToLintErrors,
8+
} from "./mapCompileErrorsToLintErrors";
9+
10+
describe("getLintBindingPrefix", () => {
11+
it("returns a space for empty source", () => {
12+
expect(getLintBindingPrefix("")).toEqual({
13+
value: " ",
14+
useLiteralMatch: false,
15+
});
16+
});
17+
18+
it("returns the full string when shorter than the prefix length", () => {
19+
const js = "const a = 1;";
20+
21+
expect(getLintBindingPrefix(js)).toEqual({
22+
value: js,
23+
useLiteralMatch: false,
24+
});
25+
});
26+
27+
it("truncates to at most LINT_BINDING_PREFIX_LENGTH", () => {
28+
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 50);
29+
const result = getLintBindingPrefix(js);
30+
31+
expect(result.value.length).toBeLessThanOrEqual(LINT_BINDING_PREFIX_LENGTH);
32+
expect(result.useLiteralMatch).toBe(true);
33+
});
34+
35+
it("trims a mid-word cut so the prefix still matches at index 0", () => {
36+
const js =
37+
'import React from "https://esm.sh/react@18.2.0";\n' +
38+
"const filler = `" +
39+
"x".repeat(400) +
40+
"`;\n";
41+
const { useLiteralMatch, value: prefix } = getLintBindingPrefix(js);
42+
43+
expect(js.startsWith(prefix)).toBe(true);
44+
expect(prefix.length).toBeLessThanOrEqual(LINT_BINDING_PREFIX_LENGTH);
45+
// Must not end mid-run of x's while more x's follow
46+
expect(prefix.endsWith("x")).toBe(false);
47+
expect(useLiteralMatch).toBe(false);
48+
});
49+
50+
it("opts into literal match when the first 128 chars are one unbroken word", () => {
51+
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 200) + "\nfunction\n";
52+
const result = getLintBindingPrefix(js);
53+
54+
expect(result.value).toBe("a".repeat(LINT_BINDING_PREFIX_LENGTH));
55+
expect(result.useLiteralMatch).toBe(true);
56+
});
57+
});
58+
59+
describe("mapCompileErrorsToLintErrors", () => {
60+
const babelError = {
61+
line: 10,
62+
column: 4,
63+
message: "SyntaxError: Unexpected token",
64+
};
65+
66+
it("returns an empty array when debuggerLogs is undefined", () => {
67+
expect(mapCompileErrorsToLintErrors(undefined, "const a = 1;")).toEqual([]);
68+
});
69+
70+
it("maps located compile errors with a short binding prefix", () => {
71+
const largeJs =
72+
'import React from "https://esm.sh/react@18.2.0";\n' +
73+
"const filler = `" +
74+
"x".repeat(40000) +
75+
"`;\nfunction\n";
76+
77+
const mapped = mapCompileErrorsToLintErrors(
78+
[
79+
{
80+
type: DebuggerLogType.ERROR,
81+
args: [babelError],
82+
},
83+
],
84+
largeJs,
85+
);
86+
87+
expect(mapped).toHaveLength(1);
88+
expect(mapped[0].originalBinding.length).toBeLessThanOrEqual(
89+
LINT_BINDING_PREFIX_LENGTH,
90+
);
91+
expect(mapped[0].originalBinding).not.toEqual(largeJs);
92+
expect(mapped[0].raw).toEqual(mapped[0].originalBinding);
93+
expect(mapped[0].errorSegment).toEqual(mapped[0].originalBinding);
94+
expect(mapped[0].line).toBe(9);
95+
expect(mapped[0].ch).toBe(6);
96+
expect(mapped[0].code).toBe("");
97+
});
98+
99+
it("does not throw in getLintAnnotations for a large source with a syntax error", () => {
100+
const largeJs =
101+
'import React from "https://esm.sh/react@18.2.0";\n' +
102+
"const filler = `" +
103+
"x".repeat(40000) +
104+
"`;\nfunction\n";
105+
106+
const mapped = mapCompileErrorsToLintErrors(
107+
[
108+
{
109+
type: DebuggerLogType.ERROR,
110+
args: [{ line: 3, column: 0, message: "Unexpected token" }],
111+
},
112+
],
113+
largeJs,
114+
);
115+
116+
expect(() => getLintAnnotations(largeJs, mapped, {})).not.toThrow();
117+
118+
const annotations = getLintAnnotations(largeJs, mapped, {});
119+
120+
expect(annotations.length).toBeGreaterThan(0);
121+
expect(annotations[0].from?.line).toBe(2);
122+
});
123+
124+
it("still produces an annotation for a small default-template sized source", () => {
125+
const smallJs = `import React from "https://esm.sh/react@18.2.0";
126+
import ReactDOM from "https://esm.sh/react-dom@18.2.0";
127+
128+
function App() {
129+
return <div>hi</div>;
130+
}
131+
132+
appsmith.onReady(() => {
133+
ReactDOM.render(<App />, document.getElementById("root"));
134+
});
135+
function
136+
`;
137+
138+
const mapped = mapCompileErrorsToLintErrors(
139+
[
140+
{
141+
type: DebuggerLogType.ERROR,
142+
args: [{ line: 11, column: 0, message: "Unexpected token" }],
143+
},
144+
],
145+
smallJs,
146+
);
147+
148+
expect(mapped[0].originalBinding.length).toBeLessThanOrEqual(
149+
LINT_BINDING_PREFIX_LENGTH,
150+
);
151+
152+
const annotations = getLintAnnotations(smallJs, mapped, {});
153+
154+
expect(annotations.length).toBeGreaterThan(0);
155+
expect(annotations[0].from?.line).toBe(10);
156+
});
157+
158+
it("produces an underline when the source starts with more than 128 word characters", () => {
159+
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 200) + "\nfunction\n";
160+
const errorLine = 2; // 1-based Babel line of `function`
161+
162+
const mapped = mapCompileErrorsToLintErrors(
163+
[
164+
{
165+
type: DebuggerLogType.ERROR,
166+
args: [{ line: errorLine, column: 0, message: "Unexpected token" }],
167+
},
168+
],
169+
js,
170+
);
171+
172+
expect(mapped[0].code).toBe(LINT_BINDING_LITERAL_MATCH_ERROR_CODE);
173+
expect(mapped[0].originalBinding.length).toBe(LINT_BINDING_PREFIX_LENGTH);
174+
175+
const annotations = getLintAnnotations(js, mapped, {});
176+
177+
expect(annotations.length).toBeGreaterThan(0);
178+
expect(annotations[0].from?.line).toBe(errorLine - 1);
179+
});
180+
181+
it("skips errors without line or column", () => {
182+
const mapped = mapCompileErrorsToLintErrors(
183+
[
184+
{
185+
type: DebuggerLogType.ERROR,
186+
args: [{ message: "no location" }],
187+
},
188+
],
189+
"const a = 1;",
190+
);
191+
192+
expect(mapped).toEqual([]);
193+
});
194+
});

0 commit comments

Comments
 (0)