Skip to content

perf(rendering): cut measure-phase reflows for node sizing#7871

Merged
pbrolin47 merged 5 commits into
developfrom
perf/measure-phase-reflows
Jun 18, 2026
Merged

perf(rendering): cut measure-phase reflows for node sizing#7871
pbrolin47 merged 5 commits into
developfrom
perf/measure-phase-reflows

Conversation

@knsv-bot

Copy link
Copy Markdown
Collaborator

Summary

Two targeted forced-reflow reductions in the per-node measure path. Layout-agnostic
(dagre + elk), no visual change. Part 2 of a large-diagram render-perf series
(profiler → this → batching). Stacked on #7870.

Evidence

A Chrome trace of a large dagre flowchart put measure at 461 ms / 55% of render,
of which instrumented Layout (forced reflow) was 173 ms across 2,216 events
~2 forced reflows per node (write DOM → read size → write → read …).

Changes

  1. Skip the dead getBBox for HTML labels (shapes/util.ts): for HTML labels
    the SVG <text> getBBox() result is immediately overwritten by the inner div's
    getBoundingClientRect() (and text is the oversized foreignObject), so it's a
    dead read. Measure getBBox only on the non-HTML path.
  2. Analytic bounds for plain rects (drawRect.ts + updateNodeBounds): a plain
    <rect>'s getBBox() equals the width/height just drawn, so pass those known
    bounds instead of forcing a reflow. Hand-drawn (roughjs) rects still measure.

Where it helps

These remove forced reflows that dominate the cold first render (page load). In
the warm re-render benchmark forced reflow is only ~4% of measure, so the win here
is first-paint latency; the steady-state win is in the follow-up batching PR.

Testing

Unit tests pass; cypress visual snapshots clean (rendered SVG unchanged).

🤖 Generated with Claude Code

knsv and others added 2 commits June 17, 2026 09:09
Two forced-reflow reductions in the per-node measure path, the dominant cost
of rendering large diagrams:

- labelHelper/insertLabel: for HTML labels the SVG <text> getBBox() result is
  immediately overwritten by the inner div's getBoundingClientRect (text is the
  oversized foreignObject). Skip that dead read on the HTML path.
- drawRect: a plain axis-aligned <rect>'s getBBox equals the width/height we
  just drew, so pass those known bounds to updateNodeBounds instead of forcing
  a getBBox reflow. Hand-drawn (roughjs) rects still measure, since their paths
  overflow the nominal box.

updateNodeBounds gains an optional knownBounds argument for callers that
already know their exact rendered geometry.

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

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9cf68a6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
mermaid Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 2.91%. Comparing base (a2d9686) to head (9cf68a6).

Files with missing lines Patch % Lines
...g-util/layout-algorithms/dagre/mermaid-graphlib.js 0.00% 29 Missing ⚠️
.esbuild/dev-explorer/console-panel.ts 0.00% 27 Missing ⚠️
...c/rendering-util/rendering-elements/shapes/util.ts 0.00% 19 Missing ⚠️
...ndering-util/rendering-elements/shapes/drawRect.ts 0.00% 4 Missing ⚠️
...rc/rendering-util/rendering-elements/edgeMarker.ts 0.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           develop   #7871      +/-   ##
==========================================
- Coverage     2.91%   2.91%   -0.01%     
==========================================
  Files          657     657              
  Lines        70417   70452      +35     
  Branches       979     979              
==========================================
  Hits          2055    2055              
- Misses       68362   68397      +35     
Flag Coverage Δ
unit 2.91% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...rc/rendering-util/layout-algorithms/dagre/index.js 0.14% <ø> (+<0.01%) ⬆️
...rc/rendering-util/rendering-elements/edgeMarker.ts 1.01% <0.00%> (-0.04%) ⬇️
...ndering-util/rendering-elements/shapes/drawRect.ts 0.00% <0.00%> (ø)
...c/rendering-util/rendering-elements/shapes/util.ts 0.00% <0.00%> (ø)
.esbuild/dev-explorer/console-panel.ts 0.00% <0.00%> (ø)
...g-util/layout-algorithms/dagre/mermaid-graphlib.js 0.00% <0.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@argos-ci

argos-ci Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Jun 17, 2026, 1:27 PM

Base automatically changed from feat/render-profiler to develop June 17, 2026 12:29
On large diagrams the dagre path emits thousands of per-node/edge/cluster
log lines; whenever anything captures console output (the dev explorer, an
app logger, DevTools) that log volume — not the layout — dominates render
wall-clock.

- mermaid-graphlib.js: downgrade ~20 misleveled `log.warn` algorithm traces
  to `log.debug` (they are internal diagnostics, not user warnings)
- dagre/index.js: downgrade the per-render "Graph at first" `log.warn` to debug
- edgeMarker.ts: stop warning for `none`/empty arrow type (valid "no
  arrowhead"); only genuinely unknown types warn
- dev-explorer console panel: cheap manual `formatTs` (drop the `Intl`
  `toLocaleTimeString`), batch appends into one render per frame, and cap at
  5000 entries — it was O(n²) on log volume (9s+ self-time in CPU profiles)

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

netlify Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploy Preview for mermaid-js ready!

Name Link
🔨 Latest commit 9cf68a6
🔍 Latest deploy log https://app.netlify.com/projects/mermaid-js/deploys/6a329bbfd49b8500080cde83
😎 Deploy Preview https://deploy-preview-7871--mermaid-js.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@knsv-bot knsv-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[sisyphus-bot]

Nice, surgical perf work — and thank you for the profiler trace and the "why" comments, they made this fast to verify. Since it's a draft, treating this as early-stage direction feedback rather than a merge gate; nothing below is a blocker. I read the full diff at head 8d874c4, traced the three rendering changes for behavioral equivalence, and ran an XSS pass.

What's working well

  • 🎉 The "no visual change" claim checks out on all three rendering changes. I verified each rather than taking it on faith:
    • updateNodeBounds (shapes/util.ts) — the existing getBBox() path sets only node.width/node.height, and the new knownBounds early-return sets exactly those two and nothing else. No side effect is skipped.
    • drawRect.ts:68-74 — for a plain <rect> drawn with .attr('width', totalWidth).attr('height', totalHeight), getBBox() returns exactly those dimensions (stroke width and rx/ry don't affect the geometry bbox), so the analytic bounds are exact. Keeping hand-drawn/roughjs on the real-measurement path is the right call — those paths overflow the nominal box.
    • The HTML-label getBBox() skip is a genuine dead read — the value was unconditionally overwritten by div.getBoundingClientRect() before any use, and label content still flows through the same sanitizeText/measurement path.
    • edgeMarker.ts:80-85 — for 'none'/empty, the old code already fell into the !arrowTypeInfo branch and returned without writing any marker attribute, so the early return suppresses only a log.warn and an already-no-op path. Non-'none' arrow types are byte-identical.
  • 🎉 The knownBounds JSDoc is exactly the warning a future caller needs — spelling out "only pass this when the value equals what getBBox() would return" is what keeps this from being misused on a shape where it isn't safe.
  • 🎉 The dev console-panel O(n²) fix is a tidy bit of work — coalescing per-entry this.logs = [...] reassignments into one rAF-batched, bounded update is the right shape, and clear() correctly cancels the pending frame.

Things to consider (for when you take it out of draft)

🟡 The log-level downgrade and the real per-render cost are two different things

The warn → debug downgrades in mermaid-graphlib.js / dagre/index.js change console output, but the expensive part of several of those lines is the argument, which JS evaluates before the call regardless of level. At Mermaid's default logLevel: 'fatal', log.warn and log.debug are both no-op functions (logger.ts:41-72) — yet log.debug('Graph at first:', JSON.stringify(graphlibJson.write(graph))) still runs the full-graph serialization every render, and extractor does a graphlibJson.write(graph) per recursion. So:

  • The downgrade is output-neutral at the default level (neither warn nor debug prints); it only changes what reaches the console for consumers at warn/info (or the dev profiler capturing everything — which the console-panel.ts companion fix addresses).
  • The CPU cost that would actually show up in a production large-diagram render — the eager JSON.stringify(graphlibJson.write(graph)) / graphlibJson.write(graph) argument expressions — is untouched by this PR and still runs at every level.

If the production-render win is the goal, the higher-leverage follow-up is to stop building those serialized strings (guard behind a level check, or only pass the raw graph and let the logger stringify lazily) rather than only changing the level. Not blocking, and the changeset is honest that this is about log volume — just flagging that the perf benefit here is mostly the dev-tooling path, not default renders. Also note dagre/index.js still has one log.warn('Graph after XAX:', JSON.stringify(graphlibJson.write(graph))) left at warn next to the one you downgraded — likely an oversight given the others.

💡 Changeset scope

The changeset (dagre-quiet-debug-logs.md) covers only the logging change. The forced-reflow reductions (util.ts / drawRect.ts) are the headline of the PR and are user-affecting (faster first paint), so they'd be worth their own changeset line — or, if this is intentionally landing as one entry for the whole perf series, a one-word note to that effect would save a future reader the double-take.

Housekeeping

  • The PR description carries a 🤖 Generated with Claude Code trailer, which the repo's own contribution policy asks us to keep out of PR descriptions — worth stripping before this leaves draft.
  • mergeStateStatus is currently DIRTY (conflicts with develop) since it's stacked on #7870 — expected for the stack, just noting it'll need a rebase once #7870 lands so CI/Argos can run cleanly.

Security

Ran a dedicated XSS/injection pass. No XSS or injection issues identified. The substituted bounds are numeric layout sizes (never reach a DOM attribute as attacker-influenced strings), the edgeMarker early-return doesn't change any marker URL/attribute for valid arrow types, no new innerHTML/href/foreignObject/sink is introduced, DOMPurify config is untouched, and the dev console-panel binds log messages via Lit escaped text (no unsafeHTML).


Really clean change overall — the correctness reasoning is sound and the safety boundary (hand-drawn still measures) is exactly right. Looking forward to the batching PR that closes out the steady-state side. 🙏

Severity tally: 🔴 0 · 🟡 1 · 💡 1 · 🎉 3 — draft PR, so posted as COMMENT. Reviewed the full diff at head 8d874c4; XSS pass clean; did not run the suite (working tree is on an unrelated branch).

…-reflows

# Conflicts:
#	packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts
@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@mermaid-js/examples

npm i https://pkg.pr.new/@mermaid-js/examples@7871

mermaid

npm i https://pkg.pr.new/mermaid@7871

@mermaid-js/layout-elk

npm i https://pkg.pr.new/@mermaid-js/layout-elk@7871

@mermaid-js/layout-tidy-tree

npm i https://pkg.pr.new/@mermaid-js/layout-tidy-tree@7871

@mermaid-js/mermaid-zenuml

npm i https://pkg.pr.new/@mermaid-js/mermaid-zenuml@7871

@mermaid-js/parser

npm i https://pkg.pr.new/@mermaid-js/parser@7871

@mermaid-js/tiny

npm i https://pkg.pr.new/@mermaid-js/tiny@7871

commit: 9cf68a6

Addresses review feedback on #7871: the warn→debug downgrade doesn't remove the
real per-render cost, because the argument `JSON.stringify(graphlibJson.write(graph))`
/ `graphlibJson.write(graph)` serializes the whole graph and JS evaluates it on
every render regardless of log level (both warn and debug are no-ops at the
default `fatal` level).

Comment those serializing logs out so the serialization never runs, and drop the
now-unused `graphlibJson` import from both files. Also disables the leftover
`log.warn('Graph after XAX', …)` the review flagged. Cheap per-element traces
remain at `log.debug`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@knsv knsv marked this pull request as ready for review June 17, 2026 13:10
@pbrolin47

pbrolin47 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Hi @knsv-bot

Some minor things to address for this PR:
[sisyphos-bot]


What's working well

🎉 The knownBounds optimization in updateNodeBounds is the right abstraction. For axis-aligned plain rects, width and height ARE analytically exact at draw time — passing
undefined for handDrawn is the correct carve-out since rough.js sketch strokes can extend beyond the computed bounds.

🎉 Commit 3's insight is the most important correctness fix in this PR: JSON.stringify(graphlibJson.write(graph)) evaluates eagerly as a function argument, so JavaScript builds
the full serialized string even when the logger fires at fatal level and no-ops. Downgrading to debug in commit 2 doesn't remove the CPU cost; commenting out the call site is
the only real fix. This is subtle and correct.

🎉 The console-panel O(n²) fix is well-designed. Swapping synchronous per-entry DOM appends for RAF-batched #pending flushes is the right approach, and the 5000-entry cap
prevents the panel from becoming its own memory leak under a verbose diagrams session.

🎉 The edgeMarker.ts early return is a clean semantic fix — none IS a valid "no arrowhead" signal, not an unknown type, and silencing that log.warn removes misleading noise from
the console.


Things to address

🟢 [nit] updateNodeBounds — knownBounds guard doesn't account for zero dimensions
packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts

If knownBounds is { width: 0, height: 0 } (e.g. a degenerate zero-size rect), the function returns early and sets node.width = 0, node.height = 0. The getBBox() fallback would
at least get a real measurement. Not a blocker — degenerate rects shouldn't occur in practice — but worth a comment explaining the assumption that callers only pass knownBounds
when dimensions are non-trivially known.

🟢 [nit] dagre/mermaid-graphlib.js — one stray log.warn remains
Line ~134: log.warn('Graph after XAX:', JSON.stringify(...)) — verify commit 3 comments this out in the final state. The patch lines up, but the "XAX" comment reads like a debug
artifact that was never cleaned up regardless of level.


Tests

The knownBounds fast path doesn't have a dedicated unit test, but that's acceptable here — correctness is verified by the full Cypress visual suite, and the path is simple
enough that a visual regression would catch any bounds miscalculation. The dagre log changes have no observable behavior to test.


Changeset

dagre-quiet-debug-logs.md — patch bump — present and appropriately scoped. ✓

@pbrolin47 pbrolin47 added this pull request to the merge queue Jun 18, 2026
Merged via the queue into develop with commit 35d0761 Jun 18, 2026
31 checks passed
@pbrolin47 pbrolin47 deleted the perf/measure-phase-reflows branch June 18, 2026 07:54
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.

3 participants