# Browser Back/Forward Navigation Fix - Design Document

**Date:** 2026-02-12
**Status:** Approved
**Goal:** Ensure browser back/forward buttons work correctly across all LiveView pages

## Problem Statement

18 LiveViews in the application don't properly sync UI state to URL parameters, causing browser back/forward buttons to not work as expected. Users cannot navigate back through tab changes, modal opens/closes, or filter selections.

## Root Causes

1. **Missing `handle_params/3` callbacks** - 18 LiveViews lack this required callback
2. **State changes via `assign` only** - Event handlers update assigns without updating URL
3. **No URL synchronization** - Tabs, modals, filters change without `push_patch/2`
4. **Cookie consent forced reload** - JavaScript breaks navigation flow

## Solution: The Idiomatic Phoenix Pattern

Use Phoenix LiveView's built-in `push_patch/2` and `handle_params/3` pattern. No custom helpers needed.

### Core Architecture

**Principle:** URL is the single source of truth for all visible state.

**Flow:**
```
User interaction (click tab, open modal)
  → Event handler calls push_patch(socket, to: ~p"/path?param=value")
  → Browser URL updates
  → handle_params/3 receives new params
  → Validates params, updates assigns
  → Minimal diff sent to client
  → UI updates
```

**Browser back/forward:**
```
User clicks browser back
  → Browser navigates to previous URL
  → handle_params/3 receives previous params
  → Assigns update to previous state
  → UI restores previous state
```

## Implementation Patterns

### Pattern 1: Tab Navigation

**LiveView:**
```elixir
@impl true
def handle_params(params, _url, socket) do
  tab = case params["tab"] do
    tab when tab in ~w(overview sensors interfaces) -> tab
    _ -> "overview"  # Default
  end

  {:noreply, socket
    |> assign(:active_tab, tab)
    |> load_tab_data(tab)}
end

def handle_event("select_tab", %{"tab" => tab}, socket) do
  {:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}?tab=#{tab}")}
end
```

**Template:**
```heex
<.link patch={~p"/devices/#{@device.id}?tab=overview"}
       class={if @active_tab == "overview", do: "active", else: ""}>
  Overview
</.link>
```

### Pattern 2: Modal State

**LiveView:**
```elixir
@impl true
def handle_params(params, _url, socket) do
  modal = params["modal"]

  {:noreply, socket
    |> assign(:show_modal, modal != nil)
    |> assign(:modal_name, modal)
    |> maybe_initialize_form(modal)}
end

def handle_event("show_add_token", _params, socket) do
  {:noreply, push_patch(socket, to: ~p"/settings?modal=add_token")}
end

def handle_event("save_token", %{"token" => attrs}, socket) do
  case Accounts.create_api_token(attrs) do
    {:ok, _token} ->
      {:noreply, socket
        |> put_flash(:info, "Token created")
        |> push_patch(to: ~p"/settings")}  # Closes modal

    {:error, changeset} ->
      {:noreply, assign(socket, :form, to_form(changeset))}
  end
end
```

**Template:**
```heex
<.button phx-click="show_add_token">Add API Token</.button>

<.modal :if={@show_modal && @modal_name == "add_token"}
        id="add-token-modal"
        on_cancel={JS.patch(~p"/settings")}>
  <.simple_form for={@form} phx-submit="save_token">
    <!-- form fields -->
  </.simple_form>
</.modal>
```

### Pattern 3: Filters + Pagination

**LiveView:**
```elixir
@impl true
def handle_params(params, _url, socket) do
  filter = case params["filter"] do
    f when f in ~w(active resolved all) -> f
    _ -> "active"
  end

  page = params["page"] || "1" |> String.to_integer()

  {:noreply, socket
    |> assign(:filter, filter)
    |> assign(:page, page)
    |> load_alerts(filter, page)}
end

def handle_event("apply_filters", %{"filter" => filter}, socket) do
  # Reset to page 1 when filter changes
  {:noreply, push_patch(socket, to: ~p"/alerts?filter=#{filter}&page=1")}
end
```

**Template:**
```heex
<form phx-change="apply_filters">
  <.input name="filter" value={@filter} options={[...]} />
</form>

<.pagination meta={@pagination} path={~p"/alerts"} params={%{"filter" => @filter}} />
```

## Migration Strategy

### The 18 LiveViews to Update

**Priority 1: High-traffic user-facing (5 LiveViews)**
1. `DashboardLive` - Main landing page
2. `NetworkMapLive` - Topology view with tabs
3. `AlertLive.Index` - Alerts with filters
4. `UserSettingsLive` - Settings with modals
5. `DeviceLive.Show` - Device details (add modal state)

**Priority 2: Admin and specialized (8 LiveViews)**
6. `Admin.MonitoringLive`
7. `Admin.DashboardLive`
8. `AgentLive.Index`
9. `AgentLive.Show`
10. `SiteLive.Index`
11. `SiteLive.Show`
12. `OrganizationLive.Index`
13. `BackupLive.*` (multiple)

**Priority 3: Auth and low-traffic (5 LiveViews)**
14. `MobileQrLive`
15. `OrgLive.New`
16. `UserRegistrationLive`
17. `UserResetPasswordLive`
18. `Account.TotpEnrollment`
19. `Account.Activity`
20. `Account.MyData`

### Update Checklist Per LiveView

```markdown
- [ ] 1. Read current implementation
- [ ] 2. Identify stateful UI elements (tabs, modals, filters, pagination)
- [ ] 3. Add/update `handle_params/3`:
  - [ ] Validate params with whitelist
  - [ ] Provide sensible defaults
  - [ ] Update assigns from params only
- [ ] 4. Update event handlers:
  - [ ] Replace `assign` with `push_patch` for state changes
  - [ ] Preserve existing params when adding new ones
- [ ] 5. Update templates:
  - [ ] Use `<.link patch={...}>` for navigation
  - [ ] Use `JS.patch(...)` in modal `on_cancel`
- [ ] 6. Test manually:
  - [ ] Navigate through states
  - [ ] Press back - returns to previous state
  - [ ] Press forward - restores next state
  - [ ] Refresh page - state preserved
```

### Batch Timeline

- **Days 1-2:** Priority 1 (high-traffic user-facing)
- **Days 3-4:** Priority 2 (admin/specialized)
- **Day 5:** Priority 3 (auth/low-traffic)
- **Day 6:** Cookie consent fix, final testing

Each batch is a separate commit with manual testing before proceeding.

## Testing Strategy

### Manual Testing Protocol

For each LiveView:

**Tab Navigation Test:**
1. Load page → verify default tab in URL (?tab=default)
2. Click Tab 2 → verify URL updates (?tab=tab2)
3. Browser back → verify returns to Tab 1
4. Browser forward → verify returns to Tab 2
5. Refresh → verify stays on current tab

**Modal State Test:**
1. Open modal → verify ?modal=name in URL
2. Browser back → verify modal closes
3. Browser forward → verify modal reopens
4. Cancel button → verify modal closes and ?modal= removed
5. Copy URL with ?modal → paste in new tab → modal opens

**Filter + Pagination Test:**
1. Change filter → verify ?filter=value&page=1
2. Navigate to page 3 → verify ?filter=value&page=3
3. Browser back → verify returns to page 1 with filter
4. Change filter → verify resets to page 1
5. Bookmark URL → reopen → verify state restored

### Automated Testing

**LiveView Test Pattern:**
```elixir
defmodule ToweropsWeb.DeviceLiveTest do
  use ToweropsWeb.ConnCase
  import Phoenix.LiveViewTest

  describe "browser history with tabs" do
    test "navigating tabs updates URL", %{conn: conn} do
      {:ok, view, _html} = live(conn, ~p"/devices/#{device}")

      # Click sensors tab
      view |> element("a", "Sensors") |> render_click()
      assert_patch(view, ~p"/devices/#{device}?tab=sensors")

      # Simulate back button
      {:ok, view, _html} = live(conn, ~p"/devices/#{device}?tab=overview")
      assert has_element?(view, "[data-active-tab='overview']")
    end

    test "handles invalid tab param", %{conn: conn} do
      {:ok, view, _html} = live(conn, ~p"/devices/#{device}?tab=invalid")
      assert has_element?(view, "[data-active-tab='overview']")
    end
  end
end
```

## Cookie Consent Fix

**File:** `assets/js/cookie_consent.js`

**Problem:**
```javascript
window.location.href = location.href;  // Forces reload, breaks history
```

**Fix:**
```javascript
// Remove the reload line - LiveView handles state updates
```

**Test:**
1. Accept cookies → no page reload
2. Navigate away and back → cookies still accepted
3. Browser back → smooth navigation

## Key Principles

1. **URL is source of truth** - All visible state must be in URL params
2. **Never trust params** - Always validate in `handle_params/3`
3. **Use Phoenix built-ins** - `push_patch/2`, `<.link patch={...}>`, `JS.patch(...)`
4. **No custom abstractions** - Phoenix provides everything needed
5. **Test back/forward** - Manual testing is critical for UX validation

## Success Criteria

✅ Browser back button returns to previous tab/filter/modal state
✅ Browser forward button restores next state
✅ Page refresh preserves state from URL
✅ Bookmarked URLs restore exact application state
✅ All 18 LiveViews implement `handle_params/3`
✅ Automated tests verify URL state synchronization
✅ Cookie consent doesn't break navigation

## References

- [Phoenix LiveView Live Navigation Guide](https://hexdocs.pm/phoenix_live_view/live-navigation.html)
- [Persistent Forms with URL on LiveView](https://fly.io/phoenix-files/persistent-forms-with-your-url-on-liveview/)
- Phoenix LiveView `push_patch/2` documentation
- Phoenix LiveView `handle_params/3` documentation
