Repository: zauberzeug/nicegui
Pull request #4722 (2025-05-09 07:27:57)

Use Mermaid API directly to fix nested diagram inside `ui.dialog` inside `ui.markdown`
This PR is extremely similar in nature to #4692, in that it fixes: 
- broken text as in https://github.com/zauberzeug/nicegui/pull/4170#issuecomment-2865321544
- showing the mermaid diagrams source code for about half a second whenever a page is refreshed containing the markdown component using mermaid, as in https://github.com/zauberzeug/nicegui/pull/4170#issue-2767685163

by, again invoking the Mermaid API exactly as prescribed at https://mermaid.js.org/config/usage.html#api-usage

Notable differences compared to past attempt at #4170:

- Uses `mermaid.render` not `mermaid.run`, more easy API integration
- `this.mermaid.initialize({ startOnLoad: false });` should not be awaited, according to the Mermaid docs.
- Since the SVG is added by me because I control the API usage, I always make the text hidden no-matter-what in the CSS, and add the SVG in another class so that it shows up. No need stuff like `.mermaid[data-processed="true"]``

Notable differences compared to #4692:

- Stores the last rendered Mermaid diagram, since the markdown is somehow updated when the `ui.dialog` finishes the animation, and otherwise we'd render twice and there'd be Layout Shift. 

There are still tasks to do:

- [x] Address multiple-mermaid situation (I'd imagine this implementation breaking, especially with the caching of rendered diagrams).

But the mermaid integration with markdown isn't perfect. There are still some tasks on my wishlist. I hope to address some of them some day soon:

- Cannot influence the mermaid initialize parameters so as to enable click events like https://nicegui.io/documentation/mermaid#handle_click_events
- Cannot listen for mermaid error messages like https://nicegui.io/documentation/mermaid#handle_errors

---

Test Script (updated to include multi-mermaid scenario):

<details>
<summary> It's a bit long but it works </summary>

```py
from nicegui import ui
import random


def generate_random_graph():
    nodes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
    two_random_nodes = random.sample(nodes, 2)
    # Generate a random graph with 1 edge
    edges = [f"{two_random_nodes[0]} --> {two_random_nodes[1]}"]
    return f"graph TD; {' '.join(edges)}"


@ui.page('/mermaid_inside_markdown')
def mermaid_inside_markdown():
    md = ui.markdown('''
    - Mermaid inside markdown

    ```mermaid
    graph TD;
        A-->B;
        A-->C;
        B-->D;
        C-->D;
    ```
''', extras=['mermaid'])

# mermaid inside markdown inside ui.dialog


@ui.page('/mermaid_inside_markdown_dialog')
def mermaid_inside_markdown_dialog():
    with ui.dialog(value=True).props('transition-duration=1500'), ui.card():
        my_markdown = ui.markdown('''
        - Mermaid inside markdown inside dialog        

        ```mermaid
        graph TD;
            A-->B;
            A-->C;
            B-->D;
            C-->D;
        ```
        ''', extras=['mermaid'])

        def change_to_random_markdown_with_mermaid():
            my_markdown.set_content(f'''
            - Mermaid inside markdown inside dialog        

            ```mermaid
            {generate_random_graph()}
            ```
            ''')

        def change_to_markdown_with_errorneous_mermaid():
            my_markdown.set_content('''
            - Mermaid inside markdown inside dialog        

            ```mermaid
            graph TD;
                A-->B;
                A->C;
            ```
            ''')

        def change_to_markdown_with_many_mermaid():
            my_markdown.set_content(f'''
            - Mermaid inside markdown inside dialog        

            ```mermaid
            {generate_random_graph()}
            ```
            ```mermaid
            {generate_random_graph()}
            ```
            ```mermaid
            {generate_random_graph()}
            ```
            ''')
        ui.button('Change to random graph', on_click=change_to_random_markdown_with_mermaid)
        ui.button('Change to erroneous graph', on_click=change_to_markdown_with_errorneous_mermaid)
        ui.button('Change to many graphs', on_click=change_to_markdown_with_many_mermaid)

# many meriaid inside markdown inside dialog


@ui.page('/mermaid_inside_markdown_dialog_many')
def mermaid_inside_markdown_dialog_many():
    with ui.dialog(value=True).props('transition-duration=1500'), ui.card():
        my_markdown = ui.markdown(f'''
        - Mermaid inside markdown inside dialog        

        ```mermaid
        {generate_random_graph()}
        ```
        ```mermaid
        {generate_random_graph()}
        ```
        ```mermaid
        {generate_random_graph()}
        ```
        ''', extras=['mermaid'])


ui.link('mermaid inside markdown', '/mermaid_inside_markdown')
ui.link('mermaid inside markdown dialog', '/mermaid_inside_markdown_dialog')
ui.link('mermaid inside markdown dialog many', '/mermaid_inside_markdown_dialog_many')

ui.run(show=False, port=9191)

```

</details>

<img width="253" alt="{7C71FC62-1331-43CC-900D-5682586111C3}" src="https://github.com/user-attachments/assets/3a2191f9-fe0c-4439-a279-12e6a2562792" />

<img width="271" alt="{28A96891-BF42-43B3-8027-D0C7E6CDD3C2}" src="https://github.com/user-attachments/assets/34c3afd5-528b-4177-bd1b-30a54c612736" />



evnchn: Multiple-mermaid is done with https://github.com/zauberzeug/nicegui/pull/4722/commits/0661f6ef864ecb72879b11c5081e180297f9a9a5

Test script is updated in the first comment
evnchn: Hi @falkoschindler I need some help with pytest. Not sure if what I done is right, but the tests are passing. 

I don't want to make the test too "dumb" and always pass. That just takes away from the purpose. So please assess. 

Patch:
diff --git a/nicegui/air.py b/nicegui/air.py
index a04fb4c2..5d2ab77d 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -14,8 +14,8 @@ import socketio.exceptions
 from . import background_tasks, core, helpers
 from .client import Client
 from .dataclasses import KWONLY_SLOTS
-from .elements.timer import Timer as timer
 from .logging import log
+from .timer import Timer as timer
 
 if TYPE_CHECKING:
     import httpx
@@ -178,6 +178,7 @@ class Air:
                 helpers.warn_once(self._host_unreachable_warning)
             else:
                 self.log.warning(f'Connection error: {message}')
+            await self.relay.disconnect()
 
         @self.relay.on('event')
         def _handle_event(data: Dict[str, Any]) -> None:
diff --git a/nicegui/elements/markdown.js b/nicegui/elements/markdown.js
index ad9f54ed..f6fdc912 100644
--- a/nicegui/elements/markdown.js
+++ b/nicegui/elements/markdown.js
@@ -7,12 +7,14 @@ export default {
     await loadResource(window.path_prefix + this.codehilite_css_url);
     if (this.use_mermaid) {
       this.mermaid = (await import("mermaid")).default;
+      this.mermaid.initialize({ startOnLoad: false });
       this.renderMermaid();
     }
   },
   data() {
     return {
       mermaid: null,
+      diagrams: {},
     };
   },
   updated() {
@@ -20,9 +22,33 @@ export default {
   },
   methods: {
     renderMermaid() {
+      // render new diagrams
+      const usedKeys = new Set();
       this.$el.querySelectorAll(".mermaid-pre").forEach(async (pre, i) => {
-        await this.mermaid.run({ nodes: [pre.children[0]] });
+        const key = pre.children[0].innerText + "\n" + i;
+        usedKeys.add(key);
+        if (!this.diagrams[key]) {
+          try {
+            this.diagrams[key] = await this.mermaid.render(this.$el.id + "_mermaid_" + i, pre.children[0].innerText);
+          } catch (error) {
+            this.diagrams[key] = await this.mermaid.render(this.$el.id + "_mermaid_" + i, "error");
+            console.error(error);
+          }
+        }
+        const svgElement = document.createElement("div");
+        svgElement.classList.add("mermaid-svg");
+        svgElement.innerHTML = this.diagrams[key].svg;
+        this.diagrams[key].bindFunctions?.(svgElement);
+        pre.querySelectorAll(".mermaid-svg").forEach((svg) => svg.remove());
+        pre.appendChild(svgElement);
       });
+
+      // prune cached diagrams that are not used anymore
+      for (const key in this.diagrams) {
+        if (!usedKeys.has(key)) {
+          delete this.diagrams[key];
+        }
+      }
     },
   },
   props: {
diff --git a/nicegui/functions/download.py b/nicegui/functions/download.py
index 804e0bf6..9fbe4669 100644
--- a/nicegui/functions/download.py
+++ b/nicegui/functions/download.py
@@ -10,7 +10,7 @@ class Download:
 
     These functions allow you to download files, URLs or raw data.
 
-    *Added in version 2.x.0*
+    *Added in version 2.14.0*
     """
 
     def __call__(self, src: Union[str, Path, bytes], filename: Optional[str] = None, media_type: str = '') -> None:
@@ -35,7 +35,7 @@ class Download:
 
         Function to trigger the download of a file.
 
-        *Added in version 2.x.0*
+        *Added in version 2.14.0*
 
         :param path: local path of the file
         :param filename: name of the file to download (default: name of the file on the server)
@@ -49,7 +49,7 @@ class Download:
 
         Function to trigger the download from a URL.
 
-        *Added in version 2.x.0*
+        *Added in version 2.14.0*
 
         :param url: URL
         :param filename: name of the file to download (default: name of the file on the server)
@@ -62,7 +62,7 @@ class Download:
 
         Function to trigger the download of raw data.
 
-        *Added in version 2.x.0*
+        *Added in version 2.14.0*
 
         :param content: raw bytes or string
         :param filename: name of the file to download (default: name of the file on the server)
diff --git a/nicegui/javascript_request.py b/nicegui/javascript_request.py
index 37903f24..716ab918 100644
--- a/nicegui/javascript_request.py
+++ b/nicegui/javascript_request.py
@@ -19,6 +19,8 @@ class JavaScriptRequest:
     @classmethod
     def resolve(cls, request_id: str, result: Any) -> None:
         """Store the result of a JavaScript request and unblock the awaiter."""
+        if request_id not in cls._instances:
+            return
         request = cls._instances[request_id]
         request._result = result  # pylint: disable=protected-access
         request._event.set()  # pylint: disable=protected-access
diff --git a/nicegui/static/nicegui.css b/nicegui/static/nicegui.css
index 7b83ae21..850be7c5 100644
--- a/nicegui/static/nicegui.css
+++ b/nicegui/static/nicegui.css
@@ -193,6 +193,9 @@
 .nicegui-markdown .codehilite pre {
   margin: 0.5rem 0;
 }
+.nicegui-markdown .mermaid-pre > .mermaid {
+  display: none;
+}
 
 /* other NiceGUI elements */
 .nicegui-grid {
diff --git a/npm.py b/npm.py
index 3344eb90..66f65b86 100755
--- a/npm.py
+++ b/npm.py
@@ -15,12 +15,15 @@ import json
 import re
 import shutil
 import tarfile
+import tempfile
 from argparse import ArgumentParser
 from pathlib import Path
 from typing import Dict, List
 
 import requests
 
+temp_dir = tempfile.TemporaryDirectory()
+
 parser = ArgumentParser()
 parser.add_argument('path', default='.', help='path to the root of the repository')
 parser.add_argument('--name', nargs='*', help='filter library updates by name')
@@ -44,9 +47,7 @@ def url_to_filename(url: str) -> str:
 
 
 def download_buffered(url: str) -> Path:
-    path = Path('/tmp/nicegui_dependencies')
-    path.mkdir(exist_ok=True)
-    filepath = path / url_to_filename(url)
+    filepath = Path(temp_dir.name) / url_to_filename(url)
     if not filepath.exists():
         response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=3)
         filepath.write_bytes(response.content)
@@ -121,6 +122,8 @@ for key, dependency in dependencies.items():
                 filename = filename.replace(rename, dependency['rename'][rename])
 
             newfile = prepare(Path(destination, filename))
+            if newfile.exists():
+                newfile.unlink()
             Path(tmp, key, extracted.name).rename(newfile)
 
             if 'GLTFLoader' in filename:
@@ -144,8 +147,13 @@ for key, dependency in dependencies.items():
                 content = re.sub(r'"\./chunks/mermaid.esm.min/(.*?)\.mjs"', r'"\1"', content)
                 newfile.write_text(content, encoding='utf-8')
 
-    # Delete destination folder if empty.
-    if not any(destination.iterdir()):
-        destination.rmdir()
+    try:
+        # Delete destination folder if empty.
+        if not any(destination.iterdir()):
+            destination.rmdir()
+    except Exception:
+        pass
+
+temp_dir.cleanup()
 
 cleanup(tmp)


Test patch:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index fba43011..9176f0ca 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -31,9 +31,8 @@ def test_markdown_with_mermaid(screen: Screen):
     screen.open('/')
     screen.wait(0.5)  # wait for Mermaid to render
     screen.should_contain('Mermaid')
-    assert screen.find_by_tag('svg').get_attribute('id').startswith('mermaid-')
-    node_a = screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]')
-    assert node_a.get_attribute('class') == 'nodeLabel'
+    assert screen.find_by_tag('svg').get_attribute('id') == f'{m.html_id}_mermaid_0'
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]').is_displayed()
 
     m.set_content('''
         New:
@@ -44,8 +43,7 @@ def test_markdown_with_mermaid(screen: Screen):
         ```
     ''')
     screen.should_contain('New')
-    node_c = screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_C")]]')
-    assert node_c.get_attribute('class') == 'nodeLabel'
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_C")]]').is_displayed()
     screen.should_not_contain('Node_A')
 
 
@@ -59,8 +57,8 @@ def test_markdown_with_mermaid_on_demand(screen: Screen):
 
     screen.open('/')
     screen.click('Create Mermaid')
-    screen.should_contain('Node_A')
-    screen.should_contain('Node_B')
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]').is_displayed()
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_B")]]').is_displayed()
 
 
 def test_strip_indentation(screen: Screen):