Skip to main content
Issues are grouped by area. Each entry documents the symptom, the wrong pattern that caused it, the root cause, and the exact fix. The goal is that no one wastes time hitting the same wall twice.

AG Grid cell styling

This is the most reliably confusing area in NiceGUI’s AG Grid integration. Here is the full decision tree.

What works

GoalMechanismNotes
Same style on every cell in a column"cellStyle": {"color": "#f00"}Plain dict; AG Grid applies as-is
Per-cell dynamic colour / contenthtml_columns + pre-built HTMLThe only reliable dynamic option
Column fills remaining width"flex": 1 on the column + "autoSizeStrategy": None on the gridflex is ignored when autoSizeStrategy is set (NiceGUI default)

What does NOT work

MechanismWhy it fails
"cellStyle": {"function": "params => ..."}NiceGUI serialises grid options as JSON. The {"function":"..."} wrapper is not converted to a live JS function; AG Grid receives an inert object and ignores it.
"cellClassRules": {"my-class": "params.value === 'X'"}Same root cause — the expression string is never evaluated; no class is ever added.
Dynamically computed CSS variable names in JS ('var(--color-' + idx + ')')Browsers do not resolve CSS custom-property names that are constructed by string concatenation in JavaScript inline styles.

The html_columns pattern (correct approach)

Pre-render styled HTML in Python when building row data. Keep raw values in separate fields for filtering and sorting. Enable HTML rendering per column index with html_columns.
# 1. CSS — inject with ui.add_head_html()
_CSS = """
<style>
.log-lvl-info     { color: #16a34a !important; }
.log-lvl-warning  { color: #ca8a04 !important; font-weight: bold; }
.log-lvl-error    { color: #dc2626 !important; font-weight: bold; }
.log-mod-0        { color: #0891b2 !important; }
.log-mod-1        { color: #c026d3 !important; }
/* Dark-mode overrides — Quasar adds body--dark to <body> */
.body--dark .log-lvl-info    { color: #4ade80 !important; }
.body--dark .log-lvl-error   { color: #f87171 !important; }
.body--dark .log-mod-0       { color: #22d3ee !important; }
.body--dark .log-mod-1       { color: #e879f9 !important; }
</style>
"""

# 2. Row building — pre-compute HTML alongside raw values
_LEVEL_CLASS = {"INFO": "log-lvl-info", "WARNING": "log-lvl-warning", "ERROR": "log-lvl-error"}

def build_row(level: str, module: str, message: str) -> dict:
    mod_idx = sum(ord(c) for c in module) % 4   # stable hash bucket
    return {
        # raw fields — used by Python filter logic, sorting
        "level":  level,
        "module": module,
        # html fields — displayed in the grid via html_columns
        "level_html":  f'<span class="{_LEVEL_CLASS.get(level, "")}">{level}</span>',
        "module_html": f'<span class="log-mod-{mod_idx}">{module}</span>',
        "message": message,
    }

# 3. Column defs — html fields are display-only; no filter on them
COL_DEFS = [
    {"headerName": "Lvl",     "field": "level_html",  "width": 80},   # col index 0
    {"headerName": "Module",  "field": "module_html", "width": 120},  # col index 1
    {"headerName": "Message", "field": "message",     "flex": 1},     # col index 2
]

# 4. Grid creation — html_columns lists the 0-based column indices
ui.add_head_html(_CSS)
grid = ui.aggrid(
    {
        "columnDefs": COL_DEFS,
        "rowData": rows,
        "autoSizeStrategy": None,   # required for flex to work on any column
    },
    html_columns=[0, 1],            # Lvl and Module render as HTML
)
Always keep a raw-value counterpart (e.g. level) alongside the HTML field (level_html). Filter and sort logic should compare against the raw string, not the HTML markup.

AG Grid data and updates

Symptom: The grid renders with 0 rows. Server-side logs confirm rows are fetched and filtered, yet the browser shows nothing. No JS errors visible.Wrong pattern:
# Data loaded AFTER grid is created — the update fires before the browser
# has mounted the grid component.
grid = ui.aggrid({"columnDefs": ..., "rowData": []})
rows = load_data()
grid.options["rowData"] = rows
grid.update()           # ← lost: grid not mounted in browser yet
Root cause: grid.update() sends the new options to the currently connected WebSocket clients. If it is called synchronously during page construction, the browser has not yet received and mounted the component, so the update is silently discarded.Fix: Pre-load data before creating the grid and pass it directly to the constructor. This means the correct rowData is part of the initial render payload and never needs an early update.
rows = load_data()          # ← run first
grid = ui.aggrid({
    "columnDefs": ...,
    "rowData": rows,        # ← pre-populated at construction time
})
Subsequent updates (polling, filter changes) still use grid.options["rowData"] = rows; grid.update().
Symptom: Browser console prints Method "setRowData" not found (repeated). Grid does not update.Wrong pattern:
grid.run_grid_method("setRowData", rows)
Root cause: setRowData was removed in AG Grid v28+. The version bundled with NiceGUI does not expose this method.Fix: Mutate grid.options directly and call grid.update():
grid.options["rowData"] = rows
grid.update()
Symptom: User sorts the grid (e.g. newest-first). When the polling timer appends new rows, the sort reverts to the default.Root cause: grid.update() re-sends the full grid.options dict to the client, including columnDefs without any sort state. AG Grid resets its internal column state to match the incoming options, wiping the sort the user applied.Fix: Re-apply the desired sort immediately after every grid.update() call using applyColumnState:
def _append_rows(rows):
    grid.options["rowData"].extend(rows)
    grid.update()
    _apply_sort()       # ← must follow every grid.update()

def _apply_sort():
    direction = "desc" if sort_order == "newest" else "asc"
    grid.run_grid_method(
        "applyColumnState",
        {
            "state": [{"colId": "timestamp", "sort": direction}],
            "defaultState": {"sort": None},
        },
    )
Also defer the initial sort application with a one-shot timer so the grid API is available on the client before it is called:
ui.timer(0.1, _apply_sort, once=True)
Symptom: The grid columns shrink or grow slightly every time new rows arrive or filters are applied, making the layout feel jumpy.Root cause: NiceGUI sets a default autoSizeStrategy on every ui.aggrid instance. Every grid.update() re-sends the full options object, which re-triggers the strategy and causes AG Grid to auto-resize all columns.Fix: Disable the strategy in the grid options. Columns then respect only their width / minWidth / flex definitions and never auto-resize:
grid = ui.aggrid({
    "columnDefs": ...,
    "autoSizeStrategy": None,   # disable NiceGUI default
})
Symptom: Browser console warning about flex on column definitions. The column does not expand to fill remaining space.Root cause: NiceGUI’s default autoSizeStrategy conflicts with column-level flex. AG Grid logs a warning and ignores flex when autoSizeStrategy is present.Fix: Set "autoSizeStrategy": None on the grid (see entry above), then add flex to whichever column should fill the remaining space:
{"headerName": "Extra", "field": "extra", "flex": 1, "minWidth": 150}
Symptom: TypeError: a coroutine was expected, got <AwaitableResponse ...> when wrapping a grid method call in asyncio.create_task() or await.Wrong pattern:
async def _scroll():
    await asyncio.create_task(grid.run_grid_method("ensureIndexVisible", 0, "top"))
Root cause: grid.run_grid_method() returns a NiceGUI AwaitableResponse, which is an awaitable but not a coroutine. asyncio.create_task() requires a coroutine and raises TypeError when handed anything else.In practice, run_grid_method sends the command to the browser immediately (fire-and- forget). You do not need to await it for scrolling or applying column state.Fix: Call it as a plain synchronous statement from a regular def function:
def _scroll_to_edge():
    grid.run_grid_method("ensureIndexVisible", 0, "top")
Symptom: Calling grid.run_grid_method(...) synchronously during page construction has no effect (e.g. initial sort is not applied). No error is raised.Root cause: run_grid_method sends a JavaScript call to the browser-side AG Grid instance. During server-side page construction the browser has not yet received or mounted the component, so the call is sent to a non-existent grid and silently dropped.Fix: Defer any grid API calls that must run after mount using a one-shot timer:
ui.timer(0.1, _apply_sort, once=True)
0.1 s is enough for the WebSocket round-trip and component mount in a local environment. Increase if deploying to a slower network.

NiceGUI UI element state

Symptom: Clicking a “chip” button (a Quasar button used as a toggle) correctly removes the filled appearance the first time. Clicking it again to turn it back on does nothing.Wrong pattern:
# Turning off: add the flat prop
btn.props("flat dense rounded")

# Turning on: re-list props without flat — BUT flat is still there from before
btn.props("dense rounded")
Root cause: NiceGUI’s btn.props(...) is additive. Calling it with a new string adds those props to whatever is already present; it does not replace them. So once flat is added to make the button look inactive, passing "dense rounded" without flat does not remove flat — the button stays flat forever.Fix: Use the add= and remove= keyword arguments to explicitly add or remove individual props:
def _set_chip_on(btn: ui.button) -> None:
    btn.props(remove="flat")    # remove flat → filled/active appearance

def _set_chip_off(btn: ui.button) -> None:
    btn.props(add="flat")       # add flat → outline/inactive appearance
Initialise the button with the base props it always needs, then call the appropriate helper:
btn = ui.button(label).props("dense rounded")
if not is_active:
    _set_chip_off(btn)
Symptom: User preferences are read from storage correctly, but chips still appear in the wrong on/off state when the page loads.Wrong pattern:
# Trying to encode state in the initial props string
btn = ui.button(label).props(
    f"{'dense' if is_on else 'flat dense'} rounded"
)
Root cause: This looks correct but is fragile — it relies on the same additive props bug described above for subsequent toggles.Fix: Always start with the full base props, then apply the on/off helper:
btn = ui.button(label).props("dense rounded")
if not (name in active_list):
    _set_chip_off(btn)
This makes the initial state and the toggle path use identical logic.

NiceGUI event handlers

Symptom: The search box triggers a server error, or the search text is always empty or None.Wrong pattern:
def _on_search(e):
    text = e.args[0]        # wrong: args is not the right accessor
    # or:
    text = str(e)           # wrong: e is an event object, not the value
Root cause: NiceGUI’s on_value_change callback receives a ValueChangeEventArguments object. The new value is on e.value, not e.args[0] or the object itself.Fix:
def _on_search(e) -> None:
    text = (e.value or "").strip() if hasattr(e, "value") else ""
The hasattr guard also protects against edge cases where the event object shape changes (e.g. clearable input firing a clear event with a slightly different signature).

Page lifecycle and timers

Symptom: Server terminal prints RuntimeError: The parent slot of the element has been deleted repeatedly after the user navigates away from a page that has a ui.timer.Root cause: ui.timer callbacks continue to fire even after the user leaves the page. If the callback tries to read or update a UI element that has been garbage- collected (its parent slot deleted), NiceGUI raises RuntimeError.Fix: Wrap the timer callback body in a try/except RuntimeError block:
def _poll() -> None:
    try:
        # ... interact with grid, labels, etc. ...
    except RuntimeError:
        pass   # page was navigated away; element slots are gone
    except Exception:
        pass
This is the recommended pattern for any polling timer attached to a page with navigation. The timer will keep firing, but the errors are silently swallowed rather than flooding the terminal.

Log file reading

Symptom: Log tools report no files found, or log files appear in an unexpected directory, when the server is started through VS Code’s debugger (launch.json).Root cause: debugpy changes the process working directory to the project root configured in launch.json, which may differ from the directory where the server normally runs. Any relative log-file path resolves differently depending on launch method.Fix: Always resolve log paths to absolute paths using the configured workspace directory, never relative to os.getcwd():
log_dir = Path(workspace_path) / "logs"   # always absolute
When exposing the log directory to the UI, store the resolved absolute Path in the shared state object during server startup, before any UI page is rendered.