Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@
"@storybook/theming": "^6.5.9",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/debug": "^4.1.12",
"@types/debug": "^4.1.13",
"@types/node": "^22.7.4",
"@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.11",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitest/coverage-v8": "^3.2.4",
"babel-plugin-named-exports-order": "0.0.2",
"eslint": "^8.57.0",
Expand All @@ -83,7 +83,7 @@
"jsdom": "^27.1.0",
"lint-staged": "^15.2.10",
"path-browserify": "^1.0.1",
"prettier": "^3.6.2",
"prettier": "^3.8.3",
"prop-types": "15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand All @@ -92,7 +92,7 @@
"ts-node": "^10.9.2",
"typescript": "^4.8.2",
"vitest": "^3.2.4",
"webpack": "5.102.1"
"webpack": "5.106.2"
},
"dependencies": {
"@making-sense/antlr4ng": "^3.0.4",
Expand Down
147 changes: 78 additions & 69 deletions src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { getEditorWillMount, cleanupProviders } from "./utils/providers";
import { Tools, Error, Variables } from "./model";
import { buildVariables, buildUniqueVariables } from "./utils/variables";
import EditorFooter from "./EditorFooter";
import { shouldSuppressMonacoError } from "./utils/monaco-errors";
import { IDisposable } from "monaco-editor";

// Check if we're in a test environment
const isTestEnvironment = typeof process !== "undefined" && process.env.NODE_ENV === "test";
Expand Down Expand Up @@ -98,7 +100,18 @@ const Editor = ({
});

// Cleanup function to properly dispose of Monaco resources
const subscriptionsRef = useRef<IDisposable[]>([]);

const cleanupMonaco = useCallback(() => {
subscriptionsRef.current.forEach(disposable => {
try {
disposable.dispose();
} catch {
// Best-effort cleanup: Monaco can already be partially disposed.
}
});
subscriptionsRef.current = [];

if (editorRef.current) {
try {
// Get the model before disposing
Expand Down Expand Up @@ -129,18 +142,12 @@ const Editor = ({
cleanupProviders();
}, []);

// Handle Monaco disposal errors gracefully - suppress instead of remounting
// Handle Monaco disposal errors gracefully without global monkey patches.
useEffect(() => {
const handleMonacoError = (event: ErrorEvent) => {
const message = event.error?.message || "";
if (
message.includes("InstantiationService has been disposed") ||
message.includes("domNode") ||
message.includes("renderText") ||
message.includes("AnimationFrameQueueItem")
) {
if (shouldSuppressMonacoError(event.error ?? event.message)) {
// Suppress Monaco cleanup errors - they're harmless during layout changes
console.debug("Monaco cleanup error suppressed:", message);
console.debug("Monaco cleanup error suppressed:", event.error?.message || event.message);
event.preventDefault();
event.stopPropagation();
return false;
Expand All @@ -149,14 +156,12 @@ const Editor = ({
};

const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
const message = event.reason?.message || "";
if (
message.includes("InstantiationService has been disposed") ||
message.includes("domNode") ||
message.includes("renderText")
) {
if (shouldSuppressMonacoError(event.reason)) {
// Suppress Monaco cleanup errors in promises
console.debug("Monaco cleanup promise error suppressed:", message);
console.debug(
"Monaco cleanup promise error suppressed:",
event.reason?.message || String(event.reason)
);
event.preventDefault();
return false;
}
Expand Down Expand Up @@ -275,39 +280,46 @@ const Editor = ({

let parseContentTO: NodeJS.Timeout;
let contentChangeTO: NodeJS.Timeout | undefined;
parseContent(t, script);

editor.onDidChangeModelContent(() => {
if (parseContentTO) clearTimeout(parseContentTO);
parseContentTO = setTimeout(() => {
parseContent(t, script);
}, 0);
if (!contentChangeTO) {
if (setScript) {
contentChangeTO = setTimeout(() => {
setScript(editor.getValue());
contentChangeTO = undefined;
}, 200);
parseContent(t);

subscriptionsRef.current.push(
editor.onDidChangeModelContent(() => {
if (parseContentTO) clearTimeout(parseContentTO);
parseContentTO = setTimeout(() => {
// Always validate the live Monaco buffer to avoid stale-prop races.
parseContent(t);
}, 0);
if (!contentChangeTO) {
if (setScript) {
contentChangeTO = setTimeout(() => {
setScript(editor.getValue());
contentChangeTO = undefined;
}, 200);
}
}
}
});

editor.onDidChangeCursorPosition((e: MonacoCursorPositionEvent) => {
setCursor(prev => ({
...prev,
line: e.position.lineNumber,
column: e.position.column
}));
});

editor.onDidChangeCursorSelection((e: MonacoSelectionEvent) => {
const selection = e.selection;
const length = editor?.getModel()?.getValueInRange(selection).length;
setCursor(prev => ({
...prev,
selectionLength: length || 0
}));
});
})
);

subscriptionsRef.current.push(
editor.onDidChangeCursorPosition((e: MonacoCursorPositionEvent) => {
setCursor(prev => ({
...prev,
line: e.position.lineNumber,
column: e.position.column
}));
})
);

subscriptionsRef.current.push(
editor.onDidChangeCursorSelection((e: MonacoSelectionEvent) => {
const selection = e.selection;
const length = editor?.getModel()?.getValueInRange(selection).length;
setCursor(prev => ({
...prev,
selectionLength: length || 0
}));
})
);

if (shortcuts) {
Object.entries(shortcuts).forEach(([comboString, action]) => {
Expand Down Expand Up @@ -343,22 +355,24 @@ const Editor = ({
});
}

editor.onKeyDown((e: MonacoKeyDownEvent) => {
const isMac = /Mac/.test(navigator.userAgent);
const metaPressed = e.metaKey;
const ctrlPressed = e.ctrlKey;

if (
(isMac && metaPressed && e.code === "Enter") ||
(!isMac && ctrlPressed && e.code === "Enter")
) {
e.preventDefault();
e.stopPropagation();
shortcuts["ctrl+enter, meta+enter"]?.();
}
});
subscriptionsRef.current.push(
editor.onKeyDown((e: MonacoKeyDownEvent) => {
const isMac = /Mac/.test(navigator.userAgent);
const metaPressed = e.metaKey;
const ctrlPressed = e.ctrlKey;

if (
(isMac && metaPressed && e.code === "Enter") ||
(!isMac && ctrlPressed && e.code === "Enter")
) {
e.preventDefault();
e.stopPropagation();
shortcuts["ctrl+enter, meta+enter"]?.();
}
})
);
},
[script, shortcuts]
[shortcuts]
);

const parseContent = useCallback(
Expand Down Expand Up @@ -478,18 +492,13 @@ const Editor = ({
height="100%"
width="100%"
onMount={(e: any, m: any) => {
parseContent(tools, script);
parseContent(tools);
onMount(e, m, tools);
getEditorWillMount(tools)({
variables: vars,
editor: e
})(m);
}}
onChange={() => {
if (isEditorReady) {
parseContent(tools);
}
}}
theme={theme}
language={tools.id}
options={options}
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/monaco-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";

import { applyMonacoPatch } from "../monaco-patch";

describe("applyMonacoPatch", () => {
it("registers and unregisters global listeners", () => {
const addSpy = vi.spyOn(window, "addEventListener");
const removeSpy = vi.spyOn(window, "removeEventListener");

const cleanup = applyMonacoPatch();

expect(addSpy).toHaveBeenCalledWith("error", expect.any(Function), true);
expect(addSpy).toHaveBeenCalledWith("unhandledrejection", expect.any(Function));

cleanup();

expect(removeSpy).toHaveBeenCalledWith("error", expect.any(Function), true);
expect(removeSpy).toHaveBeenCalledWith("unhandledrejection", expect.any(Function));
});

it("is idempotent while already applied", () => {
const addSpy = vi.spyOn(window, "addEventListener");

const cleanupA = applyMonacoPatch();
const cleanupB = applyMonacoPatch();

const errorRegistrations = addSpy.mock.calls.filter(args => args[0] === "error");
expect(errorRegistrations).toHaveLength(1);

cleanupB();
cleanupA();
});
});
23 changes: 23 additions & 0 deletions src/__tests__/utils/monaco-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";

import { extractErrorMessage, shouldSuppressMonacoError } from "../../utils/monaco-errors";

describe("monaco-errors utils", () => {
it("extracts a message from common inputs", () => {
expect(extractErrorMessage("plain message")).toBe("plain message");
expect(extractErrorMessage(new Error("boom"))).toBe("boom");
expect(extractErrorMessage({ message: "from-object" })).toBe("from-object");
});

it("detects suppressible Monaco lifecycle errors", () => {
expect(shouldSuppressMonacoError(new Error("InstantiationService has been disposed"))).toBe(
true
);
expect(shouldSuppressMonacoError("Cannot access domNode while rendering")).toBe(true);
expect(shouldSuppressMonacoError("AnimationFrameQueueItem failed")).toBe(true);
});

it("does not suppress unrelated errors", () => {
expect(shouldSuppressMonacoError(new Error("Network request failed"))).toBe(false);
});
});
5 changes: 1 addition & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { applyMonacoPatch } from "./monaco-patch";

// Apply Monaco error suppression patch automatically on import
applyMonacoPatch();
// Opt-in patch: host apps decide if they need global listeners.

export { default as AntlrEditor, CursorType } from "./Editor";
export { cleanupProviders } from "./utils/providers";
Expand Down
Loading
Loading