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:
@@ -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,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
@@ -1,4 +1,3 @@
|
||||
import '@/settings/mcp-and-apis/utils/setupGraphiqlMonacoWorkers';
|
||||
import {
|
||||
isPlaygroundApiKeyFresh,
|
||||
playgroundApiKeyState,
|
||||
|
||||
-22
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user