fix(front): run a single Monaco instance across the app (#23855)

## Problem

Three Sentry issues, all `Missing requestHandler or method: <method>`,
first seen in v2.27.0:

| Method | Page | Events |
|---|---|---|
| `findDocumentColors` | `/settings/mcp-apis` | 170 |
| `resetSchema` | `/settings/mcp-apis` | 22 |
| `getCodeFixesAtPosition` | `/object/workflow/…` | 4 |

The first two are Monaco **JSON worker** methods, the third a
**TypeScript worker** method. All of them bottom out in
`monaco-editor/esm/vs/editor/common/services/editorSimpleWorker.js` at
`$fmr` — the foreign-module dispatcher — with `_foreignModule` still
`null`.

## Root cause

Two Monaco copies end up on the page at **different versions**:

1. **ESM `monaco-editor@0.52.2`**, bundled by Vite — what GraphiQL 5
uses.
2. **AMD `monaco-editor@0.55.1` from jsDelivr** — `@monaco-editor/react`
→ `@monaco-editor/loader@1.7.0`, whose default CDN path is hardcoded to
`monaco-editor@0.55.1/min/vs`. Nothing calls `loader.config({ monaco
})`, so it goes to the CDN.

`setupGraphiqlMonacoWorkers.ts` assigns
**`globalThis.MonacoEnvironment`**, a single global both instances read,
as a module side effect of the lazily-routed `GraphQLPlayground`. So
once the playground has been opened, the 0.55.1 CDN instance stops using
its own AMD workers and starts getting Vite-bundled 0.52.2 ones.

The two versions don't share a worker protocol: 0.52 routes
language-service calls through `$loadForeignModule` + `$fmr`, which
0.55's client never sends. `_foreignModule` stays `null`, and every call
rejects.

On `/settings/mcp-apis` the consumer is `SettingsMcpSetup.tsx` —
`<CodeEditor language="json">` for the MCP config. Monaco fires
`resetSchema` on `onWillDisposeModel` / `onDidChangeModelLanguage` and
`findDocumentColors` continuously, which is why one bug produces 170
events and 22.

There is a second, independent bug in the same file: the `switch` only
maps `json` and `graphql`, so `typescript` / `javascript` / `css` /
`html` fall through to the bare `EditorWorker`, which carries no
language service at all. That's the workflow-page
`getCodeFixesAtPosition`, and it would break even with matching
versions.

Impact is worse than the log noise suggests: after visiting the
playground, JSON validation/colors in the MCP config editor and TS
intellisense/quick-fixes in the workflow code editor silently stop
working for the rest of the session.

## Changes

- **`twenty-ui/src/input/CodeEditor/CodeEditor.tsx`** — configure
`@monaco-editor/react` with the bundled Monaco (`loader.config({ monaco
})`) instead of letting it fetch its own from jsDelivr. The import stays
dynamic so Monaco is still only downloaded when an editor actually
renders; the component shows its existing `Loader` until the loader is
configured.
- **`twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts`**
(new, replaces
`settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts`) — app-level
worker factory mapping every label Monaco can ask for: `json`,
`css`/`scss`/`less`, `html`/`handlebars`/`razor`,
`typescript`/`javascript`, `graphql`, and the generic editor worker as
the fallback.
- **`twenty-front/src/index.tsx`** and **`.storybook/preview.tsx`** —
set it up once for the app and for stories, rather than as a side effect
of one lazy route.

Dropping the CDN also means the code editors work in self-hosted and
air-gapped deployments, which today silently fall back to a broken
editor when jsDelivr is unreachable.

## Verification

- `nx build twenty-front` passes; `css.worker`, `html.worker` and
`ts.worker` chunks are now emitted alongside the existing
`editor`/`json`/`graphql` ones.
- Monaco stays lazy — `edcore.main` is not statically imported by the
entry chunk and is absent from `index.html`'s modulepreloads. Measured
against a baseline build of `main`, the entry chunk goes from 2,598,088
B to 2,599,040 B (**+952 B**).
- `oxlint` and `oxfmt --check` clean on all touched files; `tsc
--noEmit` clean for `twenty-ui` and reports nothing new for the touched
`twenty-front` files.

Not verified in a browser — worth a manual pass on the playground → MCP
tab → workflow code editor sequence that reproduced the original errors.

Fixes TWENTY-FRONT-8YV
Fixes TWENTY-FRONT-8YW
Fixes TWENTY-FRONT-ADE

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RgPXkmUHANwD7ooUMqNYr9)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23855?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-08-06 14:26:54 +02:00
committed by GitHub
parent 829ef9d8b9
commit f4d5500fc4
6 changed files with 99 additions and 25 deletions
@@ -24,6 +24,10 @@ import { RootDecorator } from '../src/testing/decorators/RootDecorator';
import { resetJotaiStore } from '../src/modules/ui/utilities/state/jotai/jotaiStore';
// oxlint-disable-next-line no-restricted-imports
import { UserContext } from '../src/modules/users/contexts/UserContext';
// Stories rendering CodeEditor / GraphiQL need Monaco's worker factory, which
// the app normally sets up in src/index.tsx.
// oxlint-disable-next-line no-restricted-imports
import '../src/modules/app/utils/setupMonacoEnvironment';
import 'react-loading-skeleton/dist/skeleton.css';
import 'twenty-ui/style.css';
+1
View File
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client';
import { App } from '@/app/components/App';
import '@/app/utils/setupMonacoEnvironment';
import { migrateTokenPairCookieToLocalStorage } from '@/auth/utils/migrateTokenPairCookieToLocalStorage';
import { hydrateMetadataStore } from '@/metadata-store/storage/metadataStoreStorage';
import '@fontsource/dm-mono/400.css';
@@ -0,0 +1,44 @@
import { type Environment } from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker.js?worker';
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker.js?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker';
import TypeScriptWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker.js?worker';
import GraphqlWorker from 'monaco-graphql/esm/graphql.worker.js?worker';
// Monaco resolves every language worker through this single global, shared by
// all editors on the page (GraphiQL's and CodeEditor's alike), so it has to be
// set up once for the whole app rather than per feature.
//
// Every label Monaco can ask for must be mapped: an unmapped label silently
// falls back to the generic editor worker, which carries no language service.
// Requests to it then reject with "Missing requestHandler or method: <method>"
// (`resetSchema`, `findDocumentColors`, `getCodeFixesAtPosition`, ...) and the
// language features go quietly dead.
const monacoEnvironment: Environment = {
getWorker: (_workerId, label) => {
switch (label) {
case 'json':
return new JsonWorker();
case 'css':
case 'scss':
case 'less':
return new CssWorker();
case 'html':
case 'handlebars':
case 'razor':
return new HtmlWorker();
case 'typescript':
case 'javascript':
return new TypeScriptWorker();
case 'graphql':
return new GraphqlWorker();
default:
return new EditorWorker();
}
},
};
(
globalThis as unknown as { MonacoEnvironment?: Environment }
).MonacoEnvironment = monacoEnvironment;
@@ -1,4 +1,3 @@
import '@/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
@@ -1,22 +0,0 @@
import { type Environment } from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker';
import GraphqlWorker from 'monaco-graphql/esm/graphql.worker.js?worker';
// GraphiQL 5's Monaco editors need a worker factory; without it Monaco throws "Cannot read properties of undefined (reading 'toUrl')".
const monacoEnvironment: Environment = {
getWorker: (_workerId, label) => {
switch (label) {
case 'json':
return new JsonWorker();
case 'graphql':
return new GraphqlWorker();
default:
return new EditorWorker();
}
},
};
(
globalThis as unknown as { MonacoEnvironment?: Environment }
).MonacoEnvironment = monacoEnvironment;
@@ -1,4 +1,8 @@
import Editor, { type EditorProps, type Monaco } from '@monaco-editor/react';
import Editor, {
loader,
type EditorProps,
type Monaco,
} from '@monaco-editor/react';
import { Loader } from '@ui/feedback/Loader/Loader';
import { BASE_CODE_EDITOR_THEME_ID } from '@ui/input/CodeEditor/constants/BaseCodeEditorThemeId';
import { getBaseCodeEditorTheme } from '@ui/input/CodeEditor/utils/getBaseCodeEditorTheme';
@@ -18,6 +22,34 @@ import styles from './CodeEditor.module.scss';
type CodeEditorVariant = 'default' | 'with-header' | 'borderless';
type CodeEditorContentPadding = 'default' | 'comfortable';
// Left alone, `@monaco-editor/loader` downloads Monaco from a CDN at runtime,
// which puts a second Monaco — pinned to a different version than the one we
// bundle — on the page next to the one GraphiQL uses. The two then fight over
// the single global `MonacoEnvironment` and end up talking to each other's
// workers over an incompatible protocol. Point the loader at the bundled
// instance so there is exactly one Monaco (and one that also works offline /
// in self-hosted deployments, unlike the CDN).
//
// The import stays dynamic so Monaco is still only fetched when a code editor
// is actually rendered, instead of weighing down every chunk importing this
// component.
//
// Contract: the bundled ESM Monaco (unlike the CDN AMD build) resolves its
// language workers through `globalThis.MonacoEnvironment`, which the host app
// must set up before an editor mounts — worker wiring is bundler-specific, so
// it can't live in this library. twenty-front does this in
// `src/modules/app/utils/setupMonacoEnvironment.ts`; without it, language
// services (validation, intellisense) are silently unavailable.
let monacoLoaderConfiguration: Promise<void> | undefined;
const configureMonacoLoader = () => {
monacoLoaderConfiguration ??= import('monaco-editor').then((monaco) => {
loader.config({ monaco });
});
return monacoLoaderConfiguration;
};
const setCodeEditorTheme = (
monaco: Monaco,
theme: ThemeType,
@@ -67,6 +99,8 @@ export const CodeEditor = ({
editor.IStandaloneCodeEditor | undefined
>(undefined);
const [isEditorFocused, setIsEditorFocused] = useState(false);
const [isMonacoLoaderConfigured, setIsMonacoLoaderConfigured] =
useState(false);
const [autoHeightContentHeight, setAutoHeightContentHeight] = useState<
number | undefined
>(undefined);
@@ -124,6 +158,20 @@ export const CodeEditor = ({
}
};
useEffect(() => {
let isStillMounted = true;
configureMonacoLoader().then(() => {
if (isStillMounted) {
setIsMonacoLoaderConfigured(true);
}
});
return () => {
isStillMounted = false;
};
}, []);
useEffect(() => {
if (!isDefined(monaco)) {
return;
@@ -161,7 +209,7 @@ export const CodeEditor = ({
};
}, [editor, shouldAutoHeight]);
return isLoading ? (
return isLoading || !isMonacoLoaderConfigured ? (
<div
className={styles.editorLoader}
data-variant={variant}