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); + }); +});