perf(image export): replace html2canvas with snapdom - #8066
Conversation
html2canvas took 1.2 to 2.4 seconds per export regardless of element size, because it clones the whole document and re-parses every stylesheet on each capture before running its own renderer. Captures now happen in a reused offscreen iframe of a fixed export width. That keeps what html2canvas gave us through windowWidth, exports that look the same on a phone as on a desktop and stay independent of the reader's window size, which cannot be reproduced in the live document because the responsive layout here is driven by viewport media queries. The export fixes keep running against a real DOM with real stylesheets, so the prizepool expansion still works. Three things the capture needs on top of the swap: - Content the browser does not paint is dropped first. A capture reads the whole subtree and inlines every image in it, and a collapsed match list can hide hundreds of team logos behind a box a few lines tall. - The frame's document is rebuilt after every capture. snapdom keeps caches keyed to the document it read, and reusing it renders CSS-constrained images at their intrinsic size, so team logos came out oversized from the second export onwards. - A subgrid root gets the tracks its parent resolved pinned onto it, since a capture is re-laid out on its own where subgrid has no parent grid to inherit from and collapses into a single column. Measured across brackets, group tables, crosstables, match lists, prize pools and participant grids on four real pages: 3.4x faster, with identical image dimensions in 28 of 30 captures and no layout drift.
The guard dates from html2canvas, which rasterised the live document and could produce misaligned captures once browser zoom changed the ratio between CSS and device pixels. Captures now happen in an offscreen iframe of a fixed export width, and nothing left in the capture path reads the live viewport: getLayoutHeight only sizes the frame, getScale only picks a resolution and clamps it to 2 to 3 whatever the device ratio is, and the one innerWidth read positions the dropdown. Zoom can no longer skew the output, so the guard only costs exports. It cost more than it looks. Any devicePixelRatio change latched it, including dragging the window to a monitor of a different density, which was never zoom at all, and the only way out was reloading the page. Going with it: the refresh-to-export menu dead end, and a resize listener that stayed on every page for the life of the document. Three smaller cleanups in passing: - The inactive-tab check in isElementVisible ran inside the ancestor walk, but closest searches every ancestor by itself, so the first iteration already covered what the rest repeated. Hoisting it out makes the walk linear in depth again. - wrapText guarded against an empty word list that cannot happen, since splitting an empty string yields one empty entry rather than none. - copyDynamicState pairs live and cloned nodes by position, so a tag mismatch means the two lists have drifted and no later pair can be trusted either. That is why it abandons the whole pass instead of skipping the one node, which now says so.
The try block only covered the capture itself, so a throw from anywhere earlier skipped the rebuild: replaceContent failing to find its marker, the zero-dimension guard, or the font wait rejecting. The frame was then left holding a mutated clone and an unreset document, which is the state the rebuild exists to clear, and each subsequent attempt appended another shadow-suppression stylesheet to the head it should have discarded.
The clone starts every image load again in a document that has none of them, and a team logo takes its size from the container while the image itself is unconstrained, so one that has not arrived measures zero wide. The bounds read straight afterwards feeds both the zero-dimension guard and the scale budget, so a cold cache could reject a perfectly visible table with "the content is not visible", or pick a scale from a height that was still growing. It passes today only because the logos come back from cache inside the same tick as the font wait. Broken images resolve rather than reject, and the whole wait races a timeout, so neither a 404 nor a stalled request can strand an export.
Removing a display:none node renumbers everything after it, and the prize
pool table stripes its rows in CSS:
tbody tr:not( :has( th ):not( :has( td ) ) ):nth-child( odd )
so one pruned row inverted the striping for every row below it. Brackets
carry another forty-odd structural rules, and whether any of them is hit
depends on the page, which is the worst way for an export to be wrong.
The pruning was still buying something real. snapdom skips hidden nodes when
it serialises, so they were never going to be drawn, but its image passes are
flat querySelectorAll("img") with no display test, so every logo behind a
collapsed match list was fetched and inlined regardless. Clearing src, srcset
and background-image keeps that saving while leaving the tree the shape the
page had. Nothing inside a hidden subtree is drawn, so none of it can be
missed.
The budget assumed HEADER_HEIGHT, but calculateHeaderLayout grows the header when the page title will not sit beside the section title, up to the number of wrapped lines times the line height plus padding. Content already at the limit therefore overflowed by the difference, and a composed canvas over the limit comes back blank rather than merely large. Reserving the tallest header instead only costs the few hundred pixels of headroom that content near the ceiling would have used.
The budget reserved a fixed 120px for the header, but wrapping is bounded by the title's word count rather than by six lines, and the case that wraps hardest is the same one that approaches the height limit: tall narrow content leaves the least room beside the section title. A long tournament name over a 9000px table overflowed the reserve. Measuring it properly turns out not to be circular. Wrapping depends on the ratio of text width to available width, and the font size and the available width both scale by the same factor, so the line count is the same at any scale. The composer now reports its own chrome height for a given content width, measured at scale 1, and the frame clamps against that. Both axes are checked rather than the longest side, and the area as well. Per-side alone permits a near-square canvas of 16384 squared, which no browser will allocate: a 6000px square capture composed at scale 2 asked for 147 Mpx, against roughly 125 for the largest square Firefox allows. Note that the area constant is the tightest desktop figure, and iOS is lower again, so it may need dropping if a large export ever returns blank.
There was a problem hiding this comment.
🟡 Changes recommended
There are capture-fidelity and correctness risks in the new iframe render path (iframe height source and unconditional shadow suppression) that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR replaces the existing html2canvas-based export capture path with a snapdom-based pipeline that renders exportable content inside a reusable offscreen iframe at a fixed 1440px layout width, aiming to significantly reduce export latency while keeping output consistent across devices and zoom levels.
Changes:
- Introduces
ExportLayoutFrameto build/reset an offscreen iframe, clone page content/styles, and capture viasnapdom.toCanvas()with fixedscaleanddpr. - Updates export composition to measure chrome height and fill transparent capture pixels with the page background during canvas composition.
- Removes the browser-zoom lockout and updates prewarming/loading logic to use
mw.loader.using('snapdom')plus frame preparation.
File summaries
| File | Description |
|---|---|
| javascript/commons/ExportImage.js | Replaces html2canvas capture with snapdom + offscreen iframe lifecycle; updates export preparation, capture scaling/clamping, and canvas composition. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
It replaces a core rendering/capture pipeline with a new iframe-based snapdom flow whose correctness and cross-browser behavior can’t be confidently validated here without runtime verification.
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 0 new
- Review effort level: Lite
Summary
Replace html2canvas with snapdom for image exports. This is an alternative to #7920, which was put on hold due to issues with its proposed approach.
html2canvas clones the whole document and reparses every stylesheet for each capture, taking 1.2–2.4 seconds regardless of element size. Local measurements across brackets, tables, prize pools, and participant grids found the new capture path approximately 3.4× faster without observed layout drift.
The new implementation:
The browser-zoom lockout is removed because capture layout and resolution no longer depend on the reader's viewport or device pixel ratio.
This remains one change because the iframe lifecycle, snapdom capture, scaling, and canvas composition form a coupled replacement; splitting them would leave broken intermediate export paths.
Known caveat: the fixed area ceiling uses the conservative desktop Firefox limit. Stricter iOS limits could require lowering it if very large iOS exports return blank.
How did you test this change?
Manually tested image exports on:
Local checks:
./node_modules/.bin/eslint --no-fix javascript/commons/ExportImage.jsnpm run test:js -- --runInBand --reporters=default— 21 tests passednode --check javascript/commons/ExportImage.jsgit diff --check