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
18 changes: 18 additions & 0 deletions .changeset/25899-monaco-self-host.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/

Expand All @@ -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,
Expand All @@ -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({}) } };
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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 `<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 `<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.
Expand Down Expand Up @@ -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<void>((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<typeof import('@object-ui/plugin-editor')>()),
configureBundledMonaco: async () => {
await chunk.answered;
throw new TypeError(`Failed to fetch dynamically imported module: ${CHUNK_URL}`);
},
}));

let uncaught: unknown[] = [];
let vitestListeners: NodeJS.UnhandledRejectionListener[] = [];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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, "<Editor>'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
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,12 @@ export function JsonSourceEditor({
const editorRef = React.useRef<any>(null);
const monacoRef = React.useRef<any>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'>(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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.
Expand Down
Loading
Loading