mathpix-markdown-it
Version:
Mathpix-markdown-it is an open source implementation of the mathpix-markdown spec written in Typescript. It relies on the following open source libraries: MathJax v3 (to render math with SVGs), markdown-it (for standard Markdown parsing)
213 lines (152 loc) • 13.2 kB
Markdown
# PR: Global state cleanup and performance improvements
Status: Implemented
Owner: @OlgaRedozubova
---
## Context
Audit of the codebase revealed multiple module-level mutable state variables that accumulate data across documents in long-lived processes. Several hot-path data structures used `Array` + `findIndex()` for O(n) lookups that should be O(1) Maps, and `getInlineCodeListFromString` result was scanned with `.find()` per character — O(n × m).
Two additional memory-retention bugs were uncovered during this work:
- `mdPluginTOC` stored the parse `state` in a module-level `gstate` variable so the TOC render rule could reach the top-level token list. That reference was never cleared and kept the ENTIRE token tree pinned across unrelated parses until the next `md.use()` call. On tabular-heavy documents this alone retained hundreds of megabytes.
- `coreInline` rebound `state.env = Object.assign({}, ...)` inside the inline loop. That desynced state.env from the env reference the caller of `md.render(src, env)` still held, so parse-time mutations (including our TOC / cache bookkeeping) became invisible to render rules that received the original env.
---
## Goal
Fix memory leaks from module-level state, improve lookup performance for tabular-related data structures, add per-parse cleanup guarantees for reused md instances, and preserve the caller's env reference contract between parse and render.
---
## Non-Goals
- Highlight dedup optimization (order-dependent behavior is pre-existing, changing risks regression without dedicated test coverage).
- Migrating `mathTable` / `subTabular` / `extractedCodeBlocks` to `state.env` (they now work correctly with the two-hook scheme — `reset_tabular_state` at the start of parse plus `cleanup_tabular_state` at the end).
- Browser-only concerns (click handlers, setInterval, context menu listeners).
---
## Current Behavior (before)
- `Clear*` functions run once at `md.use()` time. If the md instance is reused for multiple documents, module-level state accumulates.
- `mdPluginTOC` pins every parsed token tree on module-level `gstate` indefinitely.
- `coreInline` replaces `state.env` with a fresh object per inline token, breaking the shared-env contract.
- `diagboxTable` has no cleanup function at all — unbounded growth.
- `subTabular`, `extractedCodeBlocks` use `Array` + `findIndex()` — O(n) per lookup.
- `findEndMarker()` calls `.find()` on inline code list for every character position — O(n × m).
- `labelsList` uses `Array` for all lookups — O(n) per `find`/`findIndex`.
- `makeTagRegexes` creates 6 new `RegExp` objects per HTML block.
- `SetItemizeLevelTokens` clones the entire `md.options` object on every call.
---
## Desired Behavior (after)
- Every `md.parse()` begins with a `reset_tabular_state` core-ruler hook that clears module-level tabular state. A second `cleanup_tabular_state` hook runs at the end of the core pipeline and drops parse-only caches (`subTabular`, `mathTable`, `extractedCodeBlocks`, `diagboxTable`, and the column-style intern cache) so they're not retained through render.
- `mdPluginTOC` stores the token list on `state.env[TOC_ENV_KEY]`, so the reference is released together with env when the parse ends. The TOC render rule reads the list from env instead of the removed `gstate`.
- `coreInline` mutates `state.env` in place for the inline pass and derives a private `inlineEnv` for the nested `inline.parse()` call. The caller's env binding stays intact for downstream render rules.
- All lookup data structures use `Map` for O(1) access.
- Inline code position check is O(1) via `Set<number>`.
- Tag-regex objects are cached and reused; `g`-flag regexes are matched with `.match()` to avoid `lastIndex` corruption across calls.
- `SetItemizeLevelTokens` saves/restores only `outMath` with `try/finally`.
---
## Constraints / Invariants
- `reset_tabular_state` and `cleanup_tabular_state` both respect `renderElement.startLine` — partial renders skip cleanup so the enclosing re-parse still sees the cached state.
- `labelsByKey` / `labelsByUuid` survive `cleanup_tabular_state` — labels are read by render rules for `\ref{}` / `\eqref{}` resolution.
- Highlight rendering files (`render-rule-highlights.ts`, `common.ts`) are NOT modified — reverted to master to avoid behavioural regression.
- `labelsList` export kept for deep-import backward compatibility (deprecated, exposed as a `Proxy` that returns a version-cached snapshot of `labelsByKey.values()` — snapshot is rebuilt only when `addIntoLabelsList` / `clearLabelsList` bumps the version. Supports `.length`, iteration, and Array methods. Mutations (`.push`, index assignment) target the throwaway target array and are effectively ignored).
### Per-parse cross-plugin state reset
Sub-plugins (TOC, theorem, labels, footnotes, list-env, text counters) each maintain module-level state that was previously cleared only at `md.use` time or inside the `initMathpixMarkdown.parse` / `renderer.render` wrappers. Direct callers of `markdownIt().use(mathpixMarkdownPlugin)` that reuse one md instance across documents saw drift:
- TOC slug registry (`slugsTocItems`) accumulated → second parse of the same document produced `#introduction-2`, `#introduction-3`, …
- `theoremEnvironments` / `environmentsCounter` / `counterProof` kept stale theorem numbers across documents.
- `labelsByKey` / `labelsByUuid` still held prior document's `\label{}` entries, so `\ref{}` could resolve to the wrong block.
- `mmd_footnotes_list` carried old footnote items.
- `itemizeLevelTokens` held parsed token trees from prior `\renewcommand{\labelitemi}` — visible as stale marker tokens in the next document.
- `resetTextCounter` / size counter / `MathJax.Reset()` (equation numbering) were also wrapper-only.
- `ParseErrorList` was the worst offender: `ClearParseErrorList()` was defined but never called anywhere — tabular parse errors grew monotonically.
A new core-ruler hook `reset_mmd_global_state` (registered `before('normalize')` from `mathpixMarkdownPlugin`) calls all the reset functions at the start of every `md.parse()`. It respects `renderElement.startLine` so partial re-renders don't tear down the enclosing parse's cross-reference state. The same implementation is exported as `resetMmdGlobalState()` from the package root so one-shot converters (e.g. DOCX export) can release module-level state immediately after render without waiting for the next parse.
### Additional parse-only retention fixes
- `cleanup_math_cache` core-ruler hook (pushed, end of pipeline) clears `state.env.__mathpix`. Previously the per-parse math dedup cache was only initialized, never released — MathJax html/svg strings for every unique expression stayed on env until the caller dropped it.
- `mdPluginTOC.grab_state` stashes `state.tokens` on `state.env[TOC_ENV_KEY]` only when the document actually used `[[toc]]`, detected by a one-pass scan of inline-token children for `toc_body`. Documents without `[[toc]]` no longer retain the whole token tree on env.
### `markdownToHtmlPipelineSegments` balance fix
The segments renderer tracked a single `pendingCloseTag` + `pendingLevel` pair, so a nested same-type same-level `_open` terminated the segment mid-block. Concrete case: md-theorem wraps an inner `paragraph_open` at level 0 inside the outer `paragraph_open` class `theorem_block` — the first `paragraph_close` closed the segment prematurely, leaving `<div><div>...</div>` in one segment and `</div></div>...` in the next. Added a `pendingDepth` counter so nested opens of the same type at the same level increment depth; the segment closes only when depth drops back to zero. Regression test in `tests/_html-segments.js` covers 38 scenarios across all block rules from `mmdRules.ts`.
---
## Done When
- [x] Per-parse `reset_tabular_state` hook clears all tabular module-level state at the start of parse
- [x] Post-parse `cleanup_tabular_state` hook drops parse-only caches (`subTabular`, `mathTable`, `extractedCodeBlocks`, `diagboxTable`, column-style intern cache) at the end of the core pipeline
- [x] `mdPluginTOC` stores the token list on `state.env[TOC_ENV_KEY]`; module-level `gstate` removed
- [x] `coreInline` mutates `state.env` in place instead of rebinding it
- [x] `diagboxTable` has `ClearDiagboxTable()` + `diagboxById` reverse Map
- [x] `subTabular` converted from Array to Map
- [x] `extractedCodeBlocks` converted from Array to Map
- [x] `labelsByKey` + `labelsByUuid` Map indexes for O(1) lookups
- [x] `buildInlineCodePositionSet()` → `Set<number>` for O(1) position check
- [x] `tagRegexCache` memoization + `.test()` → `.match()` fix for g-regex `lastIndex`
- [x] `utf8Encode` uses `parts[]` + `join()` instead of `+=`
- [x] `SetItemizeLevelTokens` saves/restores only `outMath` with `try/finally`
- [x] All 3,286 tests pass
---
## Architecture
### Two-hook cleanup scheme
```
core.ruler.before('normalize', 'reset_tabular_state', resetHook)
// clears state before parsing starts (defensive — also catches the case
// where the previous parse threw)
core.ruler.push( 'cleanup_tabular_state', cleanupHook)
// drops parse-only caches after the last core rule — they're never read
// during render
```
### TOC token-list handoff
Old (leaked entire token tree):
```ts
let gstate; // module scope
md.core.ruler.push('grab_state', (state) => { gstate = state; });
// ...
const dataToc = getTocList(0, gstate.tokens, ...);
```
New (per-parse scope):
```ts
const TOC_ENV_KEY = '__mathpix_toc_tokens';
md.core.ruler.push('grab_state', (state) => {
if (!state.env) state.env = {};
state.env[TOC_ENV_KEY] = state.tokens;
});
// ...
const allTokens = (env && env[TOC_ENV_KEY]) || tokens || [];
const dataToc = getTocList(0, allTokens, ...);
```
### coreInline env preservation
Old (broke caller env):
```ts
state.env = Object.assign({}, {...state.env}, {
currentTag, ...envToInline,
});
state.md.inline.parse(token.content, state.md, state.env, token.children);
```
New (preserves caller env binding):
```ts
const inlineEnv = Object.assign({}, state.env, {currentTag}, envToInline);
state.env.currentTag = currentTag;
if (envToInline && typeof envToInline === 'object') {
Object.assign(state.env, envToInline);
}
state.md.inline.parse(token.content, state.md, inlineEnv, token.children);
```
The same pattern is applied in the deeper recursive walker `walkInlineInTokens` (used by `footnote_latex` / `tabular` deep-walk): it now also builds a private `inlineEnv` per token and mutates `state.env` in place, rather than rebinding it.
---
## Memory impact
Benchmark document: 16 MB MMD with 13,713 tabular blocks, ~479K `<td>` cells, and ~49K inline math expressions.
### Full SVG+HTML render
| Stage | Before | After | Δ |
|-----------------------------|--------:|--------:|-------------:|
| Peak heap (html held) | 2597 MB | 778 MB | −1819 (−70%) |
| Heap after releasing html | 1887 MB | 68 MB | −1819 (−96%) |
The bulk of the reduction comes from the TOC `gstate` / `coreInline` env fix: without it the token tree stayed pinned across parses and dominated the retained heap. The `cleanup_tabular_state` hook removes the remaining ~45 MB of parse-only caches that used to survive into the render phase.
---
## Files Changed
| File | Change |
|------|--------|
| `src/markdown/mdPluginTableTabular.ts` | `clearTabularState()` + two core-ruler hooks: `reset_tabular_state` (before normalize) and `cleanup_tabular_state` (push, end of pipeline) |
| `src/markdown/mdPluginTOC.ts` | `TOC_ENV_KEY` on `state.env` replaces module-level `gstate`; render rule reads from env |
| `src/markdown/md-inline-rule/core-inline.ts` | In-place `state.env` mutation, derived `inlineEnv` for nested `inline.parse` |
| `src/markdown/md-block-rule/begin-tabular/sub-cell.ts` | `ClearDiagboxTable()`, `diagboxById` reverse Map, `buildInlineCodePositionSet` in `extractNextBraceContent` |
| `src/markdown/md-block-rule/begin-tabular/sub-tabular.ts` | Array → Map, all `findIndex` → `.get()` |
| `src/markdown/md-block-rule/begin-tabular/sub-code.ts` | Array → Map |
| `src/markdown/md-block-rule/begin-tabular/sub-math.ts` | Single `tail` slice in `getMathTableContent` |
| `src/markdown/common.ts` | `buildInlineCodePositionSet()`, `findEndMarker` uses `Set.has()` |
| `src/markdown/common/labels.ts` | `labelsByKey` + `labelsByUuid` Map indexes |
| `src/markdown/md-block-rule/mmd-html-block.ts` | `tagRegexCache` memoization, `.test()` → `.match()` for g-regex |
| `src/markdown/md-svg-to-base64/base64.ts` | `parts[]` + `join()` in utf8Encode |
| `src/markdown/md-latex-lists-env/re-level.ts` | Save/restore only `outMath` with `try/finally` + `beginCacheBypass`/`endCacheBypass` |
| `src/markdown/mdPluginSeparateForBlock.ts` | Removed — dead code, never imported anywhere |
---
## Testing
- All 3,286 tests pass
- Per-parse cleanup verified: 100 sequential parses on the same md instance show no memory growth
- Highlight rendering files reverted to master — zero risk of behavioural regression