LearnNewsExamplesServices
Frontmatter
id18569
titleMonaco's theme awareness reads a config that is null by design
stateClosed
labels
bugairegressionportal
assigneesneo-opus-grace
createdAtSep 9, 2026, 11:49 PM
updatedAtSep 10, 2026, 6:23 AM
githubUrlhttps://github.com/neomjs/neo/issues/18569
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 10, 2026, 6:23 AM

Monaco's theme awareness reads a config that is null by design

Closed v13.2.0/archive-v13-2-0-chunk-20 bugairegressionportal
neo-opus-grace
neo-opus-grace commented on Sep 9, 2026, 11:49 PM

Context

Reported by @tobiu against the local portal in dark mode: every Monaco source pane renders with the light vs theme while the page around it is dark. Reproduced at dev@87e3786673 on /learn/benefits/body/FormsEngine.

This is a regression of #8845 (Sync Monaco Editor Theme in Portal, closed), under epic #8727 (Portal App Dark Theme, closed).

The Problem

Measured on three live editor instances via the Neural Link, on a page whose document.body carries neo-theme-neo-dark:

property value
theme (the config) null
getTheme() 'neo-theme-neo-dark'
editorTheme 'vs'
useThemeAwareness true
mounted true

DOM confirms it: all three .monaco-editor nodes carry class vs with background-color: rgb(255, 255, 254).

The resolved theme is correct and available. The component simply never asks for it.

src/component/wrapper/MonacoEditor.mjs:339:

afterSetTheme(value, oldValue) {
    if (this.useThemeAwareness && value) {
        this.editorTheme = value.includes('dark') ? 'vs-dark' : 'vs'
    }
}

It gates on the raw theme config, which is theme_: null by default (component/Base.mjs:269). For a component that inherits its theme rather than declaring one, that config stays null — so the hook never fires with a value and editorTheme keeps its 'vs' default. afterSetUseThemeAwareness (:311) has the same defect: it re-invokes afterSetTheme(this.theme, null), passing the same null.

And null there is intentional, not an accident to repair upstream. #18111's own docblock on getTheme() says so: "a child inside a themed scope carries no theme class of its own by design". getTheme() (component/Base.mjs:1522) is the accessor that answers the real question by walking the component chain. Monaco's theme awareness predates that accessor and was never migrated to it.

Nothing supplies the config from outside either: src/code/LivePreview.mjs:92 creates the editor with module / header / hideLabel / listeners / style / reference and no theme.

The Architectural Reality

The idiom already exists in the file that hosts this editor. src/code/LivePreview.mjs:486 reaches for exactly the right accessor when it themes the detached preview app:

theme = me.getTheme?.();
// We overrule the viewport theme on purpose
if (theme) {
    mainView.theme = theme
}

So LivePreview asks getTheme() for the preview and the MonacoEditor beside it asks a null config for itself.

The delivery path is intact — proven, not assumed. Setting editorTheme: 'vs-dark' on one live instance turned all three editors dark (rgb(30, 30, 30)). Worth recording for whoever implements this: main/addon/MonacoEditor.mjs:181 calls monaco.editor.setTheme(), which is global to the page rather than per-editor — this.map[data.id] && is a created-yet gate, not a scoping mechanism. So the addon, the remote and the DOM all work; only the trigger is wrong.

Two timing hazards a fix must not reintroduce. getInitialOptions() passes theme: me.editorTheme at creation (:450), and ffd860b664 (#18355) removed a me.timeout(150) that used to sit before createInstance. A repair that only corrects afterSetTheme still has to be right at creation, or it depends on a delay that no longer exists.

The Fix

Resolve through getTheme() rather than the raw config, at both the hook and the creation site:

  1. afterSetTheme and afterSetUseThemeAwareness consult getTheme() — the accessor that answers for an inheriting child.
  2. getInitialOptions() builds its theme from the same resolution, so the editor is created correct rather than corrected afterwards.

Acceptance Criteria

  • AC-1 In the dark portal, every .monaco-editor node carries vs-dark. Asserted on the rendered node, not on editorTheme — the config was never the thing the reader sees.
  • AC-2 ⚠️ Both directions witnessed. Light portal → vs, dark portal → vs-dark. An arm that only checks dark passes on a component hardcoded to vs-dark, which is the mirror of the bug being fixed.
  • AC-3 Correct at creation, not merely corrected after. getInitialOptions() must carry the resolved theme, since #18355 removed the delay a post-hoc correction used to hide behind.
  • AC-4 useThemeAwareness: false still opts out entirely, and an explicitly configured theme still wins over the inherited one.
  • AC-5 A theme switch while editors are mounted still repaints them. The portal has a live toggle, and this is the path #8845 originally shipped.

Out of Scope

component/Base.mjs#getTheme() and theme propagation generally — the resolver is correct and returns the right answer here; this is a consumer that does not call it. Any other useThemeAwareness consumer (none found in src/ or apps/). Mermaid's loader collision with Monaco (#18568) — same addon, unrelated defect.

Avoided Traps

Do not "fix" this by propagating theme down to every child so the config stops being null. #18111 records the null as deliberate, and forcing a theme class onto children is the behaviour that ticket removed.

Do not reintroduce a delay before createInstance to let the theme arrive. #18355 removed that timeout(150) on purpose and a fix that depends on ordering luck will regress the same way.

Do not assert editorTheme and call it covered. It was 'vs' here and the DOM agreed, but the reverse — a correct config that never reaches the DOM — is exactly the shape the addon's map[data.id] && guard can produce silently.

Related

  • #8845 — the sync this regresses; #8727 the epic it shipped under.
  • #18111 / #18106 — getTheme() walks components, and records the null-by-design contract.
  • #18355 / ffd860b664 — removed the pre-createInstance delay; relevant to AC-3.
  • #18568 — Monaco's loader, same addon, different defect.

Decision Record impact

none — a consumer calls the wrong accessor.

Sweeps

Live latest-open sweep: latest 12 open issues at 2026-09-09T21:48:43Z, nothing equivalent. All-state search on monaco theme dark editorTheme: #8845 and #8727, both closed, both this feature's origin rather than duplicates. A2A claim sweep: no open claim on Monaco theming (@neo-opus-ada holds #18568 on the loader, a different surface). Own-assignment sweep: no overlap.

Retrieval Hint: monaco editorTheme vs-dark useThemeAwareness getTheme null by design portal dark theme regression

Origin Session ID: c4dc8abc-31fa-4ecd-9aef-5209ccfd16c0