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
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
Context
In a
dist/esmbuild, 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 itsnode_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/esmtree.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 flatteneddist/esmengine. This artifact is correct and complete.The runtime loads a different file.
dist/esm/src/Main.mjsemits: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.appsin the App worker stays{}, the main view never mounts. Removing theWS/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 insidemainThreadAddons.forEach../../../src/main/addon/is correct forenvironment: 'development', whereMain.mjsruns fromnode_modules/neo.mjs/src/and three levels up is the workspace root. It is wrong fordist/esm, where the engine has been flattened todist/esm/src/and so has the workspace's ownsrc/— both halves land in the same tree, which is precisely what makes theWS/distinction collapse there. The output-tree-correct specifier fordist/esmis 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:738adjusts itsresources/theme-map.jsonURL onconfig.environment === 'development' || config.environment === 'dist/esm'before fetching.Why no existing guard catches it.
findUnresolvableImports'engine-identityarm exists to catch specifiers that reach a second engine graph, but it matches on anode_modules/neo.mjssegment. This specifier has none — it escapesdist/esmthrough plain../traversal — so it is classified as acomputed-rootdirectory report at most, and inside this repository it resolves to a realsrc/main/addon/and is not reported at all.This is the same defect class
.github/workflows/consumer-runtime-build.ymlwas built for, and its docblock records the webpack half already fixed:dist/esmis the build where that class was never re-checked.The Fix
Make the
WS/specifier environment-aware insrc/Main.mjs, at both call sites, sodist/esmresolves into the output tree rather than out of it. Follow theApp.mjs:738precedent 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
Neo.config.mainThreadAddonsentries prefixedWS/src/Main.mjsimportAddon/onDomContentLoadeddevelopment; no new config keylearn/main-thread-addon material if it names theWS/pathdist/esmconsumer workspace, addon present, before/afterDecision Record impact
none.Acceptance Criteria
dist/esmworkspace declaring oneWS/addon boots: the App worker reachesNeo.appswith the main view mounted, and the main thread logs no error.developmentbehaviour is unchanged: aWS/addon still resolves to the workspace'ssrc/main/addon/, proven by an arm that fails if thedist/esmbranch is applied unconditionally.WS/addon still boots, and no emptysrc/main/addon/directory is required.Out of Scope
computed-rootguard 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/andexamples/conditional roots insrc/worker/Data.mjs. The specifiers are correct there; only the guard's reading of them is at issue.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 fordist/esmwould re-open a closed defect in a second build.Rewriting the specifier in the
dist/esmtransform instead of inMain.mjs. The transform rewritesnode_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
engine-identityarm 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 cloneOnGetReproducer: consumer workspace, addon atsrc/main/addon/Probe.mjs,mainThreadAddonscontainingWS/Probe, builddist/esm, serve the workspace root, opendist/esm/apps/<name>/index.html.Origin Session ID: 4b3f102d-6b92-411f-98fb-890fe00684a0