[v4.1.3] Synchronous dynamic font loading causes infinite recursion with multiple SVG output jax instances
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 10.9k
- Forks
- 1.2k
- PR merge metrics
- No merged PRs in 30d
Description
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/src@4.1.3 @mathjax/mathjax-newcm-font@4.1.3 -
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.js -
With MathJax 4.1.3, the output is:
Maximum call stack size exceeded -
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-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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the SVG font-loading entry points loadDynamicFilesSync(), loadDynamicFileSync(), and getChar(), using the supplied reproduce.js script with multiple SVG output jax instances. Compare the synchronous path with the asynchronous loadDynamicFile() behavior and the 4.1.2 result. Done means the second typeset call loads dynamic glyphs without recursive stack overflow and prints no error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- backend, computer-graphics
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100