ESM compatibility testing
Files and images attached to this public thread are only available to signed-in users.
Sign inI’m blocked on locating the ESM implementation: this checkout doesn’t contain it.
The repository is lishaduck/lamdera, whose default—and only remote—branch is lamdera-next, not main. On that branch:
- The JavaScript generator emits the classic
(function(scope){ … }(this));wrapper. makehas no ESM flag, and its output parser accepts.jsand.html, not.mjs.reactoris disabled in the CLI; Lamdera exposesliveinstead.- Searching the full fetched history found no ESM implementation commits.
I haven’t changed files or claimed runtime coverage against this unrelated version. Which repository or branch contains your ESM work? Once I have that, I can test the build-mode matrix, module loading, and the relevant live/hot-reload paths.
Updated and pushed lishaduck/lamdera:lamdera-next to match lamdera/compiler:lamdera-next, fast-forwarding 517 commits. The worktree is clean.
Added upstream → https://github.com/lamdera/compiler.git locally and saved an idempotent Amp project setup script so future Amp orbs include that remote. Ordinary git clone outside Amp won’t inherit additional remotes.
The updated source contains ESM support. The ESM compatibility audit remains outstanding; CI for the synchronized revision is running, with results subscribed to this thread.
CI failed before the compiler or tests could run. GHC 9.8.4 failed to link Stack’s setup executable because the linker rejected the macOS 27 SDK’s arm64e.x1 architecture entries:
tapi error: malformed file
error: unknown architecture
ghc-9.8.4: `gcc' failed in phase `Linker'
Both automatic build attempts hit the same failure. This is a CI toolchain/SDK compatibility failure, not evidence of an ESM regression. The upstream synchronization is complete, but ESM behavior remains unverified. I haven’t changed the workflow or rerun it.
Yes—you were right. The macOS CI failure didn’t block local verification. I built the synchronized compiler and tested it in this Linux orb.
What passes
- ESM in dev,
--debug,--optimize, and--optimize-legible. - 32 Node worker cases covering ESM/classic output, single/multiple mains, nested module names, flags, ports, and Wire enabled/disabled.
- 24 Chromium cases covering static HTML views,
Browser.elementinteraction, andBrowser.applicationnavigation. - 52 existing tests across
Test.JsOutput,Test.HotPatch, andTest.LiveMemory, including disk-backed and in-memory live builds.
Gaps reproduced
- HTML output is broken with
--esm, including the default single-main output. Compilation succeeds, but the browser reportsUnexpected token 'export'. - ESM doesn’t expose
Elm.hot.reload. Low-levelapp.hotReloadworks with the emitted metadata and preserves state, but the standard reload integration is missing.live --esmis rejected;reactorisn’t an available command. - The esbuild
elm-pkg-jspath breaks underLDEBUG=1: its exports become module-local rather than available to the page. The default packager works.
No compiler changes were made, and the worktree is clean. This covers Linux/Node/Chromium, not Safari, downstream bundlers, or full-stack Lamdera ESM runtime behavior.
Detailed results · Fixtures and captured outputs
I can also persist the Linux toolchain setup so future orbs start ready to test.
Fixed the three reproduced bugs locally:
- ESM HTML output renders and initializes, including default
index.htmloutput. - Dev/debug hot reload exposes
Elm.hot.reloadand preserves application state. elm-pkg-jsexports reachwindowacross fresh, cached, and custom esbuild configurations.
ESM reuses the classic exporter rather than maintaining a separate initialization path:
−<> toMainEsmExports mode mains+<> toMainExports mode mains+<> "\nexport const Elm = scope.Elm;\n"The shared exporter registers hot-reload and cleanup hooks; Elm remains a named module export.Local verification: stack test --test-arguments Test.JsOutput passed all 8 tests; Test.HotPatch passed 16 and Test.LiveMemory passed 31. Runtime checks covered dev/debug/optimize/optimize-legible, classic/ESM controls, workers, browser programs, and HTML output.
Reactor/live remain classic-only; this does not add --esm support to those commands. Full-suite validation was blocked by missing elm-format; cross-platform and full-stack Lamdera ESM runtime coverage remain unverified.


The inspected recording shows 16 → click → 22 → hot reload → 37, retaining the model:
The fixes and regression tests are uncommitted and unpushed.
Organized the complete diff into three commits, each with its regression coverage:
- Fix ESM hot-reload initialization
- Render ESM HTML output as a module
- Publish packaged JavaScript exports from ESM output
Verified that the final Git tree exactly matches the pre-refactor snapshot. git diff --check passes, and the working tree is clean. The earlier test results still apply because the files are unchanged.
Nothing was pushed.
build-tool-depends: elm:lamderadeclares that the tests need this package’slamderaexecutable.elmis the Cabal package name;lamderais its executable component—not an external dependency.The regression tests launch
lamdera makein subprocesses because ESM mode andLDEBUGuse process-global state. This declaration makes Cabal/Stack build the compiler and put it on the tests’PATH, rather than accidentally testing an installed release."var scope = {};\n"supplies the destination object expected by_Platform_export. Classic output getsscopefrom its enclosing IIFE; ESM has no such wrapper, and top-levelthisisundefined.The original ESM generator exported the raw module tree directly, bypassing
_Platform_exportand therefore its hot-reload initialization. The fix uses this sequence:module-local scope = {} → _Platform_export(module tree) → registers Elm.hot and wraps init/stop → export const Elm = scope.Elmscopestays private to that ES module: it does not publishwindow.Elmor share globals between bundles. Optimized output uses the production exporter, which does not installElm.hot. This reuses the existing runtime contract rather than duplicating hot-reload machinery for ESM.
No code changed in response to these questions.
Moved the ESM cases directly into the existing test list and removed esmSuite.
Committed separately as “Inline ESM cases in the JavaScript output test suite.” Only the test file changed. All 8 Test.JsOutput tests pass, the working tree is clean, and nothing was pushed.
You’re right to question it. The syntax is valid—export must remain at module scope—but this helper weakens error handling:
Classic: try { compiled code; app.init(); } catch …
ESM: compiled code; try { app.init(); } catch …
An exception while evaluating the compiled module bypasses the ESM page’s “Initialization Error” display. I checked that distinction with Node’s module evaluator: the classic handler runs; the ESM handler doesn’t. The earlier bad-flags check only exercised app.init(), so it missed this gap.
It also hides an unmatched try { inside a helper called script, leaving callers responsible for closing it. That’s unnecessarily hard to follow.
A better structure is to keep the compiled ESM separate from the bootstrap and catch failures around its dynamic import and initialization, covering both module evaluation and init. That needs an explicit module-loading design rather than this prefix helper. No code changed yet.
Got it. I’ll keep this to the requested inlining—no separate module file or loader redesign. The real Elm counterexample is already compiling, so I’ll finish that check and the regression run.
That explains it—the current test suite is already inlined.
Functions.functions emits Elm’s JavaScript calling-convention helpers:
F2–F9wrap multi-argument functions for curried calls and record their arity.A2–A9call those functions directly when fully applied, otherwise fall back to curried application.
It’s shared runtime boilerplate, not ESM-specific code.
HTML helper: removed and inlined into both templates. Generated classic and ESM HTML are byte-for-byte identical to before this refactor. All 8 Test.JsOutput tests pass.
And the real Elm counterexample confirmed your concession: a top-level Debug.todo throws during module import, before any init call. A non-tail-recursive calculation also overflows during import in dev, debug, optimize, and optimize-legible modes.
The HTML inlining is uncommitted; nothing was pushed. No separate output file or loader redesign was added.
Yep—Functions.functions stayed; the added <> just joins it after var scope = {};.
There are two separate concerns here:
- JS output:
scopelets us reuse_Platform_export, including hot-reload registration.export const Elm = scope.Elmthen exposes that result. I’d keep this. - HTML output: an inline module’s exports aren’t consumed by the page anyway. The question is how to initialize it and display evaluation errors.
The practical alternatives are:
Generate classic JS for HTML, even with
--esm. Keep ESM for.jsoutput; HTML uses the existing self-contained script andtry/catch. Simplest implementation and complete error handling, but it makes--esmirrelevant for HTML. We should explicitly choose that contract rather than silently imply the HTML contains ESM.Keep inline ESM and register an error handler before it. A small preceding classic script installs a narrowly scoped
errorlistener to display module-evaluation errors; the module retains itstry/catchforinit. Still one HTML file, no dynamic import. More browser-specific plumbing and care needed to avoid catching unrelated page errors.Dynamic-import an embedded module via a Blob URL. Still one HTML file—not another output file—and
import()rejection covers evaluation errors. But it introduces asynchronous bootstrapping, escaping, URL cleanup, and CSP restrictions. Probably too much machinery here.
Simply moving export into Generate.Html wouldn’t fix evaluation-error handling, and standalone .js output still needs that export.
My preference is option 1 if you’re comfortable defining --esm as a JavaScript-output option. Otherwise, option 2 preserves actual inline ESM without introducing a loader.
Agreed—one self-contained output file is a requirement, not something to trade away. No separate module file.
The simplest option is to generate classic JS inside HTML, preserving the existing try/catch, and use ESM only for JavaScript output. An inline HTML module’s export isn’t useful to another importer anyway.
If --esm must also mean the HTML contains an actual module script, we can keep that inline and handle evaluation errors with a preceding inline error handler. Still one file, but more complexity.
I’d favor the first: standalone .js gets ESM exports; standalone .html stays self-contained with the existing initialization and error handling.
Yes—implemented. --esm rejects HTML output with:
ESM REQUIRES JAVASCRIPT OUTPUT
The error suggests --output=elm.js or removing --esm.
+case (esm, maybeOutput, getMains artifacts) of+ (True, Just (Html _), _) -> Task.throw Exit.MakeEsmHtml+ (True, Nothing, [_]) -> Task.throw Exit.MakeEsmHtml+ _ -> return ()Reject explicit HTML and the single-main default, but preserve multiple-main default JavaScript output.Removed the ESM-specific HTML machinery, restoring the original HTML generator.
Verified: all 8 Test.JsOutput tests pass, covering rejection in all four build modes, no HTML written on rejection, and successful classic HTML, ESM JS, multiple-main defaults, and /dev/null. JSON reporting also returns the error correctly.
Changes are uncommitted and unpushed.
Reorganized all changes since the merge base into three commits, each with its regression tests:
- Fix ESM hot-reload initialization
- Reject ESM with HTML output
- Publish packaged JavaScript exports from ESM output
Removed the superseded HTML-support and test-layout history.
Verified the final tree exactly matches the pre-restack snapshot. Every commit passes whitespace checks, and the working tree is clean. The prior 8-test pass applies unchanged.
Nothing was pushed.
Removed that sentence. The wording change is uncommitted.
For Injection.hs:540, this section appends companion JavaScript from elm-pkg-js to the generated Elm bundle. It has two packaging routes:
- Fallback packager: wraps the package files itself and explicitly writes
window.elmPkgJsIncludes. - esbuild: bundles the includes file using
--global-name=elmPkgJsIncludes. Fresh builds, cached bundles, and custom config output all feed intopackaged.
The catch is that esbuild’s “global name” produces a declaration like:
var elmPkgJsIncludes = /* bundled exports */;
In a classic browser script, that top-level var becomes a property on window. Inside an ES module, it stays module-local. Code outside that module cannot find it.
Line 540 explicitly publishes the result:
if (typeof window !== 'undefined'
&& typeof elmPkgJsIncludes !== 'undefined') {
window.elmPkgJsIncludes = elmPkgJsIncludes;
}
The guards keep browserless imports working and avoid assigning an absent bundle. Putting it after the packaging branches covers fresh, cached, and custom-config output. The fallback already publishes its exports, so it needs no extra work.
This only publishes the exports; it doesn’t initialize a plugin. The existing live bootstrap calls elmPkgJsIncludes.init(app). The fix preserves the browser-global contract that the fallback packager already provides, rather than introducing a separate package-loading API for ESM.
Absorbed into “Reject ESM with HTML output.” The three-commit structure and final tree are preserved. Working tree is clean; nothing pushed.
You’re right to challenge that. I haven’t established a supported ESM consumer outside the module.
The only production consumer I found in this repo is the live bootstrap, which calls elmPkgJsIncludes.init(app). But live/reactor are classic-only, and we now reject ESM HTML output. Standalone ESM generation doesn’t include that bootstrap.
So:
- Classic output already makes esbuild’s top-level
varglobally available. - ESM keeps it module-local, but I found no supported path that requires it on
window. - The fallback packager’s existing
windowassignment establishes its implementation, not necessarily an ESM contract we need to preserve.
My regression test asserted the global publication I had chosen; it didn’t demonstrate a real consumer failing without it. That was insufficient justification for the change.
I recommend dropping the package-publication commit and its test. Any future ESM package initialization should be designed around an actual consumer, rather than adding a browser global speculatively. No changes made yet.
Dropped the package-publication commit and its test. Two commits remain:
- Fix ESM hot-reload initialization
- Reject ESM with HTML output
All 7 remaining Test.JsOutput tests pass. Working tree is clean; nothing pushed.
Split elm.cabal into its own commit. The order is:
- Build the compiler executable for CLI regression tests — only
elm.cabal - Fix ESM hot-reload initialization
- Reject ESM with HTML output
Final tree is identical, working tree is clean, and nothing was pushed.
Pushed the three commits to lishaduck:esm-output-fixes, but couldn’t create the draft PR: GitHub denied the integration permission to open PRs in lamdera/compiler. No PR was created.
You can finish from the upstream PR creation page and select Create draft pull request.
Suggested title: Fix ESM hot reloading and reject HTML output.
No CI checks ran for the pushed branch. The 7 locally passing tests remain the verification available; upstream draft PR creation is still blocked by GitHub permissions.
Got it—stopping here.