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>
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
- Missing
handle_params/3callbacks - 18 LiveViews lack this required callback - State changes via
assignonly - Event handlers update assigns without updating URL - No URL synchronization - Tabs, modals, filters change without
push_patch/2 - 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)
DashboardLive- Main landing pageNetworkMapLive- Topology view with tabsAlertLive.Index- Alerts with filtersUserSettingsLive- Settings with modalsDeviceLive.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:
- Load page → verify default tab in URL (?tab=default)
- Click Tab 2 → verify URL updates (?tab=tab2)
- Browser back → verify returns to Tab 1
- Browser forward → verify returns to Tab 2
- Refresh → verify stays on current tab
Modal State Test:
- Open modal → verify ?modal=name in URL
- Browser back → verify modal closes
- Browser forward → verify modal reopens
- Cancel button → verify modal closes and ?modal= removed
- Copy URL with ?modal → paste in new tab → modal opens
Filter + Pagination Test:
- Change filter → verify ?filter=value&page=1
- Navigate to page 3 → verify ?filter=value&page=3
- Browser back → verify returns to page 1 with filter
- Change filter → verify resets to page 1
- 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
Cookie Consent Fix
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:
- Accept cookies → no page reload
- Navigate away and back → cookies still accepted
- Browser back → smooth navigation
Key Principles
- URL is source of truth - All visible state must be in URL params
- Never trust params - Always validate in
handle_params/3 - Use Phoenix built-ins -
push_patch/2,<.link patch={...}>,JS.patch(...) - No custom abstractions - Phoenix provides everything needed
- 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
- Persistent Forms with URL on LiveView
- Phoenix LiveView
push_patch/2documentation - Phoenix LiveView
handle_params/3documentation