Skip to content

perf(image export): replace html2canvas with snapdom - #8066

Open
Eetwalt wants to merge 10 commits into
mainfrom
shareimg-snapdom-rework
Open

perf(image export): replace html2canvas with snapdom#8066
Eetwalt wants to merge 10 commits into
mainfrom
shareimg-snapdom-rework

Conversation

@Eetwalt

@Eetwalt Eetwalt commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

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:

  • renders exports in a reusable offscreen iframe at the existing fixed 1440px layout width;
  • copies page styles, root attributes, and dynamic form/canvas state;
  • waits for stylesheets, fonts, and cloned images before measuring;
  • strips media from hidden subtrees without changing their DOM structure;
  • pins resolved subgrid tracks so match lists retain their layout;
  • rebuilds the frame after every capture, including failed renders, to avoid stale snapdom state;
  • fixes output at scale 2 and DPR 1 so browser zoom and display density do not affect exports;
  • measures the composed header and footer exactly, then clamps the complete canvas against per-side and total-area limits.

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.js
  • npm run test:js -- --runInBand --reporters=default — 21 tests passed
  • node --check javascript/commons/ExportImage.js
  • git diff --check

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.
@Eetwalt
Eetwalt requested review from a team as code owners September 8, 2026 09:33
Copilot AI lite review requested due to automatic review settings September 8, 2026 09:33
@Eetwalt
Eetwalt requested a review from fikrydev September 8, 2026 09:38

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.

🟡 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 ExportLayoutFrame to build/reset an offscreen iframe, clone page content/styles, and capture via snapdom.toCanvas() with fixed scale and dpr.
  • 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.

Comment thread javascript/commons/ExportImage.js
Comment thread javascript/commons/ExportImage.js

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.

🔵 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

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.

2 participants