* perf(export): split the transcript renderer's embedded CSS into a versioned asset The export renderer carried the web-shell component stylesheet as a ~2.3 MB string literal, so every reader parsed and compiled 4.1 MB of JS (56% of it dead CSS) before a transcript could render. Lift that literal out at export build time into a version-pinned, SRI-protected export-transcript-document.css served from unpkg and loaded via a nonce-bearing <link>, dropping the renderer JS to ~1.83 MB. The transform is an esbuild onLoad plugin in the web-templates export build that strips the injected CSS constant from web-shell's dist/transcript.js; web-shell source and runtime behavior are untouched. The document's fail-closed load-error path is extended to the stylesheet so a missing CSS asset fails the same way as a missing renderer. * fix(export): match the transcript CSS entry on Windows paths too esbuild hands plugin callbacks the platform-native absolute path, so the extract-transcript-css `onLoad` filter never matched on Windows: the callback did not run, `extractedTranscriptCss.css` stayed undefined, and the mandatory extraction guard below aborted the build. That build is not platform-gated — `scripts/prepare.js` runs it from `prepare`, so `npm ci` itself would fail on every Windows contributor and on the windows-latest legs of test_windows and desktop-release. Widen the separator to `[\\/]`, keeping the `transcript\.js$` tail so the barred `web-shell/dist/index.js` package root still does not match. The filter moves to transcript-css-entry.mjs because build.mjs is a top-level-await script with no harness — the same reason scripts/sdk-node-exporter-stub.js exists — so scripts/tests/transcript-css-entry-filter.test.js can pin both separators. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(export): latch a stylesheet failure ahead of the parser block The `<link id="transcript-stylesheet">` sits in `<head>` while the `window` error listener that catches its failure is registered by an inline script in `<body>`. Chromium parser-blocks that script on the pending stylesheet, so when the CSS failure settles first the error event is dispatched with no listener to receive it: nothing marks the render as failed, both renderComplete guards in document-main.tsx pass, React mounts the transcript without any of the component CSS, and the requestAnimationFrame stamps `data-render-complete="true"`. The reviewer measured this fail-open above roughly 2.1 MB of document HTML (272 of the 1,000 permitted blocks) for a 404, an SRI rejection, a truncated body and a destroyed socket alike, and fail-closed for a *late* failure — so size, not failure kind, decides it. Latch the failure in `<head>` before the `<link>` is parsed and act on the latch from the existing body IIFE. The head script only records: `showLoadError()` writes `document.body.dataset` and `#app`, neither of which exists while the parser is still in `<head>`. It carries `nonce="__EXPORT_NONCE__"` because the document CSP allows no inline script, which is safe — `formatters/html.ts:53` replaces every occurrence. The listener is capture-phase because resource error events do not bubble. Not the `link.sheet === null` variant: the reviewer measured `sheet` non-null for a 404, a truncated body and a destroyed socket, so it only detects SRI rejection. scripts/tests/export-transcript-document-template.test.js pins the position, the nonce, the capture phase and the record-only shape; all five cases go red against the unpatched template. The behavioural witness (real Chromium, large document, instant CSS abort) belongs to the playwright transcript gate, which is out of budget on this host. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(scripts): name the missing export renderer assets, pin the CSS gate The bundle copy became all-or-nothing over two artifacts but its `else` warning still named only the renderer, so the one new way to reach that branch — a tree built before the split, then `npm run bundle`d without rebuilding web-templates, which has the JS and no CSS — told the operator to go looking for a `export-transcript-document.js` that was sitting right there, and silently discarded it. List the paths that are actually absent, matching the sibling Web Shell warning twenty lines above. Stays warn-and-skip: prepare-package.js is the release gate. Also pin that release gate. Every fixture that reached `preparePackage` staged `dist/export-transcript-document.css` unconditionally, so deleting the new required-path entry left the whole test:scripts lane green; a release built with `npm ci --ignore-scripts` would then publish documents whose stylesheet 404s on unpkg for that version. `verifyBundleArtifacts` reports through console.error + process.exit(1) rather than a throw, so the new case stubs exit instead of copying the audio-capture sibling's `toThrow` idiom. Both cases were flip-checked: restoring the old warning text, and deleting the CSS line from prepare-package.js, each turn their case red. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtufhms2u4 * fix(export): pin the stylesheet-failure id contract and sync the design docs The <head> latch, the body listener and the <link> each spell 'transcript-stylesheet' independently and nothing compared them, so renaming either listener's id left the whole suite green while the latch recorded nothing - reinstating the fail-open the latch was added to close. Derive the id from the <link> and assert both listeners compare against it. Verified red under both mutations: latch id -> 'transcript-renderer' (1 failed | 5 passed), and the mirror with the body listener's id wrong and the latch intact (same). Both design docs still specified the two shapes the previous round replaced: the forward-slash-only onLoad filter that never matches on Windows, and the body-listener-only fail-closed extension. Section 1 now quotes the shipped TRANSCRIPT_CSS_ENTRY_FILTER and names transcript-css-entry.mjs, section 2 describes the <head> latch (position, nonce, capture phase, record-only), section 3 names the module-level render guard, and "Files affected" lists the three omitted files. EN and zh-CN are updated in the same commit. Also correct the shape-guard comment in build.mjs: the document nonces every <style> created through document.createElement, so the CSP would not block an un-stripped duplicate, and a 367-byte regrowth stays inside both byte budgets. That throw is the only guard on the duplicate-injection path. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-closeout/jmtuo29vduf * fix(export): close out transcript CSS review comments * fix(export): tighten transcript CSS closeout Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> |
||
|---|---|---|
| .. | ||
| README.md | ||
/export html runtime size — verification plan (#11031 / PR #11038)
Audience: an agent (or person) on a machine that can run npm install, vite/esbuild
builds, vitest and Playwright. The machine that wrote the change could not, so every
number and every test result below is unverified — that is the whole point of this
document.
This file lives on the PR branch itself, so the only thing you need to be handed is the branch name:
git clone --depth=1 --branch issue-11031 https://github.com/QwenLM/qwen-code.git
# then read docs/verification/export-html-runtime-size/README.md
Commit your findings next to it as results.md (the abort-controller-refactor/
package in this directory is the shape to follow) and/or reply on the PR.
- PR: https://github.com/QwenLM/qwen-code/pull/11038 (branch
issue-11031) - Issue: https://github.com/QwenLM/qwen-code/issues/11031
- Commits under test:
9515e5b78d— PR author's original fix (transcript-only subpath entry + byte budget)a9ff4f485b— follow-up: strip Shiki, drop CodeMirror, structural guard, CSS-key fix
Read §6 first if you only have time for one thing: two constants in the repo are known to be stale and must be replaced with measured values.
1. What changed and why it needs measuring
| Change | Where | What it should do |
|---|---|---|
Transcript-only entry @qwen-code/web-shell/transcript |
packages/web-shell/client/transcript.ts |
Keeps App, daemon providers and app chrome out of the export |
| Shiki resolved to a stub in the document build | packages/web-templates/src/export-html/build.mjs, src/document-shiki-stub.ts |
Removes ~9.7 MB of pre-minify input that document mode never executes |
| Composer-tag getters moved out of the CodeMirror module | packages/web-shell/client/utils/composerTag.ts |
Removes ~1 MB of CodeMirror from the transcript graph |
| Structural guard on the document bundle | FORBIDDEN_DOCUMENT_INPUTS in build.mjs |
Fails the build if Shiki / the web-shell package root / CodeMirror reappear |
| Per-entry CSS injection key | packages/web-shell/vite.lib.config.ts, client/shadowDom.ts |
Stops the transcript stylesheet from suppressing the full one |
The two size claims in the table are estimates read off the issue's triage comment, not measurements. Steps 3 and 4 turn them into facts.
Rationale for stubbing Shiki (verify this premise, see §5.3): CodeBlock in
packages/web-shell/client/components/messages/Markdown.tsx returns before touching the
highlighter when renderMode === 'document', and its render branch always emits a plain
<pre> in that mode. The export's CSP (script-src 'nonce-…', no 'wasm-unsafe-eval')
would also block Shiki's Oniguruma WASM engine from starting.
2. Setup
cd <your clone of issue-11031>
git log --oneline -2 # expect a9ff4f485b (or later) on top of 9515e5b78d
npm install # runs every workspace build as postinstall; ~25 min
If npm install did not build the workspaces, build the two that matter, in this order
(web-templates resolves @qwen-code/web-shell/transcript to web-shell's dist/, so
web-shell must be built first):
npm run build --workspace=@qwen-code/web-shell
npm run build --workspace=@qwen-code/web-templates
web-shell's build now runs three vite passes (app, lib, lib --mode transcript)
plus tsc. Note the wall-clock time — if the third pass adds more than ~90 s, say so;
it is a cost the PR pays on every CI build.
3. Primary measurement — export size
cd packages/web-templates
EXPORT_HTML_METAFILE=/tmp/document-metafile.json node src/export-html/build.mjs
The build prints the lines that matter:
Document export top inputs (pre-minify bytes): <pkg> <bytes>, ...
Document export renderer JS is <N> bytes; component CSS moved to export-transcript-document.css is <M> bytes
Then measure the generated assets (the actual /export html file is the template
plus a small data envelope):
Changed by #9812 (merged 2026-09-05). The renderer is no longer inlined into
document.html. That file is now a small template that loads a version-pinned, SRI-protectedexport-transcript-document.jsfrom unpkg, and the legacyindex.htmlrenderer is gone. Measure the asset, not the template — it is the download every reader of an exported file now pays before the transcript renders.Changed by #11478. The web-shell component stylesheet is no longer a string literal inside
export-transcript-document.js. The build lifts it out into a second version-pinned, SRI-protected asset,export-transcript-document.css, loaded via a nonce-bearing<link>. The JS is the asset a browser must parse and compile before rendering, and is the number the build's byte budget ratchets; the CSS is fetched, parsed and cached separately, in parallel.
wc -c src/export-html/dist/export-transcript-document.js # the renderer JS — the parse/compile-critical number
gzip -9 -c src/export-html/dist/export-transcript-document.js | wc -c
wc -c src/export-html/dist/export-transcript-document.css # the split component stylesheet
gzip -9 -c src/export-html/dist/export-transcript-document.css | wc -c
wc -c src/export-html/dist/document.html # template only; small
Record all of these. Known reference points, all from the PR author's machine:
All rows below were taken before #9812, when the runtime was still inlined, so
document.html raw ≈ the runtime. Compare them against the new
export-transcript-document.js asset, not against the new document.html.
| Revision | document.html raw |
gzip | inline runtime |
|---|---|---|---|
main (before any fix) |
19,525,807 | 4,775,943 | 19,523,259 |
9515e5b78d (PR as reviewed) |
17,966,485 | 4,512,650 | 17,963,937 |
a9ff4f485b (with follow-up) |
? | ? | ? |
legacy renderer index.html |
311,854 | — | — |
To get the middle row on your own machine for a like-for-like delta:
git stash list; git -C ../.. checkout 9515e5b78d -- . # or: git checkout 9515e5b78d && rebuild
Cleanest is a second clone at 9515e5b78d, built the same way — the numbers above came
from a different machine and a different node_modules.
What "good" looks like
There is no target number, only a direction: the follow-up commit should remove Shiki
and CodeMirror entirely, so expect a multi-MB drop, not a few hundred KB. If the
drop is under ~2 MB, something did not take effect — check the top-inputs line for
shiki / @shikijs / codemirror (they should be absent) and report it.
4. Input breakdown — where the remaining bytes are
node -e '
const m = require("/tmp/document-metafile.json");
const by = new Map();
for (const [k, v] of Object.entries(m.inputs)) {
const p = k.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//);
const key = p ? p[1] : "first-party";
by.set(key, (by.get(key) ?? 0) + v.bytes);
}
for (const [k, v] of [...by].sort((a,b)=>b[1]-a[1]).slice(0,25))
console.log(String(v).padStart(10), k);
'
Report this whole table. It is the input to the open product decision in §7, and it is the first thing anyone will want when the next size regression lands.
Expected shape after the follow-up: mermaid, echarts/zrender, lucide-react,
react-markdown + remark-*/rehype-*, katex. Expected absent: shiki,
@shikijs/*, codemirror, @codemirror/*, vaul, @qwen-code/web-shell/dist/index.js.
5. Correctness checks
5.1 Tests
# web-shell — the moved module, the shadow-DOM reader, the new build assertions
npx vitest run --config vitest.config.ts \
client/build-artifact.test.ts \
client/utils/composerTag.test.ts \
client/hooks/useComposerCore.test.ts \
client/hooks/useComposerCore.dom.test.tsx \
client/hooks/useComposerCore.mobile.dom.test.tsx \
client/components/messages/UserMessage.test.tsx \
client/components/messages/Markdown.test.ts \
client/components/messages/Markdown.coldHighlight.test.ts \
client/components/messages/Markdown.mermaid.test.ts \
client/shadowDom.test.ts \
client/index.test.tsx
npm run typecheck --workspace=@qwen-code/web-shell
# cli — the export formatter and document envelope
npx vitest run \
src/ui/utils/export/formatters/html.test.ts \
src/ui/utils/export/export-transcript-document.test.ts
npm run typecheck --workspace=@qwen-code/qwen-code
client/build-artifact.test.ts reads packages/web-shell/dist/*.js, so it only means
anything after a web-shell build. Its three new cases are the ones to watch:
they assert dist/transcript.js has no @codemirror/ / codemirror / vaul, still
carries react-markdown and WebShellTranscript, and that the two entries inject their
stylesheets under distinct data-qwen-web-shell-entry keys.
If a not.toContain assertion fails, do not relax it — it means a module graph
reopened. Report the failing string and, from the metafile, what pulled it back in.
5.2 Rendering parity (the important one)
Removing Shiki and CodeMirror must be invisible in the rendered output. Reproduce the PR's own parity method:
- Take one export envelope containing a user message, an assistant markdown message with a fenced code block (with a language tag), a KaTeX expression, a list, and a mermaid diagram.
- Inject it into
document.htmlbuilt at9515e5b78dand ata9ff4f485b. - Open both over
file://in headless Chromium (Playwright is already a devDependency inpackages/web-shell). - Compare:
document.body.dataset.renderComplete === 'true', the metadata sidebar, theme toggle, expand/collapse, the code block and its language label, the KaTeX output, the mermaid diagram, and full-page screenshots (md5).
Mermaid is the one to watch. Unlike Shiki, mermaid is used in document mode
(Markdown.tsx has an explicit documentMode branch around mermaid.render), and
nothing in these commits should have touched it. If a mermaid diagram renders on
9515e5b78d but not on a9ff4f485b, that is a blocking regression — report it with the
console output.
Also confirm the console is clean: no shiki is not bundled into /export html documents error. That string appearing means document mode reached the highlighter
after all and the premise in §5.3 is wrong.
5.3 Premise check — is Shiki really dead in document mode?
Independent of the screenshots, confirm the claim directly on 9515e5b78d
(i.e. before the stub, where the real Shiki is still bundled):
- Open an export containing a fenced code block with a language tag.
- Inspect the code block's DOM. It should be a plain
<pre><code>with no per-token<span style="color:…">markup. - Check the console for a CSP violation mentioning WebAssembly.
If instead you find highlighted tokens, stop and report — the Shiki stub would then be a behaviour change, not dead-code removal, and the follow-up commit needs reverting in part.
5.4 The interactive app still works
shadowDom.ts and UserMessage.tsx/useComposerCore.ts are on the live app path:
npm run test:e2e:smoke --workspace=@qwen-code/web-shell
Manual smoke, if the harness is available: open the web shell, type an @ mention (the
CodeMirror composer and its tag chips), send it, and confirm the sent user message
renders its tag chips with the right labels — those chips go through the three getters
that moved.
6. Two constants that are known stale — please fix
packages/web-templates/src/export-html/build.mjs:
const DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000;
const MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000;
These were set against the 9515e5b78d baseline of 17,963,937 and are now far above the
real size. Once you have the measured Document export renderer JS is N bytes from §3:
MAX_DOCUMENT_RUNTIME_BYTES→ roughlyN * 1.05, rounded up to a readable numberDOCUMENT_RUNTIME_WARNING_BYTES→ roughlyN * 1.02
and update the comment block above them with the measured baseline and the commit it was measured at. A cap that sits 6 % above the truth is a ratchet; one that sits 40 % above it is decoration.
7. Open decision to inform, not to make
After Shiki and CodeMirror are gone, the remaining payload is dominated by mermaid (~6 MB) and echarts (~4.3 MB) — both genuinely used by document mode. Removing them (rendering diagrams and charts as their source fences in exports) is the only remaining way to approach the legacy renderer's 311,854 bytes without the CDN/release-asset direction the issue proposed, which collides with the export's deliberate offline/self-contained/nonce-only CSP design.
That is a product call, not a build fix. What is needed from this run is just the §4 table: the real cost of each, measured, so the call can be made on numbers.
8. What to report back
Write it to docs/verification/export-html-runtime-size/results.md on the branch (and
commit it — that is what makes the next person's job cheap), then summarise on the PR.
- The filled-in row of the §3 table, plus the delta against
9515e5b78d. - The §4 top-25 input table verbatim.
- Pass/fail for every command in §5.1, with output for anything red.
- §5.2 parity result: identical / different (with screenshots or a diff).
- §5.3 premise: confirmed dead / not dead.
- The two values you chose in §6, and whether you pushed that change.
- Anything in the PR description at https://github.com/QwenLM/qwen-code/pull/11038 that your numbers contradict — the current description's before/after table and its claim that the editor chrome and daemon SDK client "no longer ship" are both known to be wrong and need rewriting.
Known-wrong claims in the current PR description
- "the remaining interactive-shell dead weight (App, daemon SDK client, editor/terminal
chrome, app-only stylesheet portion) no longer ships" — CodeMirror did still ship
at
9515e5b78d(viaUserMessage → hooks/useComposerCore); the follow-up commit is what removes it. - The daemon React SDK still ships even after the follow-up. It is reachable from
the transcript at five points, including
client/hooks/useMessages.ts, whichWebShellTranscriptimports directly. Roughly 236 KB; deliberately left alone. - The before/after size table is stale as of
a9ff4f485b.