@molecule/app-code-editor-monaco
Provider bond · code-editor · App (browser) · v1.0.3 · Apache-2.0
Monaco Editor implementation for code editing
npm install @molecule/app-code-editor-monaconpm · Source on GitHub · Implements @molecule/app-code-editor
How it works
@molecule/app-code-editor-monaco is a provider bond on the app (browser) side: it implements the code-editor core interface (@molecule/app-code-editor) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/app-code-editor'
import { provider } from '@molecule/app-code-editor-monaco'
setProvider(provider) // custom fonts/theme: setProvider(createProvider({...}))Live demos
Running apps built with Molecule that install this package — open the app or study the template.
Reference
Monaco code editor provider for molecule.dev — the VS Code editor core
wired to the @molecule/app-code-editor interface (mount, models, themes,
diff view, LSP client).
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/app-code-editor'
import { provider } from '@molecule/app-code-editor-monaco'
setProvider(provider) // custom fonts/theme: setProvider(createProvider({...}))
Type
provider
Installation
npm install @molecule/app-code-editor-monaco @molecule/app-code-editor @molecule/app-i18n @molecule/app-logger monaco-editor
API
Interfaces
MonacoConfig
Configuration for monaco.
interface MonacoConfig {
/** Theme for the editor. Defaults to the bundled 'molecule-dark' (registered at mount); the diff editor defaults to 'vs-dark'. */
theme?: string
/** Default font family. */
fontFamily?: string
/** Default font size. */
fontSize?: number
/** Default tab size. */
tabSize?: number
/** Enable word wrap by default. */
wordWrap?: boolean
/** Show minimap by default. */
minimap?: boolean
/** Allow scrolling past the last line by default (Monaco default: true). */
scrollBeyondLastLine?: boolean
/** Show code-folding controls in the gutter by default (Monaco default: true). */
folding?: boolean
/** TypeScript compiler options for the language service. */
tsCompilerOptions?: Record<string, unknown>
}
Classes
MonacoEditorProvider
Monaco Editor implementation of EditorProvider. Dynamically imports the Monaco Editor
library at runtime to avoid large bundle sizes. Manages multiple file tabs with
independent Monaco models, cursor tracking, and change event listeners.
class MonacoEditorProvider implements EditorProvider {
readonly name = 'monaco'
private config: MonacoConfig
private monaco: MonacoModule | null = null
private editor: MonacoEditor | null = null
private monacoModels: Map<string, MonacoModel> = new Map()
private fileContents: Map<string, { content: string; language?: string }> = new Map()
private tabs: Map<string, EditorTab> = new Map()
private activeFile: string | null = null
private changeListeners: Set<(event: EditorChangeEvent) => void> = new Set()
private changeDisposable: { dispose(): void } | null = null
private versionCounter = 0
/** Guard against re-entrant changeListeners.forEach() calls. */
private notifying = false
/** Timer for debounced marker-change notifications. */
private markerNotifyTimer: ReturnType<typeof setTimeout> | null = null
/** Incremented on each mount() call; continuations check this to cancel stale async mounts. */
private mountGeneration = 0
/** The DOM element the normal editor is mounted into (saved for diff toggle). */
private containerElement: HTMLElement | null = null
/** Active diff editor instance, if any. */
private diffEditor: MonacoDiffEditor | null = null
/** Models used by the diff editor (disposed when diff closes). */
private diffModels: { original: MonacoModel; modified: MonacoModel } | null = null
/** Container element created for the diff editor. */
private diffContainerEl: HTMLElement | null = null
/** Disposable for the onDidChangeMarkers listener. */
private markerDisposable: { dispose(): void } | null = null
/** Map of filePath → disposable for registered extra libs. */
private extraLibDisposables: Map<string, { dispose(): void }> = new Map()
/** Active LSP client for remote language intelligence. */
private lspClient: LspClient | null = null
/** Mutable ref read by LSP providers — swapped on reconnect without re-registration. */
private lspClientRef: LspClientRef = { current: null }
/** Disposable for LSP-registered Monaco language providers (registered once, never re-registered). */
private lspProviders: { dispose(): void } | null = null
/** Models created lazily by resolveModel (Peek Definition) — disposed on LSP disconnect. */
private lspResolvedModels: MonacoModel[] = []
/** Callback invoked when the LSP connection drops unexpectedly. */
private onLspDisconnectCb: (() => void) | null = null
/** LSP document version counters keyed by file path. */
private documentVersions: Map<string, number> = new Map()
/** Callback to fetch file content by path — used for Go to Definition cross-file navigation. */
private fileResolver:
((path: string) => Promise<{ content: string; language?: string } | null>) | null = null
/** Disposable for the editor opener registration. */
private editorOpenerDisposable: { dispose(): void } | null = null
/** Callbacks for "Fix with AI" requests from the editor. */
private fixWithAIListeners: Set<(request: FixWithAIRequest) => void> = new Set()
/** Disposables for Fix with AI registrations (code action provider + context menu action). */
private fixWithAIDisposables: { dispose(): void }[] = []
/** Queued silent content updates, keyed by file path — flushed on the next animation frame. */
private pendingSilentUpdates: Map<string, string> = new Map()
/** Handle for the scheduled rAF that flushes pendingSilentUpdates, or null if none is pending. */
private silentUpdateRaf: number | null = null
/** Files whose Monaco model content is stale (fileContents updated but model.setValue skipped). */
private staleModels: Set<string> = new Set()
/** Reverse lookup: Monaco model URI string → file path, for targeted marker checks. */
private uriToPath: Map<string, string> = new Map()
constructor(config: MonacoConfig = {}) {
this.config = {
theme: config.theme,
fontFamily: config.fontFamily ?? "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
fontSize: config.fontSize ?? 12,
tabSize: config.tabSize ?? 2,
wordWrap: config.wordWrap ?? false,
minimap: config.minimap ?? true,
...config,
}
}
/**
* Mounts the Monaco Editor into a DOM element. Dynamically imports the `monaco-editor` package.
* Uses a generation counter to cancel stale async continuations from React StrictMode's
* double-mount behaviour.
* @param element - The HTML element to mount the editor into.
* @param config - Optional editor configuration merged with constructor defaults.
*/
async mount(element: HTMLElement, config?: EditorConfig): Promise<void> {
const generation = ++this.mountGeneration
const mergedConfig = { ...this.config, ...config }
// Dynamic import — Monaco is a large library, loaded at runtime.
// Must use a string literal (not a variable) so Vite can rewrite the bare specifier
// 'monaco-editor' to the resolved node_modules path at dev-serve time.
const monaco = (await import('monaco-editor')) as unknown as MonacoModule
// If a newer mount() call started while we were awaiting, bail out.
// This handles React StrictMode's double-invoke of useEffect.
if (generation !== this.mountGeneration) return
this.monaco = monaco
this.containerElement = element
// Configure Monaco workers for language services.
// Always override — stale MonacoEnvironment from HMR reloads may still
// reference the TS worker, bypassing our fix to exclude it.
// Workers must be module type since Monaco's ESM worker files use `import`.
// Vite dev server serves node_modules with fs.strict=false so these paths work.
const win = window as unknown as Record<string, unknown>
win.MonacoEnvironment = {
// Use new URL(..., import.meta.url) so Vite resolves bare specifiers
// through its module resolver (finds hoisted workspace node_modules).
// Hardcoded /node_modules/ paths fail in monorepo setups where node_modules
// is at the workspace root, not the app root.
getWorker(_moduleId: string, label: string): Worker {
// monaco 0.56 exports map rewrites './*.js' to './esm/vs/*.js', so the
// specifier must NOT repeat the esm/vs prefix (the old deep paths now
// resolve to a doubled ./esm/vs/esm/vs/… and fail). These short forms
// land on the same files the pre-0.56 deep paths pointed at.
if (label === 'json') {
return new Worker(
new URL('monaco-editor/language/json/json.worker.js', import.meta.url),
{ type: 'module' },
)
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new Worker(new URL('monaco-editor/language/css/css.worker.js', import.meta.url), {
type: 'module',
})
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new Worker(
new URL('monaco-editor/language/html/html.worker.js', import.meta.url),
{ type: 'module' },
)
}
// Intentionally NOT loading ts.worker.js for TypeScript/JavaScript.
// The TS worker attempts to resolve every import (including @molecule/*
// packages that don't exist in the browser) and freezes the tab.
// LSP provides all language features we need — the basic editor worker
// handles tokenization and syntax highlighting via Monarch grammars.
return new Worker(new URL('monaco-editor/editor/editor.worker.js', import.meta.url), {
type: 'module',
})
},
}
// Dispose previous editor if exists
if (this.editor) {
this.changeDisposable?.dispose()
this.changeDisposable = null
this.editor.dispose()
}
registerTsxHighlighting(monaco as Parameters<typeof registerTsxHighlighting>[0])
this.editor = monaco.editor.create(element, {
theme: mergedConfig.theme ?? 'molecule-dark',
// … truncated — the full declaration is in node_modules/@molecule/app-code-editor-monaco/dist/provider.d.ts
Functions
lspCanCarry(text)
Whether a document is small enough to send to the language server.
function lspCanCarry(text: string): boolean
text— The document text.
Returns: True when it can be sent.
createProvider(config)
Creates a MonacoEditorProvider instance with optional configuration.
function createProvider(config?: MonacoConfig): MonacoEditorProvider
config— Monaco-specific editor options (theme, font size, etc.).
Returns: A MonacoEditorProvider that manages Monaco Editor instances.
preloadMonaco()
Warm the Monaco core bundle ahead of the first MonacoEditorProvider.mount.
Monaco is a large library loaded via await import('monaco-editor') inside
mount(), which lets it code-split out of the initial app bundle. The cost is
that the first editor mount has to download ~1 MB before it can paint. Call
this during idle time (e.g. while the user is still on the landing page) to
fetch that exact chunk into the browser cache in advance — mount() then
resolves its import('monaco-editor') from cache and the editor appears with
no network wait.
Uses the identical bare specifier as mount(), so the bundler resolves both
to the same chunk and they share one download and one cache entry. Idempotent
and best-effort: the returned promise never rejects the caller's flow.
function preloadMonaco(): Promise<unknown>
Returns: A promise that resolves once the Monaco core module is fetched.
Constants
provider
Pre-instantiated provider singleton.
const provider: MonacoEditorProvider
LSP_MAX_DOCUMENT_CHARS
The most characters of one document sent to the language server. The server’s WebSocket refuses a message over 1 MB with a close that the client answers by reconnecting, so a single large open file (a lockfile, a bundle) put the language server into an endless reconnect loop and stopped completions for every file. Such a file simply gets no language-server features.
const LSP_MAX_DOCUMENT_CHARS: 250000
Core Interface
Implements @molecule/app-code-editor interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/app-code-editor'
import { provider } from '@molecule/app-code-editor-monaco'
export function setupCodeEditorMonaco(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/app-code-editor^1.0.1@molecule/app-i18n^1.0.1@molecule/app-logger^1.0.1monaco-editor>=0.40.0
Runtime Dependencies
-
@molecule/app-code-editor -
@molecule/app-i18n -
@molecule/app-logger -
monaco-editor -
Monaco is code-split:
mount()doesawait import('monaco-editor')(~1 MB) on first use — callpreloadMonaco()during idle time to prefetch.monaco-editoris a peer dependency your app must install. -
TypeScript/JavaScript IntelliSense needs an LSP connection. This bond deliberately does NOT load Monaco's TS worker (it would try to resolve imports in the browser and freeze the tab); TS/JS gets syntax highlighting out of the box, and completion/hover/diagnostics only after
provider.connectLsp(wsUrl)— a WebSocket URL to a running LSP server. JSON/CSS/HTML language features work without LSP via their bundled workers. -
Default theme is the bundled
'molecule-dark'(registered at mount), not Monaco's'vs-dark'.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. Drive it through the preview: navigate_preview to the screen that embeds the editor, read_preview_ui to read the rendered editor surface and its file tabs/controls, and interact_preview to click and type — target the app's own data-mol-id controls (file-tree entries, tab bar, save button), since the editor engine renders its own inner chrome. A box you can't check is an integration bug to fix — not a skip:
- Opening a file loads its content into the editor surface, and syntax
highlighting matches the file's language — a
.tsfile colorizes as TypeScript (keywords, strings, comments tokenized in distinct colors), not flat plain text. Opening a second file of a different language re-highlights for that language. - Typing into the editor edits the content and the surface reflects every
keystroke: read_preview_ui shows the new text, and
getContent()for that path returns it. - Changing content marks the file dirty — its tab/label shows the unsaved
indicator (dot/asterisk) — and saving clears it back to clean (
markSaved). After save, reopening the file (close the tab, open it again) shows the SAVED text, not the pre-edit content — the change was persisted, not just held in the buffer. - With multiple files open as tabs, switching tabs preserves each file's own content and cursor position: edit file A, switch to B, switch back, and A still has its edit with the caret where you left it (no bleed-over between files, no reset to the top).
- A file the app opens read-only (readOnly config) cannot be edited — typing or paste does not change its content and it never shows a dirty indicator.
- Change (and save) events fire so the app can react: whatever this app
wires onto
onChange(autosave, live validation/diagnostics, an unsaved guard on navigation) actually triggers when you edit and when you save.