Skip to content

fix: bump math-expressions so odeSystem works in the browser - #1667

Merged
dqnykamp merged 3 commits into
Doenet:mainfrom
dqnykamp:fix/ode-numeric-not-defined
Aug 7, 2026
Merged

fix: bump math-expressions so odeSystem works in the browser#1667
dqnykamp merged 3 commits into
Doenet:mainfrom
dqnykamp:fix/ode-numeric-not-defined

Conversation

@dqnykamp

@dqnykamp dqnykamp commented Aug 7, 2026

Copy link
Copy Markdown
Member

Any document containing an <odeSystem> renders as a red "numeric is not defined" banner instead of a document, and any graph of a solution draws no curve. This bumps math-expressions to 2.0.0-alpha95, which fixes it, and adds a Cypress regression test.

The bug

dopri, the ODE solver, comes from numeric.js, bundled inside math-expressions. numeric builds most of its helpers at load time with the Function constructor, and the generated bodies reference a bare numeric:

if(typeof _s === "undefined") _s = numeric.dim(x);

Functions made with Function(...) are evaluated in global scope, so that reference resolves only if numeric is a property of the global object. numeric registered itself there through a Node-ism:

if (typeof global !== "undefined") { global.numeric = numeric; }

Neither a browser main thread nor a web worker has global, so the assignment was skipped and every generated helper threw the first time it was called — and dopri reaches them immediately.

Both sides of the worker boundary go through that path, and both failed:

  • the worker computing ODESystem's numericalSolutions (packages/doenetml-worker-javascript/src/components/dynamicalSystems/ODESystem.js), which is what killed the whole document;
  • the main-thread renderer sampling the same solution to draw the curve, via createFunctionFromDefinitionreturnODESolutionFunction in packages/utils/src/components/function.ts.

On the main thread the error reads dim is not a function rather than numeric is not defined, because the virtual keyboard has a button with id="numeric" and the named-element global makes the slot look occupied while still being useless to the generated code.

window.global = window.global || window in packages/doenetml/src/doenetml.tsx (the react-mathquill hack) does not help: it runs in the module body, after every import — math-expressions included — has already been evaluated.

The fix

Upstream, in Doenet/math-expressions#86: lib/mathjs.js registers numeric itself, which covers every runtime. Released as 2.0.0-alpha95. No source change was needed here — this is the version bump (the root package.json and packages/utils/package.json, which pinned alpha93) plus a test.

The regression test

packages/test-cypress/cypress/e2e/dynamicalsystem/odesystem.cy.js (@group2) loads an <odeSystem> with both a $$f(1) evaluation and a graphed solution, then asserts the rendered value and that a curve was actually plotted — one assertion per side of the worker boundary.

It is a Cypress spec rather than a Vitest one for a specific reason worth recording: Vitest cannot catch this class of bug. It runs under Node, where global exists and numeric registers itself, so packages/doenetml-worker-javascript/src/test/dynamicalsystem/odesystem.test.ts passed cleanly through the entire lifetime of the bug. Only a real browser reproduces it.

Verified in both directions with a clean rebuild each way (npm run clean:cache between them — wireit does not treat node_modules as a build input, so a dependency bump alone will not invalidate its cache):

math-expressions result
2.0.0-alpha94 Expected to find element: #pVal, but never found it — the document is the error banner
2.0.0-alpha95 ✓ passes

The failure screenshot on alpha94 shows the "numeric is not defined" banner in place of the document.

Other checks

  • cypress/e2e/dynamicalsystem/* — 3 passing (the new spec plus both cobwebpolyline tests).
  • npm run test -w @doenet/doenetml-worker-javascript -- run src/test/dynamicalsystem — 16 passing, 5 files.
  • Verified by hand in the dev server: the value renders (518.99), the solution curve plots, and globalThis.numeric.dim is a function on the main thread.

🤖 Generated with Claude Code

dqnykamp and others added 3 commits August 7, 2026 13:41
… browser

Any document containing an <odeSystem> rendered as a red "numeric is not
defined" banner instead of a document, and any graph of a solution drew
no curve.

`dopri`, the solver, comes from numeric.js, bundled inside
math-expressions. numeric 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
registered itself there solely through Node's `global`, which neither a
browser main thread nor a web worker has, so every generated helper threw
on first call — and `dopri` reaches them immediately.

Both sides of the worker boundary went through that path: the worker
evaluating `$$f(1)`, and the main-thread renderer sampling the same
solution to draw the curve. math-expressions 2.0.0-alpha95 registers
numeric itself, which covers both.

No source change was needed, so the regression test is a Cypress spec
rather than a Vitest one: Vitest runs under Node, where `global` exists
and numeric registers itself, so the suite stayed green through the whole
bug. Only a real browser reproduces it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The curve assertion took the longest `d` string among every path in the
board and required it to exceed 5000 characters. Measured, the curve is
23418 characters and the x-axis tick path is 4830 — a 3% margin, and the
axis path grows with the tick count, so a different viewport could clear
the floor with no curve drawn at all.

Only the axes and their ticks are stroked in the axis color, so
excluding that color leaves the curve alone; the assertion now counts
its segments, which separates a sampled curve from anything else by an
order of magnitude.

Also drop the changeset's closing paragraph on why the regression test
is Cypress rather than Vitest: that is repo-internal test strategy, not
user-visible behavior, and it is recorded in the spec's own comment and
the PR description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRxvB5p6GSwm2jGD1pMBvu
The comment restated the whole bug narrative that the changeset and PR
description already carry. Keep what a reader of this spec needs — why
`numeric` has to be global, why the test cannot be a Vitest one, and why
both sides of the worker boundary are asserted — and drop the rest. Also
drop an `async` on the `cy.window()` callback that never awaited anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRxvB5p6GSwm2jGD1pMBvu
@dqnykamp
dqnykamp merged commit d21fbc5 into Doenet:main Aug 7, 2026
24 checks passed
@dqnykamp
dqnykamp deleted the fix/ode-numeric-not-defined branch August 7, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant