Created
May 20, 2026 19:49
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 #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 & 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 & deployment</a> | |
| <a href="#subpages">10. Sub-pages</a> | |
| <a href="#memory">11. Memory / perf</a> | |
| <a href="#dialogs">12. Dialogs & scenes</a> | |
| <a href="#testing">13. Testing</a> | |
| <a href="#native">14. Native & 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 & 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 (< 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 & 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> > 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 > 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 (< 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) => 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 & 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 & 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 & 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><run_path></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">'<run_path>'</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 & 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><a></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 & 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><frozen importlib._bootstrap_external>: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 & <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> & <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 & 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