Skip to content

fix(routes): clearing observers in the edit modal now persists - #319

Merged
jinglemansweep merged 1 commit into
mainfrom
fix/route-clear-observers
Jul 19, 2026
Merged

fix(routes): clearing observers in the edit modal now persists#319
jinglemansweep merged 1 commit into
mainfrom
fix/route-clear-observers

Conversation

@jinglemansweep

Copy link
Copy Markdown
Contributor

What

Fixes a bug where removing all observers in the Route edit modal silently failed — the PUT returned 200, but the observers came back on next page load.

Root cause

src/meshcore_hub/web/static/js/spa/pages/routes.js:812 built the PUT body with a ternary that collapsed an empty observer list to null:

observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,

On the backend, Pydantic parsed null as None, and the PUT handler's guard at api/routes/routes.py:589 skipped the sync:

if body.observer_public_keys is not None:
    observer_nodes = _resolve_nodes_by_pubkey(session, body.observer_public_keys)
    _sync_observers(session, route, observer_nodes)

So:

  • Add observers → array → guard passes → _sync_observers deletes + recreates → worked.
  • Clear observersnull → guard failed → _sync_observers skipped → DB unchanged.

The None-means-skip semantic is correct for partial PATCH-style updates, so the bug is purely on the frontend: the route modal is a full-form PUT (every field is sent every time), so it shouldn't use the "send null when empty" pattern.

Fix

One-line change in routes.js — always send the array:

// Before
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
// After
observer_public_keys: observerPublicKeys,

When empty, JS serializes [], Pydantic parses as [] (not None), the guard passes, and _sync_observers deletes every existing RouteObserver row.

Regression test

tests/test_api/test_routes.py — added test_update_clear_observers_with_empty_list next to the existing test_update_observers. It:

  1. PUTs with one observer pubkey → asserts the response has 1 observer.
  2. PUTs again with observer_public_keys: [] → asserts response route_observers is [].
  3. GETs the detail endpoint to confirm persistence (catches any future "response lies but DB didn't update" regression).

This locks the contract so a future client (frontend or otherwise) can't silently reintroduce the bug by sending null for the empty case.

Scope (explicitly unchanged)

  • RouteUpdate.observer_public_keys: Optional[list[str]] = None — the None-means-skip semantic stays; it's correct for true partial updates from other clients.
  • _sync_observers (routes.py:158) — already does delete-all-then-recreate correctly.
  • Create flow — null and [] are equivalent on create (both mean "all observers"), so the same line worked by accident when creating. No change needed.
  • node_public_keys (line 811) — already sends the array unconditionally; no change.
  • Preview endpoint — read-only.

Tests

pytest --no-cov tests/test_api/test_routes.py
# → 50 passed

pytest --no-cov   # full suite
# → 1461 passed, 22 skipped

pre-commit run --all-files → clean (black, flake8, mypy, hooks).

Manual verification

Build the stack, then in the running app:

  1. Open /routes, edit a route, add an observer, save. Confirm it appears.
  2. Re-edit the same route, click ✕ on the observer chip (so the chip list is empty), save.
  3. Reload the page. Observer list is now empty.

The route edit modal builds its PUT body with a ternary that collapses
an empty observer list to null:

    observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,

Pydantic parses null as None, and the PUT handler's guard

    if body.observer_public_keys is not None:
        _sync_observers(session, route, observer_nodes)

skips the sync entirely when the field is None. So removing all
observers in the modal sent null -> no DB change. Adding observers
worked because a non-empty array passed the guard and _sync_observers
deleted + recreated.

The None-means-skip semantic is correct for true partial updates, so
the fix is on the frontend: the edit modal is a full-form PUT, so it
must always send the array. When empty, it sends [], which Pydantic
parses as [] (not None), the guard passes, and _sync_observers deletes
every existing RouteObserver row.

Tests: add test_update_clear_observers_with_empty_list next to the
existing test_update_observers as a regression guard. It seeds one
observer via PUT, then PUTs observer_public_keys: [] and asserts both
the response and a fresh GET come back with an empty list.
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@jinglemansweep
jinglemansweep merged commit f2137d0 into main Jul 19, 2026
4 checks passed
@jinglemansweep
jinglemansweep deleted the fix/route-clear-observers branch July 19, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant