Skip to content

feat(rehype-mathjax)!: update mathjax from v3 to v4#119

Open
nachogarcia wants to merge 3 commits into
remarkjs:mainfrom
nachogarcia:main
Open

feat(rehype-mathjax)!: update mathjax from v3 to v4#119
nachogarcia wants to merge 3 commits into
remarkjs:mainfrom
nachogarcia:main

Conversation

@nachogarcia
Copy link
Copy Markdown

@nachogarcia nachogarcia commented Mar 26, 2026

Initial checklist

  • I read the support docs
  • I read the contributing guide
  • I agree to follow the code of conduct
  • I searched issues and discussions and couldn’t find anything or linked relevant results below
  • I made sure the docs are up to date
  • I included tests (or that’s not needed)

Description of changes

Update MathJax from v3 to v4

Closes #114

Summary

Upgrades rehype-mathjax from mathjax-full@^3.0.0 (MathJax v3, resolved to 3.2.1) to @mathjax/src@^4.0.0 (MathJax v4). This is a major dependency upgrade reflecting MathJax's package rename, new font system, improved error handling, and a fundamentally different font loading architecture that required a custom preloading strategy for bundler and runSync() compatibility.

Motivation

MathJax v4 was released with significant improvements:

  • New Computer Modern (NCM) font replacing the legacy TeX font, with better glyph coverage
  • Improved accessibility (data-latex attributes on SVG elements)
  • Dark mode support in generated CSS
  • More robust error handling (undefined TeX commands render gracefully instead of throwing)
  • Line-break support in math output
  • New TeX extensions: action, bbm, bbox, begingroup, dsfont, texhtml, units

Breaking changes

This is a semver-major change for rehype-mathjax:

  • Output differs: All rendered SVG and CHTML output changes (font IDs, path data, CSS classes, accessibility attributes). Snapshot tests downstream will need updating.
  • Font changed: TeX font → New Computer Modern (NCM). Glyph metrics differ slightly; visual output is similar but not pixel-identical.
  • CHTML fontURL changed: Must now point to @mathjax/mathjax-newcm-font/chtml/woff2 instead of mathjax@3/es5/output/chtml/fonts/woff-v2.
  • Error behavior changed: Undefined TeX commands (e.g., \foo) now render as red text inline instead of throwing. The catch-block fallback still works for actual renderer exceptions.

Design decisions

Why static font imports instead of dynamic loaders

MathJax v4 fundamentally changed its font architecture. In v3, all font data was bundled into the output constructors. In v4, font data is split into 40 "dynamic" files per output format (SVG/CHTML) that are loaded on-demand at runtime via mathjax.asyncLoad().

MathJax v4 provides two built-in loaders for this:

  1. Sync Node.js loader (require.mjs + asyncLoad/node.js): Uses createRequire from the module builtin to synchronously require() font files. This works in plain Node.js but crashes in bundlers (webpack, Vite, Next.js, esbuild, Turbopack) with createRequire is not a function because bundlers don't provide the module builtin.

  2. Async ESM loader (asyncLoad/esm.js): Uses dynamic import() to load font files on demand. When document.convert() encounters an unloaded glyph, MathJax's retry mechanism throws a synchronous Error with a .retry promise, caught by handleRetriesFor() which awaits the font load and retries. This has two problems:

    • Breaks runSync(): The transform must be async to use handleRetriesFor(). Libraries like react-markdown use unified's runSync() internally, which throws if any transform returns a Promise.
    • Breaks bundlers: The import() call receives a runtime-computed string (built inside MathJax's dynamicFileName(): prefix + '/' + file + '.js'). Bundlers cannot statically analyze this — webpack emits "Critical dependency: the request of a dependency is an expression" and Vite/Rollup silently fail to resolve the paths.

Our approach: Statically import all font data files from @mathjax/mathjax-newcm-font at build time. Each file calls dynamicSetup() on the font class as a side effect, registering glyph data at import time. We set mathjax.asyncLoad to a no-op since nothing needs runtime loading. The transform stays fully synchronous.

Font imports are split per output format and trimmed to math-essential files only (12 of 40). Extended scripts (Cyrillic, Arabic, Hebrew, Braille, Cherokee, Devanagari, etc.) and extra font variants (sans-serif, monospace, phonetics, extended Latin/Greek) are omitted to keep the SVG bundle close to v3's ~1 MB:

  • lib/svg.js: 12 SVG font files (~1.1 MB) — accents, arrows, calligraphic, double-struck, fraktur, marrows, math, mshapes, script, shapes, symbols, variants
  • lib/chtml.js: 12 CHTML font files (~113 KB) — same set

Why per-instance setup(font) in register()

MathJax's dynamicSetup() (called by the static font imports) registers setup functions on the class-level dynamicFiles object. These functions populate a font instance with character data when called with setup(font).

MathJax's loadDynamicFiles() and loadDynamicFileSync() both have a class-level if (!dynamic.promise) guard: once a file is marked as loaded for the class, subsequent calls skip setup() entirely. This means new font instances (created on each register() call) never get populated.

We work around this by explicitly iterating dynamicFiles in register() and calling df.setup(font) for each entry. The df.promise ??= Promise.resolve() ensures MathJax's internal tracking considers the file loaded (preventing any attempted runtime load via the no-op asyncLoad).

Bundle size impact

Measured with esbuild (--bundle --platform=node), comparing the v3 (mathjax-full@3.2.1) and v4 (@mathjax/src@4.1.1) bundles:

SVG entry (rehype-mathjax/svg):

v3 v4 Change
Output bundle 2.6 MB 3.1 MB +19%
Font data 998 KB 2.1 MB +1.1 MB
TeX extensions 532 KB 471 KB -61 KB
MathJax core 874 KB 578 KB -296 KB

CHTML entry (rehype-mathjax/chtml):

v3 v4 Change
Output bundle 1.7 MB 1.3 MB -24%
Font data 56 KB 282 KB +226 KB
TeX extensions 532 KB 471 KB -61 KB
MathJax core 898 KB 604 KB -294 KB

Why SVG is +19%: MathJax v4's core engine and TeX extensions are smaller than v3 (-300 KB combined), but the font architecture changed. In v3, all font data was bundled in a single 998 KB blob inside the SVG output class. In v4, the base font was split into a 1,021 KB static class plus 40 on-demand "dynamic" files. We import 12 math-essential dynamic files (~1 MB), which together with the static class totals ~2 MB — roughly double v3's font cost. The core savings partially offset this.

Why CHTML is -24%: Same core savings (-270 KB), but CHTML dynamic files store only numeric metrics (not SVG paths), so the 12 files add just 113 KB — far less than the core reduction.

MathJax v4 provides 40 dynamic font files per format (totaling ~9.9 MB for SVG). We import only the 12 math-essential files, keeping the SVG bundle at 3.1 MB. The omitted 28 files contain extended scripts and font variants:

Omitted category Files SVG size
Extended scripts (Cyrillic, Arabic, Hebrew, Braille, Cherokee, Devanagari) 10 2.2 MB
Extended Latin/Greek variants (italic, bold, bold-italic, sans-serif) 12 5.3 MB
Monospace/phonetics/PUA 6 1.4 MB

Included (math-essential): accents, arrows, calligraphic (\mathcal), double-struck (\mathbb), fraktur (\mathfrak), marrows, math, mshapes, script (\mathscr), shapes, symbols, variants.

Characters in omitted files that are encountered at runtime will trigger a MathJax retry error (rendered as a red <span class="mathjax-error">). Users needing extended scripts can add the imports in a custom plugin.

Why sideEffects array instead of false

The original package.json had "sideEffects": false. This is incorrect now because lib/svg.js, lib/chtml.js, lib/tex-packages.js, and lib/create-renderer.js all have side-effect imports (font data registration, TeX extension registration, mathjax.asyncLoad assignment). While most bundlers handle this correctly for actively-used modules, the explicit array is defensive and documents the intent.

TeX package audit

MathJax v4 ships 42 TeX extensions. We include 40, matching v3's AllPackages plus new v4 extensions. Two are excluded:

Excluded Reason
autoload Loads extensions on-demand via asyncLoad, which requires the async runtime loader — incompatible with our synchronous preloading approach.
bboldx Requires a -bboldx font variant not present in the NCM font package (produces Invalid variant warnings at runtime).

New v4 packages not in v3's AllPackages: action (tooltips), bbm (blackboard bold), bbox (bounding boxes), begingroup (TeX grouping), dsfont (double-struck), texhtml (HTML in TeX), units (unit formatting).

Known command overrides (same behavior as v3):

  • physics redefines \div, \Re, \Im, and trig/log functions — included to match v3
  • colorv2 overrides \color from the color package — included to match v3
  • ams enhances \frac and \boxed from base (standard LaTeX behavior)
  • mathtools enhances \shoveleft/\shoveright from ams

Changes

Dependency changes (package.json)

Before After
mathjax-full: ^3.0.0 @mathjax/src: ^4.0.0
@types/mathjax: ^0.0.40 (removed — no v4-compatible types)
(none) @mathjax/mathjax-newcm-font: ^4.0.0
"sideEffects": false "sideEffects": ["./lib/svg.js", "./lib/chtml.js", "./lib/tex-packages.js", "./lib/create-renderer.js"]

Added "import/no-unassigned-import": "off" to XO lint config to allow the side-effect imports required by v4.

Code organization

Module Responsibility
lib/tex-packages.js (new) 40 TeX extension side-effect imports + exported allPackages array (shared by SVG and CHTML)
lib/svg.js 12 SVG font side-effect imports (~1.1 MB) + SVG plugin entry point
lib/chtml.js 12 CHTML font side-effect imports (~113 KB) + CHTML plugin entry point
lib/create-renderer.js mathjax.asyncLoad no-op, renderer factory with per-instance font setup loop, fromLiteElement hast converter
lib/create-plugin.js rehype plugin framework, visitor pattern, error catch block (logic unchanged)

Import path migration

All imports updated from mathjax-full/js/... to @mathjax/src/js/.... MathJax v4 renamed the npm package but kept the same internal module structure, so the paths after the package name are unchanged.

Documentation update (create-plugin.js)

Updated the CHTML fontURL example in JSDoc from mathjax@3/es5/output/chtml/fonts/woff-v2 to @mathjax/mathjax-newcm-font/chtml/woff2 to reflect v4's new font package.

Error handling behavior change

MathJax v4's noundefined package now renders undefined TeX commands as red text inline (e.g., \a renders as red \a in the SVG) instead of throwing a TypeError. This is a user-visible improvement — malformed LaTeX degrades gracefully instead of crashing.

The error handling test was split into two:

  1. "should render undefined commands gracefully (mathjax v4)" — Verifies that \a{₹} (which threw in v3) now renders with fill="red" and produces no file.messages.

  2. "should catch renderer exceptions" — Tests the catch-block safety net using a custom renderer that throws, verifying the <span class="mathjax-error"> fallback still works.

New tests

  • "should render dynamic font characters (e.g. double-struck)" — Verifies \mathbb{R} renders correctly, testing that the static font preloading works for characters outside the base font range (these are in the dynamic font files, not the static base font).

  • "should work with processSync" — Verifies the transform works synchronously with processSync() / runSync(), which react-markdown always uses internally. This is the key compatibility constraint that prevents using async dynamic loaders.

Test fixture regeneration

All 9 SVG/CHTML fixture files were regenerated. The output differences are:

Change Reason
Font IDs MJX-*-TEX-*MJX-*-NCM-* Default font changed from Computer Modern to New Computer Modern
Different SVG <path> data New font glyphs
New overflow="overflow" attribute on <mjx-container> v4 overflow handling feature
New data-latex="..." attributes on SVG <g> elements v4 accessibility / debugging
CHTML class MJX-TEXNCM-N New font family identifier
CHTML elements contain Unicode characters (e.g., 𝛼) v4 embeds actual characters instead of CSS content
CHTML fonts: woff → woff2, MJXTEX-*NCM-* families New font format and naming
Minor viewBox / vertical-align metric changes Slightly different glyph metrics in NCM
Expanded CSS (dark mode, line breaks, mjx-break, MJX-ZERO font) v4 feature additions

Browser variant fixtures (small-browser.html, small-browser-delimiters.html) are unchanged since that variant wraps math for client-side MathJax rather than rendering server-side.

Files modified

  • packages/rehype-mathjax/package.json — dependencies, sideEffects, lint config
  • packages/rehype-mathjax/lib/tex-packages.js (new) — TeX extension imports + allPackages
  • packages/rehype-mathjax/lib/create-renderer.js — asyncLoad no-op, renderer with font setup
  • packages/rehype-mathjax/lib/create-plugin.js — fontURL docs update
  • packages/rehype-mathjax/lib/svg.js — 40 SVG font imports + plugin entry
  • packages/rehype-mathjax/lib/chtml.js — 40 CHTML font imports + plugin entry
  • packages/rehype-mathjax/test/index.js — 3 new tests, 1 split test
  • packages/rehype-mathjax/test/fixture/*.html — 9 regenerated SVG/CHTML fixtures

Verification

  • All 22 rehype-mathjax tests pass (including 3 new tests)
  • remark-math tests pass (53/53)
  • processSync() works (verifies runSync() / react-markdown compatibility)
  • Dynamic font characters (\mathbb{R}) render correctly via static preloading
  • No createRequire is not a function crash in bundled environments
  • No Invalid variant warnings at runtime
  • rehype-katex has 1 pre-existing failure unrelated to this change (Node.js version compatibility with vfile message properties)

Before usage in production environment
image
After
image

BREAKING CHANGE: mathjax-full replaced with @mathjax/src.
Output HTML/SVG/CSS differs due to new font (New Computer Modern),
new attributes (overflow, data-latex), and expanded styles (dark mode,
line breaks). Undefined TeX commands now render as red text instead
of throwing exceptions.
@github-actions github-actions Bot added the 👋 phase/new Post is being triaged automatically label Mar 26, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 26, 2026

Hi! It seems you removed the template which we require. Here are our templates (pick the one you want to use and click *raw* to see its source):

I won’t send you any further notifications about this, but I’ll keep on updating this comment, and hide it when done!

Thanks,
— bb

@jajaperson
Copy link
Copy Markdown

lol so now we have 3 separate PRs doing this... still no response from maintainers. paging @wooorm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

👋 phase/new Post is being triaged automatically

Development

Successfully merging this pull request may close these issues.

bump mathjax into v4

2 participants