ESM compatibility testing

ESM support was very much just hacked into the compiler. Can you please go through and check it works in all of the possible combinations? I know it worked for `make {,--optimize,--debug}`, but check those again b/c main has shifted. I don't think we support reactor or anything but maybe worth checking of the hot reloading stuff works?

I’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.
  • make has no ESM flag, and its output parser accepts .js and .html, not .mjs.
  • reactor is disabled in the CLI; Lamdera exposes live instead.
  • 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.

Please update lamdera-next to lamdera-next from the https://github.com/lamdera, and ensure that every clone onward will include that as a remote.

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 check failed, All CI checks complete

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.

Does it really matter? Can you run it locally?
Checked on 1 command (1 cancelled)

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.element interaction, and Browser.application navigation.
  • 52 existing tests across Test.JsOutput, Test.HotPatch, and Test.LiveMemory, including disk-backed and in-memory live builds.

Gaps reproduced

  1. HTML output is broken with --esm, including the default single-main output. Compilation succeeds, but the browser reports Unexpected token 'export'.
  2. ESM doesn’t expose Elm.hot.reload. Low-level app.hotReload works with the emitted metadata and preserves state, but the standard reload integration is missing. live --esm is rejected; reactor isn’t an available command.
  3. The esbuild elm-pkg-js path breaks under LDEBUG=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.

yah pls go fix these bugs
Checked on 1 command (1 cancelled)
Subagent finished: Add ESM regression tests

Fixed the three reproduced bugs locally:

  • ESM HTML output renders and initializes, including default index.html output.
  • Dev/debug hot reload exposes Elm.hot.reload and preserves application state.
  • elm-pkg-js exports reach window across fresh, cached, and custom esbuild configurations.

ESM reuses the classic exporter rather than maintaining a separate initialization path:

JavaScript.hs compiler/src/Generate
<> 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.

ESM HTML, development
ESM HTML, development
ESM HTML, debugger enabled
ESM HTML, debugger enabled

The inspected recording shows 16 → click → 22 → hot reload → 37, retaining the model:

The fixes and regression tests are uncommitted and unpushed.

Refactor all committed and uncommitted changes since the merge base into logical, easy-to-review commits. Inspect the complete diff first, then reorder, split, squash, and edit commits as needed. Preserve the final tree and behavior. Do not push.

Organized the complete diff into three commits, each with its regression coverage:

  1. Fix ESM hot-reload initialization
  2. Render ESM HTML output as a module
  3. 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.

Diff review comments: 1. elm.cabal:515 [commit fc9349334c87cb2854c2ef46493c17baeb3aead1 in repo: Fix ESM hot-reload initialization] - ?? 2. compiler/src/Generate/JavaScript.hs:726 [commit fc9349334c87cb2854c2ef46493c17baeb3aead1 in repo: Fix ESM hot-reload initialization] - Care to explain this one?
  1. build-tool-depends: elm:lamdera declares that the tests need this package’s lamdera executable. elm is the Cabal package name; lamdera is its executable component—not an external dependency.

    The regression tests launch lamdera make in subprocesses because ESM mode and LDEBUG use 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.

  2. "var scope = {};\n" supplies the destination object expected by _Platform_export. Classic output gets scope from its enclosing IIFE; ESM has no such wrapper, and top-level this is undefined.

    The original ESM generator exported the raw module tree directly, bypassing _Platform_export and 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.Elm
    

    scope stays private to that ES module: it does not publish window.Elm or share globals between bundles. Optimized output uses the production exporter, which does not install Elm.hot. This reuses the existing runtime contract rather than duplicating hot-reload machinery for ESM.

No code changed in response to these questions.

Diff review comments: 1. test/Test/JsOutput.hs:28 [commit fc9349334c87cb2854c2ef46493c17baeb3aead1 in repo: Fix ESM hot-reload initialization] - the other tests just go here. don't abstract all wierd split the test fix into own commit

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.

Diff review comments: 1. compiler/src/Generate/Html.hs:112-117 [commit 7b69b7f4812bd1f4a971cbc68a7b7151e9809616 in repo: Render ESM HTML output as a module] - This does not feel correct.

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.

> "var scope = {};\n" supplies the destination object expected by _Platform_export. I was asking what does Functions.functions do? > It also hides an unmatched try { inside a helper called script, leaving callers responsible for closing it. That’s unnecessarily hard to follow. So first yeah inline the fn and then figure out how to simplify from there. > 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. You mean new file and all that? Nah ain't happening. Is it safe to assume Elm code doesn't crash? I know in general but ports and all that can't affect the initial decls, so it's fine?? Prove to me I'm wrong.
Explored 3 files, 1 search
Diff review comments: 1. test/Test/JsOutput.hs:246-260 [commit fc9349334c87cb2854c2ef46493c17baeb3aead1 in repo: Fix ESM hot-reload initialization] - Also you still didn't inline this suite
Ran 1 command
Oh wait no u right

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.

Checked on 1 command (1 cancelled)
Diff viewer borked

That explains it—the current test suite is already inlined.

Functions.functions emits Elm’s JavaScript calling-convention helpers:

  • F2–F9 wrap multi-argument functions for curried calls and record their arity.
  • A2–A9 call 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.

Oh wait poor diff again, right we just added the <> I thought we rmd the functions.functions. What are alternatives to fix the esm outputs? I mean really it's just adding `export const Elm = scope.Elm` can we do that in generate.html? No not really. hmmm...

Yep—Functions.functions stayed; the added <> just joins it after var scope = {};.

There are two separate concerns here:

  • JS output: scope lets us reuse _Platform_export, including hot-reload registration. export const Elm = scope.Elm then 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:

  1. Generate classic JS for HTML, even with --esm. Keep ESM for .js output; HTML uses the existing self-contained script and try/catch. Simplest implementation and complete error handling, but it makes --esm irrelevant for HTML. We should explicitly choose that contract rather than silently imply the HTML contains ESM.

  2. Keep inline ESM and register an error handler before it. A small preceding classic script installs a narrowly scoped error listener to display module-evaluation errors; the module retains its try/catch for init. Still one HTML file, no dynamic import. More browser-specific plumbing and care needed to avoid catching unrelated page errors.

  3. 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.

cuz we need one file, we can't split two files that's not the point

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.

Can we ban --esm with --html?

Yes—implemented. --esm rejects HTML output with:

ESM REQUIRES JAVASCRIPT OUTPUT

The error suggests --output=elm.js or removing --esm.

Make.hs terminal/src
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.

Refactor all committed and uncommitted changes since the merge base into logical, easy-to-review commits. Inspect the complete diff first, then reorder, split, squash, and edit commits as needed. Preserve the final tree and behavior. Do not push.

Reorganized all changes since the merge base into three commits, each with its regression tests:

  1. Fix ESM hot-reload initialization
  2. Reject ESM with HTML output
  3. 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.

Diff review comments: 1. builder/src/Reporting/Exit.hs:1881 - Del, nobody cares: "A single main without --output defaults to index.html." 2. extra/Lamdera/Injection.hs:540 - Talk me thru this one, not familar with this part of the codebase

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 into packaged.

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.

absorb into commit

Absorbed into “Reject ESM with HTML output.” The three-commit structure and final tree are preserved. Working tree is clean; nothing pushed.

> Code outside that module cannot find it. Will there be code outside of the module? I assumed the contract was nay but wdik?

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 var globally available.
  • ESM keeps it module-local, but I found no supported path that requires it on window.
  • The fallback packager’s existing window assignment 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.

Agreed. Drop it

Dropped the package-publication commit and its test. Two commits remain:

  1. Fix ESM hot-reload initialization
  2. Reject ESM with HTML output

All 7 remaining Test.JsOutput tests pass. Working tree is clean; nothing pushed.

Again the elm.cabal change should be its own commit

Split elm.cabal into its own commit. The order is:

  1. Build the compiler executable for CLI regression tests — only elm.cabal
  2. Fix ESM hot-reload initialization
  3. Reject ESM with HTML output

Final tree is identical, working tree is clean, and nothing was pushed.

please file a draft pr

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 found for this commit.

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.

There is a PR now. Associate yourself
nvm

Got it—stopping here.