Skip to content

perf: use the fastdom library to avoid unnecessary reflows#7951

Open
aloisklink wants to merge 3 commits into
mermaid-js:developfrom
aloisklink:perf/use-fastdom-to-batch-reflows
Open

perf: use the fastdom library to avoid unnecessary reflows#7951
aloisklink wants to merge 3 commits into
mermaid-js:developfrom
aloisklink:perf/use-fastdom-to-batch-reflows

Conversation

@aloisklink

@aloisklink aloisklink commented Jul 7, 2026

Copy link
Copy Markdown
Member

📑 Summary

Currently, Mermaid suffers from a lot of layout thrashing, by constantly measuring bounding boxes/widths, then updating the DOM, and measuring bounding boxes/widths again, forcing a lot of reflows.

Using a library like fastdom can be used to easily batch these, to avoid unnecessary reflows (assuming that changes can run in parallel).

From my tests, a flowchart with 1000 labels can sometimes be 25-30%.

Closes #7872 (that PR also batches measurements, but without the fastdom library).

See: https://github.com/wilsonpage/fastdom

📏 Design Decisions

The fastdom library was used to batch, so that we could add the strict-fastdom tool later, which throws an error if fastdom is not used. I've made sure to pin the dependency to reduce the risk of any supply-chain attacks.

I've also only updated the simplest and highest impact code calls in this PR. There are other places where we can update this too, but we can leave those for future PRs. htmlLabels: false is still a big slowdown, since the current line-wrapping algorithm for SVG labels might do multiple reflows for a single label, but I've got an idea on how to fix that!

📋 Tasks

Make sure you

  • 📖 have read the contribution guidelines
  • 💻 have added necessary unit/e2e tests.
    • Covered by existing tests
  • 📓 have added documentation. Make sure MERMAID_RELEASE_VERSION is used for all new features.
    • N/A
  • 🦋 If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.
    • N/A

This PR introduces fastdom-based batching to reduce layout thrashing in Mermaid’s HTML/SVG label rendering paths.

Highlights

  • Adds a pinned fastdom dependency (1.0.12) and a local wrapper module to centralize scheduling behavior.
  • Updates label measurement in the main rendering hotspots to use fastdom.measure(...) instead of synchronous DOM reads.
  • Applies the batching approach to:
    • HTML text labels in addHtmlSpan
    • Edge label placement in insertEdgeLabel
    • Node label helpers in labelHelper / insertLabel
  • Adds linting guidance to prevent direct fastdom imports outside the wrapper.
  • Updates the spellcheck dictionary to recognize fastdom.

Impact

  • Reduces unnecessary reflows and DOM measurement churn during rendering.
  • Reported performance improvements reach roughly 25–30% in large flowchart benchmarks, with up to 15% gains in some hot paths.
  • Keeps the scope focused on the highest-impact rendering paths for now, with further refactoring deferred.

Add the `fastdom` library to batch reflows when `getBoundingBox()` is
called in `addHtmlSpan`, leading to a performance increase.

Only the `addHtmlSpan` function was edited at first, since it's the
common bottleneck, we can add more later.

I've pinned the used version of `fastdom` to v1.0.12 to lower the risk
of supply-chain attacks, since this library is quite simple, has no
dependencies (`strictdom` is a `dependency`, but not for any of the code
we use), and so is unlikely to need urgent bugfixes.
Extend the `fastdom` batching from `addHtmlSpan` to other hot
call sites: `insertEdgeLabel`, `labelHelper`, and `insertLabel`.

This increases performance by up to 15% in some cases.
Fixes a conflict with
6510e01 (feat(profiling): DevTools phase markers + per-op measure buckets, 2026-06-16)
that wrapped some `getBBox()` and `getBoundingClientRect()` calls.

Conflicts:
- packages/mermaid/package.json
- packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1795c5f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@netlify

netlify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploy Preview for mermaid-js ready!

Name Link
🔨 Latest commit 1795c5f
🔍 Latest deploy log https://app.netlify.com/projects/mermaid-js/deploys/6a4d22d27e367c0008f8e14b
😎 Deploy Preview https://deploy-preview-7951--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.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Lockfile Validation Failed

The following issue(s) were detected:

Please address these and push an update.

Posted automatically by GitHub Actions

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a fastdom wrapper module using microtask-based scheduling and fastdom-promised, adds it as a package dependency, restricts direct fastdom imports via ESLint, and replaces synchronous DOM bounding-box reads with fastdom.measure calls in createText.ts, edges.js, and shapes/util.ts.

Changes

Fastdom Measurement Integration

Layer / File(s) Summary
Fastdom wrapper and dependency setup
packages/mermaid/src/rendering-util/fastdom.ts, packages/mermaid/package.json, eslint.config.js, .cspell/libraries.txt
New wrapper module overrides raf scheduling to use queueMicrotask/setTimeout and composes fastdom-promised; fastdom added as a dependency; ESLint restricts direct fastdom imports pointing to the wrapper; spellcheck dictionary updated.
HTML label measurement
packages/mermaid/src/rendering-util/createText.ts
addHtmlSpan now measures the label div's bounding box via fastdom.measure(...) instead of a direct synchronous call.
Edge label measurement
packages/mermaid/src/rendering-util/rendering-elements/edges.js
insertEdgeLabel measures both HTML and SVG label dimensions via fastdom.measure(...) before computing label transforms.
Shape label measurement
packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts
labelHelper and insertLabel wrap getBoundingClientRect()/getBBox() calls in fastdom.measure(...); a TODO notes future async batching in updateNodeBounds.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Renderer as Rendering Code
    participant Fastdom as fastdom wrapper
    participant Scheduler as queueMicrotask/setTimeout
    participant DOM as Browser DOM

    Renderer->>Fastdom: fastdom.measure(() => element.getBoundingClientRect())
    Fastdom->>Scheduler: schedule read via raf override
    Scheduler->>DOM: execute getBoundingClientRect()/getBBox()
    DOM-->>Scheduler: bounding box result
    Scheduler-->>Fastdom: resolve promise
    Fastdom-->>Renderer: bbox/transformBbox
    Renderer->>Renderer: apply computeLabelTransform / style updates
Loading

Related PRs: None identified.

Suggested labels: performance, dependencies

Suggested reviewers: None identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly captures the main change: adopting fastdom to reduce unnecessary reflows.
Description check ✅ Passed The description matches the template well with Summary, Design Decisions, and Tasks; it only lacks an explicit issue-id line.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@mermaid-js/examples

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

mermaid

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

@mermaid-js/layout-elk

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

@mermaid-js/layout-tidy-tree

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

@mermaid-js/mermaid-zenuml

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

@mermaid-js/parser

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

@mermaid-js/tiny

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

commit: 1795c5f

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.57%. Comparing base (0d1ee0a) to head (1795c5f).
⚠️ Report is 13 commits behind head on develop.

Files with missing lines Patch % Lines
packages/mermaid/src/rendering-util/fastdom.ts 86.66% 2 Missing ⚠️
...c/rendering-util/rendering-elements/shapes/util.ts 77.77% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #7951      +/-   ##
===========================================
+ Coverage    77.42%   77.57%   +0.15%     
===========================================
  Files          564      565       +1     
  Lines        74900    74930      +30     
  Branches     12659    14626    +1967     
===========================================
+ Hits         57988    58125     +137     
+ Misses       15915    15806     -109     
- Partials       997      999       +2     
Flag Coverage Δ
e2e 70.81% <85.00%> (+0.40%) ⬆️
unit 74.83% <85.00%> (+<0.01%) ⬆️

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

Files with missing lines Coverage Δ
packages/mermaid/src/rendering-util/createText.ts 97.38% <100.00%> (ø)
...aid/src/rendering-util/rendering-elements/edges.js 74.78% <100.00%> (+0.21%) ⬆️
packages/mermaid/src/rendering-util/fastdom.ts 86.66% <86.66%> (ø)
...c/rendering-util/rendering-elements/shapes/util.ts 73.25% <77.77%> (+0.11%) ⬆️

... and 11 files with indirect coverage changes

🚀 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
eslint.config.js (1)

74-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restriction only covers the bare fastdom specifier, not subpath imports.

paths: [{ name: 'fastdom' }] blocks import ... from 'fastdom' but not import ... from 'fastdom/extensions/fastdom-promised.js', which fastdom.ts itself uses. A future contributor could import that subpath directly elsewhere, bypassing the wrapper without triggering this rule.

Suggested fix to also block subpath imports
       '`@typescript-eslint/no-restricted-imports`': [
         'error',
         {
           paths: [
             {
               name: 'fastdom',
               message:
                 'Use the `fastdom` wrapper from `rendering-util/fastdom.ts` instead of importing `fastdom` directly.',
             },
           ],
+          patterns: [
+            {
+              group: ['fastdom/*'],
+              message:
+                'Use the `fastdom` wrapper from `rendering-util/fastdom.ts` instead of importing `fastdom` directly.',
+            },
+          ],
         },
       ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@eslint.config.js` around lines 74 - 85, The
`@typescript-eslint/no-restricted-imports` rule in `eslint.config.js` only
blocks the bare `fastdom` import and can still be bypassed with subpath imports.
Update the restriction entry for `fastdom` so it also matches and rejects
subpath specifiers like `fastdom/extensions/fastdom-promised.js`, keeping the
`rendering-util/fastdom.ts` wrapper as the only allowed entry point.
packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts (2)

85-86: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Same write-batching gap as dv.attr(...) in edges.js.

The width/height attribute writes here also execute synchronously right after the awaited fastdom.measure, outside a fastdom.mutate call, leaving the write phase unbatched. Same recommendation as raised in edges.js: wrap these in fastdom.mutate if the wrapper exposes it, to complete the read/write separation.

Also applies to: 150-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts` around
lines 85 - 86, The width and height attribute updates in the rendering path are
being applied immediately after the `fastdom.measure` read, so they bypass write
batching. Update the `dv.attr(...)` calls in the relevant shape rendering helper
to run inside `fastdom.mutate` when available, matching the read/write
separation used elsewhere such as in `edges.js` and the same helper’s other
`dv.attr` writes.

80-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate fastdom.measure logic between labelHelper and insertLabel.

Both functions now carry nearly identical measure blocks (HTML getBoundingClientRect vs SVG getBBox, each with the same injected.profiling && profiler.tickSync branching). Since both were touched in this change, extracting a shared measureLabelBBox(useHtml, div, text) helper would reduce the risk of the two copies diverging (e.g., if profiling instrumentation or fastdom usage is later updated in only one place).

Also applies to: 145-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts` around
lines 80 - 93, The `fastdom.measure` sizing logic is duplicated in `labelHelper`
and `insertLabel`, with the same `injected.profiling && profiler.tickSync`
branching for HTML and SVG measurement. Extract that shared behavior into a
helper like `measureLabelBBox(useHtml, div, text)` in the rendering utilities,
then have both functions call it so the `getBoundingClientRect`/`getBBox` and
profiling paths stay consistent in one place.
packages/mermaid/src/rendering-util/rendering-elements/edges.js (1)

114-117: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider batching the follow-up writes too.

dv.attr('width', ...)/dv.attr('height', ...) execute synchronously right after the awaited fastdom.measure resolves, outside any fastdom.mutate call. This keeps the read phase batched but leaves writes unbatched, so a write can still interleave with another pending read scheduled in the same microtask cycle, partially undermining the reflow-avoidance goal. If the wrapper exposes fastdom.mutate (typical of fastdom-promised), wrapping these writes would complete the read/write separation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mermaid/src/rendering-util/rendering-elements/edges.js` around lines
114 - 117, The `edges.js` rendering flow batches the read with
`fastdom.measure`, but the subsequent `dv.attr('width', ...)` and
`dv.attr('height', ...)` writes are still performed synchronously outside a
mutate phase. Update the logic around the `bbox`/`transformBbox` assignment in
the edge rendering path to wrap those attribute updates in `fastdom.mutate` (if
available from the fastdom wrapper) so the read/write separation is preserved
and follow-up writes stay batched.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@eslint.config.js`:
- Around line 74-85: The `@typescript-eslint/no-restricted-imports` rule in
`eslint.config.js` only blocks the bare `fastdom` import and can still be
bypassed with subpath imports. Update the restriction entry for `fastdom` so it
also matches and rejects subpath specifiers like
`fastdom/extensions/fastdom-promised.js`, keeping the
`rendering-util/fastdom.ts` wrapper as the only allowed entry point.

In `@packages/mermaid/src/rendering-util/rendering-elements/edges.js`:
- Around line 114-117: The `edges.js` rendering flow batches the read with
`fastdom.measure`, but the subsequent `dv.attr('width', ...)` and
`dv.attr('height', ...)` writes are still performed synchronously outside a
mutate phase. Update the logic around the `bbox`/`transformBbox` assignment in
the edge rendering path to wrap those attribute updates in `fastdom.mutate` (if
available from the fastdom wrapper) so the read/write separation is preserved
and follow-up writes stay batched.

In `@packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts`:
- Around line 85-86: The width and height attribute updates in the rendering
path are being applied immediately after the `fastdom.measure` read, so they
bypass write batching. Update the `dv.attr(...)` calls in the relevant shape
rendering helper to run inside `fastdom.mutate` when available, matching the
read/write separation used elsewhere such as in `edges.js` and the same helper’s
other `dv.attr` writes.
- Around line 80-93: The `fastdom.measure` sizing logic is duplicated in
`labelHelper` and `insertLabel`, with the same `injected.profiling &&
profiler.tickSync` branching for HTML and SVG measurement. Extract that shared
behavior into a helper like `measureLabelBBox(useHtml, div, text)` in the
rendering utilities, then have both functions call it so the
`getBoundingClientRect`/`getBBox` and profiling paths stay consistent in one
place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 467cc4a9-208c-46db-85e5-dfee104fa66f

📥 Commits

Reviewing files that changed from the base of the PR and between f3dea58 and 1795c5f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • .cspell/libraries.txt
  • eslint.config.js
  • packages/mermaid/package.json
  • packages/mermaid/src/rendering-util/createText.ts
  • packages/mermaid/src/rendering-util/fastdom.ts
  • packages/mermaid/src/rendering-util/rendering-elements/edges.js
  • packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts

@argos-ci

argos-ci Bot commented Jul 7, 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 - Jul 7, 2026, 4:22 PM

node.height = knownBounds.height;
return;
}
// TODO: Make this function `async` and use `fastdom.measure` to batch reflow

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shall this be fixed within this PR??

@pbrolin47

pbrolin47 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this PR @aloisklink

[sisyphos-bot]


What's working well

🎉 Exact version pin on "fastdom": "1.0.12" is good supply-chain hygiene for a library that ends up in every user's bundle.

🎉 The ESLint no-restricted-imports rule forcing all code through the wrapper is excellent architecture. Centralizing scheduling means a future swap (different scheduler, debug instrumentation) is a
one-liner in one file, not a grep-and-replace.

🎉 queueMicrotask over requestAnimationFrame is a clever tradeoff. rAF delays reads to the next frame (~16ms at 60fps); queueMicrotask batches without the artificial latency. The setTimeout(cb, 0)
fallback for environments lacking queueMicrotask is correctly handled.

🎉 The dead re-measure in createText.ts was correctly removed. The original code called getBoundingClientRect() a second time inside the if (bbox.width === width) block, but the result was assigned
back to bbox and never consumed — the function returns fo.node()!, not bbox. Removing that is a genuine free win.

🎉 Incremental scope is the right call here. Changing every DOM read site at once across shared rendering code would be a review and regression nightmare. Limiting to the three highest-impact
hotspots with a TODO comment at updateNodeBounds is sensible.


Things to address

🟡 [important] Missing changeset — packages/mermaid gained a new runtime dependency and a reported 25–30% performance improvement on large diagrams. The PR marks changeset N/A, but CONTRIBUTING.md
requires one for any user-facing change, and a quarter-faster rendering on large flowcharts is clearly user-facing. A patch is fine.


Nits

🟢 JSDoc typo in edges.js — /** @type {@DOMRect} / has a spurious @ before the type name. Should be /* @type {DOMRect} */.

🟢 ESLint restriction doesn't cover sub-path imports — the rule bans 'fastdom' but not 'fastdom/extensions/fastdom-promised.js'. A developer could import the promisified extension directly without a
lint error. Worth adding a second entry:
{
name: 'fastdom/extensions/fastdom-promised.js',
message: 'Use the fastdom wrapper from rendering-util/fastdom.ts instead.',
}


Security

XSS sub-agent reviewed all changed files — no issues found. All fastdom.measure() callbacks contain only reads (getBoundingClientRect, getBBox). No DOM writes, no innerHTML, no eval-equivalent calls
were added. DOMPurify sanitization of the final SVG output is unaffected; the fastdom library itself only schedules callbacks and does not manipulate the DOM. The fastdom dependency is tiny (~700
bytes gzipped), MIT licensed, and fully pinned.

One forward-looking note: the wrapper exports the full fastdom object including .mutate(). No current code uses it, but if a future caller puts user-controlled content inside fastdom.mutate(), the
normal write-safety review applies.


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