perf(static): vendor KaTeX and Mermaid, and load them on first use - #5994
Open
o3LL wants to merge 3 commits into
Open
perf(static): vendor KaTeX and Mermaid, and load them on first use#5994o3LL wants to merge 3 commits into
o3LL wants to merge 3 commits into
Conversation
15 tasks
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 & before < and >. mdToHtml escapes the source first, so a literal "<" typed inside a formula arrives here as "&lt;", turns back into "<" on the ampersand pass, and is then eaten by the very next one. Typing $a < b$ rendered as "a < b" instead of the literal text. The code-block pass in the same function already unescapes & 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
force-pushed
the
fix/2705-vendor-lazy-katex-mermaid
branch
from
August 12, 2026 11:45
15e475b to
8b39224
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
static/index.htmlloaded KaTeX (CSS + JS) and Mermaid fromcdn.jsdelivr.neton 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 existinghighlight.min.jsconvention) 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
11tag, to 11.16.1.Target branch
dev, notmain. All PRs land indev;mainis curated by the maintainer at each release. If your PR is onmainby accident, click "Edit" on this PR and change the base.Linked Issue
Part of #2705
(
Part of, notFixes: #2705 also covers thedocs/index.htmlexternal avatar and droppingcdn.jsdelivr.netfrom 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
Checklist
devdocker compose uporuvicorn app:app) and verified the change works end-to-end. Type-checks and unit tests are not enough.Changes
Two commits, reviewable separately:
1.
fix(static): vendor KaTeX and Mermaid instead of loading them from a CDNis behaviour-neutral. The three<head>URLs become local paths; nothing else moves.static/lib/mermaid.min.js(11.16.1) andstatic/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 relativeurl(fonts/…), so the vendored CSS needs no rewrite..woff2fonts ship (20 files, 296 KB), matchingstatic/fonts/, which is already woff2-only. A browser that supports woff2 never requests the.woff/.ttfalternatives the stylesheet also lists. Verified in the running app: only the 2 fonts a given formula needs are fetched.licenses/, following the convention already used for OpenDyslexic and DeepResearch, linked fromACKNOWLEDGMENTS.md..gitattributes:static/lib/** -whitespace linguist-vendored.2.
perf(markdown): load KaTeX and Mermaid on first use, not on every page loadis 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.One implementation note worth a reviewer's eye. The flush is scheduled with
setTimeout, notrequestAnimationFrame. 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.txtandlicenses/Mermaid-MIT-LICENSE.txt, linked from theACKNOWLEDGMENTS.mdtable. I usedlicenses/rather than a newstatic/lib/THIRD_PARTY_NOTICES.mdbecause that is the convention the repo already has.git diff --checkfails on the vendored Mermaid bundle (@pewdiepie-archdaemon). Fixed via.gitattributesas @phetzy proposed, rather than stripping bytes and desyncing the bundle from the published npm artifact.git diff --check origin/dev..HEADis now clean; it reports 30 trailing-whitespace lines without it.Add the renderer assets to
PRECACHEand bump the cache name (@RaresKeY).CACHE_NAMEis nowodysseus-v378-lazy-katex-mermaid, andkatex.min.js,katex.min.cssand all 20KaTeX_*.woff2are 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_NAMEbump (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
codeRunner.js: 13.8 MB of binaries (pyodide.asm.wasmalone is 10.1 MB), and vendoring would not fix it. The runtime is already broken because the CSP has no'wasm-unsafe-eval', soWebAssembly.instantiateStreamingis refused and the panel hangs on "Loading Python runtime…" forever. Pyodide also callssetCdnUrl(…)internally after boot, soloadPackage/micropipwould still hit the CDN. Filing separately.docs/index.htmlexternal avatar and droppingcdn.jsdelivr.netfrom 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 7099performance.getEntriesByType('resource').filter(r => /katex|mermaid/.test(r.name))is[], andwindow.katex/window.mermaidare bothundefined. There are zero requests to any third-party host.$x^2 + y^2 = z^2$and$$\frac{a}{b} = c$$. Both typeset. Re-run the filter above:katex.min.js,katex.min.cssand the 2 fonts that formula needs are now there, all200from/static/lib/katex/….```mermaidfence (e.g.graph TD; A[Start] --> B[End];) draws an SVG, and/static/lib/mermaid.min.jsappears, only now and not at page load.performance.getEntriesByType('resource').reduce((n, r) => n + r.encodedBodySize, 0)on this branch vsdev, 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.git diff --check origin/dev..HEADis clean.What I ran
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 unmodifieddev. I checked with the branch stashed.node --checkonstatic/js/markdown.js,static/sw.jsand both vendored bundles;python -m compileallonapp.py core routes src services scripts tests.dev.tests/test_markdown_lazy_lib_loading_js.py, following the pytest-shells-out-to-node idiom already used bytest_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.netin the browser only intercepts page-scriptfetch(), not document subresource loads, so unmodifieddevkept loading the CDN tags regardless. The air-gap claim therefore rests on the measurement (3 CDN requests before, 0 after, with all five assets served200from 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
<span>is inert and replaced by KaTeX's own markup.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
devat both 1280 px and 390 px: identical.katexand 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.