Skip to content

perf(editor): load Monaco when the editor is opened, not at startup - #423

Merged
PathGao merged 1 commit into
masterfrom
perf/load-monaco-on-demand
Aug 3, 2026
Merged

perf(editor): load Monaco when the editor is opened, not at startup#423
PathGao merged 1 commit into
masterfrom
perf/load-monaco-on-demand

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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.

                    requests   KB decoded   largest chunk
before  first paint     14        4770        4410 KB   (Monaco + app in one chunk)
after   first paint     14         904         686 KB   (app only)
                                ────────
                                 −3866 KB   (−81 %)

after   entering edit mode:  +4 requests / +3868 KB / 327 ms

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 over python3 -m http.server so decoded equals transferred, read from performance.getEntriesByType('resource').

The workers were already lazy — so the weight is Monaco itself

Checked before changing anything, three ways:

Build output emitted as standalone files under _app/immutable/workers/, not chunks in the main graph
Reference in the parent chunk new Worker(""+new URL("../workers/editor.worker-….js",import.meta.url)) — a URL string, nothing more
First paint, before and after 0 worker requests
Runtime editor.worker on the first keystroke; ts/json/css/html workers never fetched for a Markdown document

So the four ?worker imports 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 ?worker specifiers.

MarkdownViewer.svelte is untouched

Making the import inside Editor.svelte dynamic was enough: nothing statically reachable from the entry names monaco-editor any more, so Rollup splits it. MarkdownViewer keeps its static import Editor, which is now a cheap component shell.

Three structural consequences had to be handled, and two of them would have failed silently:

  • onMount stays synchronous. Svelte only accepts a synchronously returned teardown; an async callback hands it a promise and leaks the editor, its listeners and the window.open patch. The await lives in an inner task, and the sync return closes over a cancelled flag.
  • Every effect touching the editor gates on a reactive editorReady. editor is a plain let, so if (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.
  • The tab id is re-read at create time, since the tab can change while the chunk is in flight, and the tab-switch effect bails during that window.

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. Every editorPane.* 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: saveContent reads tab.rawContent from the store, never editor.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

Chunk Total Parse+eval
before, monolith (4410 KB) 391 / 401 / 420 ms ~360 ms
after, startup chunk (686 KB) 60 / 60 / 52 ms ~50 ms
after, full edit-mode entry 327 ms ~264 ms

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 startInEditor users:

before  ~400 ms blocking startup JS ──────────────────────► editor
after   ~50 ms startup JS ─► shell paints ─► +327 ms ─────► editor

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:

  • boots to the home screen with 0 Monaco requests
  • new file → spinner → editor mounts → spinner gone; typed text, status bar tracked to 列 18, tab went dirty
  • localised actions registered (fmt-bold → 粗体, toggle-vim-mode → Vim模式); updateOptions re-ran reactively (minimap, wordWrap); theme class applied, defineTheme still ordered before create; Vim toggled on → --NORMAL-- → off
  • the decisive case: left Vim and minimap on, unmounted the Editor, remounted → Vim bar back, minimap rendered, content preserved, actions registered, 0 new requests. That is precisely the scenario a non-reactive if (editor) gate would have dropped.
  • 0 console errors throughout

Tests

scripts/monacoStartupGraph.test.ts walks the static import graph from src/routes/+page.svelte — resolving $lib/, .js.ts, .svelte, and stripping type-only statements — and asserts nothing reachable names monaco-editor or monaco-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.js could still merge it, which needs a real build, hence the numbers above).

Mutation Result
baseline 533/533, 5/5 in the new file
static import * as monaco restored 3 of 5 red
transitive static import via a reachable util 2 of 5 red, naming src/lib/utils/__probe.ts -> monaco-editor
one editorReady gate dropped (Vim) 1 of 5 red

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.ts sliced on the literal if (editor && onscrollsync) and silently produced an empty string once the guard gained editorReady &&. Re-anchored with an explicit notEqual(-1) so it fails loudly instead of passing vacuously.

npm run check   438 files, 0 errors
npm test        545 / 545
cargo test      141 / 141
npm run build   clean

Not covered

  • theme.ts still pulls Monaco at startup for vscode:* theme usersparseAndApplyVscodeTheme does its own await 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.
  • Measurements are Chromium over localhost. Tauri serves over its own protocol into WKWebView / WebView2 / WebKitGTK; transfer should be cheaper, and parse+eval dominates and is engine-dependent. JavaScriptCore was not measured.
  • The cancel-while-loading path is asserted and correct by construction, but could not be raced by hand once the module was cached.
  • Split view mounts one Editor, so two concurrent Monaco instances in one window were not exercised.
  • window.open is patched ~330 ms later on first entry. Nothing in the editor path calls it inside that window.

🤖 Generated with Claude Code

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>
@PathGao
PathGao merged commit 8c16c34 into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the perf/load-monaco-on-demand branch August 3, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant