perf(editor): load Monaco when the editor is opened, not at startup - #423
Merged
Conversation
Every window loaded Monaco before painting anything, including windows that only ever show rendered Markdown. First paint fetched 4770 KB, of which 4410 KB was one chunk holding Monaco and the app together, and about 360ms of that was parse and eval alone - repeated per window, since each one is its own webview. before 14 requests / 4770 KB / largest chunk 4410 KB after 14 requests / 904 KB / largest chunk 686 KB entering edit mode +4 requests / +3868 KB / 327 ms 904 + 3868 = 4772, so nothing is duplicated or lost - it is deferred. The four `?worker` imports were already lazy and stay static. Verified three ways: they are emitted as standalone files, referenced only as a URL string, and fetched at zero on first paint. `editor.worker` arrives on the first keystroke; the ts/json/css/html workers are never fetched for a Markdown document. So the weight was Monaco's own body, not the workers, and making those dynamic would move a few hundred bytes of URL and buy nothing. `MarkdownViewer.svelte` is untouched: making the import inside Editor.svelte dynamic was enough for Rollup to split the chunk, since nothing statically reachable from the entry names it any more. Three consequences had to be handled. `onMount` stays synchronous, because Svelte only accepts a synchronously returned teardown and an async callback would leak the editor and its listeners. Every effect touching the editor gates on a reactive `editorReady` rather than `if (editor)`, which would run once against nothing and never re-fire - that would have silently dropped scroll sync, zoom-aware font size, theme and Vim mode for anyone who had them on at mount. And the tab id is re-read at create time, since the tab can change while the chunk is in flight. First entry now costs 327ms, so it shows the spinner the app already uses while booting. Re-entering edit mode later fetches nothing. No idle prefetch: that would restore most of the parse cost for viewer-only users, per window, which is the cost this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every window loads Monaco before painting anything — including windows that only ever show rendered Markdown. Each window is its own webview, so the cost repeats per window.
904 + 3868 = 4772 ≈ 4770 — nothing is duplicated or lost; it is deferred.
Measured with the repo's own config (
svelte-kit sync && vite build), served overpython3 -m http.serverso decoded equals transferred, read fromperformance.getEntriesByType('resource').The workers were already lazy — so the weight is Monaco itself
Checked before changing anything, three ways:
_app/immutable/workers/, not chunks in the main graphnew Worker(""+new URL("../workers/editor.worker-….js",import.meta.url))— a URL string, nothing moreeditor.workeron the first keystroke; ts/json/css/html workers never fetched for a Markdown documentSo the four
?workerimports stay static — making them dynamic would move a few hundred bytes of URL string and buy nothing. That is written at the import site and asserted in the test, which explicitly skips?workerspecifiers.MarkdownViewer.svelteis untouchedMaking the import inside
Editor.sveltedynamic was enough: nothing statically reachable from the entry namesmonaco-editorany more, so Rollup splits it.MarkdownViewerkeeps its staticimport Editor, which is now a cheap component shell.Three structural consequences had to be handled, and two of them would have failed silently:
onMountstays synchronous. Svelte only accepts a synchronously returned teardown; anasynccallback hands it a promise and leaks the editor, its listeners and thewindow.openpatch. The await lives in an inner task, and the sync return closes over acancelledflag.editorReady.editoris a plainlet, soif (editor)runs once against nothing and never re-fires — that would have silently dropped scroll sync, zoom-aware font size, theme and Vim mode for anyone who had them on at mount. The file already carried this warning for the localised-actions effect; it now covers the other five.Every entry point, verified
startInEditor·newFileDefaultMode· restored session tabs · Ctrl+E / pencil / context menu · Ctrl+\ split · typing into the preview · Ctrl+L live mode — all funnel through one{#if isEditing || isSplit}block, so one fix covers them. EveryeditorPane.*call site is?.-guarded or behind a truthiness check, and every exported method self-guards, so calls during the load window are no-ops rather than crashes.Saving is safe during the window:
saveContentreadstab.rawContentfrom the store, nevereditor.getValue(), so a save landing mid-load cannot write an empty file.First entry costs 327 ms, so it gets the spinner the app already has
Well past "tens of ms", so it shows the same wordless SVG spinner the app already displays while booting, scoped inside
.editor-outer— no new i18n string, nothing that reads as an error. It paints once per window; re-entering edit mode later fetches nothing and shows no spinner frame.No idle prefetch. It would restore most of the parse cost for viewer-only users, multiplied per window — exactly the cost this removes.
The cost is not moved from one group to another. For
startInEditorusers:Roughly a wash on time-to-editor, and the shell is interactive ~340 ms earlier.
Verified by running the built app
With the Tauri IPC layer stubbed (in scratch, never in the repo), driving the real production build:
fmt-bold→ 粗体,toggle-vim-mode→ Vim模式);updateOptionsre-ran reactively (minimap, wordWrap); theme class applied,defineThemestill ordered beforecreate; Vim toggled on →--NORMAL--→ offif (editor)gate would have dropped.Tests
scripts/monacoStartupGraph.test.tswalks the static import graph fromsrc/routes/+page.svelte— resolving$lib/,.js→.ts,.svelte, and stripping type-only statements — and asserts nothing reachable namesmonaco-editorormonaco-vim. Its header states what it proves (no static source path) and what it does not (that Rollup actually emitted a separate chunk —vite.config.jscould still merge it, which needs a real build, hence the numbers above).import * as monacorestoredsrc/lib/utils/__probe.ts -> monaco-editoreditorReadygate dropped (Vim)The first red run caught a bug in the test's own first draft: type-only imports were subtracted by specifier string, so
Editor.svelte's own type import masked a value import of the same module. Fixed by deleting type statements from the text before matching, with a planted-regression self-check.scripts/scrollSyncInput.test.tssliced on the literalif (editor && onscrollsync)and silently produced an empty string once the guard gainededitorReady &&. Re-anchored with an explicitnotEqual(-1)so it fails loudly instead of passing vacuously.Not covered
theme.tsstill pulls Monaco at startup forvscode:*theme users —parseAndApplyVscodeThemedoes its ownawait import('monaco-editor')from a boot-time effect. Those users are no worse off (it used to be a blocking static import and is now async, off the critical path) but they do not get the full win. Deferring it is a separate behavioural change.window.openis patched ~330 ms later on first entry. Nothing in the editor path calls it inside that window.🤖 Generated with Claude Code