From e1789ee3d1b1c6eb2bc89503b3b56f368562506c Mon Sep 17 00:00:00 2001 From: ishikawa999 <14245262+ishikawa999@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:34:49 +0900 Subject: [PATCH] Hide Mermaid code blocks until they are rendered to avoid flashing their source The source of a Mermaid code block was visible until Mermaid.js had been loaded and had rendered the diagram, and then it was replaced with the diagram, which looked like a flicker. The code blocks are now hidden with visibility: hidden from the first paint when Mermaid.js is available, keeping their space so that the content below moves only once. The source is shown again when Mermaid.js fails to load or the diagram cannot be rendered. --- app/assets/stylesheets/application.css | 7 +++++++ app/helpers/application_helper.rb | 6 +++++- .../controllers/mermaid_controller.js | 16 ++++++++++++++- test/helpers/application_helper_test.rb | 5 +++++ test/system/mermaid_rendering_test.rb | 20 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d9b598341b..0a7845014d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1991,6 +1991,13 @@ div.wiki div.pre-wrapper { position: relative; } +/* Hide Mermaid code blocks until they are rendered so that their source does not flash. + visibility keeps their space so that the content below does not jump twice. + The mermaid Stimulus controller adds mermaid-failed to show the source again on failure. */ +html.mermaid-available pre:has(> code[data-controller="mermaid"]):not(.mermaid-failed) { + visibility: hidden; +} + div.wiki pre { margin-block: 1em; margin-inline: 1.6em 1em; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ea533c776e..607c8ae6cc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1834,10 +1834,14 @@ module ApplicationHelper # several MB, so it is not part of every page load. When it is available, # its URL is exposed here and the mermaid Stimulus controller loads it # only when a Mermaid code block is actually present on the page. + # The mermaid-available class hides Mermaid code blocks from the first + # paint so that their source does not flash before the diagram replaces + # it. It is set by JavaScript so that the blocks stay visible without it. if Redmine::Mermaid.available? tags << javascript_tag( - "window.MermaidAssetUrl = #{asset_path(Redmine::Mermaid::FILENAME).to_json};" + "window.MermaidAssetUrl = #{asset_path(Redmine::Mermaid::FILENAME).to_json};" \ + "document.documentElement.classList.add('mermaid-available');" ) end unless User.current.pref.warn_on_leaving_unsaved == '0' diff --git a/app/javascript/controllers/mermaid_controller.js b/app/javascript/controllers/mermaid_controller.js index 1b95f6c7e1..38c63f733b 100644 --- a/app/javascript/controllers/mermaid_controller.js +++ b/app/javascript/controllers/mermaid_controller.js @@ -56,6 +56,8 @@ export default class extends Controller { if (this.element.isConnected) this.render(mermaid); }).catch((error) => { console.error('Failed to load Mermaid.js:', error); + // Show the source that the mermaid-available CSS rule hides. + this.element.closest('pre')?.classList.add('mermaid-failed'); }); } @@ -65,7 +67,10 @@ export default class extends Controller { this.errorElement?.remove(); this.container = this.errorElement = null; const pre = this.element.closest('pre'); - if (pre) pre.style.display = ''; + if (pre) { + pre.style.display = ''; + pre.classList.remove('mermaid-failed'); + } } render(mermaid) { @@ -80,6 +85,13 @@ export default class extends Controller { const container = document.createElement('div'); container.className = 'mermaid'; container.textContent = this.element.textContent.trim(); + // Keep the source text in the container from flashing until Mermaid.js + // replaces it with the diagram. Mermaid.js measures the diagram inside + // the container, so it is made invisible rather than display: none, and + // taken out of the flow so that only the hidden
reserves space.
+ container.style.visibility = 'hidden';
+ container.style.position = 'absolute';
+ container.style.insetInline = '0';
pre.insertAdjacentElement('afterend', container);
this.container = container;
@@ -87,9 +99,11 @@ export default class extends Controller {
// visible, so that the invalid diagram can be inspected and copied.
mermaid.run({ nodes: [container], suppressErrors: false }).then(() => {
pre.style.display = 'none';
+ container.style.visibility = container.style.position = container.style.insetInline = '';
}).catch((error) => {
container.remove();
if (this.container === container) this.container = null;
+ pre.classList.add('mermaid-failed');
const errorElement = document.createElement('div');
errorElement.className = 'flash error';
diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb
index ca60d176fb..c7b1afa4c8 100644
--- a/test/helpers/application_helper_test.rb
+++ b/test/helpers/application_helper_test.rb
@@ -2220,6 +2220,11 @@ class ApplicationHelperTest < Redmine::HelperTest
assert_not_include 'MermaidAssetUrl', javascript_heads
end
+ def test_javascript_heads_should_add_mermaid_available_class_when_mermaid_is_available
+ Redmine::Mermaid.stubs(:available?).returns(true)
+ assert_include "document.documentElement.classList.add('mermaid-available');", javascript_heads
+ end
+
def test_favicon_path
assert_match %r{^/assets/favicon-\w+\.ico}, favicon_path
end
diff --git a/test/system/mermaid_rendering_test.rb b/test/system/mermaid_rendering_test.rb
index 2119d7c8de..cf29d5e25c 100644
--- a/test/system/mermaid_rendering_test.rb
+++ b/test/system/mermaid_rendering_test.rb
@@ -88,6 +88,26 @@ class MermaidRenderingTest < ApplicationSystemTestCase
end
end
+ def test_should_show_mermaid_source_when_mermaid_js_fails_to_load
+ skip unless mermaid_installed?
+
+ log_user('jsmith', 'jsmith')
+ visit '/projects/ecookbook/issues/new'
+ # The source of Mermaid code blocks is hidden until rendered; it must be
+ # shown again if Mermaid.js cannot be loaded.
+ page.execute_script("window.MermaidAssetUrl = '/nonexistent-mermaid.js';")
+ within('form#issue-form') do
+ fill_in 'Subject', :with => 'mermaid load failure test'
+ fill_in 'Description', :with => "```mermaid\ngraph TD;\nA-->B;\n```"
+ click_link 'Preview'
+
+ within('div.wiki-preview') do
+ assert_selector 'pre.mermaid-failed', text: 'graph TD;'
+ assert_no_selector 'div.mermaid'
+ end
+ end
+ end
+
def test_should_not_execute_script_from_malicious_mermaid_syntax
skip unless mermaid_installed?
--
2.56.0