From f4d5500fc480b95702543e9d60dd001eb0901f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 6 Aug 2026 14:26:54 +0200 Subject: [PATCH] fix(front): run a single Monaco instance across the app (#23855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Three Sentry issues, all `Missing requestHandler or 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` — `` 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)_ Review in cubic --- packages/twenty-front/.storybook/preview.tsx | 4 ++ packages/twenty-front/src/index.tsx | 1 + .../app/utils/setupMonacoEnvironment.ts | 44 ++++++++++++++++ .../components/GraphQLPlayground.tsx | 1 - .../utils/setupGraphiqlMonacoWorkers.ts | 22 -------- .../src/input/CodeEditor/CodeEditor.tsx | 52 ++++++++++++++++++- 6 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 packages/twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts delete mode 100644 packages/twenty-front/src/modules/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx index 7629752005..1d2b2de6f7 100644 --- a/packages/twenty-front/.storybook/preview.tsx +++ b/packages/twenty-front/.storybook/preview.tsx @@ -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'; diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx index 7707173bfa..23c414a622 100644 --- a/packages/twenty-front/src/index.tsx +++ b/packages/twenty-front/src/index.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts b/packages/twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts new file mode 100644 index 0000000000..6d3b62e226 --- /dev/null +++ b/packages/twenty-front/src/modules/app/utils/setupMonacoEnvironment.ts @@ -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: " +// (`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; diff --git a/packages/twenty-front/src/modules/settings/mcp-and-apis/components/GraphQLPlayground.tsx b/packages/twenty-front/src/modules/settings/mcp-and-apis/components/GraphQLPlayground.tsx index 7fea4751a1..151e8025a8 100644 --- a/packages/twenty-front/src/modules/settings/mcp-and-apis/components/GraphQLPlayground.tsx +++ b/packages/twenty-front/src/modules/settings/mcp-and-apis/components/GraphQLPlayground.tsx @@ -1,4 +1,3 @@ -import '@/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers'; import { isPlaygroundApiKeyFresh, playgroundApiKeyState, diff --git a/packages/twenty-front/src/modules/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts b/packages/twenty-front/src/modules/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts deleted file mode 100644 index b3c9c3700d..0000000000 --- a/packages/twenty-front/src/modules/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers.ts +++ /dev/null @@ -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; diff --git a/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx b/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx index 35130ba8b2..ec1c8f71cf 100644 --- a/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx +++ b/packages/twenty-ui/src/input/CodeEditor/CodeEditor.tsx @@ -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 | 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 ? (