LearnNewsExamplesServices
Frontmatter
id17972
titleA WS/ main-thread addon loads unbuilt source in dist/esm, booting a second engine graph
stateClosed
labels
bugaicore
assigneesneo-opus-grace
createdAtAug 31, 2026, 9:48 PM
updatedAtSep 15, 2026, 12:36 AM
githubUrlhttps://github.com/neomjs/neo/issues/17972
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 15, 2026, 12:36 AM

A WS/ main-thread addon loads unbuilt source in dist/esm, booting a second engine graph

neo-opus-grace
neo-opus-grace commented on Aug 31, 2026, 9:48 PM

Context

In a dist/esm build, a workspace-owned main-thread addon (WS/ prefix) is loaded from the workspace's unbuilt source tree instead of from the emitted output. The build produces the correct artifact and the runtime never asks for it. The source copy still carries its node_modules/neo.mjs/… specifiers, so loading it pulls a second engine module graph into the main thread and the app never boots.

Found while building the real-engine boot fixture for #17931, which is the first thing to have booted a consumer dist/esm tree.

The Problem — measured, not inferred

Workspace with the engine installed, one addon at <workspace>/src/main/addon/Probe.mjs, mainThreadAddons: ['DragDrop', 'Navigator', 'WS/Probe'], built and served headlessly.

The build emits the addon correctly. dist/esm/src/main/addon/Probe.mjs:

import a from"../../../src/main/addon/Base.mjs";export default class s extends a{...}

The workspace's src/ is a default source root, so the addon is copied, minified, and its engine specifier rewritten to the flattened dist/esm engine. This artifact is correct and complete.

The runtime loads a different file. dist/esm/src/Main.mjs emits:

await import(`../../../src/main/addon/${i.substring(3)}.mjs`)

From dist/esm/src/, that resolves to <workspace>/src/main/addon/Probe.mjs — the original source, which reads:

import Base from '../../../node_modules/neo.mjs/src/main/addon/Base.mjs';

Observed result: the request succeeds (no 404 — the source file exists and is served), a second engine graph loads from raw source, and the main thread throws Cannot read properties of undefined (reading 'cloneOnGet'). Neo.apps in the App worker stays {}, the main view never mounts. Removing the WS/ addon from the same fixture boots cleanly with zero failed requests and zero console errors.

The addon's own code is not the variable: it is the same class in both copies. Only the resolved location differs, and only one of the two copies has been through the import rewrite.

The Architectural Reality

src/Main.mjs, two call sites:

  • importAddon(data) — if (name.startsWith('WS/')) { module = await import(../../../src/main/addon/${name.substring(3)}.mjs) }
  • onDomContentLoaded() — the same specifier inside mainThreadAddons.forEach

../../../src/main/addon/ is correct for environment: 'development', where Main.mjs runs from node_modules/neo.mjs/src/ and three levels up is the workspace root. It is wrong for dist/esm, where the engine has been flattened to dist/esm/src/ and so has the workspace's own src/ — both halves land in the same tree, which is precisely what makes the WS/ distinction collapse there. The output-tree-correct specifier for dist/esm is the one the non-WS/ branch already uses: ./main/addon/.

The engine has a precedent for exactly this environment-dependent path arithmetic — src/worker/App.mjs:738 adjusts its resources/theme-map.json URL on config.environment === 'development' || config.environment === 'dist/esm' before fetching.

Why no existing guard catches it. findUnresolvableImports' engine-identity arm exists to catch specifiers that reach a second engine graph, but it matches on a node_modules/neo.mjs segment. This specifier has none — it escapes dist/esm through plain ../ traversal — so it is classified as a computed-root directory report at most, and inside this repository it resolves to a real src/main/addon/ and is not reported at all.

This is the same defect class .github/workflows/consumer-runtime-build.yml was built for, and its docblock records the webpack half already fixed:

"Main's opt-in WS/ context resolved the consumer's optional src/main/addon directory even when no workspace addon existed, so a generated consumer needed a meaningless empty directory."

dist/esm is the build where that class was never re-checked.

The Fix

Make the WS/ specifier environment-aware in src/Main.mjs, at both call sites, so dist/esm resolves into the output tree rather than out of it. Follow the App.mjs:738 precedent rather than inventing a second idiom.

A shared helper for the two call sites is preferable to duplicating the branch — they are the same decision made twice, and the two spellings are already textually identical.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.config.mainThreadAddons entries prefixed WS/ src/Main.mjs importAddon / onDomContentLoaded resolve to the workspace addon copy inside the active environment's tree unchanged for development; no new config key learn/ main-thread-addon material if it names the WS/ path headless boot of a dist/esm consumer workspace, addon present, before/after

Decision Record impact

none.

Acceptance Criteria

  • A consumer dist/esm workspace declaring one WS/ addon boots: the App worker reaches Neo.apps with the main view mounted, and the main thread logs no error.
  • The loaded addon module is the emitted one — provable by a marker present only in the emitted copy, so the assertion cannot pass against the source copy.
  • development behaviour is unchanged: a WS/ addon still resolves to the workspace's src/main/addon/, proven by an arm that fails if the dist/esm branch is applied unconditionally.
  • A workspace declaring no WS/ addon still boots, and no empty src/main/addon/ directory is required.
  • The two call sites do not carry duplicate spellings of the same decision.

Out of Scope

  • The computed-root guard over-strictness that also blocks these builds — filed separately; this ticket's fixture needs that cleared or worked around to reach its own red.
  • docs/app/ and examples/ conditional roots in src/worker/Data.mjs. The specifiers are correct there; only the guard's reading of them is at issue.
  • The webpack WS/ context, already fixed.

Avoided Traps

Requiring consumers to keep an empty src/main/addon/. That is the exact workaround the webpack half of this class was fixed to remove; re-introducing it for dist/esm would re-open a closed defect in a second build.

Rewriting the specifier in the dist/esm transform instead of in Main.mjs. The transform rewrites node_modules-bearing specifiers; this one carries none, and widening it to rewrite plain ../ traversals would make every relative import in the tree a candidate. The path is environment-dependent by nature, so the environment-aware runtime is the substrate that owns it.

Related

  • #17931 — the fixture that observed this; its boot arm is the natural regression home.
  • #17921 — the resolution guard whose engine-identity arm structurally cannot see this specifier.
  • .github/workflows/consumer-runtime-build.yml — the webpack-side guard for the same class.

Handoff Retrieval Hints

Retrieval Hint: WS/ main thread addon dist/esm loads unbuilt source second engine graph cloneOnGet Reproducer: consumer workspace, addon at src/main/addon/Probe.mjs, mainThreadAddons containing WS/Probe, build dist/esm, serve the workspace root, open dist/esm/apps/<name>/index.html.

Origin Session ID: 4b3f102d-6b92-411f-98fb-890fe00684a0

tobiu unassigned from @neo-opus-grace on Sep 1, 2026, 10:07 AM
tobiu unassigned from @neo-gpt on Sep 14, 2026, 8:23 PM
tobiu referenced in commit 5e24f19 - "fix(main): a WS/ main thread addon resolves inside the dist/esm tree, and an installed engine keeps the workspace root (#17972) (#18712) on Sep 15, 2026, 12:36 AM
tobiu closed this issue on Sep 15, 2026, 12:36 AM