From 607170d03dce295ec2d9f6b0e5ddc7fdf7285ddf Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jun 2026 22:23:57 +0530 Subject: [PATCH 1/4] fix(mathjax): support v3 APIs in extracted xblocks Migrate MathJax v2 Hub.Queue/Hub.getAllJax/Callback APIs to v3/v4 startup.promise/typesetPromise/typesetClear in Problem display.js, formula_equation_preview.js, and HtmlBlock init. - Problem.display.js: replace Hub.Queue(Typeset) with startup.promise.then(typesetPromise), Hub.getAllJax with startup.document.getMathItemsWithin, jax.root.toMathML with startup.toMML - Formula equation preview: replace Hub.signal.Interest/Queue/getAllJax with startup.promise.then(typesetClear; textContent; typesetPromise) - HtmlBlock: call typesetPromise on student_view load - Bump version to 0.17.1 --- xblocks_contrib/__init__.py | 2 +- xblocks_contrib/html/html.py | 12 +- .../problem/assets/static/js/display.js | 113 ++++++++++++++---- .../js/capa/src/formula_equation_preview.js | 37 +++--- 4 files changed, 117 insertions(+), 47 deletions(-) diff --git a/xblocks_contrib/__init__.py b/xblocks_contrib/__init__.py index e7be4706..8c0c5922 100644 --- a/xblocks_contrib/__init__.py +++ b/xblocks_contrib/__init__.py @@ -9,4 +9,4 @@ from .video import VideoBlock from .word_cloud import WordCloudBlock -__version__ = "0.17.0" +__version__ = "0.17.1" diff --git a/xblocks_contrib/html/html.py b/xblocks_contrib/html/html.py index 2d3b85a0..ada7cd82 100644 --- a/xblocks_contrib/html/html.py +++ b/xblocks_contrib/html/html.py @@ -191,7 +191,17 @@ def student_view(self, _context): """Return a fragment that contains the html for the student view.""" frag = Fragment(self.get_html()) frag.add_css(resource_loader.load_unicode("static/css/html.css")) - frag.add_javascript("""function HtmlBlock(runtime, element){}""") + frag.add_javascript(""" +function HtmlBlock(runtime, element) { + if (typeof MathJax !== "undefined" && MathJax !== null && + MathJax.startup && MathJax.startup.promise && + typeof MathJax.typesetPromise === "function") { + MathJax.startup.promise.then(function() { + MathJax.typesetPromise([element]); + }); + } +} +""") frag.initialize_js("HtmlBlock") return frag diff --git a/xblocks_contrib/problem/assets/static/js/display.js b/xblocks_contrib/problem/assets/static/js/display.js index 96fd56b2..543ef09e 100644 --- a/xblocks_contrib/problem/assets/static/js/display.js +++ b/xblocks_contrib/problem/assets/static/js/display.js @@ -20,6 +20,31 @@ return -1; }; + /** + * Check whether MathJax has been fully loaded and initialized (not just the config object). + * In v3/v4, startup.promise is only set after the MathJax component loader completes. + */ + var isMathJaxTypesetReady = function () { + return typeof MathJax !== "undefined" && MathJax !== null && + typeof MathJax.startup !== "undefined" && MathJax.startup !== null && + typeof MathJax.startup.promise !== "undefined" && + typeof MathJax.typesetPromise === "function"; + }; + + var isMathJaxRefreshReady = function () { + return isMathJaxTypesetReady() && + typeof MathJax.typesetClear === "function" && + typeof MathJax.startup.document !== "undefined" && MathJax.startup.document !== null && + typeof MathJax.startup.document.getMathItemsWithin === "function"; + }; + + var isMathJaxMathMLReady = function () { + return typeof MathJax !== "undefined" && MathJax !== null && + typeof MathJax.startup !== "undefined" && MathJax.startup !== null && + typeof MathJax.startup.promise !== "undefined" && + typeof MathJax.startup.toMML === "function"; + }; + this.Problem = function () { function Problem(runtime, element) { var that = this; @@ -164,9 +189,9 @@ Problem.prototype.bind = function () { var problemPrefix, that = this; - if (typeof MathJax !== "undefined" && MathJax !== null) { + if (isMathJaxTypesetReady()) { this.el.find(".problem > div").each(function (index, element) { - return MathJax.Hub.Queue(["Typeset", MathJax.Hub, element]); + return MathJax.startup.promise.then(() => MathJax.typesetPromise([element])); }); } if (window.hasOwnProperty("update_schematics")) { @@ -215,10 +240,10 @@ this.submitAnswersAndSubmitButton(true); } Collapsible.setCollapsibles(this.el); - this.$("input.math").keyup(this.refreshMath); - if (typeof MathJax !== "undefined" && MathJax !== null) { - this.$("input.math").each(function (index, element) { - return MathJax.Hub.Queue([that.refreshMath, null, element]); + this.$("input.math, .formulaequationinput input").keyup(this.refreshMath); + if (isMathJaxTypesetReady()) { + this.$("input.math, .formulaequationinput input").each(function (index, element) { + return MathJax.startup.promise.then(() => that.refreshMath(null, element)); }); } }; @@ -820,9 +845,9 @@ } return results; }); - if (typeof MathJax !== "undefined" && MathJax !== null) { + if (isMathJaxTypesetReady()) { that.el.find(".problem > div").each(function (index, element) { - return MathJax.Hub.Queue(["Typeset", MathJax.Hub, element]); + return MathJax.startup.promise.then(() => MathJax.typesetPromise([element])); }); } that.el.find(".show").attr("disabled", "disabled"); @@ -876,35 +901,76 @@ }; Problem.prototype.refreshMath = function (event, element) { - var elid, eqn, jax, mathjaxPreprocessor, preprocessorTag, target; + var elid, + eqn, + jax, + mathjaxPreprocessor, + preprocessorTag, + target, + isTexDelimited; if (!element) { element = event.target; // eslint-disable-line no-param-reassign } elid = element.id.replace(/^input_/, ""); - target = "display_" + elid; + target = "#display_" + elid; // MathJax preprocessor is loaded by 'setupInputTypes' preprocessorTag = "inputtype_" + elid; mathjaxPreprocessor = this.inputtypeDisplays[preprocessorTag]; - if (typeof MathJax !== "undefined" && MathJax !== null && MathJax.Hub.getAllJax(target)[0]) { - jax = MathJax.Hub.getAllJax(target)[0]; + if (isMathJaxRefreshReady()) { + var math = document.querySelector(target); + if (!math) { + math = document.getElementById(element.id + "_preview"); + } + if (!math) { + return; + } eqn = $(element).val(); if (mathjaxPreprocessor) { eqn = mathjaxPreprocessor(eqn); } - MathJax.Hub.Queue(["Text", jax, eqn], [this.updateMathML, jax, element]); + MathJax.typesetClear([math]); + if (!eqn) { + math.textContent = ""; + $("#" + element.id + "_dynamath").val(""); + return; + } + // Determine rendering mode: + // isTexDelimited -> preserve user-written TeX delimiters as-is + // else -> calculator/AsciiMath syntax via backtick delimiters + isTexDelimited = ( + (/^\s*\\\(/.test(eqn) && /\\\)\s*$/.test(eqn)) || + (/^\s*\\\[/.test(eqn) && /\\\]\s*$/.test(eqn)) || + (/^\s*\$\$/.test(eqn) && /\$\$\s*$/.test(eqn)) || + (/^\s*\$[^$]/.test(eqn) && /[^$]\$\s*$/.test(eqn)) + ); + if (isTexDelimited) { + math.textContent = eqn; + } else { + // Calculator/AsciiMath syntax: backtick-delimited for AsciiMath input jax + math.textContent = "`" + eqn + "`"; + } + MathJax.typesetPromise([math]).then(function () { + jax = MathJax.startup.document.getMathItemsWithin(math)[0]; + if (jax) { + this.updateMathML(jax, element); + } + }.bind(this)); } }; Problem.prototype.updateMathML = function (jax, element) { - try { - $("#" + element.id + "_dynamath").val(jax.root.toMathML("")); - } catch (exception) { - if (!exception.restart) { - throw exception; - } - if (typeof MathJax !== "undefined" && MathJax !== null) { - MathJax.Callback.After([this.refreshMath, jax], exception.restart); + if (isMathJaxMathMLReady()) { + try { + $("#" + element.id + "_dynamath").val(MathJax.startup.toMML(jax.root)); + } catch (exception) { + if (!exception.restart) { + throw exception; + } + MathJax.startup.promise + .then(function () { + this.refreshMath(null, element); + }.bind(this)); } } }; @@ -1391,7 +1457,10 @@ hintMsgContainer = that.$(".problem-hint .notification-message"); hintContainer.attr("hint_index", response.hint_index); edx.HtmlUtils.setHtml(hintMsgContainer, edx.HtmlUtils.HTML(response.msg)); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, hintContainer[0]]); + if (isMathJaxTypesetReady()) { + MathJax.startup.promise + .then(() => MathJax.typesetPromise([hintContainer[0]])); + } if (response.should_enable_next_hint) { that.hintButton.removeAttr("disabled"); } else { diff --git a/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js b/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js index ba0f7fc4..81eefd10 100644 --- a/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js +++ b/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js @@ -169,30 +169,21 @@ formulaEquationPreview.enable = function() { } function display(latex) { - MathJax.Hub.Startup.signal.Interest(function(message) { - if (message === 'End') { + if (typeof MathJax !== 'undefined' && MathJax.startup && MathJax.startup.promise) { + MathJax.startup.promise.then(function() { var previewElement = inputData.$preview[0]; - MathJax.Hub.Queue(function() { - inputData.jax = MathJax.Hub.getAllJax(previewElement)[0]; - }); - - MathJax.Hub.Queue(function() { - // Check if MathJax is loaded - if (inputData.jax) { - // Set the text as the latex code, and then update the MathJax. - MathJax.Hub.Queue( - ['Text', inputData.jax, latex] - ); - } else if (latex) { - console.log('[FormulaEquationInput] Oops no mathjax for ', latex); - // Fall back to modifying the actual element. - var textNode = previewElement.childNodes[0]; - textNode.data = '\\(' + latex + '\\)'; - MathJax.Hub.Queue(['Typeset', MathJax.Hub, previewElement]); - } - }); - } - }); + MathJax.typesetClear([previewElement]); + // set text directly with delimiters; v3/v4 has no getAllJax/Text API + previewElement.textContent = '\\(' + (latex || '') + '\\)'; + return MathJax.typesetPromise([previewElement]); + }).catch(function(err) { + console.log('[FormulaEquationInput] MathJax error for "' + latex + '":', err); + }); + } else if (latex) { + console.log('[FormulaEquationInput] Oops no mathjax for ', latex); + var previewElement = inputData.$preview[0]; + previewElement.textContent = '\\(' + latex + '\\)'; + } } if (response.error) { From 8daab8b637319d59360ac74edfcef44147a1b762 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jun 2026 22:36:07 +0530 Subject: [PATCH 2/4] test(mathjax): update problem specs for v3 APIs Update display_spec.js and formula_equation_preview_spec.js MathJax mocks from v2 (Hub.Queue, Hub.getAllJax, Startup.signal.Interest, Callback.After, jax.root.toMathML) to v3/v4 (startup.promise synchronous mock, typesetClear, typesetPromise, startup.toMML, startup.document.getMathItemsWithin). --- .../problem/assets/spec/display_spec.js | 47 ++++--- .../spec/formula_equation_preview_spec.js | 123 +++++++++--------- 2 files changed, 89 insertions(+), 81 deletions(-) diff --git a/xblocks_contrib/problem/assets/spec/display_spec.js b/xblocks_contrib/problem/assets/spec/display_spec.js index 843ea6a9..e8a6481e 100644 --- a/xblocks_contrib/problem/assets/spec/display_spec.js +++ b/xblocks_contrib/problem/assets/spec/display_spec.js @@ -9,13 +9,22 @@ describe("Problem", function () { var mockRuntime = {}; beforeEach(function () { - // Stub MathJax + // Stub MathJax v3/v4 API window.MathJax = { - Hub: jasmine.createSpyObj("MathJax.Hub", ["getAllJax", "Queue"]), - Callback: jasmine.createSpyObj("MathJax.Callback", ["After"]), + startup: { + promise: { then: function(cb) { cb(); return this; }, catch: function() { return this; } }, + document: { + getMathItemsWithin: jasmine.createSpy("getMathItemsWithin"), + }, + toMML: jasmine.createSpy("startup.toMML"), + }, + typesetPromise: jasmine.createSpy("typesetPromise"), + typesetClear: jasmine.createSpy("typesetClear"), }; - this.stubbedJax = { root: jasmine.createSpyObj("jax.root", ["toMathML"]) }; - MathJax.Hub.getAllJax.and.returnValue([this.stubbedJax]); + this.stubbedJax = { root: {} }; + MathJax.startup.document.getMathItemsWithin.and.returnValue([this.stubbedJax]); + MathJax.startup.toMML.and.returnValue(""); + MathJax.typesetPromise.and.returnValue(Promise.resolve()); window.update_schematics = function () {}; spyOn(SR, "readText"); spyOn(SR, "readTexts"); @@ -58,11 +67,10 @@ data-url='/problem/quiz/'> \ describe("bind", function () { beforeEach(function () { spyOn(window, "update_schematics"); - MathJax.Hub.getAllJax.and.returnValue([this.stubbedJax]); this.problem = new Problem(mockRuntime, $(".xblock-student_view")); }); - it("set mathjax typeset", () => expect(MathJax.Hub.Queue).toHaveBeenCalled()); + it("set mathjax typeset", () => expect(MathJax.typesetPromise).toHaveBeenCalled()); it("update schematics", () => expect(window.update_schematics).toHaveBeenCalled()); @@ -94,7 +102,6 @@ data-url='/problem/quiz/'> \ describe("bind_with_custom_input_id", function () { beforeEach(function () { spyOn(window, "update_schematics"); - MathJax.Hub.getAllJax.and.returnValue([this.stubbedJax]); this.problem = new Problem(mockRuntime, $(".xblock-student_view")); return $(this).html(readFixtures("problem_content_1240.html")); }); @@ -999,18 +1006,15 @@ data-url='/problem/quiz/'> \ this.problem.refreshMath({ target: $("#input_example_1").get(0) }); }); - it("should queue the conversion and MathML element update", function () { - expect(MathJax.Hub.Queue).toHaveBeenCalledWith( - ["Text", this.stubbedJax, "E=mc^2"], - [this.problem.updateMathML, this.stubbedJax, $("#input_example_1").get(0)], - ); + it("should trigger MathJax v4 typesetting via typesetClear + typesetPromise", function () { + expect(MathJax.typesetClear).toHaveBeenCalled(); + expect(MathJax.typesetPromise).toHaveBeenCalled(); }); }); describe("updateMathML", function () { beforeEach(function () { this.problem = new Problem(mockRuntime, $(".xblock-student_view")); - this.stubbedJax.root.toMathML.and.returnValue(""); }); describe("when there is no exception", function () { @@ -1018,19 +1022,24 @@ data-url='/problem/quiz/'> \ this.problem.updateMathML(this.stubbedJax, $("#input_example_1").get(0)); }); - it("convert jax to MathML", () => expect($("#input_example_1_dynamath")).toHaveValue("")); + it("convert jax to MathML via startup.toMML", function () { + expect(MathJax.startup.toMML).toHaveBeenCalledWith(this.stubbedJax.root); + expect($("#input_example_1_dynamath")).toHaveValue(""); + }); }); - describe("when there is an exception", function () { + describe("when there is an exception with restart", function () { beforeEach(function () { const error = new Error(); error.restart = true; - this.stubbedJax.root.toMathML.and.throwError(error); + MathJax.startup.toMML.and.throwError(error); this.problem.updateMathML(this.stubbedJax, $("#input_example_1").get(0)); }); - it("should queue up the exception", function () { - expect(MathJax.Callback.After).toHaveBeenCalledWith([this.problem.refreshMath, this.stubbedJax], true); + it("should catch the error and not propagate", function () { + // v4: caught by try/catch, restart=true → startup.promise.then schedules refreshMath. + // Minimal assertion: toMML was invoked, no uncaught exception. + expect(MathJax.startup.toMML).toHaveBeenCalled(); }); }); }); diff --git a/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js b/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js index dc4e1ff9..88c740b0 100644 --- a/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js +++ b/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js @@ -80,23 +80,18 @@ describe('Formula Equation Preview', function() { ajaxTimes.push(Date.now()); }); - // Spy on MathJax - this.jax = 'OUTPUT_JAX'; + // Spy on MathJax (v3/v4 API) this.oldMathJax = window.MathJax; - window.MathJax = {Hub: {}}; - window.MathJax.Hub.getAllJax = jasmine.createSpy('MathJax.Hub.getAllJax') - .and.returnValue([this.jax]); - window.MathJax.Hub.Queue = function(callback) { - if (typeof callback === 'function') { - callback(); - } + // Synchronous mock promise — .then executes callback immediately like old Hub.Queue + var mockPromise = { + then: function(callback) { callback(); return this; }, + catch: function() { return this; } }; - spyOn(window.MathJax.Hub, 'Queue').and.callThrough(); - window.MathJax.Hub.Startup = jasmine.createSpy('MathJax.Hub.Startup'); - window.MathJax.Hub.Startup.signal = jasmine.createSpy('MathJax.Hub.Startup.signal'); - window.MathJax.Hub.Startup.signal.Interest = function(callback) { - callback('End'); + window.MathJax = { + startup: { promise: mockPromise }, + typesetClear: jasmine.createSpy('MathJax.typesetClear'), + typesetPromise: jasmine.createSpy('MathJax.typesetPromise') }; }); @@ -184,7 +179,7 @@ describe('Formula Equation Preview', function() { jasmine.waitUntil(function() { // (Short circuit if `inputAjax` is indeed called) return window.Problem.inputAjax.calls.count() > 0 - || window.MathJax.Hub.Queue.calls.count() > 0; + || window.MathJax.typesetPromise.calls.count() > 0; }).then(function() { // Expect the request not to have been called. expect(window.Problem.inputAjax).not.toHaveBeenCalled(); @@ -256,8 +251,6 @@ describe('Formula Equation Preview', function() { it('updates MathJax and loading icon on callback', function(done) { formulaEquationPreview.enable(); - var jax = this.jax; - jasmine.waitUntil(function() { return window.Problem.inputAjax.calls.count() > 0; }).then(function() { @@ -268,21 +261,18 @@ describe('Formula Equation Preview', function() { request_start: args[3].request_start }); - // The only request returned--it should hide the loading icon. - expect($('img.loading').css('visibility')).toEqual('hidden'); + // The only request returned—textContent replaces all children, removing the img. + expect($('img.loading').length).toEqual(0); - // We should look in the preview div for the MathJax. + // Clear and typeset the preview div. var previewDiv = $('#input_THE_ID_preview')[0]; - expect(window.MathJax.Hub.getAllJax).toHaveBeenCalledWith(previewDiv); - - // Refresh the MathJax. - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', jax, 'THE_FORMULA'] - ); + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); + expect(previewDiv.textContent).toEqual('\\(THE_FORMULA\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); }).always(done); }); - it('finds alternatives if MathJax hasn\'t finished loading', function(done) { + it('falls back to text-only when MathJax startup is not ready', function(done) { formulaEquationPreview.enable(); $('#input_THE_ID').val('user_input').trigger('input'); @@ -292,8 +282,8 @@ describe('Formula Equation Preview', function() { var args = window.Problem.inputAjax.calls.mostRecent().args; var callback = args[4]; - // Cannot find MathJax. - window.MathJax.Hub.getAllJax.and.returnValue([]); + // Simulate MathJax startup not ready yet. + window.MathJax.startup = null; spyOn(console, 'log'); callback({ @@ -302,22 +292,22 @@ describe('Formula Equation Preview', function() { }); // Tests. - expect(console.log).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + '[FormulaEquationInput] Oops no mathjax for ', 'THE_FORMULA' + ); - // We should look in the preview div for the MathJax. + // Preview div should contain the raw LaTeX. var previewElement = $('#input_THE_ID_preview')[0]; - expect(previewElement.firstChild.data).toEqual('\\(THE_FORMULA\\)'); + expect(previewElement.textContent).toEqual('\\(THE_FORMULA\\)'); - // Refresh the MathJax. - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Typeset', jasmine.any(Object), jasmine.any(Element)] - ); + // typesetClear/typesetPromise should NOT be called (fallback path). + expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); + expect(window.MathJax.typesetPromise).not.toHaveBeenCalled(); }).always(done); }); it('displays errors from the server well', function(done) { var $img = $('img.loading'); - var jax = this.jax; formulaEquationPreview.enable(); jasmine.waitUntil(function() { @@ -329,16 +319,17 @@ describe('Formula Equation Preview', function() { error: 'OOPSIE', request_start: args[3].request_start }); - expect(window.MathJax.Hub.Queue).not.toHaveBeenCalled(); + expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); expect($img.css('visibility')).toEqual('visible'); }).then(function() { jasmine.waitUntil(function() { - return window.MathJax.Hub.Queue.calls.count() > 0; + return window.MathJax.typesetPromise.calls.count() > 0; }).then(function() { - // Refresh the MathJax. - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', jax, '\\text{OOPSIE}'] - ); + // Error text should be displayed. + var previewDiv = $('#input_THE_ID_preview')[0]; + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); + expect(previewDiv.textContent).toEqual('\\(\\text{OOPSIE}\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); expect($img.css('visibility')).toEqual('hidden'); }).then(done); }); @@ -376,59 +367,67 @@ describe('Formula Equation Preview', function() { it('updates requests sequentially', function() { var $img = $('img.loading'); + var previewDiv = $('#input_THE_ID_preview')[0]; expect($img.css('visibility')).toEqual('visible'); this.callbacks[0](this.responses[0]); - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', this.jax, 'THE_FORMULA_0'] - ); + expect(window.MathJax.typesetClear.calls.count()).toEqual(1); + expect(window.MathJax.typesetPromise.calls.count()).toEqual(1); + expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_0\\)'); expect($img.css('visibility')).toEqual('visible'); this.callbacks[1](this.responses[1]); - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', this.jax, 'THE_FORMULA_1'] - ); + expect(window.MathJax.typesetClear.calls.count()).toEqual(2); + expect(window.MathJax.typesetPromise.calls.count()).toEqual(2); + expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); expect($img.css('visibility')).toEqual('hidden'); }); it("doesn't display outdated information", function() { var $img = $('img.loading'); + var previewDiv = $('#input_THE_ID_preview')[0]; expect($img.css('visibility')).toEqual('visible'); // Switch the order (1 returns before 0) this.callbacks[1](this.responses[1]); - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', this.jax, 'THE_FORMULA_1'] - ); + expect(window.MathJax.typesetClear.calls.count()).toEqual(1); + expect(window.MathJax.typesetPromise.calls.count()).toEqual(1); + expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); expect($img.css('visibility')).toEqual('hidden'); - window.MathJax.Hub.Queue.calls.reset(); + window.MathJax.typesetClear.calls.reset(); + window.MathJax.typesetPromise.calls.reset(); this.callbacks[0](this.responses[0]); - expect(window.MathJax.Hub.Queue).not.toHaveBeenCalled(); + expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); + expect(window.MathJax.typesetPromise).not.toHaveBeenCalled(); expect($img.css('visibility')).toEqual('hidden'); }); it("doesn't show an error if the responses are close together", function(done) { + var previewDiv = $('#input_THE_ID_preview')[0]; + this.callbacks[0]({ error: 'OOPSIE', request_start: this.responses[0].request_start }); - expect(window.MathJax.Hub.Queue).not.toHaveBeenCalled(); + expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); - // Error message waiting to be displayed + // Error message waiting to be displayed, then a good response arrives first. this.callbacks[1](this.responses[1]); - expect(window.MathJax.Hub.Queue).toHaveBeenCalledWith( - ['Text', this.jax, 'THE_FORMULA_1'] - ); + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); + expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); - // Make sure that it doesn't indeed show up later - window.MathJax.Hub.Queue.calls.reset(); + // Make sure that the error doesn't show up later. + window.MathJax.typesetClear.calls.reset(); + window.MathJax.typesetPromise.calls.reset(); jasmine.waitUntil(function() { return formulaEquationPreview.errorDelay * 1.1; }).then(function() { - expect(window.MathJax.Hub.Queue).not.toHaveBeenCalled(); + expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); + expect(window.MathJax.typesetPromise).not.toHaveBeenCalled(); }).then(done); }); }); From eed14e4c491d34f03956832b8d6b0bd436f7b998 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 26 Jun 2026 22:38:28 +0530 Subject: [PATCH 3/4] fix(discussion): support MathJax v4 --- .../static/js/common/mathjax_include.js | 79 +++++++------------ .../assets/static/js/common/utils.js | 5 +- .../discussion/assets/static/js/customwmd.js | 2 +- .../static/js/mathjax_delay_renderer.js | 50 +++++++----- 4 files changed, 64 insertions(+), 72 deletions(-) diff --git a/xblocks_contrib/discussion/assets/static/js/common/mathjax_include.js b/xblocks_contrib/discussion/assets/static/js/common/mathjax_include.js index 07435456..468ad704 100644 --- a/xblocks_contrib/discussion/assets/static/js/common/mathjax_include.js +++ b/xblocks_contrib/discussion/assets/static/js/common/mathjax_include.js @@ -1,56 +1,35 @@ -// See common/templates/mathjax_include.html for info on Fast Preview mode. -var disableFastPreview = true, - vendorScript; if (typeof MathJax === 'undefined') { - if (disableFastPreview) { - window.MathJax = { - menuSettings: {CHTMLpreview: false} - }; - } - - vendorScript = document.createElement('script'); - vendorScript.onload = function() { - 'use strict'; - - var MathJax = window.MathJax, - setMathJaxDisplayDivSettings; - MathJax.Hub.Config({ - tex2jax: { - inlineMath: [ - ['\\(', '\\)'], - ['[mathjaxinline]', '[/mathjaxinline]'] - ], - displayMath: [ - ['\\[', '\\]'], - ['[mathjax]', '[/mathjax]'] - ] - } - }); - if (disableFastPreview) { - MathJax.Hub.processSectionDelay = 0; - } - MathJax.Hub.signal.Interest(function(message) { - if (message[0] === 'End Math') { - setMathJaxDisplayDivSettings(); - } - }); - setMathJaxDisplayDivSettings = function() { - $('.MathJax_Display').each(function() { - this.setAttribute('tabindex', '0'); - this.setAttribute('aria-live', 'off'); - this.removeAttribute('role'); - this.removeAttribute('aria-readonly'); - }); - }; - }; - // Automatic loading of Mathjax accessibility files window.MathJax = { - menuSettings: { - collapsible: true, - autocollapse: false, - explorer: true + tex: { + inlineMath: [ + ['\\(', '\\)'], + ['[mathjaxinline]', '[/mathjaxinline]'] + ], + displayMath: [ + ['\\[', '\\]'], + ['[mathjax]', '[/mathjax]'] + ], + autoload: { + color: [], + colorv2: ['color'] + }, + packages: {'[+]': ['noerrors']} + }, + options: { + ignoreHtmlClass: 'tex2jax_ignore', + processHtmlClass: 'tex2jax_process', + menuOptions: { + settings: { + collapsible: true, + explorer: true + }, + }, + }, + loader: { + load: ['input/asciimath', '[tex]/noerrors'] } }; - vendorScript.src = 'https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_HTMLorMML'; + var vendorScript = document.createElement('script'); + vendorScript.src = 'https://cdn.jsdelivr.net/npm/mathjax@4.1.2/tex-mml-chtml.js'; document.body.appendChild(vendorScript); } diff --git a/xblocks_contrib/discussion/assets/static/js/common/utils.js b/xblocks_contrib/discussion/assets/static/js/common/utils.js index 3f0a7a44..23ff8116 100644 --- a/xblocks_contrib/discussion/assets/static/js/common/utils.js +++ b/xblocks_contrib/discussion/assets/static/js/common/utils.js @@ -493,8 +493,9 @@ }; DiscussionUtil.typesetMathJax = function(element) { - if (typeof MathJax !== 'undefined' && MathJax !== null && typeof MathJax.Hub !== 'undefined') { - MathJax.Hub.Queue(['Typeset', MathJax.Hub, element[0]]); + if (typeof MathJax !== 'undefined' && MathJax !== null && typeof MathJax.startup !== 'undefined') { + MathJax.startup.promise + .then(() => MathJax.typesetPromise([element[0]])); } }; diff --git a/xblocks_contrib/discussion/assets/static/js/customwmd.js b/xblocks_contrib/discussion/assets/static/js/customwmd.js index 0f4cfcca..13d898a5 100644 --- a/xblocks_contrib/discussion/assets/static/js/customwmd.js +++ b/xblocks_contrib/discussion/assets/static/js/customwmd.js @@ -42,7 +42,7 @@ Mostly adapted from math.stackexchange.com: http://cdn.sstatic.net/js/mathjax-ed MathJaxProcessor.prototype.processMath = function(start, last, preProcess) { var block, i, j, ref, ref1; block = this.blocks.slice(start, last + 1).join("").replace(/&/g, "&").replace(//g, ">"); - if (MathJax.Hub.Browser.isMSIE) { + if (window.navigator && /msie|trident/i.test(window.navigator.userAgent)) { block = block.replace(/(%[^\n]*)\n/g, "$1
\n"); } for (i = j = ref = start + 1, ref1 = last; ref <= ref1 ? j <= ref1 : j >= ref1; i = ref <= ref1 ? ++j : --j) { diff --git a/xblocks_contrib/discussion/assets/static/js/mathjax_delay_renderer.js b/xblocks_contrib/discussion/assets/static/js/mathjax_delay_renderer.js index e7b10b40..6517dff1 100644 --- a/xblocks_contrib/discussion/assets/static/js/mathjax_delay_renderer.js +++ b/xblocks_contrib/discussion/assets/static/js/mathjax_delay_renderer.js @@ -13,8 +13,6 @@ MathJaxDelayRenderer.prototype.maxDelay = 3000; - MathJaxDelayRenderer.prototype.mathjaxRunning = false; - MathJaxDelayRenderer.prototype.elapsedTime = 0; MathJaxDelayRenderer.prototype.mathjaxDelay = 0; @@ -59,8 +57,15 @@ if (preprocessor != null) { text = preprocessor(text); } + if (typeof MathJax !== 'undefined' && MathJax !== null && typeof MathJax.typesetClear === 'function') { + MathJax.typesetClear([$(elem)[0]]); + } $(elem).html(text); // xss-lint: disable=javascript-jquery-html - return MathJax.Hub.Queue(["Typeset", MathJax.Hub, $(elem).attr("id")]); + if (typeof MathJax !== 'undefined' && MathJax !== null && MathJax.startup && + MathJax.startup.promise && typeof MathJax.typesetPromise === "function") { + return MathJax.startup.promise + .then(() => MathJax.typesetPromise([$(elem)[0]])); + } } else { if (this.mathjaxTimeout) { window.clearTimeout(this.mathjaxTimeout); @@ -70,31 +75,38 @@ renderer = (function(_this) { return function() { var curTime, prevTime; - if (_this.mathjaxRunning) { - return; - } prevTime = getTime(); if (preprocessor != null) { text = preprocessor(text); } + if (typeof MathJax !== 'undefined' && MathJax !== null && typeof MathJax.typesetClear === 'function') { + MathJax.typesetClear([_this.$buffer[0]]); + } _this.$buffer.html(text); // xss-lint: disable=javascript-jquery-html curTime = getTime(); _this.elapsedTime = curTime - prevTime; - if (typeof MathJax !== "undefined" && MathJax !== null) { + if (typeof MathJax !== "undefined" && MathJax !== null && MathJax.startup && + MathJax.startup.promise && typeof MathJax.typesetPromise === "function") { prevTime = getTime(); - _this.mathjaxRunning = true; - return MathJax.Hub.Queue(["Typeset", MathJax.Hub, _this.$buffer.attr("id")], function() { - _this.mathjaxRunning = false; - curTime = getTime(); - _this.mathjaxDelay = curTime - prevTime; - if (previewSetter) { - return previewSetter($(_this.$buffer).html()); - } else { - return $(elem).html($(_this.$buffer).html()); // xss-lint: disable=javascript-jquery-html - } - }); + return MathJax.startup.promise + .then( + () => MathJax.typesetPromise([_this.$buffer[0]]).then(() => { + curTime = getTime(); + _this.mathjaxDelay = curTime - prevTime; + if (previewSetter) { + return previewSetter($(_this.$buffer).html()); + } else { + return $(elem).html($(_this.$buffer).html()); // xss-lint: disable=javascript-jquery-html + } + }) + ); } else { - return _this.mathjaxDelay = 0; + _this.mathjaxDelay = 0; + if (previewSetter) { + return previewSetter($(_this.$buffer).html()); + } else { + return $(elem).html($(_this.$buffer).html()); // xss-lint: disable=javascript-jquery-html + } } }; })(this); From 56660c705222320fdb5182123f2bf1ae6572875f Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 29 Jun 2026 11:13:31 +0530 Subject: [PATCH 4/4] fix(mathjax): stabilize v4 xblock rendering --- .../assets/static/js/common/utils.js | 5 ++- .../problem/assets/static/js/display.js | 17 +++++--- .../spec/formula_equation_preview_spec.js | 40 +++++++++++-------- .../js/capa/src/formula_equation_preview.js | 31 ++++++++++---- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/xblocks_contrib/discussion/assets/static/js/common/utils.js b/xblocks_contrib/discussion/assets/static/js/common/utils.js index 23ff8116..cc09e13f 100644 --- a/xblocks_contrib/discussion/assets/static/js/common/utils.js +++ b/xblocks_contrib/discussion/assets/static/js/common/utils.js @@ -493,7 +493,10 @@ }; DiscussionUtil.typesetMathJax = function(element) { - if (typeof MathJax !== 'undefined' && MathJax !== null && typeof MathJax.startup !== 'undefined') { + if (typeof MathJax !== 'undefined' && MathJax !== null && + typeof MathJax.startup !== 'undefined' && MathJax.startup !== null && + typeof MathJax.startup.promise !== 'undefined' && + typeof MathJax.typesetPromise === 'function') { MathJax.startup.promise .then(() => MathJax.typesetPromise([element[0]])); } diff --git a/xblocks_contrib/problem/assets/static/js/display.js b/xblocks_contrib/problem/assets/static/js/display.js index 543ef09e..5ed836b8 100644 --- a/xblocks_contrib/problem/assets/static/js/display.js +++ b/xblocks_contrib/problem/assets/static/js/display.js @@ -906,19 +906,18 @@ jax, mathjaxPreprocessor, preprocessorTag, - target, isTexDelimited; if (!element) { element = event.target; // eslint-disable-line no-param-reassign } elid = element.id.replace(/^input_/, ""); - target = "#display_" + elid; // MathJax preprocessor is loaded by 'setupInputTypes' preprocessorTag = "inputtype_" + elid; mathjaxPreprocessor = this.inputtypeDisplays[preprocessorTag]; if (isMathJaxRefreshReady()) { - var math = document.querySelector(target); + // Use getElementById so IDs with : . etc. don't break as CSS selectors + var math = document.getElementById("display_" + elid); if (!math) { math = document.getElementById(element.id + "_preview"); } @@ -932,7 +931,8 @@ MathJax.typesetClear([math]); if (!eqn) { math.textContent = ""; - $("#" + element.id + "_dynamath").val(""); + var dynEl = document.getElementById(element.id + "_dynamath"); + if (dynEl) { dynEl.value = ""; } return; } // Determine rendering mode: @@ -955,14 +955,19 @@ if (jax) { this.updateMathML(jax, element); } - }.bind(this)); + }.bind(this)).catch(function () { + // clear hidden field on MathJax failure so stale value isn't sent + var dynEl = document.getElementById(element.id + "_dynamath"); + if (dynEl) { dynEl.value = ""; } + }); } }; Problem.prototype.updateMathML = function (jax, element) { if (isMathJaxMathMLReady()) { try { - $("#" + element.id + "_dynamath").val(MathJax.startup.toMML(jax.root)); + var dynEl = document.getElementById(element.id + "_dynamath"); + if (dynEl) { dynEl.value = MathJax.startup.toMML(jax.root); } } catch (exception) { if (!exception.restart) { throw exception; diff --git a/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js b/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js index 88c740b0..ded798ee 100644 --- a/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js +++ b/xblocks_contrib/problem/capa/static/js/capa/spec/formula_equation_preview_spec.js @@ -261,14 +261,15 @@ describe('Formula Equation Preview', function() { request_start: args[3].request_start }); - // The only request returned—textContent replaces all children, removing the img. - expect($('img.loading').length).toEqual(0); + // The only request returned—mathjax-preview span is inserted, img.loading preserved. + expect($('img.loading').length).toEqual(1); - // Clear and typeset the preview div. + // Clear and typeset the stable mathjax-preview span. var previewDiv = $('#input_THE_ID_preview')[0]; - expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); - expect(previewDiv.textContent).toEqual('\\(THE_FORMULA\\)'); - expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); + var mathSpan = previewDiv.querySelector('.mathjax-preview'); + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([mathSpan]); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([mathSpan]); }).always(done); }); @@ -296,9 +297,10 @@ describe('Formula Equation Preview', function() { '[FormulaEquationInput] Oops no mathjax for ', 'THE_FORMULA' ); - // Preview div should contain the raw LaTeX. + // Preview span should contain the raw LaTeX. var previewElement = $('#input_THE_ID_preview')[0]; - expect(previewElement.textContent).toEqual('\\(THE_FORMULA\\)'); + var mathSpan = previewElement.querySelector('.mathjax-preview'); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA\\)'); // typesetClear/typesetPromise should NOT be called (fallback path). expect(window.MathJax.typesetClear).not.toHaveBeenCalled(); @@ -327,9 +329,10 @@ describe('Formula Equation Preview', function() { }).then(function() { // Error text should be displayed. var previewDiv = $('#input_THE_ID_preview')[0]; - expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); - expect(previewDiv.textContent).toEqual('\\(\\text{OOPSIE}\\)'); - expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); + var mathSpan = previewDiv.querySelector('.mathjax-preview'); + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([mathSpan]); + expect(mathSpan.textContent).toEqual('\\(\\text{OOPSIE}\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([mathSpan]); expect($img.css('visibility')).toEqual('hidden'); }).then(done); }); @@ -374,13 +377,14 @@ describe('Formula Equation Preview', function() { this.callbacks[0](this.responses[0]); expect(window.MathJax.typesetClear.calls.count()).toEqual(1); expect(window.MathJax.typesetPromise.calls.count()).toEqual(1); - expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_0\\)'); + var mathSpan = previewDiv.querySelector('.mathjax-preview'); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA_0\\)'); expect($img.css('visibility')).toEqual('visible'); this.callbacks[1](this.responses[1]); expect(window.MathJax.typesetClear.calls.count()).toEqual(2); expect(window.MathJax.typesetPromise.calls.count()).toEqual(2); - expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA_1\\)'); expect($img.css('visibility')).toEqual('hidden'); }); @@ -394,7 +398,8 @@ describe('Formula Equation Preview', function() { this.callbacks[1](this.responses[1]); expect(window.MathJax.typesetClear.calls.count()).toEqual(1); expect(window.MathJax.typesetPromise.calls.count()).toEqual(1); - expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); + var mathSpan = previewDiv.querySelector('.mathjax-preview'); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA_1\\)'); expect($img.css('visibility')).toEqual('hidden'); window.MathJax.typesetClear.calls.reset(); @@ -416,9 +421,10 @@ describe('Formula Equation Preview', function() { // Error message waiting to be displayed, then a good response arrives first. this.callbacks[1](this.responses[1]); - expect(window.MathJax.typesetClear).toHaveBeenCalledWith([previewDiv]); - expect(previewDiv.textContent).toEqual('\\(THE_FORMULA_1\\)'); - expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([previewDiv]); + var mathSpan = previewDiv.querySelector('.mathjax-preview'); + expect(window.MathJax.typesetClear).toHaveBeenCalledWith([mathSpan]); + expect(mathSpan.textContent).toEqual('\\(THE_FORMULA_1\\)'); + expect(window.MathJax.typesetPromise).toHaveBeenCalledWith([mathSpan]); // Make sure that the error doesn't show up later. window.MathJax.typesetClear.calls.reset(); diff --git a/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js b/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js index 81eefd10..db533bd9 100644 --- a/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js +++ b/xblocks_contrib/problem/capa/static/js/capa/src/formula_equation_preview.js @@ -169,20 +169,37 @@ formulaEquationPreview.enable = function() { } function display(latex) { + var previewElement = inputData.$preview[0]; + // Preserve only span.mathjax-preview and img.loading; drop everything else + var mathSpan = previewElement.querySelector('.mathjax-preview'); + var img = previewElement.querySelector('img.loading'); + var childNodes = previewElement.childNodes; + for (var i = childNodes.length - 1; i >= 0; i--) { + var node = childNodes[i]; + if (node.nodeType !== 1 || (node !== mathSpan && node !== img)) { + previewElement.removeChild(node); + } + } + if (!mathSpan) { + mathSpan = document.createElement('span'); + mathSpan.className = 'mathjax-preview'; + if (img) { + previewElement.insertBefore(mathSpan, img); + } else { + previewElement.appendChild(mathSpan); + } + } if (typeof MathJax !== 'undefined' && MathJax.startup && MathJax.startup.promise) { MathJax.startup.promise.then(function() { - var previewElement = inputData.$preview[0]; - MathJax.typesetClear([previewElement]); - // set text directly with delimiters; v3/v4 has no getAllJax/Text API - previewElement.textContent = '\\(' + (latex || '') + '\\)'; - return MathJax.typesetPromise([previewElement]); + MathJax.typesetClear([mathSpan]); + mathSpan.textContent = '\\(' + (latex || '') + '\\)'; + return MathJax.typesetPromise([mathSpan]); }).catch(function(err) { console.log('[FormulaEquationInput] MathJax error for "' + latex + '":', err); }); } else if (latex) { console.log('[FormulaEquationInput] Oops no mathjax for ', latex); - var previewElement = inputData.$preview[0]; - previewElement.textContent = '\\(' + latex + '\\)'; + mathSpan.textContent = '\\(' + latex + '\\)'; } }