Skip to content

⚡ Alias @tinacms/mdx to keep its 2 MB markdown parser out of the client bundle#4912

Open
joshbermanssw wants to merge 2 commits into
mainfrom
perf/tina-mdx-sanitize-url
Open

⚡ Alias @tinacms/mdx to keep its 2 MB markdown parser out of the client bundle#4912
joshbermanssw wants to merge 2 commits into
mainfrom
perf/tina-mdx-sanitize-url

Conversation

@joshbermanssw

Copy link
Copy Markdown
Member

TL;DR

tinacms/dist/rich-text — the module behind <TinaMarkdown> — opens with import { sanitizeUrl } from "@tinacms/mdx", and that package ships a single bundleDeps entry point of 1,973,803 bytes to supply a 22-line URL sanitiser. This aliases the package to a shim that keeps sanitizeUrl and drops the parser; no application code changes.

Why

On the deployed site, /events/ai-for-business-leaders references 5,966,858 bytes of JS across 60 chunks. dd3583a7-*.js — the @tinacms/mdx bundle — is 1,142,097 of them, making it the largest single attributable slice of the page's JavaScript and the bulk of Lighthouse's "Reduce unused JavaScript — Est savings of 1,064 KiB".

Reproduce the page total:

curl -s https://www.ssw.com.au/events/ai-for-business-leaders \
  | grep -o '/_next/static/chunks/[A-Za-z0-9_.-]*\.js' | sort -u \
  | while read c; do curl -s -o /dev/null -w "%{size_download}\n" \
      -H 'Accept-Encoding: identity' "https://www.ssw.com.au$c"; done \
  | awk '{s+=$1} END {print s" bytes"}'

Local before/after build figures to follow in a comment.

Reviewer notes

  • sanitizeUrl is copied, not reimplemented. Verbatim from @tinacms/mdx@2.1.9 and byte-identical across that package's node and browser builds. If you bump the Tina packages, re-diff it.
  • parseMDX / serializeMDX throw. Neither runs in a browser on a public page — serializeMDX is reached only from the editor's save path, parseMDX only from the Tina GraphQL layer, and both /admin and @tinacms/cli are built outside this Next config, so both still get the real package. They throw rather than returning a plausible value so a wrong assumption fails loudly instead of silently mangling content.
  • Aliased for both bundlers. next dev uses Turbopack and next build uses webpack; wiring only one would let dev and prod diverge silently.
  • This is a stopgap. ⚡ Give @tinacms/mdx a light sanitize-url subpath so rich-text skips the parser tinacms/tinacms#7233 adds a dependency-free @tinacms/mdx/sanitize-url subpath that makes the shim unnecessary — delete the file and the two aliases once it ships in a release we consume.

Tests

  • Existing suite green (13/13).
  • Differential test of the shim's sanitizeUrl against the real export across 31 inputs — allowed and blocked schemes (javascript:, data:, vbscript:, file:, ftp:), relative paths, anchors, userinfo, explicit ports, and query/hash/trailing-slash combinations: 0 mismatches, and an identical export surface.

Links

tinacms/dist/rich-text — the module behind <TinaMarkdown>, imported by 65
components here — opens with `import { sanitizeUrl } from "@tinacms/mdx"`.
That package publishes a single entry point built with bundleDeps, so the
import drags the whole remark / mdast / micromark / prettier toolchain in
to reach a 22-line URL sanitiser: 1,973,803 bytes on disk, ~1.14 MB minified
in the deployed bundle.

Alias the package to a shim exporting the real sanitizeUrl (copied verbatim
from @tinacms/mdx@2.1.9, byte-identical across its node and browser builds)
plus loudly-throwing stubs for parseMDX and serializeMDX, neither of which
runs in a browser on a public page.

Aliased for webpack and Turbopack both, so `next dev` and `next build` agree.
No application code changes — the 65 TinaMarkdown imports are untouched.

Upstream tinacms#7233 adds a `@tinacms/mdx/sanitize-url` subpath that makes
this unnecessary; delete the shim and both aliases once it ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 00:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Coverage report

St.
Category Percentage Covered / Total
🔴 Statements 0.18% 84/46845
🔴 Branches 5.04% 25/496
🔴 Functions 0.84% 4/474
🔴 Lines 0.18% 84/46845

Test suite run success

13 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from 014674c

@joshbermanssw

Copy link
Copy Markdown
Member Author

Before/after measured

Two clean production builds on the same machine, identical apart from the alias (baseline = next.config.mjs from origin/main with the shim removed).

Before After Δ
/(events)/events/[...filename] entry JS 1,752,504 B 610,909 B −1,141,595 (−65.1%)
Entry chunk count 22 22
All static/chunks bytes 10,475,021 B 9,346,839 B −1,128,182 (−10.8%)

dd3583a7-76595befd5b43fbb.js (1,142,097 B) — the @tinacms/mdx bundle — is absent from the after build.

One thing this does not fix

A second chunk still matches the markdown-parser fingerprint after the alias:

Chunk Before After
dd3583a7-*.js 1,142,097 B gone
41958015.*.js 1,198,209 B 1,198,207 B

The alias barely touches 41958015, so that copy arrives by a different route — most likely the chain in #4892, where 12 files value-import tinacms for admin form helpers and pull in tinacms/dist/index.js. Worth confirming directly rather than assuming.

Two consequences:

  1. ⚡ Keep the TinaCMS editor stack (~3.8 MB) off public pages #4892 is still required. This PR removes one of two doors; the remaining chunk is comparable in size.
  2. Upstream tinacms#7233 would not have covered this on its own. It repoints rich-text at a sanitize-url subpath — the same door this PR closes — and leaves 41958015 untouched. Useful context for that PR, and a reason not to simply wait for it.

@isaaclombardssw isaaclombardssw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really ugly but since the fix is just waiting on Tina to come through... ✅

@isaaclombardssw

Copy link
Copy Markdown
Member

Tested this branch locally — works great for next build/prod, but it breaks next dev: any page rendering <TinaMarkdown> 500s.

Cause: TINACMS_MDX_SHIM is an absolute path. Webpack's resolve.alias accepts absolute paths, but Turbopack's resolveAlias treats the value as relative — it turns /Users/.../lib/tinacms-mdx-shim.js into ./Users/.../lib/tinacms-mdx-shim.js and can't resolve it:

Module not found: Can't resolve './Users/.../lib/tinacms-mdx-shim.js'
GET /events/ai-for-business-leaders 500

So the webpack alias (prod, CI build, your bundle measurements) is fine — only next dev is affected, which is why CI didn't catch it (CI only runs next build, never next dev).

Repro:

git checkout perf/tina-mdx-sanitize-url
pnpm dev
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/events/ai-for-business-leaders
# -> 500  (dev log: Can't resolve './Users/.../lib/tinacms-mdx-shim.js')

Fix — point just the Turbopack alias at a project-relative path; webpack keeps the absolute one:

turbopack: {
  resolveAlias: { "@tinacms/mdx": "./lib/tinacms-mdx-shim.js" },
}

Confirmed: with that one change the same page returns 200 and rich text renders. Nothing else needed touching — the shim's sanitizeUrl is byte-identical to the installed @tinacms/mdx@2.1.4 and the dd3583a7 chunk is gone in prod as you measured.

Turbopack's resolveAlias resolves values against the project root and prepends
"./" to whatever it receives, so the absolute path became
"./Users/.../lib/tinacms-mdx-shim.js" and failed to resolve. Every page
rendering <TinaMarkdown> 500d under `next dev`; `next build` was unaffected
because webpack's resolve.alias does want an absolute path.

Keep the absolute form for webpack and give Turbopack a relative one. The two
are deliberately different — a comment says so, since unifying them re-breaks
one bundler or the other.

CI only runs `next build`, so it could not have caught this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joshbermanssw

Copy link
Copy Markdown
Member Author

Reproduced and fixed in 014674cb.

Confirmed the diagnosis before changing anything — Turbopack does prepend ./ to the value it's given:

HTTP=500
Module not found: Can't resolve './Users/josh/.../lib/tinacms-mdx-shim.js'

And after pointing Turbopack at a relative path, on the same page:

✓ Ready in 1384ms
HTTP=200 bytes=625202
dev log resolution errors: (none)

Also verified rich text genuinely renders rather than the route merely returning 200 — the block-2 description paragraph is present in the dev-rendered HTML, and no markdown-parser strings appear, so the shim is being used rather than silently falling back.

Kept the two forms deliberately different — absolute for webpack, relative for Turbopack — with a comment saying why, because unifying them re-breaks one bundler or the other. My original comment claimed absolute would "resolve identically" in both; that was the wrong assumption, and it introduced exactly the dev/prod divergence the change was supposed to prevent.

Your point about CI is the more important one: pr-build.yml only runs next build, so no amount of CI would have caught a Turbopack-only failure. Worth a smoke test that boots next dev and asserts a page renders — I can raise that separately rather than expand this PR.

One correction for the record: the installed version here is @tinacms/mdx@2.1.9, not 2.1.4 — worth being precise about since the shim header tells the next person to re-diff sanitizeUrl against the installed build when the Tina packages are bumped.

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.

💸 Perf - Keep the 1.1 MB TinaCMS MDX parser out of the client bundle

3 participants