Repository: zauberzeug/nicegui
Pull request #4539 (2025-03-28 02:01:24)

Cachebusting via include timestamp into SHA256
Related discussion: https://github.com/zauberzeug/nicegui/discussions/4532#discussioncomment-12644326

TL-DR: 

* NiceGUI about to cache the files served more aggressively, with up to 1 year of immutable cache planned so far. 
* For developers of custom components, it means that manual cache-busting via renaming the JS file would have otherwise needed to be done. (Think: `class Counter(Element, component='counter-final-updated-fix2.js'):`)
* This could be troublesome, so cache busting is now handled by NiceGUI, for which the path the file is served over will be dependent on file content, so any change in the file content invalidates the browser cache useless and force re-fetch. 

Potential cons: 
* Slightly slower server startup. 
* If `compute_path` is ever invoked with a folder (which I don't see why you would do that, and in reality it never does), then cache busting would not work. 

Tested with: 
https://github.com/zauberzeug/nicegui/tree/main/examples/custom_vue_component
* Changes in the `.vue` files immediately propagate before and after the PR.
* Changes in the `.js` files immediately propagate **after the PR only.** 
* _Did not bother testing `resource`, since it would probably also work._ 

evnchn: @falkoschindler check this out. Hash also the folder. And it's 10% faster for large files with an implementation taken from `hashlib.file_digest` 

(reference: https://stackoverflow.com/a/44873382, https://github.com/python/cpython/blob/66aa78cbe604a7c5731f074b869f92174a8e3b64/Lib/hashlib.py#L228-L238, 10% time increase comes from my testing, where I put the 20MB Python installer exe into CodeMirror lib folder)

Also, fixed an issue whereby the hash will be re-computed on every page reload for `add_resource`, which is not apparent before this PR, but will incur +50ms load time for `ui.codemirror`, for example. 

---

Regarding the possibility of using modification timestamp, UUID, or even for some users who like to revert to old behaviour, I propose (somewhere) that we add the option `cachebusting_strategy`
* `'hash'`: consider the file hash
* `'timestamp'`: consider the file last modified time via `os.path.getmtime`
* `'uuid'`: use a random UUID which is different on every server reboot
* `None`: don't do any of that, revert to pre-PR behaviour and look at the hash of path only. 

Tell me if you think this is OK, and where should the option be added. Then, I can work on it. 
evnchn: helper functions can't access core.app.config?

<img width="534" alt="{D9544266-EB27-4712-8EC8-433B0A11231C}" src="https://github.com/user-attachments/assets/eeca39eb-0148-498b-bccf-0ce20dbe4e2c" />

Cachebusting logic is as follows: 

```py
def hash_file_path_and_contents(path: Path) -> str:
    """Hash the given path and file content(s)."""

    try:
        cachebusting_strategy = core.app.config.cachebusting_strategy
    except AttributeError:
        print("AttributeError: core.app.config.cachebusting_strategy")
        cachebusting_strategy = None

    print(f'cachebusting_strategy: {cachebusting_strategy}')

    if cachebusting_strategy == 'uuid':
        return str(uuid.uuid4().replace('-', ''))

    hasher = hashlib.sha256()
    nicegui_base = Path(__file__).parent
    try:
        path_shortened = path.relative_to(nicegui_base)
    except ValueError:
        path_shortened = path

    hasher.update(path_shortened.parent.as_posix().encode())

    if cachebusting_strategy is None:
        return hasher.hexdigest()[:32]

    if path.is_file():
        hasher = hash_file(path, hasher)
    else:
        for p in path.rglob('*'):
            if p.is_file():
                print("*", end='', flush=True)
                if cachebusting_strategy == 'hash':
                    hasher = hash_file(p, hasher)
                else:
                    hasher.update(p.stat().st_mtime.to_bytes(8, 'big'))

    return hasher.hexdigest()[:32]
```
evnchn: @falkoschindler I still have some difficulty reading the config defined at `core.app.config` for functions defined in `helper.py`, and I may need your help. 

With that, I can't proceed in adding configurability of `cachebusting_strategy` so that it is possible to switch betweein pre-PR behaviour, timestamp, SHA256, or random-on-boot. 
falkoschindler: > I don't quite get what you mean. I'd imagine since this cachebusting thing relates to how the server is operated, that it go into ui.run, just like, say, port number, and uvicorn options.

Yes, but we only make use of this parameter in dependencies.py, which is why I thought I could be a parameter of `Element.__init_subclass__` or something similar. A `ui.run` parameter could be misinterpreted as controlling every route or at least every static file, which it doesn't.

> Sorry but no strategy is cheap and perfect.

Sure. But if one of them was "good enough", we don't need "perfect".

> If the directory is 100MB large

Yes, but it isn't. I'm trying to estimate how long it actually takes for dependencies we're actually serving.
evnchn: Hmm should we just always do timestamp-based cachebusting, since apparently it is always the case that when you modify a file the timestamp is modifed, and you must be intentionally shooting yourselves on the foot if you overwrite the timestamp after the fact. 

https://poe.com/s/t1E57ePCaz4ctsbWKX2d

For timestamp-based cachebusting, I was worrying whether there is an imaginary `write_without_modify_timestamp` OS-level function which applications writing to the file can call if they want. Apparently that doesn't exist (not unless you manually edit the bits beneath the filesystem), so we should be golden on the timestamp approach. 

@falkoschindler Since you appear to be doing the performance measurement, I won't interfere and I'll work on other things. I'm more inclined to dropping the approach of SHA256 hash every file in the folder due to the high amount of disk read involved, though. 
evnchn: https://poe.com/s/XIT5ulMk30T2V8NpZOyU

I'd recommend timestamp+size approach. 

Since, we no need to worry about the case that a hacker was sliding in changes to the files while intentionally not wanting the hash value to update (bro why, you'd want to infect more clients more quicker, right), so accuracy is less of a concern. 

Then we drop the option from `ui.run`. How nice. 

Still have work to do to un-do whatever changes I done to add the option to `ui.run`, though


evnchn: Benchmarking: 

```
None approach     : 0.008700000180397183

Timestamp approach: 0.008663699962198734

Hash file approach: 0.027972699841484427

UUID approach     : 0.03197609994094819
```

Notably:

* Generating random UUID is even slower than hashing file
* Timestamp is as fast as None (run-to-run error at this point)

> Use Timestamp is the way to go!
falkoschindler: @evnchn What exactly did you measure? I can't imagine that generating UUIDs takes more time than hashing files.
But I think using timestamps is a good (enough) solution. 👍🏻

(By the way, your conclusions are always formatted like quotes. Maybe use "->" instead of ">" to start such lines? 😉)
evnchn: @falkoschindler It's a dumb benchmarking script. Get everything you need for running it at https://github.com/zauberzeug/nicegui/tree/main/examples/custom_vue_component

Not sure if this script is fair, but I can say is, accessing timestamp is almost as fast as accessing nothing, while random and hashing the entire file is equally as slow. 

```py
import time
from counter import Counter
from on_off import OnOff

import nicegui.helpers as helpers

import uuid

TIMECONSUMED = 0

def update_hash_given_file_mode_none(path, digestobj):
    print("Doing nothing! hash was", digestobj.hexdigest()[:32])
    return digestobj

def update_hash_given_file_mode_uuid(path, digestobj):
    # hash some random data (cache-busting on every server restart)
    digestobj.update(uuid.uuid4().bytes)
    print("Hashing random data! hash was", digestobj.hexdigest()[:32])
    return helpers.hash_file(path, digestobj)

def update_hash_given_file_mode_timestamp(path, digestobj):
    digestobj.update(int(path.stat().st_mtime).to_bytes(8, 'big'))
    print("Hashing file timestamp! hash was", digestobj.hexdigest()[:32])
    return digestobj

def update_hash_given_file_mode_contents(path, digestobj):
    """Hash the given file using same algorithm as cpython/Lib/hashlib.py"""
    buf = bytearray(8192)
    view = memoryview(buf)
    with path.open('rb') as file:
        while size := file.readinto(view):
            digestobj.update(view[:size])
    print("Hashing file contents! hash was", digestobj.hexdigest()[:32])
    return digestobj

def benchmarked_update_hash_given_file(path, digestobj):
    global TIMECONSUMED
    start = time.perf_counter()
    result = update_hash_given_file_mode_none(path, digestobj) 
    TIMECONSUMED += time.perf_counter() - start
    print("Time consumed so far:", TIMECONSUMED)
    return result

helpers.update_hash_given_file = benchmarked_update_hash_given_file 

from nicegui import ui

with ui.row(align_items='center'):
    counter = Counter('Count', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
    ui.button('Reset', on_click=counter.reset).props('outline')

with ui.row(align_items='center'):
    on_off = OnOff('State', on_change=lambda e: ui.notify(f'The value changed to {e.args}.'))
    ui.button('Reset', on_click=on_off.reset).props('outline')

ui.codemirror()
 
print("Time consumed so far:", TIMECONSUMED) 

ui.run(uvicorn_reload_includes='*.py,*.js,*.vue')
```
falkoschindler: Two problems:

1. You should remove print statements for performance analysis.
2. `update_hash_given_file_mode_uuid` is using `helpers.hash_file`. I think it should simply return the updated `digestobj`.

With these changes I get the following plausible results:
```
None:      ~0.01 ms
UUID:      ~0.25 ms
Timestamp: ~0.21 ms
Contents:  ~3.30 ms
```

evnchn: Regardless, I think that for scanning changes in the file, hashing the timestamp is the best way forward. This is why I dropped the `cachebusting_strategy` outright. 

About UUID (aka random ID for each server restart): It feels a bit wasteful that we may be calling `uuid.uuid4().bytes` 100 times for 100 files in the directory, even when we need only call it once and apply it to the hash for it to be totally-random. 

So, I also let `update_hash_given_file_path` to be overridable. 

Also, I dropped the return, since the hash updates just fine regardless. 
evnchn: TL-DR: 
- Yes to `functools.lru_cache`
- Prefer keeping `hash_file_path` for compatibility with `app.add_static_file` and `app.add_media_file`
- Would like to absolutely backtrack on the idea of letting the user override the helper hash functions. 

---

> Do you agree with using `functools.lru_cache` instead of a custom memoization?

Agreed. Makes total sense. 

> On uisng `hash_file_path_and_contents` exclusively

The old function is used exactly twice for `app.add_static_file` and `app.add_media_file` respectively. 

```py
path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
...
path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
```

Notably, the old function is dependent on the file path only, but the new `hash_file_path_and_contents` depends on both the file path **and** the file contents (though indirectly, via the timestamp). 

This would result in the following knock-on effects:
1. When adding the file multiple times, if the file has changed, it would return a different path. 
2. If it is `single_use`, accessing one path does not invalidate the other. 

**Point 1 can break existing code. Point 2 is straight-up a security shortcoming.** 

There are also some minor effects:
3. Since the responsibility of trying to shorten the path to be relative to NiceGUI folder is shifted from `compute_key` to `hash_file_path_and_contents` for the latter to read the file, if we use `hash_file_path_and_contents`, it will always try (and fail) to shorten the path. 


So, either: 
- We keep the old `hash_file_path`
- We need to add extra logic to `app.add_static_file`, `app.add_media_file`, and `hash_file_path_and_contents` so that they play along nicely. 

I'd much prefer the former. 

> Do we really need to account for overriding hashing functions? 

After thinking about it for some time, **we should not endorse overriding NiceGUI internal functions for patching the behaviour.** 

If people were to ask for alternate forms of cache busting (99% won't, to be frank), they can raise another feature request, and we'll assess whether it's good, whether it's safe, whether it causes undefined behaviour, and then only add that. 

Imagine the following scenario:

```py
def really_poorly_written_hash_file_function(path: Path, digestobj: hashlib._Hash) -> None:
    # Whole range of crazy behaviour, such as:
    # Doing a bad job of hashing, with way too many hash collisions?
    # Accidentally modifies / deletes the file it is hashing?
    # UTF-8 intolerant in some manner?
    # Uses up excessive memory?
    # Tries to make a web request (in sync code, mind you) and grind the server to a halt?
    ....

helpers.update_hash_given_file = really_poorly_written_hash_file_function
```

We would be in a very bad position, with no control to "sanity check" the user's function before executing. 

Therefore, allowing for overrides, we are opening a big can of worms. 

evnchn: @falkoschindler https://github.com/zauberzeug/nicegui/pull/4539#pullrequestreview-2823255620

Sorry it's my fault but somewhere the logic went wrong. `if p.is_file():` then we should call `update_hash_given_file` aka `hash_file` and not `hasher.update(p.as_posix().encode())`
evnchn: Decided to keep helper function `hash_file` since the logic is rather bulky and too-much to inline IMHO. 

Though that would make overriding the function is still possible, we don't endorse it so it should be fine. 

After all, we can't inline everything just because we don't want people to override it. They'll find a way regardless, and a worse way I may add. 
evnchn: https://github.com/zauberzeug/nicegui/pull/4539/commits/fe667034ea2e95a3e7608e0974a38cd827414250

@falkoschindler sure about that? IIRC then the `hash_file_path_and_contents` can't read the file properly and error out. 
falkoschindler: Oh, I didn't think about that. Now I wonder _why_ it works. What if we are in a different working directory? How can `path.stat()` still read the file? Anyway, I'm still refactoring and the question might vanish in a moment.

Another two questions for you:
- Why do we need `path.parent` in our hash? Shouldn't `path` be better?
- Would it make sense to hash the most recent timestamp only? I could imagine `max()` is a tad cheaper than hashing them all.
evnchn: > Why do we need path.parent in our hash? Shouldn't path be better?

No idea but I was trying to mock old behaviour. 

> Would it make sense to hash the most recent timestamp only? I could imagine max() is a tad cheaper than hashing them all.

Theoretically a good idea, but perhaps we should benchmark this. Since hashlib is no slow thing either. 
evnchn: The new code is very elegant and almost ready to merge in all regards. 

My testing method: 
- Start a NiceGUI page, view source
- Modify the aggrid a bit, view source
- Verify that the script in the importmap section has changed its cachebusting value. 

<img width="640" alt="{4EF4ADB3-8AF1-439A-BF2D-B711349A0EA3}" src="https://github.com/user-attachments/assets/69acd259-51f4-4d36-8665-a52964e3041e" />

<img width="641" alt="{2D90C000-86E7-49A2-BE86-D3503E88CF11}" src="https://github.com/user-attachments/assets/ee7007f2-8655-42c9-a376-48704199150a" />

But, I have a concern:
- What happens if `files` iterable is empty? 
  - Then `max` would error out with `ValueError: max() iterable argument is empty`
  - Knock-on effect being that adding an empty folder would error out, then. 

Also, should we really have the two `hash_file_path` be under the same name, when they behave entirely differently? 

And, I have an extra thought, that we can put the new `hash_file_path` in `dependencies.py` back into `helpers.py`, provided we: 
- Let the new `hash_file_path` take `None` for `files`
- Assuming that we let `files` iterable be empty and not error, we pass an empty iterable, like `path = f'/_nicegui/auto/static/{helpers.hash_file_path(file, [])}/{file.name}' if url_path is None else url_path`

Can this patch work?

<img width="678" alt="{D5B47F0E-9056-4041-B6CC-A0BC8C432D46}" src="https://github.com/user-attachments/assets/95e15207-7c3e-4cc1-aa3d-70aba4bf6682" />

First patch: Make `hash_file_path(blahpath)` work, for letting it serve in helpers.py and the `app.add_XXXX_file`
Second patch, make `hash_file_path(blahpath, some_empty_iterator)` work, in case glob matches nothing. 


evnchn: Hashes for mermaid in the importmap is allovertheplace. Why?

<details>
<summary> Those hashes </summary>

```json
{
    "imports": {
        "mermaid": "/_nicegui/2.13.0.dev0/libraries/7280f6d538de10a06b6db5769384fb95/mermaid.esm.min.mjs",
        "blockDiagram-NDWNTGEE": "/_nicegui/2.13.0.dev0/libraries/4050c3db6b5319c26c7d2372db3db956/blockDiagram-NDWNTGEE.mjs",
        "c4Diagram-AUYESYAG": "/_nicegui/2.13.0.dev0/libraries/4050c3db6b5319c26c7d2372db3db956/c4Diagram-AUYESYAG.mjs",
        "chunk-2RSIMOBZ": "/_nicegui/2.13.0.dev0/libraries/c442925a03b13014a1e9363ffdb90b5f/chunk-2RSIMOBZ.mjs",
        "chunk-4BPNZXC3": "/_nicegui/2.13.0.dev0/libraries/c442925a03b13014a1e9363ffdb90b5f/chunk-4BPNZXC3.mjs",
        "chunk-4KE642ED": "/_nicegui/2.13.0.dev0/libraries/c442925a03b13014a1e9363ffdb90b5f/chunk-4KE642ED.mjs",
        "chunk-4SRTBRON": "/_nicegui/2.13.0.dev0/libraries/c442925a03b13014a1e9363ffdb90b5f/chunk-4SRTBRON.mjs",
        "chunk-6BY5RJGC": "/_nicegui/2.13.0.dev0/libraries/8ccec8d9f2ecbd3f5cf60baa1afccbd9/chunk-6BY5RJGC.mjs",
        "chunk-6IZS222M": "/_nicegui/2.13.0.dev0/libraries/8ccec8d9f2ecbd3f5cf60baa1afccbd9/chunk-6IZS222M.mjs",
        "chunk-6XGRHI2A": "/_nicegui/2.13.0.dev0/libraries/da45e3eecb8dae5a9521219815c02cfa/chunk-6XGRHI2A.mjs",
        "chunk-AC3VT7B7": "/_nicegui/2.13.0.dev0/libraries/da45e3eecb8dae5a9521219815c02cfa/chunk-AC3VT7B7.mjs",
        "chunk-BKDDFIKN": "/_nicegui/2.13.0.dev0/libraries/da45e3eecb8dae5a9521219815c02cfa/chunk-BKDDFIKN.mjs",
        "chunk-BOP2KBYH": "/_nicegui/2.13.0.dev0/libraries/da45e3eecb8dae5a9521219815c02cfa/chunk-BOP2KBYH.mjs",
        "chunk-C7NU23FD": "/_nicegui/2.13.0.dev0/libraries/6a48737a6597bd5bce04126995d64ee7/chunk-C7NU23FD.mjs",
        "chunk-CBSWTUHP": "/_nicegui/2.13.0.dev0/libraries/6a48737a6597bd5bce04126995d64ee7/chunk-CBSWTUHP.mjs",
        "chunk-D3PZO57J": "/_nicegui/2.13.0.dev0/libraries/b422f44f919bdcbeadefe88f3d83495d/chunk-D3PZO57J.mjs",
        "chunk-DZFIHE2J": "/_nicegui/2.13.0.dev0/libraries/8035ce42052e598bf8dce57218513b52/chunk-DZFIHE2J.mjs",
        "chunk-E4AWDUZE": "/_nicegui/2.13.0.dev0/libraries/8035ce42052e598bf8dce57218513b52/chunk-E4AWDUZE.mjs",
        "chunk-F4773GRL": "/_nicegui/2.13.0.dev0/libraries/53a3b77145465fb73dfa82ab7d7d3d95/chunk-F4773GRL.mjs",
        "chunk-GTKDMUJJ": "/_nicegui/2.13.0.dev0/libraries/53a3b77145465fb73dfa82ab7d7d3d95/chunk-GTKDMUJJ.mjs",
        "chunk-KMOJB3TB": "/_nicegui/2.13.0.dev0/libraries/ede453829368b6f81944726d09aa1b0e/chunk-KMOJB3TB.mjs",
        "chunk-NQURTBEV": "/_nicegui/2.13.0.dev0/libraries/0cfe8afe3ed041579feab373395cb9d6/chunk-NQURTBEV.mjs",
        "chunk-OUDNNCD4": "/_nicegui/2.13.0.dev0/libraries/0cfe8afe3ed041579feab373395cb9d6/chunk-OUDNNCD4.mjs",
        "chunk-RRFB4HDS": "/_nicegui/2.13.0.dev0/libraries/386e2ea5145e048385474c263fcd3513/chunk-RRFB4HDS.mjs",
        "chunk-TI4EEUUG": "/_nicegui/2.13.0.dev0/libraries/386e2ea5145e048385474c263fcd3513/chunk-TI4EEUUG.mjs",
        "chunk-U6LOUQAF": "/_nicegui/2.13.0.dev0/libraries/386e2ea5145e048385474c263fcd3513/chunk-U6LOUQAF.mjs",
        "chunk-UWHJNN4Q": "/_nicegui/2.13.0.dev0/libraries/1ba7cc199f464337b57bbf05718df8e8/chunk-UWHJNN4Q.mjs",
        "chunk-VRGDDFRA": "/_nicegui/2.13.0.dev0/libraries/1ba7cc199f464337b57bbf05718df8e8/chunk-VRGDDFRA.mjs",
        "chunk-VSLJSFIP": "/_nicegui/2.13.0.dev0/libraries/1ba7cc199f464337b57bbf05718df8e8/chunk-VSLJSFIP.mjs",
        "chunk-WVHPJQMP": "/_nicegui/2.13.0.dev0/libraries/750bb7add409f84d312e1e1ce2c2c732/chunk-WVHPJQMP.mjs",
        "chunk-YFFLADYN": "/_nicegui/2.13.0.dev0/libraries/750bb7add409f84d312e1e1ce2c2c732/chunk-YFFLADYN.mjs",
        "chunk-YJEQJWB7": "/_nicegui/2.13.0.dev0/libraries/aaca6a3344c8c4764ddc3f59d79d9a3e/chunk-YJEQJWB7.mjs",
        "chunk-YPUTD6PB": "/_nicegui/2.13.0.dev0/libraries/aaca6a3344c8c4764ddc3f59d79d9a3e/chunk-YPUTD6PB.mjs",
        "classDiagram-EPVPYR3L": "/_nicegui/2.13.0.dev0/libraries/aaca6a3344c8c4764ddc3f59d79d9a3e/classDiagram-EPVPYR3L.mjs",
        "classDiagram-v2-NO4EPWGV": "/_nicegui/2.13.0.dev0/libraries/fef9f67de83ed6ffe877f42f3150a86b/classDiagram-v2-NO4EPWGV.mjs",
        "dagre-EVPMPUST": "/_nicegui/2.13.0.dev0/libraries/fef9f67de83ed6ffe877f42f3150a86b/dagre-EVPMPUST.mjs",
        "diagram-V25JEYTC": "/_nicegui/2.13.0.dev0/libraries/fef9f67de83ed6ffe877f42f3150a86b/diagram-V25JEYTC.mjs",
        "erDiagram-Y4N7DENO": "/_nicegui/2.13.0.dev0/libraries/8939e48bf514de965bdfb251d13f1f0d/erDiagram-Y4N7DENO.mjs",
        "flowDiagram-JTTVBJUY": "/_nicegui/2.13.0.dev0/libraries/ea018fb7f80d0e240a35d4027a9e1f4c/flowDiagram-JTTVBJUY.mjs",
        "ganttDiagram-6SR64PWN": "/_nicegui/2.13.0.dev0/libraries/0afa164bd6dca63eb8bdc655185a615d/ganttDiagram-6SR64PWN.mjs",
        "gitGraph-F2EDSAW4-LKDW3VZQ": "/_nicegui/2.13.0.dev0/libraries/0afa164bd6dca63eb8bdc655185a615d/gitGraph-F2EDSAW4-LKDW3VZQ.mjs",
        "gitGraphDiagram-LRIBUTDQ": "/_nicegui/2.13.0.dev0/libraries/0afa164bd6dca63eb8bdc655185a615d/gitGraphDiagram-LRIBUTDQ.mjs",
        "info-PWGDJKR5-T7ZKA3RY": "/_nicegui/2.13.0.dev0/libraries/878df731c7bbdbc2e76f13d3371af54e/info-PWGDJKR5-T7ZKA3RY.mjs",
        "infoDiagram-YTPSHCSX": "/_nicegui/2.13.0.dev0/libraries/878df731c7bbdbc2e76f13d3371af54e/infoDiagram-YTPSHCSX.mjs",
        "journeyDiagram-VRXW2F6L": "/_nicegui/2.13.0.dev0/libraries/b3ec34d9c9e55dae8619c8b0715e12bf/journeyDiagram-VRXW2F6L.mjs",
        "katex-SWYD7GD6": "/_nicegui/2.13.0.dev0/libraries/23cfa6139350c5836e30ad0329a9e448/katex-SWYD7GD6.mjs",
        "mindmap-definition-KLCIANCF": "/_nicegui/2.13.0.dev0/libraries/e35e4d0efbcaf30bc16ac80705269998/mindmap-definition-KLCIANCF.mjs",
        "packet-7PPW3X5M-O6SZ3T2R": "/_nicegui/2.13.0.dev0/libraries/e35e4d0efbcaf30bc16ac80705269998/packet-7PPW3X5M-O6SZ3T2R.mjs",
        "pie-RF5LNP4B-V656W2AT": "/_nicegui/2.13.0.dev0/libraries/e35e4d0efbcaf30bc16ac80705269998/pie-RF5LNP4B-V656W2AT.mjs",
        "pieDiagram-WYSUK7CQ": "/_nicegui/2.13.0.dev0/libraries/801f9da99e221e163b98df44b993ed38/pieDiagram-WYSUK7CQ.mjs",
        "quadrantDiagram-K5BY4R5E": "/_nicegui/2.13.0.dev0/libraries/801f9da99e221e163b98df44b993ed38/quadrantDiagram-K5BY4R5E.mjs",
        "requirementDiagram-2DBX4ZW4": "/_nicegui/2.13.0.dev0/libraries/7bcfb7bedba3dbbcfafd1d10f40da928/requirementDiagram-2DBX4ZW4.mjs",
        "sankeyDiagram-F3GI3WFI": "/_nicegui/2.13.0.dev0/libraries/7bcfb7bedba3dbbcfafd1d10f40da928/sankeyDiagram-F3GI3WFI.mjs",
        "sequenceDiagram-QBGN4KDC": "/_nicegui/2.13.0.dev0/libraries/dc636272210b1d185ce0a06a788232a5/sequenceDiagram-QBGN4KDC.mjs",
        "stateDiagram-S3XGF733": "/_nicegui/2.13.0.dev0/libraries/d0138d196adc1f42948786297c3e2f49/stateDiagram-S3XGF733.mjs",
        "stateDiagram-v2-BSO5MUCZ": "/_nicegui/2.13.0.dev0/libraries/d0138d196adc1f42948786297c3e2f49/stateDiagram-v2-BSO5MUCZ.mjs",
        "timeline-definition-MHTE3MCH": "/_nicegui/2.13.0.dev0/libraries/d0138d196adc1f42948786297c3e2f49/timeline-definition-MHTE3MCH.mjs",
        "xychartDiagram-RLS75X5Z": "/_nicegui/2.13.0.dev0/libraries/d0138d196adc1f42948786297c3e2f49/xychartDiagram-RLS75X5Z.mjs"
    }
}
```

</details>
evnchn: Ah, I seem to figured out why. 

The test has always been failing for Mermaid. 

But this commit https://github.com/evnchn/nicegui/commit/6c88d08a7fa53a785f6671d4b1f804591d0de764, I mixed the two functions, leading to an effectively "less effective cachebusting implementation which doesn't hash the file (so it's the same as pre-PR) and doesn't break Mermaid. 

<img width="1020" alt="{692B5CE8-CC6D-4647-8F93-A066BC2EC0AB}" src="https://github.com/user-attachments/assets/b113054c-74cd-4753-a2be-2541ac8984e9" />


This commit made things right and the logic fails Mermaid from there. 

https://github.com/evnchn/nicegui/commit/cb7f29f725a2fc81c13afe48f53966073e2f2b04

![image](https://github.com/user-attachments/assets/2ba87930-510b-4cc3-a9de-bda24b23ce92)

Back to the drawing board, then! 

evnchn: <img width="709" alt="{C6CC3476-523B-46C8-A258-7B3766FC1030}" src="https://github.com/user-attachments/assets/73c391e1-99f8-4f18-82eb-f4a4ddc8eac7" />

All imports made by chunks are failing. 

Because they imported it like this. 

<img width="312" alt="{7693F497-1ACD-4D66-B0F9-286CE75D008D}" src="https://github.com/user-attachments/assets/4d58a739-1f0b-4aa4-8b50-e83466675878" />

Note the "./chunk-NQURTBEV.mjs"

So maybe the entire folder has to share one cachebusting value. 
evnchn: @falkoschindler 

I beat https://github.com/zauberzeug/nicegui/pull/4539/commits/1d42ad8b70c68e850ed3a33c7c57ca760f2c26f0 into workingH, but the code is very ugly IMHO

Should we instead of `register_libraries` and `register_vue_components`, and maybe `register_resources` which does the globbing and the `max_time` calculation all-in-one? 

Patch:
diff --git a/CITATION.cff b/CITATION.cff
index 1fe99063..46790e3f 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,8 +8,8 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: 2.16.1
-date-released: "2025-05-05"
+version: 2.17.0
+date-released: "2025-05-12"
 url: https://github.com/zauberzeug/nicegui
 doi: 10.5281/zenodo.7785516
 identifiers:
diff --git a/examples/search_as_you_type/main.py b/examples/search_as_you_type/main.py
index f4393709..fc2c3549 100755
--- a/examples/search_as_you_type/main.py
+++ b/examples/search_as_you_type/main.py
@@ -22,7 +22,7 @@ async def search(e: events.ValueChangeEventArguments) -> None:
     response = await running_query
     if response.text == '':
         return
-    with results:  # enter the context of the the results row
+    with results:  # enter the context of the results row
         for drink in response.json()['drinks'] or []:  # iterate over the response data of the api
             with ui.image(drink['strDrinkThumb']).classes('w-64'):
                 ui.label(drink['strDrink']).classes('absolute-bottom text-subtitle2 text-center')
diff --git a/fly.dockerfile b/fly.dockerfile
index 8357e3b9..1a56ac93 100644
--- a/fly.dockerfile
+++ b/fly.dockerfile
@@ -12,7 +12,6 @@ RUN pip install \
     matplotlib \
     pandas \
     plotly \
-    prometheus_client \
     pyecharts \
     pytest \
     requests \
@@ -41,7 +40,6 @@ RUN sed -i "/\[tool.poetry\]/,/]/s/version = .*/version = \"$VERSION\"/" pyproje
 RUN pip install .
 
 EXPOSE 8080
-EXPOSE 9062
 
 COPY fly-entrypoint.sh /entrypoint.sh
 
diff --git a/fly.toml b/fly.toml
index 2b7cea51..022c4fd5 100644
--- a/fly.toml
+++ b/fly.toml
@@ -53,7 +53,3 @@ swap_size_mb = 2048
     path = "/status"
     protocol = "http"
     tls_skip_verify = false
-
-[[metrics]]
-  path = "/"
-  port = 9062
diff --git a/main.py b/main.py
index 82cb576b..64413b4e 100755
--- a/main.py
+++ b/main.py
@@ -7,13 +7,10 @@ from fastapi import HTTPException, Request
 from fastapi.responses import RedirectResponse
 from starlette.middleware.sessions import SessionMiddleware
 
-import prometheus
 from nicegui import app, ui
 from website import anti_scroll_hack, documentation, fly, main_page, svg
 
-prometheus.start_monitor(app)
-
-# session middleware is required for demo in documentation and prometheus
+# session middleware is required for demo in documentation
 app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_KEY', ''))
 
 on_fly = fly.setup()
@@ -26,6 +23,7 @@ app.add_static_file(local_file=svg.PATH / 'logo.png', url_path='/logo.png')
 app.add_static_file(local_file=svg.PATH / 'logo_square.png', url_path='/logo_square.png')
 
 documentation.build_search_index()
+documentation.build_tree()
 
 
 @app.post('/dark_mode')
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/dependencies.py b/nicegui/dependencies.py
index c169ce88..0248c7ff 100644
--- a/nicegui/dependencies.py
+++ b/nicegui/dependencies.py
@@ -1,8 +1,9 @@
 from __future__ import annotations
 
+import functools
 from dataclasses import dataclass
 from pathlib import Path
-from typing import TYPE_CHECKING, Dict, Iterable, List, Set, Tuple
+from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple
 
 import vbuild
 
@@ -58,14 +59,14 @@ libraries: Dict[str, Library] = {}
 resources: Dict[str, Resource] = {}
 
 
-def register_vue_component(path: Path) -> Component:
+def register_vue_component(path: Path, *, max_time: Optional[float]) -> Component:
     """Register a .vue or .js Vue component.
 
     Single-file components (.vue) are built right away
     to delegate this "long" process to the bootstrap phase
     and to avoid building the component on every single request.
     """
-    key = compute_key(path)
+    key = compute_key(path, max_time=max_time)
     name = _get_name(path)
     if path.suffix == '.vue':
         if key in vue_components and vue_components[key].path == path:
@@ -83,9 +84,9 @@ def register_vue_component(path: Path) -> Component:
     raise ValueError(f'Unsupported component type "{path.suffix}"')
 
 
-def register_library(path: Path, *, expose: bool = False) -> Library:
+def register_library(path: Path, *, expose: bool = False, max_time: Optional[float]) -> Library:
     """Register a *.js library."""
-    key = compute_key(path)
+    key = compute_key(path, max_time=max_time)
     name = _get_name(path)
     if path.suffix in {'.js', '.mjs'}:
         if key in libraries and libraries[key].path == path:
@@ -96,9 +97,9 @@ def register_library(path: Path, *, expose: bool = False) -> Library:
     raise ValueError(f'Unsupported library type "{path.suffix}"')
 
 
-def register_resource(path: Path) -> Resource:
+def register_resource(path: Path, *, max_time: Optional[float]) -> Resource:
     """Register a resource."""
-    key = compute_key(path)
+    key = compute_key(path, max_time=max_time)
     if key in resources and resources[key].path == path:
         return resources[key]
     assert key not in resources, f'Duplicate resource {key}'
@@ -106,20 +107,20 @@ def register_resource(path: Path) -> Resource:
     return resources[key]
 
 
-def compute_key(path: Path) -> str:
+@functools.lru_cache(maxsize=None)
+def compute_key(path: Path, *, max_time: Optional[float]) -> str:
     """Compute a key for a given path using a hash function.
 
     If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
     """
-    nicegui_base = Path(__file__).parent
-    is_file = path.is_file()
+    NICEGUI_BASE = Path(__file__).parent
     try:
-        path = path.relative_to(nicegui_base)
+        rel_path = path.relative_to(NICEGUI_BASE)
     except ValueError:
-        pass
-    if is_file:
-        return f'{hash_file_path(path.parent)}/{path.name}'
-    return f'{hash_file_path(path)}'
+        rel_path = path
+    if path.is_file():
+        return f'{hash_file_path(rel_path.parent, max_time=max_time)}/{path.name}'
+    return hash_file_path(rel_path, max_time=max_time)
 
 
 def _get_name(path: Path) -> str:
diff --git a/nicegui/element.py b/nicegui/element.py
index 73cbf31a..ae80255a 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -116,17 +116,21 @@ class Element(Visibility):
         cls.extra_libraries = copy(cls.extra_libraries)
         cls.exposed_libraries = copy(cls.exposed_libraries)
         if component:
+            max_time = max((path.stat().st_mtime for path in glob_absolute_paths(component)), default=None)
             for path in glob_absolute_paths(component):
-                cls.component = register_vue_component(path)
+                cls.component = register_vue_component(path, max_time=max_time)
         for library in libraries:
+            max_time = max((path.stat().st_mtime for path in glob_absolute_paths(library)), default=None)
             for path in glob_absolute_paths(library):
-                cls.libraries.append(register_library(path))
+                cls.libraries.append(register_library(path, max_time=max_time))
         for library in extra_libraries:
+            max_time = max((path.stat().st_mtime for path in glob_absolute_paths(library)), default=None)
             for path in glob_absolute_paths(library):
-                cls.extra_libraries.append(register_library(path))
+                cls.extra_libraries.append(register_library(path, max_time=max_time))
         for library in exposed_libraries + dependencies:
+            max_time = max((path.stat().st_mtime for path in glob_absolute_paths(library)), default=None)
             for path in glob_absolute_paths(library):
-                cls.exposed_libraries.append(register_library(path, expose=True))
+                cls.exposed_libraries.append(register_library(path, expose=True, max_time=max_time))
 
         cls._default_props = copy(cls._default_props)
         cls._default_classes = copy(cls._default_classes)
@@ -140,7 +144,8 @@ class Element(Visibility):
 
         :param path: path to the resource (e.g. folder with CSS and JavaScript files)
         """
-        resource = register_resource(Path(path))
+        path_ = Path(path)
+        resource = register_resource(path_, max_time=path_.stat().st_mtime)
         self._props['resource_path'] = f'/_nicegui/{__version__}/resources/{resource.key}'
 
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
diff --git a/nicegui/elements/interactive_image.js b/nicegui/elements/interactive_image.js
index 47985552..71a2d717 100644
--- a/nicegui/elements/interactive_image.js
+++ b/nicegui/elements/interactive_image.js
@@ -76,7 +76,7 @@ export default {
         this.loading = true;
       }
       if (!this.src && this.size) {
-        this.viewBox = `0 0 ${this.size[0]} ${this.size[1]}`;
+        this.updateViewbox(this.size[0], this.size[1]);
       }
     },
     updateCrossHair(e) {
@@ -88,7 +88,7 @@ export default {
     onImageLoaded(e) {
       this.loaded_image_width = e.target.naturalWidth;
       this.loaded_image_height = e.target.naturalHeight;
-      this.viewBox = `0 0 ${this.loaded_image_width} ${this.loaded_image_height}`;
+      this.updateViewbox(this.loaded_image_width, this.loaded_image_height);
       this.$emit("loaded", { width: this.loaded_image_width, height: this.loaded_image_height, source: e.target.src });
     },
     onMouseEvent(type, e) {
@@ -116,6 +116,9 @@ export default {
         image_y: (e.offsetY * imageHeight) / this.$refs.svg.clientHeight,
       });
     },
+    updateViewbox(width, height) {
+      this.viewBox = `0 0 ${width} ${height}`;
+    },
   },
   computed: {
     onCrossEvents() {
diff --git a/nicegui/elements/interactive_image.py b/nicegui/elements/interactive_image.py
index b6f8aa2e..064b03a6 100644
--- a/nicegui/elements/interactive_image.py
+++ b/nicegui/elements/interactive_image.py
@@ -101,3 +101,34 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
             return
         self._props['t'] = time.time()
         self.update()
+
+    def add_layer(self, *, content: str = '') -> InteractiveImageLayer:
+        """Add a new layer with its own content.
+
+        *Added in version 2.17.0*
+        """
+        with self:
+            layer = InteractiveImageLayer(source=self.source, content=content, size=self._props['size']) \
+                .classes('nicegui-interactive-image-layer')
+            self.on('loaded', lambda e: layer.run_method('updateViewbox', e.args['width'], e.args['height']))
+            return layer
+
+
+class InteractiveImageLayer(SourceElement, ContentElement, component='interactive_image.js'):
+    CONTENT_PROP = 'content'
+    PIL_CONVERT_FORMAT = 'PNG'
+
+    def __init__(self, *, source: str, content: str, size: Optional[Tuple[float, float]]) -> None:
+        """Interactive Image Layer
+
+        This element is created when adding a layer to an ``InteractiveImage``.
+
+        *Added in version 2.17.0*
+        """
+        super().__init__(source=source, content=content)
+        self._props['size'] = size
+
+    def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:  # noqa: UP037
+        if optional_features.has('pillow') and isinstance(source, PIL_Image):
+            source = pil_to_base64(source, self.PIL_CONVERT_FORMAT)
+        super()._set_props(source)
diff --git a/nicegui/elements/log.py b/nicegui/elements/log.py
index 0c3e98f3..a15572e5 100644
--- a/nicegui/elements/log.py
+++ b/nicegui/elements/log.py
@@ -16,13 +16,26 @@ class Log(Element, default_classes='nicegui-log'):
         super().__init__()
         self.max_lines = max_lines
 
-    def push(self, line: Any) -> None:
+    def push(self, line: Any, *,
+             classes: Optional[str] = None,
+             style: Optional[str] = None,
+             props: Optional[str] = None) -> None:
         """Add a new line to the log.
 
         :param line: the line to add (can contain line breaks)
+        :param classes: classes to apply to the line (*added in version 2.18.0*)
+        :param style: style to apply to the line (*added in version 2.18.0*)
+        :param props: props to apply to the line (*added in version 2.18.0*)
         """
         for text in str(line).splitlines():
             with self:
-                Label(text)
+                label = Label(text)
+                if classes is not None:
+                    label.classes(replace=classes)
+                if style is not None:
+                    label.style(replace=style)
+                if props is not None:
+                    label.props.clear()
+                    label.props(props)
         while self.max_lines is not None and len(self.default_slot.children) > self.max_lines:
             self.remove(0)
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/elements/mermaid.js b/nicegui/elements/mermaid.js
index 3773a45b..8634ab2b 100644
--- a/nicegui/elements/mermaid.js
+++ b/nicegui/elements/mermaid.js
@@ -1,8 +1,5 @@
 import mermaid from "mermaid";
 
-let is_running = false;
-const queue = [];
-
 export default {
   template: `<div></div>`,
   data: () => ({
@@ -24,20 +21,18 @@ export default {
     async update(content) {
       if (this.last_content === content) return;
       this.last_content = content;
-      this.$el.innerHTML = content;
-      this.$el.removeAttribute("data-processed");
-      queue.push(this.$el);
-      if (is_running) return;
-      is_running = true;
-      while (queue.length) {
-        try {
-          await mermaid.run({ nodes: [queue.shift()] });
-        } catch (error) {
-          console.error(error);
-          this.$emit("error", error);
-        }
+      try {
+        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", content);
+        this.$el.innerHTML = svg;
+        bindFunctions?.(this.$el);
+      } catch (error) {
+        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", "error");
+        this.$el.innerHTML = svg;
+        bindFunctions?.(this.$el);
+        const mermaidErrorFormat = { str: error.message, message: error.message, hash: error.name, error };
+        console.error(mermaidErrorFormat);
+        this.$emit("error", mermaidErrorFormat);
       }
-      is_running = false;
     },
   },
   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/helpers.py b/nicegui/helpers.py
index a408d7bd..0acae8a5 100644
--- a/nicegui/helpers.py
+++ b/nicegui/helpers.py
@@ -3,6 +3,7 @@ import functools
 import hashlib
 import os
 import socket
+import struct
 import threading
 import time
 import webbrowser
@@ -52,9 +53,12 @@ def is_file(path: Optional[Union[str, Path]]) -> bool:
         return False
 
 
-def hash_file_path(path: Path) -> str:
-    """Hash the given path."""
-    return hashlib.sha256(path.as_posix().encode()).hexdigest()[:32]
+def hash_file_path(path: Path, *, max_time: Optional[float] = None) -> str:
+    """Hash the given path based on its string representation and optionally the last modification time of given files."""
+    hasher = hashlib.sha256(path.as_posix().encode())
+    if max_time is not None:
+        hasher.update(struct.pack('!d', max_time))
+    return hasher.hexdigest()[:32]
 
 
 def is_port_open(host: str, port: int) -> bool:
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..c9b572ae 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 {
@@ -263,8 +266,14 @@ h6.q-timeline__title {
 .nicegui-scene-view[data-initializing] {
   visibility: hidden;
 }
-
-/* CodeMirror */
+.nicegui-interactive-image-layer {
+  position: absolute !important;
+  inset: 0;
+  pointer-events: none;
+}
+.nicegui-interactive-image-layer img {
+  display: none;
+}
 .nicegui-codemirror .cm-editor.cm-focused {
   outline: none;
 }
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index e65b3db0..0de58480 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -191,7 +191,7 @@ function renderRecursively(elements, id) {
           });
         const delayed_emitter = () => {
           if (window.did_handshake) emitter();
-          else setTimeout(emitter, 10);
+          else setTimeout(delayed_emitter, 10);
         };
         throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
         if (element.props["loopback"] === False && event.type == "update:modelValue") {
@@ -358,9 +358,9 @@ function createApp(elements, options) {
               console.log("reloading because handshake failed for clientId " + window.clientId);
               window.location.reload();
             }
+            window.did_handshake = true;
             document.getElementById("popup").ariaHidden = true;
           });
-          window.did_handshake = true;
         },
         connect_error: (err) => {
           if (err.message == "timeout") {
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)
diff --git a/prometheus.py b/prometheus.py
deleted file mode 100644
index 4eff1fbf..00000000
--- a/prometheus.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import inspect
-import logging
-import uuid
-
-from fastapi import FastAPI, Request, Response
-from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
-
-EXCLUDED_USER_AGENTS = {'bot', 'spider', 'crawler', 'monitor', 'curl',
-                        'wget', 'python-requests', 'kuma', 'health check'}
-
-
-def start_monitor(app: FastAPI) -> None:
-    try:
-        import prometheus_client
-    except ModuleNotFoundError:
-        logging.info('Prometheus not installed, skipping monitoring')
-        return
-
-    visits = prometheus_client.Counter('nicegui_page_visits', 'Number of real page visits',
-                                       ['path', 'session', 'origin'])
-
-    class PrometheusMiddleware(BaseHTTPMiddleware):
-
-        async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
-            if 'id' not in request.session:
-                request.session['id'] = str(uuid.uuid4())
-            response = await call_next(request)
-            if response.headers.get('x-nicegui-content') == 'page':
-                agent = request.headers.get('user-agent', 'unknown').lower()
-                # ignore monitoring, web crawlers and the like
-                if not any(s in agent for s in EXCLUDED_USER_AGENTS):
-                    origin_url = request.headers.get('referer', 'unknown')
-                    visits.labels(request.get('path'), request.session['id'], origin_url).inc()
-            return response
-
-    if inspect.stack()[-2].filename.endswith('spawn.py'):
-        prometheus_client.start_http_server(9062)
-
-    app.add_middleware(PrometheusMiddleware)
diff --git a/pyproject.toml b/pyproject.toml
index 377b9e3c..ece469fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.16.1-dev"
+version = "2.17.0-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
diff --git a/release.dockerfile b/release.dockerfile
index dfdcfac9..13f0ec17 100644
--- a/release.dockerfile
+++ b/release.dockerfile
@@ -32,7 +32,7 @@ RUN python -m pip install \
 
 WORKDIR /app
 
-COPY main.py README.md prometheus.py ./
+COPY main.py README.md ./
 COPY examples ./examples
 COPY website ./website
 RUN mkdir /resources
diff --git a/set_scale.sh b/set_scale.sh
index c20bbfed..d08bda3b 100755
--- a/set_scale.sh
+++ b/set_scale.sh
@@ -8,8 +8,8 @@ fly scale count app=1  --region lax -y # Los Angeles, California (US)
 fly scale count app=2  --region mia -y # Miami, Florida (US)
 fly scale count app=1  --region sea -y # Seattle, Washington (US)
 
-fly scale count app=3  --region fra -y # Frankfurt, Germany
-fly scale count app=4  --region ams -y # Amsterdam, Netherlands
+fly scale count app=5  --region fra -y # Frankfurt, Germany
+fly scale count app=2  --region ams -y # Amsterdam, Netherlands
 fly scale count app=1  --region mad -y # Madrid, Spain
 fly scale count app=1  --region cdg -y # Paris, France
 fly scale count app=2  --region lhr -y # London, England (UK)
@@ -19,7 +19,7 @@ fly scale count app=1  --region jnb -y # Johannesburg, South Africa
 
 fly scale count app=1  --region bom -y # Mumbai, India
 fly scale count app=3  --region nrt -y # Tokyo, Japan
-fly scale count app=1  --region sin -y # Singapor
+fly scale count app=1  --region sin -y # Singapore
 fly scale count app=3  --region hkg -y # Hong Kong
 
 fly scale count app=1  --region syd -y # Sydney, Australia
diff --git a/website/documentation/__init__.py b/website/documentation/__init__.py
index f25557ab..6623dced 100644
--- a/website/documentation/__init__.py
+++ b/website/documentation/__init__.py
@@ -3,6 +3,7 @@ from .custom_restructured_text import CustomRestructuredText
 from .intro import create_intro
 from .rendering import render_page
 from .search import build_search_index
+from .tree import build_tree
 from .windows import bash_window, browser_window, python_window
 
 __all__ = [
@@ -10,6 +11,7 @@ __all__ = [
     'bash_window',
     'browser_window',
     'build_search_index',
+    'build_tree',
     'create_intro',
     'overview',  # ensure documentation tree is built
     'python_window',
diff --git a/website/documentation/content/interactive_image_documentation.py b/website/documentation/content/interactive_image_documentation.py
index cc1354db..3dea3ba2 100644
--- a/website/documentation/content/interactive_image_documentation.py
+++ b/website/documentation/content/interactive_image_documentation.py
@@ -16,6 +16,31 @@ def main_demo() -> None:
     ii = ui.interactive_image(src, on_mouse=mouse_handler, events=['mousedown', 'mouseup'], cross=True)
 
 
+@doc.demo('Adding layers', '''
+    In some cases you might want to add different groups of SVG elements to an image.
+    Maybe there is one element that needs frequent updates, while the other elements are rarely changed.
+    Putting all elements in the same SVG can lead to performance issues,
+    because the whole SVG needs to be sent to the client whenever one of the elements changes.
+
+    The solution is to add multiple layers to the image.
+    Each layer is a separate SVG element, which means that each layer can be updated independently.
+
+    The following demo shows this concept in action, even though both layers are changed at the same time.
+
+    *Added in version 2.17.0*
+''')
+def adding_layers():
+    from nicegui import events
+
+    def mouse_handler(e: events.MouseEventArguments):
+        image.content += f'<circle cx="{e.image_x}" cy="{e.image_y}" r="30" fill="none" stroke="red" stroke-width="4" />'
+        highlight.content = f'<circle cx="{e.image_x}" cy="{e.image_y}" r="28" fill="yellow" opacity="0.5" />'
+
+    src = 'https://picsum.photos/id/674/640/360'
+    image = ui.interactive_image(src, on_mouse=mouse_handler, cross=True)
+    highlight = image.add_layer()
+
+
 @doc.demo('Nesting elements', '''
     You can nest elements inside an interactive image.
     Use Tailwind classes like "absolute top-0 left-0" to position the label absolutely with respect to the image.
diff --git a/website/documentation/content/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 9b358990..3d701bb0 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -80,6 +80,8 @@ def move_markers() -> None:
 @doc.demo('Image Overlays', '''
     Leaflet supports [image overlays](https://leafletjs.com/reference.html#imageoverlay).
     You can add an image overlay with the `image_overlay` method.
+
+    *Added in version 2.17.0*
 ''')
 def overlay_image():
     m = ui.leaflet(center=(40.74, -74.18), zoom=11)
@@ -93,6 +95,8 @@ def overlay_image():
 @doc.demo('Video Overlays', '''
     Leaflet supports [video overlays](https://leafletjs.com/reference.html#videooverlay).
     You can add a video overlay with the `video_overlay` method.
+
+    *Added in version 2.17.0*
 ''')
 def overlay_video():
     m = ui.leaflet(center=(23.0, -115.0), zoom=3)
diff --git a/website/documentation/content/log_documentation.py b/website/documentation/content/log_documentation.py
index 3bbdec16..cd3d1f96 100644
--- a/website/documentation/content/log_documentation.py
+++ b/website/documentation/content/log_documentation.py
@@ -46,4 +46,27 @@ def logger_handler():
     page()  # HIDE
 
 
+@doc.demo('Styling lines', '''
+    On the basis that individual lines in `ui.log` are `ui.label` instances,
+    it is possible to style the inserted lines via `classes`, `style` and `props`.
+    One notable use would be colored logs.
+
+    Note that if applied, this would clear any existing
+    [classes](element#default_classes),
+    [style](element#default_style), and
+    [props](element#default_props)
+    currently set as default on `ui.label`.
+
+    *Added in version 2.18.0*
+''')
+def styling_lines_demo():
+    log = ui.log(max_lines=10).classes('w-full h-40')
+    with ui.row():
+        ui.button('Normal', on_click=lambda: log.push('Text'))
+        ui.button('Debug', on_click=lambda: log.push('Debug', classes='text-grey'))
+        ui.button('Info', on_click=lambda: log.push('Info', classes='text-blue'))
+        ui.button('Warning', on_click=lambda: log.push('Warning', classes='text-orange'))
+        ui.button('Error', on_click=lambda: log.push('Error', classes='text-red'))
+
+
 doc.reference(ui.log)
diff --git a/website/documentation/content/markdown_documentation.py b/website/documentation/content/markdown_documentation.py
index db25c532..f6798e58 100644
--- a/website/documentation/content/markdown_documentation.py
+++ b/website/documentation/content/markdown_documentation.py
@@ -55,6 +55,23 @@ def markdown_tables():
     ''', extras=['tables'])
 
 
+@doc.demo('Mermaid diagrams', '''
+    You can use Mermaid diagrams with the "mermaid" extra.
+    See the [markdown2 documentation](https://github.com/trentm/python-markdown2/wiki/Extras#implemented-extras)
+    for a list of available extras.
+''')
+def mermaid():
+    ui.markdown('''
+        ```mermaid
+        graph TD;
+            A-->B;
+            A-->C;
+            B-->D;
+            C-->D;
+        ```
+    ''', extras=['mermaid'])
+
+
 @doc.demo('LaTeX formulas', '''
     By activating the "latex" extra, you can use LaTeX formulas.
     This requires markdown2 version >=2.5 as well as latex2mathml to be installed.
diff --git a/website/documentation/content/section_binding_properties.py b/website/documentation/content/section_binding_properties.py
index 3887fc5b..e2c0dd01 100644
--- a/website/documentation/content/section_binding_properties.py
+++ b/website/documentation/content/section_binding_properties.py
@@ -28,6 +28,30 @@ def bindings_demo():
         ui.number().bind_value(demo, 'number')
 
 
+@doc.demo('Transformation functions', '''
+    You can use ``forward`` and ``backward`` transformation functions to convert the value
+    when propagating it from one object to another.
+    These functions are called whenever the source attribute changes,
+    or - in case of active links (see below) - whenever the source attribute is checked for changes.
+
+    Note:
+    NiceGUI 2.16.0 improved efficiency of binding propagation by strictly adhering to a Depth-First-Search approach,
+    updating every affected node once and executing the transformation function once.
+    If you are migrating from NiceGUI 2.15.0 or older, there may be extra runs on transformation functions,
+    especially ones in the opposite direction to the current propagation direction,
+    which are no-longer ran in NiceGUI 2.16.0.
+    As a result, you would need to change your code appropriately.
+
+    We would also like to mention that, for the most stable behaviour across releases,
+    it is best-practice that transform functions have no side-effects and do basic transform operations only.
+    This way, it will not matter how NiceGUI chooses to call them in what order and by how many times.
+''')
+def transformation_functions():
+    i = ui.input(value='Lorem ipsum')
+    ui.label().bind_text_from(i, 'value',
+                              backward=lambda text: f'{len(text)} characters')
+
+
 @doc.demo('Bind to dictionary', '''
     Here we are binding the text of labels to a dictionary.
 ''')
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index dea29bd4..1be7cabf 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -1,3 +1,5 @@
+from typing import List
+
 from nicegui import ui
 
 from ..header import add_head_html, add_header
@@ -6,6 +8,7 @@ from .content import DocumentationPage
 from .custom_restructured_text import CustomRestructuredText as custom_restructured_text
 from .demo import demo
 from .reference import generate_class_doc
+from .tree import nodes
 
 
 def render_page(documentation: DocumentationPage, *, with_menu: bool = True) -> None:
@@ -16,11 +19,16 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
         with ui.left_drawer() \
                 .classes('column no-wrap gap-1 bg-[#eee] dark:bg-[#1b1b1b] mt-[-20px] px-8 py-20') \
                 .style('height: calc(100% + 20px) !important') as menu:
-            if documentation.back_link:
-                ui.markdown(f'[← back]({documentation.back_link or "."})').classes('bold-links')
-            else:
-                ui.markdown('[← Overview](/documentation)').classes('bold-links')
-            ui.markdown(f'**{documentation.heading.replace("*", "")}**').classes('mt-4')
+            tree = ui.tree(nodes, label_key='title').classes('w-full').props('accordion no-connectors')
+            tree.add_slot('default-header', '''
+                <a :href="'/documentation/' + props.node.id" onclick="event.stopPropagation()">{{ props.node.title }}</a>
+            ''')
+            tree.expand(_ancestor_nodes(documentation.name))
+            ui.run_javascript(f'''
+                Array.from(getHtmlElement({tree.id}).getElementsByTagName("a"))
+                    .find(el => el.innerText.trim() === "{(documentation.parts[0].title or '').replace('*', '')}")
+                    .scrollIntoView({{block: "center"}});
+            ''')
     else:
         menu = None
 
@@ -64,3 +72,8 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     documentation.extra_column()
         else:
             render_content()
+
+
+def _ancestor_nodes(node_id: str) -> List[str]:
+    parent = next((node for node in nodes if any(child['id'] == node_id for child in node.get('children', []))), None)
+    return [node_id] + (_ancestor_nodes(parent['id']) if parent else [])
diff --git a/website/documentation/tree.py b/website/documentation/tree.py
new file mode 100644
index 00000000..902d960e
--- /dev/null
+++ b/website/documentation/tree.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+from .content import DocumentationPage, registry
+from .content.overview import tiles
+
+nodes: List[Dict[str, Any]] = []
+
+
+def build_tree() -> None:
+    """Build tree by recursively collecting documentation pages and parts."""
+    nodes.clear()
+    for module, _ in tiles:
+        page = registry[module.__name__.rsplit('.', 1)[-1]]
+        nodes.append({
+            'id': page.name,
+            'title': _plain(page.title),
+            'children': _children(page),
+        })
+
+
+def _children(page: DocumentationPage) -> List[Dict[str, Any]]:
+    return [
+        {
+            'id': part.link if part.link else f'{page.name}#{part.link_target}',
+            'title': _plain(part.title),
+            'children': _children(registry[part.link]) if part.link else [],
+        }
+        for part in page.parts
+        if part.link or part.link_target
+    ]
+
+
+def _plain(string: Optional[str]) -> str:
+    assert string is not None
+    return string.replace('*', '')
diff --git a/website/search.py b/website/search.py
index 7948f828..a742047e 100644
--- a/website/search.py
+++ b/website/search.py
@@ -1,6 +1,7 @@
 from nicegui import __version__, background_tasks, events, ui
 
 from .documentation import CustomRestructuredText as custom_restructured_text
+from .documentation.search import search_index
 
 
 class Search:
@@ -55,19 +56,26 @@ class Search:
     def handle_input(self, e: events.ValueChangeEventArguments) -> None:
         async def handle_input() -> None:
             with self.results:
-                results = await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 100)', timeout=6)
+                indices = await ui.run_javascript(f'''
+                    const isMobile = window.innerWidth < 610;
+                    const limit = isMobile ? 25 : 100;
+                    return window.fuse.search("{e.value}", {{ limit }}).map(result => result.refIndex);
+                ''', timeout=6)
                 self.results.clear()
                 with ui.list().props('bordered separator'):
-                    for result in results:
-                        if not result['item']['content']:
+                    for index in indices:
+                        if not 0 <= index < len(search_index):
+                            continue
+                        result_item = search_index[index]
+                        if not result_item['content']:
                             continue
                         with ui.item().props('clickable'):
                             with ui.item_section():
-                                with ui.link(target=result['item']['url']):
-                                    ui.item_label(result['item']['title'])
+                                with ui.link(target=result_item['url']):
+                                    ui.item_label(result_item['title'])
                                     with ui.item_label().props('caption'):
-                                        intro = result['item']['content'].split(':param')[0]
-                                        if result['item']['format'] == 'md':
+                                        intro = result_item['content'].split(':param')[0]
+                                        if result_item['format'] == 'md':
                                             element = ui.markdown(intro)
                                         else:
                                             element = custom_restructured_text(intro)
diff --git a/website/sponsors.json b/website/sponsors.json
index c73fcb89..dd552db6 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
-  "total": 18,
-  "contributors": 156
+  "total": 20,
+  "contributors": 158
 }
diff --git a/website/style.py b/website/style.py
index 6248871c..cc300fea 100644
--- a/website/style.py
+++ b/website/style.py
@@ -73,16 +73,6 @@ def subheading(text: str, *, link: Optional[str] = None, major: bool = False, an
             ui.label(text).classes(classes)
         with ui.link(target=f'#{name}').classes('absolute').style('transform: translateX(-150%)'):
             ui.icon('link', size='sm').classes('opacity-10 hover:opacity-80')
-    drawers = [element for element in ui.context.client.elements.values() if isinstance(element, ui.left_drawer)]
-    if drawers:
-        menu = drawers[0]
-        with menu:
-            async def click():
-                if await ui.run_javascript('!!document.querySelector("div.q-drawer__backdrop")', timeout=5.0):
-                    menu.hide()
-                    ui.navigate.to(f'#{name}')
-            ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, []) \
-                .classes('font-bold mt-4' if major else '')
 
 
 def create_anchor_name(text: str) -> str:


Test patch:
diff --git a/tests/test_interactive_image.py b/tests/test_interactive_image.py
index ef927227..5d44af6f 100644
--- a/tests/test_interactive_image.py
+++ b/tests/test_interactive_image.py
@@ -95,3 +95,14 @@ def test_loaded_event(screen: Screen):
     screen.click('Change Source')
     screen.should_contain('loaded')
     assert (screen.find_by_tag('img').get_attribute('src') or '').endswith(URL_PATH2)
+
+
+def test_add_layer(screen: Screen):
+    ii = ui.interactive_image(URL_PATH1, content='<rect x="0" y="0" width="100" height="100" fill="red" />')
+    ii.add_layer(content='<circle cx="100" cy="100" r="15" />')
+
+    screen.open('/')
+    screen.find_by_tag('svg')
+    with screen.implicitly_wait(0.5):
+        assert len(screen.find_all_by_tag('rect')) == 1
+        assert len(screen.find_all_by_tag('circle')) == 1
diff --git a/tests/test_log.py b/tests/test_log.py
index 78c8fb2d..528b9be3 100644
--- a/tests/test_log.py
+++ b/tests/test_log.py
@@ -53,3 +53,18 @@ def test_special_characters(screen: Screen):
     screen.should_contain('50%')
     screen.click('push')
     screen.should_contain('100%')
+
+
+def test_log_against_defaults(screen: Screen):
+    ui.label.default_classes('text-red')
+    ui.label.default_style('margin: 1rem')
+    ui.label.default_props('my-prop=A')
+
+    log = ui.log()
+    log.push('Log line', classes='text-green', style='margin: 2rem', props='my-prop=B')
+
+    screen.open('/')
+    line = screen.find('Log line')
+    assert line.get_attribute('my-prop') == 'B'
+    assert line.get_attribute('class') == 'text-green'
+    assert line.get_attribute('style') == 'margin: 2rem;'
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):