Flowchart htmlLabels do not wrap on fractional devicePixelRatio: exact-equality bbox.width === width is fragile
Description
In addHtmlSpan (packages/mermaid/src/rendering-util/createText.ts), whether an HTML label wraps is decided by an exact-equality float comparison between the measured width and the wrapping width:
div.style('display', 'table-cell');
div.style('white-space', 'nowrap');
div.style('line-height', '1.5');
if (width !== Number.POSITIVE_INFINITY) {
div.style('max-width', width + 'px');
div.style('text-align', 'center');
}
let bbox = div.node()!.getBoundingClientRect();
if (bbox.width === width) { // <-- fragile exact float equality
div.style('display', 'table');
div.style('white-space', 'break-spaces');
div.style('width', width + 'px');
bbox = div.node()!.getBoundingClientRect();
}
getBoundingClientRect().width is a sub-pixel float. When the max-width: <width>px clamp engages, the returned width is bit-exactly the integer width only when the page is laid out on an integer device-pixel grid (devicePixelRatio = 1). On displays with fractional scaling / certain HiDPI configurations, the clamped width comes back as e.g. 200.0078125, so bbox.width === width is false, the wrap branch is skipped, the label stays white-space: nowrap, and long labels overflow the node instead of wrapping.
Browser-specific: Chrome/Blink only — Safari is fine
On the same machine and the same fractionally-scaled HiDPI display:
- Safari (WebKit): renders the label correctly wrapped. No issue.
- Chrome/Blink: the label stays on one line and overflows the node.
So this is not a general layout problem — it is Chrome/Blink's sub-pixel getBoundingClientRect() rounding under fractional display scaling landing a hair off the integer width. Firefox and any devicePixelRatio = 1 setup also wrap correctly, which is exactly why the bug is easy to miss: the same Mermaid version in the same Chrome wraps on one machine and overflows on another, purely due to display scaling.
Environment
- Mermaid 11.15.0 (the logic is unchanged on
develop). - Google Chrome 148, macOS (Apple Silicon, M4), scaled HiDPI display.
- Note: headless Chromium via CDP
Emulation.setDeviceMetricsOverride(deviceScaleFactor tested at 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5) clamps the measurement to exactly the integer width and does not reproduce. A standard CI/headless setup may therefore not surface this, even though the strict-equality logic is clearly fragile.
Steps to reproduce
On Chrome running on a fractionally-scaled HiDPI display, render an htmlLabels: true flowchart node whose plain-text label (no <br/>, no backtick markdown string) is wider than flowchart.wrappingWidth:
flowchart TD
a["A deliberately long single line node label without any br tag that exceeds the wrapping width"]
The label renders on one line and overflows the node box. DOM probe: the label div has white-space: nowrap, and its foreignObject is approximately wrappingWidth wide but only one line tall.
- Expected: the label wraps at
wrappingWidth, as it does at devicePixelRatio = 1. - Actual: the label does not wrap and overflows the node.
What actually fixed it
In the affected Chrome, no CSS override or Mermaid config option resolved the overflow. The only fix was patching this exact comparison in a vendored 11.15.0 build:
- if (bbox.width === width) {
+ if (bbox.width >= width - 1) { // clamped to (near) max-width => needs wrapping
After that one-character change, the affected Chrome wraps correctly, while devicePixelRatio = 1 and Safari behavior are unchanged. Math.abs(bbox.width - width) < 1 would work equally well. Short labels whose natural width is well below width still do not trigger wrapping.
Related (different root cause)
#6110, #5785, #4391
Source: mermaid-js/mermaid