Skip to content

Instantly share code, notes, and snippets.

@evnchn
Created May 20, 2026 19:49
Show Gist options
  • Select an option

  • Save evnchn/11d8962581aec36fbd6f8f29e7cb4958 to your computer and use it in GitHub Desktop.

Select an option

Save evnchn/11d8962581aec36fbd6f8f29e7cb4958 to your computer and use it in GitHub Desktop.
NiceGUI debugging tips — ~80 gotchas across 14 categories, cited to closed issues / discussions. Draft inspired by zauberzeug/nicegui#6067.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pro Tips for Debugging NiceGUI Applications</title>
<style>
:root {
--bg: #0b0e14;
--bg-elev: #11151c;
--bg-card: #161b24;
--bg-code: #0a0d12;
--border: #232a36;
--border-strong: #2f3849;
--text: #d8dde6;
--text-dim: #8a93a6;
--text-faint: #5b6378;
--accent: #58a6ff;
--accent2: #7ee787;
--accent3: #d2a8ff;
--warn: #ffa657;
--danger: #ff7b72;
--code: #c9d1d9;
--keyword: #ff7b72;
--string: #a5d6ff;
--comment: #6a737d;
--func: #d2a8ff;
}
* { box-sizing: border-box; }
html, body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
margin: 0;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
body { padding: 0 0 6rem; }
header {
background: linear-gradient(180deg, #131922 0%, #0b0e14 100%);
border-bottom: 1px solid var(--border);
padding: 3.5rem 2rem 2.5rem;
}
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 1.5rem; }
h1 {
font-size: 2.4rem;
margin: 0 0 0.5rem;
letter-spacing: -0.02em;
background: linear-gradient(90deg, #58a6ff, #7ee787, #d2a8ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle { color: var(--text-dim); font-size: 1.05rem; max-width: 70ch; margin: 0.25rem 0 1.5rem; }
.source-pills { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 1.2rem; }
.pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-dim);
font-size: 0.82rem;
}
.pill b { color: var(--text); font-weight: 600; }
.pill .dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--accent); }
.pill.gold .dot { background: var(--accent2); }
.pill.violet .dot { background: var(--accent3); }
.stats { display: flex; flex-wrap: wrap; gap: 1.2rem; margin-top: 1.2rem; }
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1.2rem;
min-width: 140px;
}
.stat .n { font-size: 1.5rem; font-weight: 700; color: var(--text); display: block; }
.stat .l { font-size: 0.78rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; }
nav.toc {
position: sticky;
top: 0;
background: rgba(11, 14, 20, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 0.7rem 0;
z-index: 10;
}
nav.toc .inner { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 1140px; margin: 0 auto; padding: 0 1.5rem; }
nav.toc a {
color: var(--text-dim);
text-decoration: none;
padding: 0.3rem 0.7rem;
font-size: 0.82rem;
border-radius: 6px;
border: 1px solid transparent;
}
nav.toc a:hover { color: var(--text); background: var(--bg-card); border-color: var(--border); }
section.cat { padding: 2.5rem 0 1rem; }
h2 {
font-size: 1.5rem;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 0.6rem;
}
h2 .num { color: var(--text-faint); font-weight: 400; font-size: 0.95rem; font-variant-numeric: tabular-nums; }
h2 .badge { font-size: 0.7rem; color: var(--text-faint); border: 1px solid var(--border); padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 400; }
.cat-desc { color: var(--text-dim); font-size: 0.9rem; margin: 0 0 1.4rem; max-width: 70ch; }
.grid { display: grid; gap: 0.9rem; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); }
.tip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.1rem 1.2rem 0.9rem;
transition: border-color 0.15s;
}
.tip:hover { border-color: var(--border-strong); }
.tip h3 {
margin: 0 0 0.45rem;
font-size: 1rem;
color: var(--text);
line-height: 1.35;
font-weight: 600;
}
.tip h3 .key {
color: var(--text-faint);
font-weight: 400;
font-variant-numeric: tabular-nums;
margin-right: 0.4rem;
}
.tip p { color: var(--text-dim); font-size: 0.9rem; margin: 0 0 0.65rem; }
.tip p b { color: var(--text); font-weight: 600; }
.tip p code, .tip h3 code {
background: var(--bg-code);
border: 1px solid var(--border);
padding: 0.08rem 0.35rem;
border-radius: 4px;
font-size: 0.82em;
color: var(--accent2);
font-family: "SF Mono", Menlo, Consolas, monospace;
}
pre {
background: var(--bg-code);
border: 1px solid var(--border);
padding: 0.7rem 0.85rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.78rem;
font-family: "SF Mono", Menlo, Consolas, monospace;
color: var(--code);
line-height: 1.5;
margin: 0.4rem 0 0.7rem;
}
.cite {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--border);
}
.cite a, .cite span {
font-size: 0.74rem;
color: var(--text-faint);
text-decoration: none;
padding: 0.15rem 0.45rem;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
font-family: "SF Mono", Menlo, monospace;
}
.cite a:hover { color: var(--accent); border-color: var(--accent); }
.cite span.local { color: var(--accent3); }
.cite span.local::before { content: "📁 "; }
.k { color: var(--keyword); }
.s { color: var(--string); }
.c { color: var(--comment); font-style: italic; }
.f { color: var(--func); }
.n2 { color: var(--accent2); }
.legend {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.2rem;
margin-bottom: 2rem;
font-size: 0.86rem;
color: var(--text-dim);
}
.legend b { color: var(--accent2); }
footer {
margin-top: 3rem;
padding: 2rem 0;
border-top: 1px solid var(--border);
color: var(--text-faint);
font-size: 0.82rem;
}
footer a { color: var(--text-dim); }
@media (max-width: 600px) {
h1 { font-size: 1.7rem; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>Pro Tips for Debugging NiceGUI Applications</h1>
<p class="subtitle">A curated knowledge base of non-obvious gotchas in <a href="https://github.com/zauberzeug/nicegui" style="color:var(--accent)">zauberzeug/nicegui</a> — what surprises you, why it happens, and the one-line fix. Inspired by <a href="https://github.com/zauberzeug/nicegui/pull/6067" style="color:var(--accent)">PR&nbsp;#6067</a>'s push for a dedicated debugging section.</p>
<div class="source-pills">
<span class="pill"><span class="dot"></span>Mined from <b>~60 closed issues &amp; discussions</b></span>
<span class="pill gold"><span class="dot"></span><b>26 field-debugging notes</b></span>
<span class="pill violet"><span class="dot"></span>Live <code>gh</code> + source-tree audit</span>
</div>
<div class="stats">
<div class="stat"><span class="n">82</span><span class="l">distinct tips</span></div>
<div class="stat"><span class="n">14</span><span class="l">categories</span></div>
<div class="stat"><span class="n">3</span><span class="l">sources cross-referenced</span></div>
<div class="stat"><span class="n">2026‑05‑21</span><span class="l">last refresh</span></div>
</div>
</div>
</header>
<nav class="toc"><div class="inner">
<a href="#refreshable">1. Refreshable</a>
<a href="#slot">2. Slot stack</a>
<a href="#storage">3. Storage</a>
<a href="#async">4. Async / lifecycle</a>
<a href="#aggrid">5. AG Grid</a>
<a href="#plotly">6. Plotly</a>
<a href="#thirdparty">7. ECharts / Leaflet</a>
<a href="#events">8. Events / bindings</a>
<a href="#reload">9. Reload &amp; deployment</a>
<a href="#subpages">10. Sub-pages</a>
<a href="#memory">11. Memory / perf</a>
<a href="#dialogs">12. Dialogs &amp; scenes</a>
<a href="#testing">13. Testing</a>
<a href="#native">14. Native &amp; misc</a>
</div></nav>
<div class="wrap">
<div class="legend" style="margin-top:2rem">
<b>How to read a tip card.</b> Each card opens with the surprise, names the root cause, and gives the fix or workaround. Citations at the bottom: <code>#NNNN</code> are GitHub issues/PRs; <code>D#NNNN</code> are GitHub Discussions; 📁 entries are field-tested observations from working with the source. When two sources agree, both are cited.
</div>
<!-- ============== 1. REFRESHABLE ============== -->
<section class="cat" id="refreshable">
<h2><span class="num">§1</span> Refreshable &amp; state management <span class="badge">6 tips</span></h2>
<p class="cat-desc">The most common "why did my UI not update?" bucket. The decorator's container management is deceptively shared; the descriptor protocol matters; <code>ui.state</code> has had instance-isolation bugs.</p>
<div class="grid">
<div class="tip"><h3><span class="key">1.1</span> Global <code>@ui.refreshable</code> updates every client</h3>
<p>Define <code>@ui.refreshable</code> at module scope and you get <b>one shared instance</b>. Clicking refresh in tab A re-renders the function in tab B too. Move the decorated function <i>inside</i> the page builder so each client owns its own.</p>
<pre><span class="k">@ui.page</span>(<span class="s">'/'</span>)
<span class="k">def</span> <span class="f">page</span>():
<span class="k">@ui.refreshable</span>
<span class="k">def</span> <span class="f">my_number</span>():
ui.label(app.storage.user[<span class="s">'num'</span>])
my_number()</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/1586">#1586</a></div></div>
<div class="tip"><h3><span class="key">1.2</span> Passing <code>self.ui.refresh</code> directly captures the last instance</h3>
<p>The descriptor's <code>__get__</code> is bypassed when you pass the method by reference. Wrap in a lambda so the descriptor binds the <i>current</i> <code>self</code> each call.</p>
<pre>on_click=<span class="k">lambda</span>: self.ui.refresh() <span class="c"># right</span>
on_click=self.ui.refresh <span class="c"># wrong</span></pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/1283">#1283</a></div></div>
<div class="tip"><h3><span class="key">1.3</span> <code>@ui.refreshable</code> + <code>with self.container:</code> rebuilds into the wrong slot</h3>
<p>Refreshable creates and owns its <i>own</i> container. Entering a different container via <code>with</code> means the rebuild renders into the managed (now empty) container, not the visible one. If you need explicit control, ditch the decorator and call <code>container.clear()</code> yourself before re-rendering.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3760">#3760</a></div></div>
<div class="tip"><h3><span class="key">1.4</span> <code>refresh()</code> with no args re-uses the <i>last</i> arguments</h3>
<p>After <code>show.refresh('a')</code>, calling <code>show.refresh()</code> refreshes with <code>'a'</code> — not the original default. Intentional. To restore defaults, pass them explicitly.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5060">#5060</a></div></div>
<div class="tip"><h3><span class="key">1.5</span> <code>ui.state</code> in <code>refreshable_method</code> hits wrong instance (&lt; 3.9)</h3>
<p>Two instances of a class each rendering a <code>@ui.refreshable_method</code> with <code>ui.state</code> shared a class attribute — refreshing one re-rendered the <i>last-rendered</i> one. Upgrade to NiceGUI ≥ 3.9 (PR #5890).</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/discussions/5908">D#5908</a> <a href="https://github.com/zauberzeug/nicegui/issues/3392">#3392</a></div></div>
<div class="tip"><h3><span class="key">1.6</span> Multi-instance hosting needs Redis storage for cross-tab refresh</h3>
<p>If your app is behind a load balancer (Fly.io, k8s), two tabs may land on different replicas — refreshable propagation only works inside one process. Check the WS URL's <code>?fly_instance_id=</code>, and back storage with Redis if you need cross-replica state.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4338">#4338</a></div></div>
</div>
</section>
<!-- ============== 2. SLOT STACK ============== -->
<section class="cat" id="slot">
<h2><span class="num">§2</span> Slot stack &amp; context errors <span class="badge">4 tips</span></h2>
<p class="cat-desc">"<code>RuntimeError: current slot cannot be determined</code>" is NiceGUI's signature error. It always means: you tried to create UI without a slot in the current async context.</p>
<div class="grid">
<div class="tip"><h3><span class="key">2.1</span> Background tasks have an empty slot stack</h3>
<p>UI built from a fresh <code>asyncio.create_task</code> (or anything that wasn't spawned from a request handler) has no slot context. Either enter a container explicitly with <code>with container:</code>, or call <code>.refresh()</code> on a refreshable that already owns one.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2389">#2389</a> <a href="https://github.com/zauberzeug/nicegui/issues/1857">#1857</a></div></div>
<div class="tip"><h3><span class="key">2.2</span> <code>app.on_startup</code> can't create UI</h3>
<p>Startup hooks run before any client exists — there is no slot stack to attach to. Move UI creation into <code>@ui.page</code> or schedule it inside <code>await client.connected()</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/316">#316</a></div></div>
<div class="tip"><h3><span class="key">2.3</span> Slot pruning steals the stack from <code>run.io_bound</code> &gt; 60 s</h3>
<p>Long-running tasks holding a <code>with container:</code> block longer than 60 s get the slot popped under them by <code>Slot.prune_stacks</code> (added to fix #182), then crash with "pop from empty list". Until properly fixed: monkey-patch <code>Slot.prune_stacks</code>, or avoid keeping the context open across awaits.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4985">#4985</a></div></div>
<div class="tip"><h3><span class="key">2.4</span> <code>ui.timer</code> created in a click handler dies when its container is cleared</h3>
<p>Timers are treated as UI elements and live in the slot they were created in. If you spawn one inside a click handler and later <code>container.clear()</code>, the timer is silently deleted. Anchor it on a higher container (e.g. <code>ui.context.client.layout</code>) so it outlives the clear.</p>
<pre><span class="k">with</span> ui.context.client.layout:
ui.timer(<span class="n2">0.5</span>, <span class="k">lambda</span>: ui.notify(<span class="s">'still alive'</span>), once=<span class="k">True</span>)</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3187">#3187</a></div></div>
</div>
</section>
<!-- ============== 3. STORAGE ============== -->
<section class="cat" id="storage">
<h2><span class="num">§3</span> Storage (<code>user</code> / <code>tab</code> / <code>browser</code>) <span class="badge">10 tips</span></h2>
<p class="cat-desc">Cookie timing, observable wrappers, serialization, multi-host cookie collisions. The storage system has more sharp edges than its API surface suggests.</p>
<div class="grid">
<div class="tip"><h3><span class="key">3.1</span> "needs a storage_secret" <i>despite</i> passing one</h3>
<p>The error means: the storage cookie wasn't on the request yet. <code>app.on_connect</code> and similar hooks fire before the session cookie is parsed. Use <code>await client.connected()</code> inside an async <code>@ui.page</code> instead.</p>
<pre><span class="k">@ui.page</span>(<span class="s">'/'</span>)
<span class="k">async def</span> <span class="f">root</span>(client: Client):
<span class="k">await</span> client.connected()
app.storage.user.get(<span class="s">'k'</span>)</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2520">#2520</a> <a href="https://github.com/zauberzeug/nicegui/issues/3209">#3209</a> <a href="https://github.com/zauberzeug/nicegui/issues/3273">#3273</a></div></div>
<div class="tip"><h3><span class="key">3.2</span> Nested mutations <i>do</i> trigger hooks (don't dismiss them)</h3>
<p><code>app.storage.user['outer']['inner'] = x</code> is NOT a bypass — <code>ObservableCollection._observe()</code> recursively rewraps any inserted <code>dict</code>/<code>list</code>/<code>set</code> with a <code>_parent</code> pointer, so child mutations propagate up to the root. The only escape hatch is <code>super().__setitem__</code> during <code>__init__</code> for mass loads.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">3.3</span> Binding <code>tab_panels.value</code> to storage → "Type is not JSON serializable: Tab"</h3>
<p>You bound the <code>Tab</code> object itself. Bind on the panel (not the tab) or use the string name: <code>one._props["name"]</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3592">#3592</a></div></div>
<div class="tip"><h3><span class="key">3.4</span> Duplicate / Ctrl-clicked tabs share <code>app.storage.tab</code></h3>
<p>Tab storage is keyed by an id that survives "Duplicate Tab" — the new tab inherits the original's state. Known limitation.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3872">#3872</a></div></div>
<div class="tip"><h3><span class="key">3.5</span> User JSON files used to duplicate per API call (Redis backend)</h3>
<p>The middleware created a storage object for every non-page <code>@app.get</code> request; the file backend short-circuited but Redis published anyway. Fixed; pin a recent version.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4979">#4979</a></div></div>
<div class="tip"><h3><span class="key">3.6</span> <code>app.storage.browser['id']</code> clobbered by another app on the same host</h3>
<p>A second NiceGUI app on the same hostname can issue a session cookie that overwrites the first — and custom <code>SessionMiddleware</code> instances interact badly. Be cautious with your own session layers.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4720">#4720</a> <a href="https://github.com/zauberzeug/nicegui/issues/2578">#2578</a></div></div>
<div class="tip"><h3><span class="key">3.7</span> <code>copy.deepcopy(app.storage.user[k])</code> writes empty dict to disk</h3>
<p>Deepcopy mutates the <code>ObservableDict</code>'s internals during the copy, which triggers <code>on_change</code> mid-flight and persists <code>{}</code> over your data. Use <code>dict(d)</code> or <code>json.loads(json.dumps(d))</code> to clone.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3023">#3023</a></div></div>
<div class="tip"><h3><span class="key">3.8</span> <code>app.storage.user</code> is unreachable from pytest's main thread</h3>
<p>Read the JSON files directly (see <code>tests/test_storage.py</code>) or assert against UI elements via <code>screen</code>/<code>user</code> fixtures.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2956">#2956</a></div></div>
<div class="tip"><h3><span class="key">3.9</span> <code>PersistentDict</code> rejects <code>set</code>/<code>bytes</code> on insert</h3>
<p>The walker fast-paths through pure-Python isinstance checks before falling back to <code>json.dumps</code>. Insert a <code>set</code> and you raise immediately — not at backup time. If your value type works under <code>orjson</code> but raises here, add it to the active json wrapper's <code>KNOWN_GOOD_LEAF</code> tuple.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">3.10</span> <code>Storage.path = None</code> poisons <code>python main.py</code></h3>
<p>Running NiceGUI's own <code>main.py</code> from a fresh checkout 500s with <code>TypeError: unsupported operand type(s) for /: 'NoneType' and 'str'</code>. Cause: <code>nicegui/testing/general_fixtures.py</code> sets <code>Storage.path = None</code> as a <i>module-level</i> sentinel, and doc files like <code>screen_documentation.py</code> import <code>nicegui.testing.Screen</code> at top — mutating the live class attribute. Workaround: trust pytest; don't smoke-test via direct script exec.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<!-- ============== 4. ASYNC ============== -->
<section class="cat" id="async">
<h2><span class="num">§4</span> Async / threading / lifecycle <span class="badge">6 tips</span></h2>
<p class="cat-desc">Blocking the event loop, signal handling on Ctrl-C, context-var leaks across tasks, disconnect detection.</p>
<div class="grid">
<div class="tip"><h3><span class="key">4.1</span> <code>threading.Thread</code> + <code>time.sleep</code> kills the websocket</h3>
<p>Blocking the asyncio main loop breaks the WS heartbeat, the client gets pruned, and the next page action raises "Client has been deleted but is still being used." Use <code>await asyncio.sleep</code>, <code>run.io_bound</code>, or <code>run.cpu_bound</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4715">#4715</a> <a href="https://github.com/zauberzeug/nicegui/issues/4742">#4742</a></div></div>
<div class="tip"><h3><span class="key">4.2</span> "Client has been deleted" almost always = a blocking call</h3>
<p>A client is pruned only when (a) not shared, (b) socket disconnected, (c) created &gt; 60 s ago. In practice the trigger is a long blocking call that severed the heartbeat. Look there first.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3231">#3231</a> <a href="https://github.com/zauberzeug/nicegui/issues/4742">#4742</a></div></div>
<div class="tip"><h3><span class="key">4.3</span> <code>asyncio.create_task()</code> inherits user storage context</h3>
<p>Context vars (including user-storage context) propagate into <code>create_task</code>'d coroutines. A refresh loop spawned under user A's request will keep seeing A's request context when it fires later — a real cross-user storage leak. Audit long-lived tasks spawned from request handlers; if needed, restructure with <code>contextvars.copy_context()</code>.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">4.4</span> <code>app.on_exception</code> didn't fire from async handlers (&lt; 3.11)</h3>
<p>Fixed by PR #5946 in NiceGUI 3.11. If you're catching exceptions globally, make sure your version is recent.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5945">#5945</a></div></div>
<div class="tip"><h3><span class="key">4.5</span> <code>app.on_exception</code> lost UI context from background tasks (2.x)</h3>
<p>Exceptions in <code>ui.timer</code>-dispatched background tasks couldn't pop a notification because the slot was gone before the handler ran. Resolved in newer releases.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5218">#5218</a> <a href="https://github.com/zauberzeug/nicegui/issues/1370">#1370</a></div></div>
<div class="tip"><h3><span class="key">4.6</span> "Run on page leave" → <code>await client.disconnected()</code></h3>
<p>Inside an async <code>@ui.page</code>, awaiting <code>client.disconnected()</code> blocks until the user navigates away — clean place to flush state. For global cleanup, use <code>app.on_disconnect</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2151">#2151</a></div></div>
</div>
</section>
<!-- ============== 5. AG GRID ============== -->
<section class="cat" id="aggrid">
<h2><span class="num">§5</span> AG Grid <span class="badge">8 tips</span></h2>
<p class="cat-desc">The single richest source of NiceGUI debugging questions. <i>Why isn't my event firing? Why is the grid empty? Why did my user's edit just vanish?</i></p>
<div class="grid">
<div class="tip"><h3><span class="key">5.1</span> AG Grid events don't auto-forward — use <code>.on()</code></h3>
<p>The PR that started this whole knowledge base. Quasar-style event aliases don't bridge AG Grid's native events. You must register them explicitly.</p>
<pre>grid.on(<span class="s">'cellClicked'</span>, handler)</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/pull/6067">PR #6067</a></div></div>
<div class="tip"><h3><span class="key">5.2</span> Grids inside hidden tabs aren't mounted until visited</h3>
<p>Quasar lazy-mounts <code>q-tab-panel</code>. Updates targeting a not-yet-mounted grid no-op (and ECharts will throw). Use <code>.props('keep-alive')</code> on <code>ui.tab_panels</code>, or force mount via <code>panels.run_method('goTo', name)</code>.</p>
<pre><span class="k">with</span> ui.tab_panels(tabs, value=<span class="s">'A'</span>).props(<span class="s">'keep-alive'</span>):
...</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3033">#3033</a> <a href="https://github.com/zauberzeug/nicegui/issues/4895">#4895</a> <a href="https://github.com/zauberzeug/nicegui/issues/843">#843</a></div></div>
<div class="tip"><h3><span class="key">5.3</span> <code>refreshCells</code> does nothing because <code>rowData</code> is a stale list-comp</h3>
<p>A list comprehension like <code>[{'id': i, ...} for i, v in state.items()]</code> captures a snapshot. Mutate <code>aggrid.options['rowData']</code> and call <code>aggrid.update()</code> instead of just <code>refreshCells</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5829">#5829</a></div></div>
<div class="tip"><h3><span class="key">5.4</span> AG Grid's native <code>flex: 1</code> fights NiceGUI's <code>auto_size_columns=True</code></h3>
<p>NiceGUI calls <code>sizeColumnsToFit()</code> automatically and overrides the flex. Pass <code>auto_size_columns=False</code> when using flex.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5276">#5276</a></div></div>
<div class="tip"><h3><span class="key">5.5</span> Mutating <code>rowData</code> without <code>suspend_updates</code> wipes in-progress cell edits</h3>
<p><code>grid.options['rowData'].append(row)</code> triggers <code>update_grid</code> → full client rebuild → any cell the user is mid-editing is destroyed. Use the suspend + <code>applyTransaction</code> recipe.</p>
<pre><span class="k">with</span> grid.props.suspend_updates():
grid.options[<span class="s">'rowData'</span>].append(new_row)
grid.run_grid_method(<span class="s">'applyTransaction'</span>, {<span class="s">'add'</span>: [new_row]})</pre>
<div class="cite"><span class="local">field-tested</span> <span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">5.6</span> <code>suspend_updates()</code> does NOT auto-flush on exit</h3>
<p>The primitive is refcounted (nesting is safe), but when the count hits zero, suspended updates are <b>discarded</b> — not flushed. You owe the client the delta yourself via <code>run_grid_method('applyTransaction', ...)</code>. Mental model: <i>"this silences the broadcast; you owe the client a delta."</i></p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">5.7</span> <code>get_client_data()</code> times out on big grids</h3>
<p>Default JS round-trip timeout is too low. Pass an explicit <code>timeout=5.0</code> to <code>run_javascript</code>, or debounce via <code>background_tasks.create_lazy</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/1794">#1794</a></div></div>
<div class="tip"><h3><span class="key">5.8</span> <code>ui.dark_mode(None)</code> + AG Grid breaks page layout</h3>
<p>Known confusing interaction. Force <code>dark_mode(True)</code> or <code>(False)</code> — don't leave it auto. Also: AG Grid 34 changed the Alpine Dark theme API; NiceGUI 3 users may see the theme stop applying after dependency updates.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4886">#4886</a> <a href="https://github.com/zauberzeug/nicegui/issues/5122">#5122</a></div></div>
</div>
</section>
<!-- ============== 6. PLOTLY ============== -->
<section class="cat" id="plotly">
<h2><span class="num">§6</span> Plotly <span class="badge">5 tips</span></h2>
<p class="cat-desc">The Plotly element is the odd one out among third-party wrappers — async dynamic import, plain-attribute figure, sibling-layout side effects.</p>
<div class="grid">
<div class="tip"><h3><span class="key">6.1</span> <code>plot.props.suspend_updates()</code> is silently a no-op on <code>ui.plotly</code></h3>
<p>Unlike AG Grid, <code>ui.plotly</code> stores <code>self.figure</code> as a <b>plain attribute</b>, not in <code>_props</code>. So mutations like <code>fig['data'][0]['x'].append(t)</code> don't trigger any broadcast in the first place — the suspend wrapper silences updates that were never going to fire. Just mutate and call <code>extendTraces</code>.</p>
<pre><span class="c"># wrong — suspend_updates does nothing here</span>
<span class="k">with</span> plot.props.suspend_updates():
fig[<span class="s">'data'</span>][<span class="n2">0</span>][<span class="s">'x'</span>].append(t)
<span class="c"># right</span>
fig[<span class="s">'data'</span>][<span class="n2">0</span>][<span class="s">'x'</span>].append(t)
plot.run_plot_method(<span class="s">'extendTraces'</span>, {<span class="s">'x'</span>: [[t]]}, [<span class="n2">0</span>])</pre>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">6.2</span> Plotly is dynamically imported — <code>this.Plotly</code> can be undefined mid-mount</h3>
<p><code>plotly.js</code> uses <code>await import("nicegui-plotly")</code> inside Vue's <code>mounted()</code>. Any <code>run_plot_method</code> between mount-start and import-resolve TypeErrors. When writing your own element wrappers, prefer sync top-level imports to dodge this race entirely.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">6.3</span> <code>plotly_click</code> doesn't fire on large datasets</h3>
<p>The default event payload bundles every point and exceeds socket.io's message limit. Use a <code>js_handler</code> to forward only what you need.</p>
<pre>ui.plotly(fig).on(<span class="s">'plotly_click'</span>,
js_handler=<span class="s">'(e) =&gt; emit(e.points[0].x, e.points[0].y)'</span>,
handler=<span class="k">lambda</span> e: ui.notify(e.args))</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5340">#5340</a> <a href="https://github.com/zauberzeug/nicegui/issues/3762">#3762</a></div></div>
<div class="tip"><h3><span class="key">6.4</span> Updating Plotly resets a sibling Leaflet's pan/zoom</h3>
<p>A Plotly update triggers a window <i>relayout</i> that knocks Leaflet back to its initial center. Workaround: set <code>{'layout': {'uirevision': 'constant'}}</code> on the plot. Be aware: updating <i>labels</i> before the plot can still negate <code>uirevision</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4182">#4182</a> <a href="https://github.com/zauberzeug/nicegui/issues/4186">#4186</a></div></div>
<div class="tip"><h3><span class="key">6.5</span> <code>ui.plotly</code> drops event handlers when you toggle a non-essential class</h3>
<p>Calling <code>.classes('bg-red-100')</code> on the plot element loses registered handlers; bare <code>.classes()</code> or <code>.update()</code> doesn't. Re-register handlers after such updates.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2519">#2519</a></div></div>
</div>
</section>
<!-- ============== 7. THIRD-PARTY ============== -->
<section class="cat" id="thirdparty">
<h2><span class="num">§7</span> ECharts, Leaflet &amp; element-wrapping <span class="badge">5 tips</span></h2>
<p class="cat-desc">Common pitfalls when writing your own custom elements or wrangling the JS dispatch layer.</p>
<div class="grid">
<div class="tip"><h3><span class="key">7.1</span> ECharts in hidden tabs throws <code>GetElement() undefined</code></h3>
<p>Same root cause as the AG Grid hidden-tab issue (§5.2). Quasar lazy-mounts panels; ECharts barfs on update before mount. Use <code>.props('keep-alive')</code> on the tab panels.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4895">#4895</a></div></div>
<div class="tip"><h3><span class="key">7.2</span> Leaflet: <code>set_center</code> + <code>set_zoom</code> is order-dependent</h3>
<p><code>set_zoom</code> after <code>set_center</code> can override the center. Chain them atomically via <code>map.run_method('flyTo', [lat, lng, zoom])</code> instead.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4491">#4491</a> <a href="https://github.com/zauberzeug/nicegui/issues/2826">#2826</a></div></div>
<div class="tip"><h3><span class="key">7.3</span> <code>runMethod</code> does NOT dot-split — pass the sub-object as <code>target</code></h3>
<p><code>runMethod(obj, "Plotly.extendTraces", args)</code> looks up the literal property <code>"Plotly.extendTraces"</code>, not <code>obj.Plotly.extendTraces</code>. To reach a sub-object method, pass the sub-object directly. Bonus footgun: <code>runMethod(undefined, ...)</code> crashes with a confusing <code>TypeError</code> because the dispatcher falls into the element-lookup branch and does <code>undefined.toString()</code>.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">7.4</span> <code>convertDynamicProperties</code> is NOT a global — explicit import required</h3>
<p>Most helpers (<code>runMethod</code>, <code>logAndEmit</code>, <code>getElement</code>, <code>throttle</code>) live at the top level of <code>nicegui.js</code> and are globally available. But <code>convertDynamicProperties</code> lives in <code>static/utils/dynamic_properties.js</code> and requires an explicit ES import. Copy a dispatcher pattern from <code>aggrid.js</code> without copying the import line and your element crashes with <code>ReferenceError: convertDynamicProperties is not defined</code> on first call.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">7.5</span> <code>aggrid.gridReady</code> didn't fire on old versions</h3>
<p>The global listener used to be registered <i>after</i> grid creation. Fixed in newer releases; if you're stuck, register via <code>onGridReady</code> in the column definitions instead.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4885">#4885</a> <a href="https://github.com/zauberzeug/nicegui/issues/5141">#5141</a></div></div>
</div>
</section>
<!-- ============== 8. EVENTS / BINDINGS ============== -->
<section class="cat" id="events">
<h2><span class="num">§8</span> Events &amp; bindings <span class="badge">6 tips</span></h2>
<p class="cat-desc">Event-name mangling, post-creation registration silently failing, Quasar elements not bubbling option clicks.</p>
<div class="grid">
<div class="tip"><h3><span class="key">8.1</span> <code>.on('keydown', ...)</code> after creation is silently ignored on <code>ui.input</code></h3>
<p>Registering an event from a later <code>ui.timer</code> callback updates the Python side but the browser never opens a listener. Register at creation time.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4154">#4154</a></div></div>
<div class="tip"><h3><span class="key">8.2</span> <code>.on('keydown.-', ...)</code> ignored — the dash is camelCased away</h3>
<p>The event-name parser converts every dot-segment to camelCase, eating the literal <code>-</code>. Use Quasar's <code>@keydown</code> and inspect <code>event.key</code> in JS instead.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4210">#4210</a></div></div>
<div class="tip"><h3><span class="key">8.3</span> <code>ui.select.on('popup-hide', ...)</code> fires twice</h3>
<p>An internal handler is registered alongside yours. Use a single registration or switch to <code>.on('update:model-value', ...)</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5079">#5079</a></div></div>
<div class="tip"><h3><span class="key">8.4</span> Bindable-dataclass chained-binding ordering changed (NiceGUI 2.16)</h3>
<p>After PR #4628, the forward-transformation graph is depth-first. Code that relied on the older accidental ordering double-fires or no-fires. Audit your transform functions.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4726">#4726</a> <a href="https://github.com/zauberzeug/nicegui/issues/4626">#4626</a></div></div>
<div class="tip"><h3><span class="key">8.5</span> Mixin order matters: <code>class MyEl(Mixin, ui.input)</code></h3>
<p>Concrete NiceGUI elements are terminal in the MRO — mixins must come <i>first</i> to forward <code>**kwargs</code> properly.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5947">#5947</a></div></div>
<div class="tip"><h3><span class="key">8.6</span> <code>user.find().click()</code> vs <code>screen.click()</code> diverge after <code>.move()</code></h3>
<p><code>screen.click</code> uses Selenium's <code>find_element</code> (first DOM-order match). <code>user.find().click()</code> picks <code>min(matched, key=lambda e: e.id)</code> — lowest creation id, not DOM order. They coincide only when elements were created in DOM order; <code>.move(target_index=-1)</code> breaks the equivalence. The cleanest fix is depth-first <code>descendants()</code> over slot children — also ~7× faster than the path-tuple <code>min()</code>.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<!-- ============== 9. RELOAD / DEPLOYMENT ============== -->
<section class="cat" id="reload">
<h2><span class="num">§9</span> Reload, packaging &amp; deployment <span class="badge">10 tips</span></h2>
<p class="cat-desc">The "works locally but not in prod" bucket: dev-reload guards, sticky sessions, websocket fallback, dotfile-ignored watchers.</p>
<div class="grid">
<div class="tip"><h3><span class="key">9.1</span> Your code runs <i>twice</i> at startup with <code>reload=True</code></h3>
<p>NiceGUI spawns a child uvicorn worker. Guard top-level side effects with <code>if __name__ in {"__main__", "__mp_main__"}:</code>, or move them into <code>@ui.page</code> / <code>app.on_startup</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/794">#794</a> <a href="https://github.com/zauberzeug/nicegui/issues/5455">#5455</a></div></div>
<div class="tip"><h3><span class="key">9.2</span> NiceGUI 3.x: <code>if __name__ == '__main__':</code> renders a blank page</h3>
<p><code>runpy.run_path</code> sets <code>__name__</code> to <code>&lt;run_path&gt;</code>. Update your guard.</p>
<pre><span class="k">if</span> __name__ <span class="k">in</span> {<span class="s">'__main__'</span>, <span class="s">'__mp_main__'</span>, <span class="s">'&lt;run_path&gt;'</span>}:
...</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5280">#5280</a></div></div>
<div class="tip"><h3><span class="key">9.3</span> "Script mode" re-runs your whole script on every connection</h3>
<p>UI builders at module top-level put NiceGUI into <i>script mode</i> — the file re-executes per client and globals appear to reset on refresh. Move to <code>@ui.page</code> / <code>ui.run(root=...)</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/discussions/5997">D#5997</a> <a href="https://github.com/zauberzeug/nicegui/issues/5455">#5455</a> <a href="https://github.com/zauberzeug/nicegui/issues/5414">#5414</a></div></div>
<div class="tip"><h3><span class="key">9.4</span> 3.10.x: every WS reconnect triggers <code>window.location.reload()</code></h3>
<p>Regression from PR #5499 — <code>next_message_id</code> was read once at page load and not re-sent on reconnect, so the server always classified the client as stale. Update to a release that includes the fix.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/6018">#6018</a></div></div>
<div class="tip"><h3><span class="key">9.5</span> "Page refreshes after network drops" = client got pruned</h3>
<p>A 60s+ outage triggers server-side client GC; on reconnect the browser hard-reloads. Don't put logic in your <code>@ui.page</code> body that assumes it runs exactly once.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5409">#5409</a></div></div>
<div class="tip"><h3><span class="key">9.6</span> Auto-reload doesn't pick up dotfiles</h3>
<p><code>watchfiles</code> excludes filenames starting with <code>.</code>. Rename your config or configure the watcher.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5603">#5603</a></div></div>
<div class="tip"><h3><span class="key">9.7</span> Nginx 307s = trailing-slash mismatch</h3>
<p>Hitting <code>/path</code> vs <code>/path/</code> through Nginx's <code>proxy_pass</code> produces an automatic 307. Pair the two consistently.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4225">#4225</a></div></div>
<div class="tip"><h3><span class="key">9.8</span> Multi-replica Docker / k8s → button clicks "ignored"</h3>
<p>If sequential requests land on different replicas, the WS handshake never completes. Enable sticky sessions.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4604">#4604</a></div></div>
<div class="tip"><h3><span class="key">9.9</span> Flaky network / iOS Safari → force polling</h3>
<p>Disabling websocket entirely is sometimes the cleanest hotfix.</p>
<pre>app.config.socket_io_js_transports = [<span class="s">'polling'</span>]
ui.add_css(<span class="s">'.q-loading-bar {display:none !important;}'</span>, shared=<span class="k">True</span>)</pre>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5468">#5468</a></div></div>
<div class="tip"><h3><span class="key">9.10</span> Default-off any feature that publishes <code>@ui.page</code> paths</h3>
<p>NiceGUI doesn't enumerate page paths today: <code>Client.page_routes</code> is internal, <code>openapi_url=None</code> by default, <code>endpoint_documentation='none'</code>. Apps legitimately use long unguessable URLs (<code>/super-secret-…</code>) as soft auth. Any framework feature (sitemap, search index, route enum) that publishes those by default is a silent compat break on upgrade — caught in PR #152 review.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<!-- ============== 10. SUB-PAGES ============== -->
<section class="cat" id="subpages">
<h2><span class="num">§10</span> Sub-pages &amp; navigation <span class="badge">4 tips</span></h2>
<p class="cat-desc">SPA-style routing introduced in 3.x has a few rough edges.</p>
<div class="grid">
<div class="tip"><h3><span class="key">10.1</span> <code>ui.sub_pages</code> swallows clicks to top-level routes</h3>
<p>Sub-pages intercept all <code>&lt;a&gt;</code> clicks within their region. To navigate <i>out</i>, use <code>ui.navigate.to('/other')</code> instead of a plain anchor.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5777">#5777</a></div></div>
<div class="tip"><h3><span class="key">10.2</span> Refreshing inside <code>ui.sub_pages</code> raised on a parent element</h3>
<p>Fixed in PR #5914 — make sure your version is recent.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5912">#5912</a></div></div>
<div class="tip"><h3><span class="key">10.3</span> "Refreshable navigates via sub_pages" warning is a false positive</h3>
<p>The same warning can be triggered by a plain button click on a <code>@ui.refreshable</code>. Ignore for now.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5663">#5663</a></div></div>
<div class="tip"><h3><span class="key">10.4</span> 3.x sub-page client-nav sometimes renders blank — Vue doesn't re-render</h3>
<p>Server sends a full update with all referenced children but Vue's diff skips the swap. Confirmed via WS frame capture; watch the issue for fix release.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5816">#5816</a></div></div>
</div>
</section>
<!-- ============== 11. MEMORY ============== -->
<section class="cat" id="memory">
<h2><span class="num">§11</span> Memory &amp; performance <span class="badge">6 tips</span></h2>
<p class="cat-desc">"Is this a leak?" → almost always no. How to actually measure, plus where the real wins are.</p>
<div class="grid">
<div class="tip"><h3><span class="key">11.1</span> Python doesn't return memory to the OS — RSS isn't a leak signal</h3>
<p>After GC, Python holds freed pages indefinitely. For client-side leaks, use Chrome DevTools' "Detached DOM nodes" view instead of <code>top</code>.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/2502">#2502</a> <a href="https://github.com/zauberzeug/nicegui/issues/4892">#4892</a></div></div>
<div class="tip"><h3><span class="key">11.2</span> Prove a leak by changing the timer interval</h3>
<p>If memory growth slope scales linearly with timer frequency, you've shown causality. Otherwise you've shown noise.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4617">#4617</a></div></div>
<div class="tip"><h3><span class="key">11.3</span> <code>ui.timer(once=True)</code> used to leak forever</h3>
<p>Fixed in PR #3647 (2.x). If you're chasing a leak, check your version isn't pre-fix.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3626">#3626</a></div></div>
<div class="tip"><h3><span class="key">11.4</span> <code>tracemalloc.start()</code> alone causes ≥ 10× RSS growth</h3>
<p>Each tracked allocation gets a traceback recorded in C-side memory (NOT shown in <code>take_snapshot().statistics()</code> totals). One snapshot with ~400k allocations costs ~100 MB transient. So "RSS grows under tracemalloc" tells you nothing about your app. Use <code>snapshot.compare_to(baseline, 'lineno')</code> and watch deltas on NiceGUI-side lines. Beware huge one-time numbers at <code>&lt;frozen importlib._bootstrap_external&gt;:757</code> — that's per-<code>.pyc</code> import, not a per-request growth.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">11.5</span> Custom test clients must include <code>?client_id=…</code> in the WS URL</h3>
<p><code>_on_disconnect</code> reads <code>client_id</code> from the query string. Without it, <code>KeyError</code> and the client leaks until <code>prune_instances</code> reaps it (10s timer, 60s age threshold). Real browsers always include it; <code>socketio.AsyncClient</code> hammers may not. Verify before claiming a NiceGUI leak.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">11.6</span> <code>container.clear()</code> with a child timer used to <code>KeyError</code></h3>
<p><code>_cleanup</code> didn't handle the already-deleted timer case. Recent versions added <code>self._deleted</code>; upgrade.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3681">#3681</a></div></div>
</div>
</section>
<!-- ============== 12. DIALOGS / SCENES ============== -->
<section class="cat" id="dialogs">
<h2><span class="num">§12</span> Dialogs, notifications &amp; <code>ui.scene</code> <span class="badge">4 tips</span></h2>
<div class="grid">
<div class="tip"><h3><span class="key">12.1</span> <code>ui.dialog</code> inside <code>ui.menu</code> is clipped</h3>
<p>The menu's <code>overflow: hidden</code> hides the dialog. Move the dialog to top-level page scope; a <code>local: bool</code> parameter has been discussed for a future release.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4116">#4116</a></div></div>
<div class="tip"><h3><span class="key">12.2</span> <code>ui.notification.set_visibility()</code> is silently ignored</h3>
<p>Notifications aren't real elements. Either set <code>notification.props['options']['classes'] = 'hidden'</code> + <code>.update()</code>, or just create a new notification each time.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3670">#3670</a></div></div>
<div class="tip"><h3><span class="key">12.3</span> <code>ui.scene</code> group attachment is creation-order-dependent</h3>
<p><code>sub_group.attach(main_group)</code> only persists across reload if <code>main_group</code> was created <i>before</i> <code>sub_group</code>. Reorder your scene-building.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4751">#4751</a></div></div>
<div class="tip"><h3><span class="key">12.4</span> Tooltip stays on screen after tab switch</h3>
<p>Underlying Quasar quirk that doesn't reproduce in plain Quasar. Detach the tooltip explicitly on tab change as a workaround.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/1949">#1949</a></div></div>
</div>
</section>
<!-- ============== 13. TESTING ============== -->
<section class="cat" id="testing">
<h2><span class="num">§13</span> Pytest / <code>user</code> &amp; <code>screen</code> fixtures <span class="badge">6 tips</span></h2>
<p class="cat-desc">Cultural norms inside <code>tests/</code> matter; the fixture-tier ladder matters more.</p>
<div class="grid">
<div class="tip"><h3><span class="key">13.1</span> Catch click-handler errors via <code>ui.on_exception</code>, not <code>pytest.raises</code></h3>
<p>The <code>user</code> fixture swallows exceptions in handlers so <code>pytest.raises</code> won't fire. Register <code>ui.on_exception(exceptions.append)</code> inside the page and assert against the list.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/discussions/5948">D#5948</a></div></div>
<div class="tip"><h3><span class="key">13.2</span> Middleware in <code>app.on_startup</code> doesn't apply in tests</h3>
<p>Register middleware at module scope — the startup hook runs too late for FastAPI's chain.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4310">#4310</a></div></div>
<div class="tip"><h3><span class="key">13.3</span> "Test passed but exit code 1" — conftest caught a server-side ERROR log</h3>
<p>NiceGUI's <code>tests/conftest.py</code> installs a teardown hook that fails any test whose run emitted ERROR logs (<code>"Failed: There were unexpected ERROR logs."</code>). A click handler can raise server-side, return cleanly to the test, and still flip exit code at teardown. Useful for regression coverage; still, add a positive assertion as defense in depth.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">13.4</span> No <code>MagicMock</code> — NiceGUI tests have zero mock-library usage</h3>
<p>116 test files; <code>MagicMock</code>/<code>AsyncMock</code>/<code>@patch</code> count: 0. The culture is "use the real <code>screen</code>/<code>user</code> fixture even if slower." If you write a transport-mocked test, you're out of vibe — restructure or accept losing the unit coverage.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">13.5</span> Pick the cheapest fixture that exercises your contract — 20× speedup available</h3>
<p>Four tiers, cheapest first: (1) <b>Unit</b> — instantiate the class directly; (2) <code>nicegui_reset_globals</code> for things touching <code>core.app.*</code> without HTTP; (3) reset globals + <code>fastapi.testclient.TestClient(core.app)</code> for route/header/middleware tests; (4) <code>Screen</code> only for genuine browser-JS execution. Measured 20× speedup on PR #152 sitemap tests (~5 s → 0.25 s).</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">13.6</span> <code>user.click()</code> silently short-circuits for tabs/radio/select/tree</h3>
<p><code>UserInteraction.click()</code> has per-element-type branches that <code>return self</code> <i>before</i> the generic listener loop. For tabs this was a real bug (PR #6043). For radio/select it's faithful to the browser — Quasar's <code>q-option-group</code>/<code>q-select</code> don't bubble option-clicks to root <code>@click</code> either, so don't naively "broaden the fix". Verify with side-by-side <code>screen</code> + <code>user</code> MRE using <i>persistent label state</i> (not <code>ui.notify</code> — toasts fade and timing is brittle).</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<!-- ============== 14. NATIVE / MISC ============== -->
<section class="cat" id="native">
<h2><span class="num">§14</span> Native mode &amp; miscellany <span class="badge">7 tips</span></h2>
<div class="grid">
<div class="tip"><h3><span class="key">14.1</span> <code>ui.run(native=True)</code> errors on Python ≥ 3.11.5</h3>
<p><code>RuntimeError: A SemLock created in a fork context is being shared with a process in a spawn context.</code> appears at exactly CPython 3.11.5+. Below that, the code silently corrupted state in the spawn child (random hangs/segfaults). Three workarounds:</p>
<pre>multiprocessing.set_start_method(<span class="s">'spawn'</span>, force=<span class="k">True</span>) <span class="c"># top of script</span>
<span class="c"># OR</span>
ui.run(native=<span class="k">True</span>, reload=<span class="k">False</span>)
<span class="c"># OR pin Python ≤ 3.10</span></pre>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">14.2</span> <code>run.cpu_bound</code> Ctrl-C traceback only shows on spawn-default platforms</h3>
<p>PR #6027's SIG_IGN initializer is correct, but the bug only manifests on macOS/Windows (spawn default) or Linux with <code>set_start_method('spawn', force=True)</code>. On default-fork Linux, workers inherit uvicorn's handler before SIGINT arrives, so the bug appears "fixed" without the patch. Triage "I can't reproduce" comments by asking for OS + <code>mp.get_start_method()</code>.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">14.3</span> Fullscreen Vue embed: kill the <code>.nicegui-content</code> padding</h3>
<p>NiceGUI applies <code>padding: 1rem</code> on its content wrapper by default. <code>100vw</code>/<code>100vh</code> on your Vue root won't fill the viewport without:</p>
<pre>ui.query(<span class="s">'body'</span>).style(<span class="s">'margin:0;padding:0;'</span>)
ui.query(<span class="s">'.nicegui-content'</span>).style(<span class="s">'padding:0;width:100%;'</span>)
<span class="c"># minHeight:'100vh' (NOT 100%) on the Vue root</span></pre>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">14.4</span> <code>ui.upload</code> inherits page-auth — DON'T carve <code>/_nicegui</code> out of middleware</h3>
<p>The upload route lives at <code>@app.post('/_nicegui/client/{client.id}/upload/{element.id}')</code> with no per-route auth. The classic auth example that carves <code>/_nicegui/*</code> out of middleware leaves uploads open to anyone with a live <code>client_id</code>. <i>But</i> page-auth IS transitive: if middleware redirects unauth users <i>away</i> from the page, the builder never runs and no client+upload pair is minted. So gate the page; don't try to add per-route session binding (it'd break two-tab-same-user and headless tests).</p>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">14.5</span> React/Vue SPA + NiceGUI → mount, don't wrap</h3>
<p>If your frontend is a React/Vue/Svelte build (Figma MCP output, etc.), don't embed it via <code>ui.html</code>/iframe/Veaury. NiceGUI's <code>app</code> is a FastAPI instance — serve the built SPA as static files at <code>/</code>, expose <code>/api/*</code> directly, optionally keep <code>@ui.page('/admin')</code> alongside. Mount the SPA <b>last</b> so it owns root after API routes are registered.</p>
<pre>app.mount(<span class="s">"/"</span>, StaticFiles(directory=<span class="k">str</span>(DIST), html=<span class="k">True</span>), name=<span class="s">"spa"</span>)</pre>
<div class="cite"><span class="local">field-tested</span></div></div>
<div class="tip"><h3><span class="key">14.6</span> Firefox blank-page through a reverse proxy after multiple reloads</h3>
<p>Vue script wasn't being deferred; Firefox's module cache returned a stale module. Hard-refresh or update to a release with the defer fix.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/5851">#5851</a></div></div>
<div class="tip"><h3><span class="key">14.7</span> Snap chromium breaks the <code>screen</code> fixture on Linux</h3>
<p>Snap's <code>/usr/bin/chromedriver</code> is a wrapper that execs the snap-confined version; it can't see system Chrome outside the snap. Result: <code>SessionNotCreatedException: no chrome binary at /usr/bin/google-chrome</code> even when standalone Chrome works. Fix: download a non-snap chromedriver matching system Chrome and prepend to PATH.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<!-- ============== APPENDIX ============== -->
<section class="cat" id="appendix">
<h2><span class="num">§A</span> Appendix: also worth knowing <span class="badge">3 tips</span></h2>
<div class="grid">
<div class="tip"><h3><span class="key">A.1</span> <code>nicegui-pack --onefile</code> on macOS shows a blank window</h3>
<p>Usually a debug-vs-release path issue in PyInstaller. Run the packed binary from a terminal and capture stderr.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/4899">#4899</a></div></div>
<div class="tip"><h3><span class="key">A.2</span> On Air doesn't serve dynamic-import elements</h3>
<p><code>ui.codemirror</code> and other elements that fetch resources at runtime don't route through On Air's CDN. Known limitation.</p>
<div class="cite"><a href="https://github.com/zauberzeug/nicegui/issues/3122">#3122</a> <a href="https://github.com/zauberzeug/nicegui/issues/2901">#2901</a></div></div>
<div class="tip"><h3><span class="key">A.3</span> <code>nicegui/ui.py</code> is lazy now — three places to register an element</h3>
<p>Post-PR #5916, <code>nicegui/ui.py</code> uses <code>_LAZY_IMPORTS: dict[str, tuple[module, attr]]</code> plus a <code>TYPE_CHECKING</code> block for IDE/mypy. Adding a new element: (1) <code>_LAZY_IMPORTS</code>, (2) <code>__all__</code>, (3) <code>TYPE_CHECKING</code> — all alphabetical. <code>tests/test_lazy_imports.py</code> enforces the invariant. Dormant PRs touching the old eager block will conflict — take main's side.</p>
<div class="cite"><span class="local">field-tested</span></div></div>
</div>
</section>
<footer>
<div class="wrap">
<p><b>Provenance.</b> 60+ closed GitHub issues / discussions mined live via <code>gh search issues --repo zauberzeug/nicegui --state closed</code> across 12+ query terms (refreshable, slot, storage, async, AG Grid, Plotly, sub_pages, reload, sticky-session, etc.) + ~26 internal field-debugging notes accumulated while reviewing NiceGUI PRs + structural audit of the <code>zauberzeug/nicegui</code> source tree. Synthesized 2026-05-21.</p>
<p>Inspired by the discussion on <a href="https://github.com/zauberzeug/nicegui/pull/6067">PR #6067 — "Add docs on how to fix non-working aggrid events"</a>: <i>"do you think a dedicated debugging section for NiceGUI is coming? If so all content along the vibes of this one is welcome!"</i></p>
</div>
</footer>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment