diff --git a/.changeset/25899-monaco-self-host.md b/.changeset/25899-monaco-self-host.md new file mode 100644 index 00000000000..a402816a32d --- /dev/null +++ b/.changeset/25899-monaco-self-host.md @@ -0,0 +1,18 @@ +--- +'@object-ui/plugin-editor': patch +'@object-ui/app-shell': patch +'@objectstack/console': patch +--- + +fix(plugin-editor): the Console's code editor loads Monaco from its own build, never from cdn.jsdelivr.net + +Clause-②: no +Metadata-protocol: none + +The Studio's code editors (the JSON source tab, the html / react page source editor, the metadata form's code fields) and the `code-editor` page component fetched Monaco's core from `cdn.jsdelivr.net` at run time, through `@monaco-editor/loader`'s default config. An install with no route to that CDN, and every Console served under its content-security-policy (which lists no CDN in `script-src`), got a plain textarea instead, and the `code-editor` component stayed on "Loading..." for good. + +- **`@object-ui/plugin-editor`:** `monaco-editor` is now a dependency, bundled by the host. The new `configureBundledMonaco(loader)` imports its ESM core as a lazy chunk and hands it to the loader with `loader.config({ monaco })` before the first `loader.init()`, so the loader injects no CDN script. Each language worker is imported through Vite's `?worker` when Monaco first asks for it, and starts from a same-origin file under the build's assets. The `code-editor` component mounts the editor only once that config has landed, and when the editor chunk cannot be fetched it edits the same text in a plain textarea instead of loading forever. +- **`@object-ui/app-shell`:** the code editors' one loader probe per page awaits `configureBundledMonaco` before `loader.init()`. The plain-textarea fallback stays for a chunk that cannot be fetched. +- **`@objectstack/console`:** the built Console carries Monaco as lazy chunks and worker files under `assets/` (about 13.5 MB of uncompressed JavaScript, loaded only on a screen that opens an editor); the first-paint closure is unchanged. + +What to do: nothing. A host that bundles `@object-ui/plugin-editor` with a bundler other than Vite must handle the `?worker` imports itself. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8edf18fec3..a31b8cff10c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5276,6 +5276,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 react: specifier: 19.3.0 version: 19.3.0 diff --git a/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.expressionEnvelope-10963.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.expressionEnvelope-10963.test.tsx index 4adfe091ebc..1e378f20518 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.expressionEnvelope-10963.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.expressionEnvelope-10963.test.tsx @@ -44,7 +44,7 @@ * The editor mounts only once the page's Monaco loader probe has resolved * (objectui#11858), so the loader is stubbed to resolve, and the sink paints * one `.view-line` row as a loaded Monaco does: this file is the - * reachable-CDN page. The failing page is + * reachable-loader page. The failing page is * `CodeWidget.loaderFallback-11858.test.tsx`. */ @@ -55,6 +55,8 @@ import { render, screen, fireEvent, cleanup, act } from '@testing-library/react' import { FieldSchema, hookForm, objectForm } from '@objectstack/spec/data'; vi.mock('@object-ui/plugin-editor', () => ({ + // The bundled Monaco loads: the probe goes on to `loader.init()`. + configureBundledMonaco: () => Promise.resolve(), CodeEditorRenderer: ({ value, onChange, @@ -76,7 +78,7 @@ vi.mock('@object-ui/plugin-editor', () => ({ ), })); -// The CDN is reachable: the page's one `loader.init()` resolves. +// The loader is reachable: the page's one `loader.init()` resolves. vi.mock('@monaco-editor/react', () => { const Editor = () => null; return { Editor, default: Editor, loader: { init: () => Promise.resolve({}) } }; diff --git a/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.loaderFallback-11858.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.loaderFallback-11858.test.tsx index b4243c54cc0..504ebb81f5c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.loaderFallback-11858.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/CodeWidget.loaderFallback-11858.test.tsx @@ -1,10 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectui#11858 — when Monaco's CDN loader script cannot be fetched (no - * egress, a strict CSP), SchemaForm's `code` widget renders an editable - * textarea that saves, the failure is reported once per page, and nothing - * escapes as an uncaught error. + * objectui#11858 — when Monaco cannot be loaded (its bundled chunk cannot be + * fetched), SchemaForm's `code` widget renders an editable textarea that + * saves, the failure is reported once per page, and nothing escapes as an + * uncaught error. * * ## The defect this file keeps fixed * @@ -28,18 +28,18 @@ * formula's ADR-0089 envelope parsed by the spec: the envelope write the * editor makes (objectui#10963) is the one the textarea makes. * - * ## Nothing about Monaco is mocked + * ## Only the chunk is stood in for * * As in `useMonacoFallback.loaderRejection-11800.test.tsx`, this drives the REAL - * `@monaco-editor/loader` and the real plugin-editor ``: the leak lives - * in the loader, and a stubbed `loader.init` would hand back a promise the - * caller CAN catch. Two pieces of browser behaviour are modelled, and only - * these two, the same way that file models them: + * `@monaco-editor/loader` and the real plugin-editor ``; only + * plugin-editor's `configureBundledMonaco`, which hands the bundled Monaco to + * the loader before the page's one `loader.init()`, stands for the chunk. Two + * pieces of browser behaviour are modelled, and only these two, the same way + * that file models them: * - * - **The network.** The loader appends a `vs/loader.js` script and waits for - * its `error` event. The script is held back from the DOM and its `error` - * event fired by the test, after both lazy editor modules have loaded, so - * an editor that mounted before the loader answered would have mounted. + * - **The network.** `configureBundledMonaco` waits until the test lets the + * chunk fail, after both lazy editor modules have loaded, so an editor that + * mounted before the chunk answered would have mounted. * - **Unhandled rejections.** Each Node report is replayed as a cancelable * `unhandledrejection` event on `window`, and the ones left uncancelled are * counted. Vitest's own listeners are put back afterwards. @@ -69,6 +69,26 @@ import { CodeWidget } from './widgets'; import type { FormFieldSpec, FormViewSpec } from './form-spec'; const LOADER_SCRIPT = /\/vs\/loader\.js$/; +const CHUNK_URL = 'http://localhost/_console/assets/editor.api2-hash.js'; + +const chunk = vi.hoisted(() => { + let fail!: () => void; + const answered = new Promise((resolve) => { + fail = resolve; + }); + return { answered, fail: () => fail() }; +}); + +// The bundled Monaco chunk cannot be fetched: plugin-editor's +// `configureBundledMonaco` rejects, as its import of the chunk does, once the +// test says the network answered. The rest of plugin-editor is real. +vi.mock('@object-ui/plugin-editor', async (importOriginal) => ({ + ...(await importOriginal()), + configureBundledMonaco: async () => { + await chunk.answered; + throw new TypeError(`Failed to fetch dynamically imported module: ${CHUNK_URL}`); + }, +})); let uncaught: unknown[] = []; let vitestListeners: NodeJS.UnhandledRejectionListener[] = []; @@ -152,7 +172,7 @@ const STORED_EXPRESSION = FieldSchema.parse({ expression: { dialect: 'cel', source: 'record.amount * 2', meta: META }, }).expression; -describe('CodeWidget — a failing Monaco loader falls back to an editable textarea (objectui#11858)', () => { +describe('CodeWidget — a Monaco chunk that cannot be fetched falls back to an editable textarea (objectui#11858)', () => { it('the rows are what they claim: spec code rows, and a spec-parsed envelope', () => { // Guards the premises, so a spec that renames a row or changes its type // fails here instead of turning the pins below into tests of nothing. @@ -185,15 +205,13 @@ describe('CodeWidget — a failing Monaco loader falls back to an editable texta ); await settle(); - expect(heldLoaderScripts, 'the loader appends its script once per page').toHaveLength(1); - const [loaderScript] = heldLoaderScripts; // Before the network answers, no Monaco editor is mounted: it would show // its own "Loading..." and call `loader.init()` itself. - expect.soft(screen.queryAllByText('Loading...'), 'Monaco editors mounted before the loader answered').toHaveLength(0); + expect.soft(screen.queryAllByText('Loading...'), 'Monaco editors mounted before the chunk answered').toHaveLength(0); - // The network answers: the loader script failed to load. - act(() => { - loaderScript.dispatchEvent(new Event('error')); + // The network answers: the chunk could not be fetched. + await act(async () => { + chunk.fail(); }); const hookBody = (await screen.findByRole('textbox', { name: /^source/i }, { timeout: 2000 })) as HTMLTextAreaElement; @@ -216,14 +234,15 @@ describe('CodeWidget — a failing Monaco loader falls back to an editable texta await settle(); // Soft, so a regression reports every reading at once, not just the first. - expect.soft(uncaught, 'uncaught rejections after the loader failed').toHaveLength(0); + expect.soft(uncaught, 'uncaught rejections after the chunk failed').toHaveLength(0); const editorInitErrors = consoleError.mock.calls.filter( ([first]) => typeof first === 'string' && first.startsWith('Monaco initialization'), ); expect.soft(editorInitErrors, "'s own init error lines").toHaveLength(0); const reports = monacoReports(consoleWarn.mock.calls); expect.soft(reports, 'console lines reporting the Monaco failure').toHaveLength(1); - expect.soft(reports.join('\n'), 'the report names the loader script URL').toContain(loaderScript.src); + expect.soft(reports.join('\n'), 'the report names the chunk URL').toContain(CHUNK_URL); + expect.soft(heldLoaderScripts, "loader scripts appended (the loader's CDN default)").toHaveLength(0); expect.soft(screen.queryAllByText('Loading...'), 'Monaco editors mounted after the failure').toHaveLength(0); // Reopen: a code row mounted after the failure is known renders the @@ -248,7 +267,7 @@ describe('CodeWidget — a failing Monaco loader falls back to an editable texta await settle(); expect.soft(uncaught, 'uncaught rejections after a reopen').toHaveLength(0); - expect.soft(heldLoaderScripts, 'loader scripts after a reopen').toHaveLength(1); + expect.soft(heldLoaderScripts, 'loader scripts after a reopen').toHaveLength(0); expect.soft(monacoReports(consoleWarn.mock.calls), 'reports after a reopen').toHaveLength(1); }); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx index 04fb3667137..152a45a5207 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.fallback.test.tsx @@ -19,6 +19,10 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, loader: { init: () => Promise.resolve({}) } }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { JsonSourceEditor } from './JsonSourceEditor'; describe('JsonSourceEditor — F5 textarea fallback', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx index 7e73abc2ef1..ef82460fe7b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.monaco-export.test.tsx @@ -47,6 +47,10 @@ vi.mock('@monaco-editor/react', () => { }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { JsonSourceEditor } from './JsonSourceEditor'; describe('JsonSourceEditor — lazy Monaco export shape', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx index 689ec3463d7..7b1e1344366 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/JsonSourceEditor.tsx @@ -99,11 +99,12 @@ export function JsonSourceEditor({ const editorRef = React.useRef(null); const monacoRef = React.useRef(null); - // Monaco's core is fetched lazily and, by default, from a public CDN, and it - // also spins up web workers. When any of that is blocked — offline / - // air-gapped / CSP-restricted installs — the editor mounts an empty shell - // with no error and the Source tab looks blank. `useMonacoFallback` fast-fails - // to a plain textarea the moment the CDN loader rejects, and also backstops + // Monaco's core is a lazy chunk of the host's own build (plugin-editor's + // `configureBundledMonaco`, never a CDN), and it also spins up web workers. + // When either is blocked — a chunk that cannot be fetched, a worker refused — + // the editor mounts an empty shell with no error and the Source tab looks + // blank. `useMonacoFallback` fast-fails to a plain textarea the moment the + // chunk or the loader rejects, and also backstops // the "resolved but painted nothing" case via a `.view-line` DOM poll, so the // source is always readable and editable. The editor mounts only once the // loader has resolved: mounted earlier, its own `loader.init()` on a failing diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx index b51c2d34c5c..57923b6a504 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.nameSurvivesLabel-12680.test.tsx @@ -82,6 +82,10 @@ vi.mock('@monaco-editor/react', () => { const Editor = () => null; return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); + +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); // The page's own side reads (the AI agents roster and the like) go through the // global `fetch`; one double answers "absent" for the whole file, so no read // can reach a socket after a test body returns. diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pageRequiresStamp-11357.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pageRequiresStamp-11357.test.tsx index 1f61a0ee32f..58fabae24c5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pageRequiresStamp-11357.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pageRequiresStamp-11357.test.tsx @@ -208,6 +208,10 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + // The `page` resource config under test is registered here, at load, exactly // as the package entry registers it. import '../../services/builtinComponents.js'; diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.nonRegistryLabelling.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.nonRegistryLabelling.test.tsx index 7f08ab68e37..f545dad16d4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.nonRegistryLabelling.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.nonRegistryLabelling.test.tsx @@ -77,6 +77,10 @@ vi.mock('@monaco-editor/react', () => { const Editor = () => null; return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); + +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); import { SchemaForm, resolveFieldFace, diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.widgetLabelling.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.widgetLabelling.test.tsx index bf8a10714f5..408e9503a1e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.widgetLabelling.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.widgetLabelling.test.tsx @@ -63,6 +63,10 @@ vi.mock('@monaco-editor/react', () => { const Editor = () => null; return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); + +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); import { SchemaForm, type FormFieldSpec } from './SchemaForm'; import { WIDGETS, diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.lineNumbers-12522.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.lineNumbers-12522.test.tsx index e4de3683835..bc0d9ac95cb 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.lineNumbers-12522.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.lineNumbers-12522.test.tsx @@ -20,6 +20,10 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { SourcePageEditor } from './SourcePageEditor'; afterEach(cleanup); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx index 8b4255301bc..48b231c70c0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx @@ -50,6 +50,10 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { SourcePageEditor } from './SourcePageEditor'; const LABEL = 'Team handbook'; diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx index 50ce684020d..2df6f1c61dd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx @@ -177,10 +177,10 @@ export function SourcePageEditor({ } }, [source]); - // Monaco-unavailable fallback (headless / CSP / air-gapped) → plain textarea. - // Fast-fails the moment the CDN loader rejects instead of waiting the full - // grace period, and mounts the editor only once the loader has resolved - // (objectui#11800); see useMonacoFallback. + // Monaco-unavailable fallback (headless, a chunk that cannot be fetched) → + // plain textarea. Fast-fails the moment the editor chunk or the loader + // rejects instead of waiting the full grace period, and mounts the editor + // only once the loader has resolved (objectui#11800); see useMonacoFallback. const [monacoStatus, containerRef] = useMonacoFallback(fallbackDelayMs); const [theme, setTheme] = React.useState<'vs-dark' | 'light'>(() => diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx index 3ad5285cb3a..8c3b2db09ba 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx @@ -123,6 +123,10 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { t, tFormat } from '../i18n'; import { BLOCK_TYPE_META, CATEGORY_LABEL_KEY, TYPES_BY_CATEGORY } from './block-types'; diff --git a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx index 9f1163cd156..b81f805e356 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.fastfail.test.tsx @@ -1,8 +1,7 @@ /** - * When Monaco's CDN loader script fails outright (offline / air-gapped / CSP — - * the console embeds in servers that ship a strict CSP), `loader.init()` - * rejects. The editor must fall back to the textarea IMMEDIATELY rather than - * making the user wait out the DOM-poll grace period. We prove the fast path by + * When Monaco's loader fails outright, `loader.init()` rejects. The editor must + * fall back to the textarea IMMEDIATELY rather than making the user wait out + * the DOM-poll grace period. We prove the fast path by * setting a very long `fallbackDelayMs`: if the textarea appears, it can only be * because the rejected `loader.init()` tripped it, not the poll. */ @@ -17,14 +16,18 @@ vi.mock('@monaco-editor/react', () => { return { Editor, default: Editor, - // Simulate the CDN loader script failing to load. - loader: { init: () => Promise.reject(new Error('CDN blocked')) }, + // Simulate the loader failing. + loader: { init: () => Promise.reject(new Error('loader failed')) }, }; }); +// The bundled Monaco loads: the page's probe awaits plugin-editor's +// `configureBundledMonaco` before `loader.init()` (its own suite drives it). +vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() })); + import { JsonSourceEditor } from './JsonSourceEditor'; -describe('useMonacoFallback — fast-fail on CDN/loader rejection', () => { +describe('useMonacoFallback — fast-fail on loader rejection', () => { it('shows the textarea immediately when loader.init() rejects, without waiting out fallbackDelayMs', async () => { const draft = { name: 'work_order', fields: { title: { type: 'text' } } }; // Long grace period: only the fast path can satisfy this within the timeout. diff --git a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx index 3fd882dce5c..7d7bb71ddc5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/useMonacoFallback.loaderRejection-11800.test.tsx @@ -7,24 +7,23 @@ */ /** - * objectui#11800 — when Monaco's CDN loader script cannot be fetched (offline, - * no egress, a strict CSP), nothing escapes as an uncaught error and the - * failure is reported once per page, as one console line naming the URL. + * objectui#11800 — when Monaco cannot be loaded, nothing escapes as an + * uncaught error and the failure is reported once per page, as one console + * line naming what could not be fetched. * - * Nothing about Monaco is mocked here: this drives the REAL - * `@monaco-editor/loader` and the real ``, because the leak lives in - * the loader (its `makeCancelable` subscribes without a rejection handler, so - * every `loader.init()` made while it fails leaves one rejection no caller can - * reach). A stubbed `loader.init` would hand back a promise the caller CAN - * catch, and would pass on the defect. + * Monaco is the host's own bundled chunk (plugin-editor's + * `configureBundledMonaco` hands it to the loader before the page's one + * `loader.init()`), so what can fail is fetching that chunk, and the loader's + * CDN default is never reached: no loader `