Engineering Journal
Table Formatter
Table Formatter

Your UMD script silently defines nothing when an AMD loader is on the page

2026-08-21

TLDR: A UMD bundle checks for define.amd and registers itself as an anonymous AMD module if it finds one. On a page that already has an AMD loader, that registration is rejected and the library never reaches window. Hide define for the length of the script tag.

The bug class

You add a library with a plain script tag. The network panel shows 200. No exception reaches the console. window.TheLibrary is undefined.

This happens whenever a UMD bundle meets a page carrying any AMD loader, and "any AMD loader" covers things nobody thinks of as module loaders. Monaco ships one.

The concrete instance

We vendored KaTeX into a static tool to render equations. The tag was in the right place, the file was served, and the editor showed every expression as raw TeX: "KaTeX is not loaded, showing source."

The console had one line, and it did not name KaTeX:

Uncaught Error: Can only have one anonymous define call per script file

The page loads Monaco for its code panel, and Monaco's loader.min.js runs earlier. Enumerating scripts made it plain:

[...document.scripts].map(s => s.src.split('/').pop())
// … 'purify.min.js', 'loader.min.js', 'gsap.min.js', … 'katex.min.js' …

typeof window.define // 'function' !!window.define.amd // true typeof window.katex // 'undefined'

Why the language produces it

UMD is a negotiation written into the top of the bundle. Every UMD file begins with roughly this:

(function (root, factory) {
    if (typeof exports === 'object' && typeof module === 'object') module.exports = factory();
    else if (typeof define === 'function' && define.amd) define([], factory);
    else root.KaTeX = factory();
})(this, function () { // });

Three branches, checked in order, global assignment last. The AMD branch wins whenever define.amd is truthy, which is a statement about the page, not about how you loaded the script.

The rejection then comes from the loader, not the library. An AMD loader tracks which module it is currently fetching so it can name an anonymous define; one arriving outside a loader-initiated fetch has no name to give it, so it errors. The factory never runs, nothing is assigned anywhere, and the only symptom at the call site is a missing global.

The error message is why this costs an afternoon. It names neither the library that called define nor the file it came from.

The fix

Make the third branch the only reachable one, for the length of that script tag and no longer:

<script>
  window.__savedDefine = window.define;
  window.define = undefined;
</script>
<script src="vendor/katex/katex.min.js"></script>
<script>
  window.define = window.__savedDefine;
  delete window.__savedDefine;
</script>

Script tags execute in order, so the guard closes before anything else can observe a missing define.

The obvious alternative is moving the UMD script above the AMD loader. It works; we did not take it. Load order is an invariant nobody can see, so the next person to reorganise the head reintroduces the bug with no clue what they broke. Four lines that state the reason at the point of the problem survive that edit.

Preventing the class

Assert the global right after you load it. One line turns a silent failure into a loud one:

if (!window.katex) console.error('katex.min.js loaded but did not register: AMD loader present?');

Prefer an explicitly-named build when the vendor ships one. A .global.js or .iife.js has the negotiation stripped out, which beats guarding: there is nothing left to negotiate.

Treat "loaded but absent" as its own diagnosis. Check whether the file executed before assuming it did not load. performance.getEntriesByType('resource') shows it fetched; the missing global then tells you it took a branch you did not expect.

The lesson

A script tag is not a guarantee about what a script does. UMD files inspect their environment and behave differently depending on what else is on the page, which means a library that works in isolation can silently do nothing in your app. When a global is missing, the question is not "did it load" but "which branch did it take."

Read this post in the full Engineering Journal →