#3597·MathJax

[v4.1.3] Synchronous dynamic font loading causes infinite recursion with multiple SVG output jax instances

Author: stevenjoezhangCreated Aug 3, 2026Updated Aug 11, 2026
LabelsAcceptedMergedCode Examplev4

Replace the text below with the details of the issue you are facing.
DO NOT simply erase the form and type a free-form response.

Issue Summary

MathJax 4.1.3 can produce Maximum call stack size exceeded when multiple documents are typeset sequentially using new SVG output jax instances and synchronous dynamic font loading.

The first document renders successfully. A later document containing dynamically loaded glyphs produces an <mjx-container> with:

data-mjx-error="Maximum call stack size exceeded"

This occurs in a server-side Node.js environment, without a browser. It reproduces on macOS and in GitHub Actions on Ubuntu and Windows.

The same example works with MathJax 4.1.2, so this appears to be a regression introduced by MathJax-src#1500.

Steps to Reproduce:

  1. Install the affected packages:

    bash
    npm install @mathjax/[email protected] @mathjax/[email protected]
  2. Save the following as reproduce.js:

    javascript
    const path = require('node:path');
    const { mathjax } = require('@mathjax/src/js/mathjax.js');
    const { TeX } = require('@mathjax/src/js/input/tex.js');
    const { SVG } = require('@mathjax/src/js/output/svg.js');
    const { LiteAdaptor } =
      require('@mathjax/src/js/adaptors/liteAdaptor.js');
    const { RegisterHTMLHandler } =
      require('@mathjax/src/js/handlers/html.js');
    
    require('@mathjax/src/js/util/asyncLoad/node.js');
    
    const { MathJaxNewcmFont } =
      require('@mathjax/mathjax-newcm-font/cjs/svg.js');
    
    const adaptor = new LiteAdaptor();
    RegisterHTMLHandler(adaptor);
    
    const dynamicPrefix = path.join(
      path.dirname(
        require.resolve('@mathjax/mathjax-newcm-font/package.json')
      ),
      'cjs/svg/dynamic'
    );
    
    async function typeset(content) {
      const tex = new TeX({
        packages: ['base'],
        inlineMath: [['$', '$']]
      });
      const svg = new SVG({
        fontCache: 'none',
        fontData: MathJaxNewcmFont,
        dynamicPrefix
      });
    
      svg.font.loadDynamicFilesSync();
    
      const document = mathjax.document(content, {
        InputJax: tex,
        OutputJax: svg
      });
    
      await document.renderPromise();
      return adaptor.innerHTML(adaptor.body(document.document));
    }
    
    (async () => {
      await typeset('$E=mc^2$');
    
      const output = await typeset(
        '$\\mathbb{R} \\quad \\mathcal{L}$'
      );
    
      console.log(
        output.match(/data-mjx-error="([^"]+)/)?.[1] ?? 'no error'
      );
    })();
  3. Run:

    bash
    node reproduce.js
  4. With MathJax 4.1.3, the output is:

    Maximum call stack size exceeded
  5. Downgrade both packages to 4.1.2 and run the same script. The output is:

    no error

I consider this a bug because dynamically loaded font data appears to be shared between font classes, while its setup is only performed for the first font instance. Creating a new SVG output jax for another document should continue to work.

The synchronous loading path should load each dynamic module only once, but make its data available to every new font instance.

Technical details:

  • MathJax Version: 4.1.3
  • @mathjax/mathjax-newcm-font Version: 4.1.3
  • Client OS: macOS 15.7.3; also reproduced in GitHub Actions on Ubuntu and Windows
  • Browser: Not applicable, this is server-side Node.js
  • Node.js: 26.5.0 locally; also reproduced with Node.js 24 in GitHub Actions
  • Module format: CommonJS

I am using the following MathJax configuration:

javascript
const tex = new TeX({
  packages: ['base'],
  inlineMath: [['$', '$']]
});

const svg = new SVG({
  fontCache: 'none',
  fontData: MathJaxNewcmFont,
  dynamicPrefix
});

svg.font.loadDynamicFilesSync();

MathJax is loaded through its Node.js CommonJS modules rather than a browser script tag:

javascript
const { mathjax } = require('@mathjax/src/js/mathjax.js');
const { TeX } = require('@mathjax/src/js/input/tex.js');
const { SVG } = require('@mathjax/src/js/output/svg.js');

require('@mathjax/src/js/util/asyncLoad/node.js');

Supporting information:

  • A browser example and screenshot are not applicable because the problem occurs during server-side SVG generation.
  • The regression is visible in next-theme/hexo-filter-mathjax#89.
  • DynamicFile objects and their promise fields are shared through the font class's static dynamicFiles collection.
  • loadDynamicFileSync() calls dynamic.setup(this) only when dynamic.promise has not already been created.
  • A second font instance therefore sees the existing promise but does not receive the dynamic character data.
  • The synchronous branch added to getChar() in 4.1.3 calls getChar() recursively while the character remains unresolved, eventually exhausting the call stack.
  • The asynchronous loadDynamicFile() path does not have the same problem because it runs dynamic.setup(this) for each font instance after the shared promise resolves.