towerops/docs/plans/2026-02-12-browser-navigation-fix-design.md
Graham McIntire 97b396a763
docs: add browser navigation best practices and design document
Add comprehensive documentation for idiomatic Phoenix LiveView URL state
management to ensure browser back/forward buttons work correctly.

Changes:
- Add design document: docs/plans/2026-02-12-browser-navigation-fix-design.md
  - Documents the problem (18 LiveViews missing handle_params/3)
  - Provides idiomatic Phoenix patterns using push_patch/2
  - Includes migration strategy for updating all LiveViews
  - Contains testing protocols and success criteria

- Update CLAUDE.md: Add "Browser Navigation and URL State Management" section
  - Documents the three core patterns: tabs, modals, filters
  - Establishes critical rules for all future LiveView development
  - Provides working code examples for each pattern
  - Links to official Phoenix documentation

Key Principles:
- URL is single source of truth for visible state
- Use push_patch/2 in event handlers, never assign for URL-backed state
- Always implement handle_params/3 to validate and process URL params
- Use Phoenix built-ins (no custom helpers needed)

This ensures future LiveView development follows the correct pattern
and browser navigation works as users expect.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 09:20:14 -06:00

9 KiB

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:

@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:

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

Pattern 2: Modal State

LiveView:

@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:

<.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:

@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:

<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

- [ ] 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:

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

File: assets/js/cookie_consent.js

Problem:

window.location.href = location.href;  // Forces reload, breaks history

Fix:

// 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