Skip to content

perf(static): vendor KaTeX and Mermaid, and load them on first use - #5994

Open
o3LL wants to merge 3 commits into
odysseus-dev:devfrom
o3LL:fix/2705-vendor-lazy-katex-mermaid
Open

perf(static): vendor KaTeX and Mermaid, and load them on first use#5994
o3LL wants to merge 3 commits into
odysseus-dev:devfrom
o3LL:fix/2705-vendor-lazy-katex-mermaid

Conversation

@o3LL

@o3LL o3LL commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

static/index.html loaded KaTeX (CSS + JS) and Mermaid from cdn.jsdelivr.net on every page load. For self-hosted software that is three problems at once: an air-gapped install renders no math and no diagrams, every session announces its IP/User-Agent/Referer to a third party, and ~985 KB crosses the wire on every load even though most chats contain neither a formula nor a diagram.

This vendors both into static/lib/ (following the existing highlight.min.js convention) and then loads them lazily, on the first formula or ```mermaid fence. Measured with the Resource Timing API on a cold profile: third-party requests per load 3 to 0, and JS bytes per load 3,102,141 to 2,098,634, a saving of 1,003,507. Rendered output is unchanged.

This revives #290, which both @pewdiepie-archdaemon and @RaresKeY called mergeable but which stalled on three follow-ups. All three are addressed here (see "Review points from #290" below). Vendoring also pins Mermaid, which was floating on the 11 tag, to 11.16.1.

Target branch

  • This PR targets dev, not main. All PRs land in dev; main is curated by the maintainer at each release. If your PR is on main by accident, click "Edit" on this PR and change the base.

Linked Issue

Part of #2705

(Part of, not Fixes: #2705 also covers the docs/index.html external avatar and dropping cdn.jsdelivr.net from the CSP. Neither is in this PR, and the CSP entry has to stay while Pyodide still loads from the CDN. Supersedes #290 and #284.)

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)
  • New feature (non-breaking — adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

Checklist

Changes

Two commits, reviewable separately:

1. fix(static): vendor KaTeX and Mermaid instead of loading them from a CDN is behaviour-neutral. The three <head> URLs become local paths; nothing else moves.

  • static/lib/mermaid.min.js (11.16.1) and static/lib/katex/ (0.16.22: katex.min.js, katex.min.css, fonts/*.woff2). KaTeX gets its own directory because its stylesheet resolves fonts via a relative url(fonts/…), so the vendored CSS needs no rewrite.
  • Only the .woff2 fonts ship (20 files, 296 KB), matching static/fonts/, which is already woff2-only. A browser that supports woff2 never requests the .woff/.ttf alternatives the stylesheet also lists. Verified in the running app: only the 2 fonts a given formula needs are fetched.
  • Licence texts in licenses/, following the convention already used for OpenDyslexic and DeepResearch, linked from ACKNOWLEDGMENTS.md.
  • .gitattributes: static/lib/** -whitespace linguist-vendored.

2. perf(markdown): load KaTeX and Mermaid on first use, not on every page load is the ~980 KB.

  • renderMermaid() checks for an unprocessed fence before touching the network, and re-queries the DOM after the load, so a diagram whose message body was replaced mid-stream still renders.
  • mdToHtml() is synchronous, so it cannot await KaTeX. When KaTeX is not loaded yet it banks the math source in an inert <span class="ody-math-pending"> and schedules a flush that loads the library and swaps the placeholders in. Once KaTeX is in, it typesets inline exactly as before, so callers that never call a render helper still get their math and the existing math tests pass unchanged.
  • Both loaders memoise the promise, not the module: concurrent callers share one fetch, and a double trigger cannot start two loads. A failed load clears the memo so the next formula retries instead of being poisoned for the session.
  • Degradation: if a library never loads, math shows as readable source text and a diagram as its fence contents, rather than as nothing.

One implementation note worth a reviewer's eye. The flush is scheduled with setTimeout, not requestAnimationFrame. rAF is throttled to a stop in a background tab and does not fire at all in a headless browser, which left math sitting as plain source until the tab was focused. There is a regression test pinning it.

Review points from #290

  • Licence/notice files travel with the bundles (@pewdiepie-archdaemon). Added as licenses/KaTeX-MIT-LICENSE.txt and licenses/Mermaid-MIT-LICENSE.txt, linked from the ACKNOWLEDGMENTS.md table. I used licenses/ rather than a new static/lib/THIRD_PARTY_NOTICES.md because that is the convention the repo already has.

  • git diff --check fails on the vendored Mermaid bundle (@pewdiepie-archdaemon). Fixed via .gitattributes as @phetzy proposed, rather than stripping bytes and desyncing the bundle from the published npm artifact. git diff --check origin/dev..HEAD is now clean; it reports 30 trailing-whitespace lines without it.

  • Add the renderer assets to PRECACHE and bump the cache name (@RaresKeY). CACHE_NAME is now odysseus-v378-lazy-katex-mermaid, and katex.min.js, katex.min.css and all 20 KaTeX_*.woff2 are precached (verified: 70 cache entries after install, up from 48). There is also the source-shape test you asked for, covering the app-shell /static/lib/… references and every font URL the KaTeX stylesheet resolves.

    One deliberate deviation, @RaresKeY, so please push back if you disagree. Mermaid is not precached. At 3.57 MB it would be re-downloaded on every CACHE_NAME bump (we are at v376 already), which would spend the entire saving this PR just bought, on a library most sessions never touch. It is picked up by the existing cache-first /static/* rule the first time a diagram renders, which is also the first moment it matters offline. Your original comment was written against feat(offline): vendor KaTeX/Mermaid + strip template CDN images for self-host #290, where both libraries still loaded eagerly. Happy to add it if you would still rather have it.

Out of scope

  • Pyodide in codeRunner.js: 13.8 MB of binaries (pyodide.asm.wasm alone is 10.1 MB), and vendoring would not fix it. The runtime is already broken because the CSP has no 'wasm-unsafe-eval', so WebAssembly.instantiateStreaming is refused and the panel hangs on "Loading Python runtime…" forever. Pyodide also calls setCdnUrl(…) internally after boot, so loadPackage/micropip would still hit the CDN. Filing separately.
  • docs/index.html external avatar and dropping cdn.jsdelivr.net from the CSP: both are in Vendor CDN assets for offline/self-hosted mode #2705, and the CSP entry has to stay while Pyodide is on the CDN.

How to Test

python3.11 -m venv venv && ./venv/bin/python -m pip install -r requirements.txt
mkdir -p data
CHROMADB_PORT=1 ./venv/bin/python -m pytest -q
./venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 7099
  1. Nothing loads eagerly. Open the app with a cleared cache. In DevTools, performance.getEntriesByType('resource').filter(r => /katex|mermaid/.test(r.name)) is [], and window.katex / window.mermaid are both undefined. There are zero requests to any third-party host.
  2. Math renders. Send or open a message containing $x^2 + y^2 = z^2$ and $$\frac{a}{b} = c$$. Both typeset. Re-run the filter above: katex.min.js, katex.min.css and the 2 fonts that formula needs are now there, all 200 from /static/lib/katex/….
  3. Diagrams render. A ```mermaid fence (e.g. graph TD; A[Start] --> B[End];) draws an SVG, and /static/lib/mermaid.min.js appears, only now and not at page load.
  4. Byte proof. Compare performance.getEntriesByType('resource').reduce((n, r) => n + r.encodedBodySize, 0) on this branch vs dev, on a cold profile with the service worker unregistered and caches cleared. Use Resource Timing rather than the HAR: with the SW active, a warm reload reports every entry as an identical 694-byte body.
  5. Whitespace gate. git diff --check origin/dev..HEAD is clean.

What I ran

  • Full suite: 4913 passed, 2 failed, 4 skipped in 126 s. Both failures (test_glob_confined_e2e, test_real_socket_falls_back_from_dead_first_to_live_second) are the known macOS environment ones and reproduce identically on unmodified dev. I checked with the branch stashed.
  • node --check on static/js/markdown.js, static/sw.js and both vendored bundles; python -m compileall on app.py core routes src services scripts tests.
  • Ran the app and exercised it end-to-end through the real chat render path (a stored session with math and a fence, loaded cold by deep link) at 1280 px and 390 px: 2 KaTeX renders, 1 Mermaid SVG, no leftover placeholders, identical counts to unmodified dev.
  • 11 new tests in tests/test_markdown_lazy_lib_loading_js.py, following the pytest-shells-out-to-node idiom already used by test_markdown_rendering_js.py.

What I did not verify

I could not produce a live air-gapped failure demo for the control arm. Blocking cdn.jsdelivr.net in the browser only intercepts page-script fetch(), not document subresource loads, so unmodified dev kept loading the CDN tags regardless. The air-gap claim therefore rests on the measurement (3 CDN requests before, 0 after, with all five assets served 200 from the local origin) rather than on a demonstrated failure. I also have not run the Docker path.

Visual / UI changes — REQUIRED if you touched anything that renders

  • Screenshot or short clip of the change in the running app, attached below. Mobile screenshot too if the change affects mobile.
  • Style match: the change uses Odysseus's existing visual language. No CSS is touched at all: no new variables, colours, font sizes or spacing units, and no Unicode emoji added.
  • No new component patterns. Existing tags repointed to local paths; the placeholder <span> is inert and replaced by KaTeX's own markup.
  • I am not an LLM agent submitting a bulk PR.

Screenshots / clips

Screenshot box left unticked deliberately rather than ticked falsely: this PR does not change what renders. KaTeX and Mermaid produce byte-identical output to the CDN copies, same versions and same libraries, just served locally and later. Verified against unmodified dev at both 1280 px and 390 px: identical .katex and Mermaid <svg> counts, identical HTML. The only observable difference is timing, in that a diagram appears after its library loads instead of after the page load that already fetched it.

Happy to attach before/after captures if you would like them for the record.

@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Aug 11, 2026
Comment thread static/js/markdown.js Fixed
o3LL added 3 commits August 12, 2026 13:41
index.html pulled katex.min.{js,css} and mermaid.min.js from cdn.jsdelivr.net on
every page load. For self-hosted software that is three problems at once: an
air-gapped or offline install renders no math and no diagrams at all, every
session announces its IP, User-Agent and Referer to a third party, and the "runs
on your own hardware" promise quietly isn't true.

static/lib/ already vendors highlight.js, docx, xlsx, mammoth, html2pdf and
qrcode, so the CDN usage was an inconsistency rather than a policy. Vendoring
also pins Mermaid, which was floating on the `11` tag, to 11.16.1.

Behaviour is unchanged: both libraries still load eagerly from <head>, just from
this machine.

- KaTeX goes in its own directory because its stylesheet resolves fonts with a
  relative url(fonts/...), so the vendored CSS needs no rewrite. Only the .woff2
  variants ship, matching static/fonts/, since a browser that supports woff2
  never requests the .woff/.ttf alternatives the stylesheet also lists.
- The service worker precaches KaTeX and its fonts so offline math is typeset
  rather than falling back to system glyphs, and CACHE_NAME is bumped. Mermaid
  is left to the existing cache-first rule: at 3.5 MB, precaching it would mean
  re-downloading it on every cache bump for a library most sessions never touch.
- Licence texts travel with the bundles in licenses/, following the convention
  the repo already uses for OpenDyslexic and DeepResearch.
- .gitattributes turns the whitespace check off for static/lib/ so `git diff
  --check` passes without stripping bytes from the published npm artifacts,
  which would desync them from upstream.
…e load

Both libraries loaded eagerly from <head>, costing every session ~985 KB on the
wire (929 KB of that Mermaid) even though most chats contain neither a formula
nor a diagram. Measured on a cold profile via the Resource Timing API: JS bytes
per page load drop from 3,102,141 to 2,098,634, a saving of 1,003,507 bytes, and
third-party requests per load go from 3 to 0.

markdown.js now fetches each library the first time one is actually needed:

- renderMermaid() checks for an unprocessed mermaid fence before touching the
  network, and re-queries the DOM after the load so a diagram replaced mid-stream
  still renders.
- mdToHtml() is synchronous, so when KaTeX is not in yet it banks the math source
  in an inert placeholder and schedules a flush that loads the library and swaps
  the placeholders in. Once KaTeX is loaded it typesets inline exactly as before,
  so callers that never call a render helper still get their math.

Both loaders memoise the promise rather than the module, so concurrent callers
share one fetch and a double trigger cannot start two loads; a failed load clears
the memo so the next formula retries instead of being poisoned for the session.
The flush is scheduled with setTimeout rather than requestAnimationFrame, which
is throttled to a stop in a background tab and never fires at all in a headless
browser, so math would have sat as plain source text until the tab was focused.

If neither library ever loads, math degrades to readable source text and diagrams
to their fence contents, rather than to nothing.
The math pass unescaped &amp; before &lt; and &gt;. mdToHtml escapes the source
first, so a literal "&lt;" typed inside a formula arrives here as "&amp;lt;",
turns back into "&lt;" on the ampersand pass, and is then eaten by the very next
one. Typing $a &lt; b$ rendered as "a < b" instead of the literal text.

The code-block pass in the same function already unescapes &amp; last; only the
math paths were the outlier, in all four of the copies this branch consolidated
into pushMath(). Reordering to match makes them consistent and clears the
js/double-escaping alert CodeQL raised on this PR.

Math containing a genuinely typed "<" is unaffected, which is why this went
unnoticed for so long. Covered by a regression test asserting both cases.
@o3LL
o3LL force-pushed the fix/2705-vendor-lazy-katex-mermaid branch from 15e475b to 8b39224 Compare August 12, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants