[v4.1.3] Synchronous dynamic font loading causes infinite recursion with multiple SVG output jax instances
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:
Install the affected packages:
npm install @mathjax/[email protected] @mathjax/[email protected]Save the following as
reproduce.js: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' ); })();Run:
node reproduce.jsWith MathJax 4.1.3, the output is:
Maximum call stack size exceededDowngrade 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-fontVersion: 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:
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:
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.
DynamicFileobjects and theirpromisefields are shared through the font class's staticdynamicFilescollection.loadDynamicFileSync()callsdynamic.setup(this)only whendynamic.promisehas 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 callsgetChar()recursively while the character remains unresolved, eventually exhausting the call stack. - The asynchronous
loadDynamicFile()path does not have the same problem because it runsdynamic.setup(this)for each font instance after the shared promise resolves.
Source: mathjax/MathJax