From a4d1ffd7759365f9186dc6be803a90553bf81af9 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 7 Aug 2026 13:03:09 -0500 Subject: [PATCH] Keep numeric.js's functions on `me.math` in the js-compat drop-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `me.math` in the original library was math.js *plus* numeric.js: math.import(numeric, { wrap: true, silent: true }); The drop-in re-exports a bare math.js instance, so every name numeric contributed and math.js has no equivalent of is gone. DoenetML's `` integrates with `me.math.dopri`, which is one of them — so moving to this package turns a working ODE into `dopri is not a function`. Import numeric here too; `silent` keeps math.js's own implementations for the names both define. Registering numeric on the global object is part of making it usable. numeric.js builds most of its helpers at load time with the `Function` constructor, and the generated bodies reference a bare `numeric` — e.g. `if(typeof _s === "undefined") _s = numeric.dim(x);`. Functions made that way are evaluated in global scope, so that reference resolves only if `numeric` is a property of the global object. numeric.js puts it there itself, but only through Node's `global`, which a browser and a web worker both lack; there, every generated helper throws `ReferenceError: numeric is not defined` on first call, `dopri` included. The assignment is unconditional rather than guarded on `globalThis.numeric === undefined`: on a page holding an element whose id is `numeric`, the named-element global makes the slot look occupied while still being useless to the generated code. `spec/quick_mathjs-numeric.spec.ts` deletes `global` before the first import of `lib/mathjs`, reproducing the shape a browser sees, then integrates x' = x from x(0) = 1 and checks it lands on e. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 3 +- .../math-expressions-js-compat/lib/mathjs.ts | 24 +++++++ .../math-expressions-js-compat/package.json | 3 +- .../spec/quick_mathjs-numeric.spec.ts | 64 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/math-expressions-js-compat/spec/quick_mathjs-numeric.spec.ts diff --git a/package-lock.json b/package-lock.json index 2f8467bb..d4acdadb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3251,7 +3251,8 @@ "license": "(GPL-3.0 OR Apache-2.0)", "dependencies": { "math-expressions-rs-wasm": "*", - "mathjs": "^15.2.0" + "mathjs": "^15.2.0", + "numeric": "1.2.6" }, "devDependencies": { "underscore": "^1.13.6", diff --git a/packages/math-expressions-js-compat/lib/mathjs.ts b/packages/math-expressions-js-compat/lib/mathjs.ts index 602400a4..3fbbc2c4 100644 --- a/packages/math-expressions-js-compat/lib/mathjs.ts +++ b/packages/math-expressions-js-compat/lib/mathjs.ts @@ -2,7 +2,31 @@ // as `me.math` / `../lib/mathjs`. We re-export the npm `mathjs` default so specs // that reach for it keep working. import * as mathjs from "mathjs"; +import numeric from "numeric"; const math = mathjs.create ? mathjs.create(mathjs.all) : mathjs; +// The original `me.math` carried numeric.js's functions alongside math.js's, and +// consumers reach for the ones math.js has no equivalent of: DoenetML's +// `` integrates with `me.math.dopri`. Keep importing them, or this +// drop-in silently drops those names. `silent` skips the ones math.js already +// defines, leaving math.js's own implementations in place. +(math as mathjs.MathJsInstance).import(numeric, { wrap: true, silent: true }); + +// numeric.js builds most of its helpers at load time with the `Function` +// constructor, and the generated bodies reference a bare `numeric` (e.g. +// `_s = numeric.dim(x)`). Functions made that way are evaluated in global scope, +// so the reference resolves only if `numeric` is a property of the global +// object. numeric.js puts it there itself, but only through `global`, which +// exists in Node and nowhere else — so in a browser or a web worker every +// generated helper throws `ReferenceError: numeric is not defined` the first +// time it is called, `dopri` included. Publish it ourselves to cover every +// runtime. +// +// Assigned unconditionally rather than guarded on `globalThis.numeric === +// undefined`: on a page holding an element whose id is `numeric`, the +// named-element global makes the slot look occupied while still being useless +// to the generated code. +(globalThis as Record).numeric = numeric; + export default math; diff --git a/packages/math-expressions-js-compat/package.json b/packages/math-expressions-js-compat/package.json index 06d9aced..217fb6f9 100644 --- a/packages/math-expressions-js-compat/package.json +++ b/packages/math-expressions-js-compat/package.json @@ -23,7 +23,8 @@ }, "dependencies": { "math-expressions-rs-wasm": "*", - "mathjs": "^15.2.0" + "mathjs": "^15.2.0", + "numeric": "1.2.6" }, "devDependencies": { "underscore": "^1.13.6", diff --git a/packages/math-expressions-js-compat/spec/quick_mathjs-numeric.spec.ts b/packages/math-expressions-js-compat/spec/quick_mathjs-numeric.spec.ts new file mode 100644 index 00000000..a3a13dfd --- /dev/null +++ b/packages/math-expressions-js-compat/spec/quick_mathjs-numeric.spec.ts @@ -0,0 +1,64 @@ +// `me.math` in the original library was math.js *plus* numeric.js, and callers +// reach for names only numeric provides — DoenetML's `` integrates +// with `me.math.dopri`. This covers that the drop-in still carries them, and +// that they work outside Node. +// +// numeric.js builds most of its helpers at load time with the `Function` +// constructor, and the generated bodies reference a bare `numeric`, which +// resolves only against the global object. numeric.js registers itself there +// through Node's `global`; under Node that would happen with or without +// `lib/mathjs` doing it too, so `global` is deleted before the first import of +// the module — reproducing the shape a browser or a web worker sees. +// +// Hence no static import of `../lib/mathjs` here: it has to load *after* the +// deletion, inside the test. + +type Dopri = ( + t0: number, + t1: number, + x0: number[], + f: (t: number, x: number[]) => number[], + tolerance: number, + maxIterations: number, +) => { at: (t: number) => number[] }; + +describe("numeric functions on me.math", function () { + let hadGlobal: boolean, savedGlobal: unknown; + let hadNumeric: boolean, savedNumeric: unknown; + + beforeEach(function () { + hadGlobal = "global" in globalThis; + savedGlobal = (globalThis as Record).global; + hadNumeric = "numeric" in globalThis; + savedNumeric = (globalThis as Record).numeric; + }); + + afterEach(function () { + const g = globalThis as Record; + if (hadGlobal) g.global = savedGlobal; + else delete g.global; + if (hadNumeric) g.numeric = savedNumeric; + else delete g.numeric; + }); + + it("keeps the names math.js has no equivalent of, with no `global`", async function () { + const g = globalThis as Record; + delete g.global; + delete g.numeric; + + const { default: math } = await import("../lib/mathjs"); + + // The registration `lib/mathjs` makes on numeric's behalf. + expect(typeof g.numeric).toBe("object"); + expect(typeof (g.numeric as Record).dim).toBe("function"); + + // `dopri` has no math.js equivalent, and it reaches numeric's generated + // `add`/`mul`/`sub` helpers — so it throws `ReferenceError: numeric is not + // defined` without that registration. x' = x from x(0) = 1 integrates to e. + // Cast because it comes from numeric, so math.js's types do not name it. + const { dopri } = math as unknown as { dopri: Dopri }; + expect(typeof dopri).toBe("function"); + const solution = dopri(0, 1, [1], (t, x) => [x[0]], 1e-6, 1000); + expect(solution.at(1)[0]).toBeCloseTo(Math.E, 5); + }); +});