Skip to content

Add UrlState: URL-backed search, filter, sort and page for list LiveViews - #680

Merged
ddon merged 10 commits into
BeamLabEU:mainfrom
timujinne:feature/url-state-search
Aug 5, 2026
Merged

Add UrlState: URL-backed search, filter, sort and page for list LiveViews#680
ddon merged 10 commits into
BeamLabEU:mainfrom
timujinne:feature/url-state-search

Conversation

@timujinne

Copy link
Copy Markdown
Contributor

Why

Typing in a list's search box filters the table but leaves the address bar untouched on most admin screens. The result cannot be shared, does not survive a reload, and Back walks out of the page instead of back to the previous query.

A workspace-wide audit found 26 LiveViews with this defect and — more to the point — seven independently hand-rolled implementations of the fix on the screens that do work: MediaBrowser.Embed, Activity.Index, and one each in comments, billing, crm, emails and ecommerce. They differ only cosmetically (maybe_put vs Enum.reject, flat vs nested form params).

Six of the seven share a real defect: they rebuild the path from a literal (Routes.path("/admin/comments?…")), so a LiveView reachable at more than one route — a sub-tab such as /orders/:id/edit/files — patches itself to the wrong page.

What this adds

PhoenixKitWeb.Live.UrlState — declare the state, implement one callback, push changes:

usePhoenixKitWeb.Live.UrlState,params: [search_query: [default: "",url_key: "q",alias: "search"],filter_role: [default: "all",url_key: "role"],page: [default: 1,cast: :integer,min: 1]]defhandle_url_state(_state,socket),do: load_users(socket)defhandle_event("search",%{"search"=>q},socket),do: {:noreply,push_url_state(socket,[search_query: q],replace: true)}
  • Param key = assign name, with url_key: naming the query key separately — so conversions touch no templates. users.html.heex (700 lines) needed no edit.
  • alias: is read but never written, letting screens that already published ?search= links converge on ?q= without breaking them.
  • cast: / in: whitelist values; a forged one falls back to the default. No atom is ever created from user input.
  • Defaults are dropped from the query, so an unfiltered list is /admin/users, not /admin/users?q=&role=all&page=1.
  • The path comes from the live uri, not a literal — locale segments and parent-resource ids survive.
  • replace: true for debounced input: a typed-out query leaves one history entry instead of one per pause. Every existing implementation gets this wrong, so Back currently walks the search box backwards a few characters at a time.
  • Unknown query keys are preserved — the media selector's ?return_to=…&mode=single survives a search.
  • The callback fires only on a real change, so an unrelated patch does not re-run the list's queries.

Converted

users, sessions, live_sessions, jobs/index, media_selector. live_sessions also gains URL-backed sort: its compound %{by:, dir:} assign is split into two flat params and recombined in the callback, leaving the template untouched.

use UrlState makes a LiveView router-only

Verified against phoenix_live_view source, and the reason phoenix_kit_projects is out of scope:

  • push_patch reaches sync_handle_params_with_live_redirect/5, which calls Utils.call_handle_params!/4 — the arity whose exported? defaults to true. So handle_params/3must be exported.
  • On an embedded mount (root_pid != self()), maybe_call_mount_handle_params/4 sees any? = callbacks? or exported? and raises through Route.live_link_info!. So exporting handle_params/3 — whatever its body — makes a LiveView un-embeddable.

One requires what the other forbids. MediaBrowser.Embed's url_sync: true has always had this constraint through its injected stub; this PR writes it into its moduledoc.

Tests

41 tests, no PostgreSQL required — everything deciding a URL is pure. Covers default-dropping, alias decode, cast/whitelist rejection, the integer ceiling, page reset, unknown-key preservation, path capture and fallbacks, and reload?/3.

Not covered: the on_mount → hook → host handle_params/3 ordering. live_isolated/3 mounts without a router, which this module refuses by design, so testing it needs a test router and endpoint core does not have yet. Called out in the design doc rather than papered over.

Review

ask-glm (elixir-review) reviewed the branch and approved it with four findings; three are fixed in d186b395:

  • integer params now carry a default ceiling of 1,000,000 — an unbounded ?page= survives Integer.parse, reaches Ecto as OFFSET and overflows PostgreSQL's bigint, turning a crafted link into a 500;
  • push_url_state now raises on a change keyed by URL key instead of assign name, which previously did nothing but reset the page;
  • the dead_render: :skip docs were wrong — the callback not running means its assigns do not exist, so the dead render raises rather than painting empty.

The fourth (LiveView-level test coverage) is the gap recorded above.

Design doc

dev_docs/plans/2026-08-04-url-state-search-persistence.md — full audit table, the constraint derivation, and the rollout order for the remaining repos.

…iews
Convert the users admin list to it and record the router-only contract on
MediaBrowser.Embed, which has always had it implicitly.
Sessions, live sessions, jobs and the media selector now carry search,
filters, sort and page in the query string. Live sessions gains URL-backed
sort by splitting the compound %{by:, dir:} assign into two flat params and
recombining them, so its template is untouched.
url_state_path/2 now also accepts a bare assigns map: LiveView swaps
socket.assigns for %AssignsNotInSocket{} while rendering, so @socket cannot
carry state into a template.
- Cap integer params at 1_000_000 by default. An unbounded ?page= survives
Integer.parse, reaches Ecto as OFFSET and overflows PostgreSQL's bigint,
turning a crafted link into a 500 on every converted list.
- Raise on a change keyed by URL key rather than assign name. It previously
did nothing but reset the page, presenting as "search is broken" with no
error anywhere.
- Make reload?/3 public so the reload decision is testable without a router.
- Correct the dead_render: :skip docs: the callback not running means its
assigns do not exist, so the dead render raises rather than painting empty.
@timujinne

Copy link
Copy Markdown
ContributorAuthor

Review rounds

Two ask-glm (elixir-review) passes; full log committed to dev_docs/pull_requests/2026/680-url-state-search-persistence/GLM_REVIEW.md. (ask-kimi skipped — account at its rate limit.)

Round 1 — APPROVE with findings. Three fixed in d186b395:

  • Unbounded integer → 500. Page params bounded min but not max; ?page=999999999999999999999999 parses, reaches Ecto as OFFSET, and overflows PostgreSQL's bigint on all five converted screens. Fixed in the module (default ceiling 1,000,000, overridable) rather than per-LiveView, so the ~20 remaining conversions inherit it.
  • dead_render: :skip docs were wrong — they promised an empty first paint, but since conversions move loading out of mount/3, the callback's assigns simply do not exist on the dead render and the template raises. No conversion uses :skip, so nothing ships broken; the doc now states the requirement.
  • The motivating example was inertshow_add_user_modal is assigned but read nowhere. Comments now cite the media selector's ?return_to=…&mode=single, which is a live round-trip.

Accepted gap: LiveView-level tests of the on_mount → hook → host handle_params/3 ordering. live_isolated/3 mounts without a router, which this module refuses by design, so it needs a test router and endpoint core does not have. Recorded in the design doc rather than papered over. reload?/3 was made public so the reload decision is at least pinned without one.

Round 2 — one BUG-HIGH reported, which does not reproduce. The reviewer read toggle_sort at live_sessions.ex:109 as passing dir: (a URL key) and crashing validate_changes! on every column-header click. The committed keyword list is sort_by: sort_by, sort_dir: sort_dir — both declared params. The dir: it saw belongs to the destructuring pattern one line above, where toggle_sort/2 returns the compound %{by:, dir:} map the template consumes. No change made.

Everything else in round 2 verified clean: identical-URL pushes cannot leave a screen stale (media_selector reloads in place when already on page 1; the other four route mutations through a direct load_*), no assign dropped by mount/3 is still read, reset_url_state/1 behaves, and the .heex changes hold at runtime.

normalize!/2 ran at macro expansion, where option values are still quoted.
A literal `in: [:asc, :desc]` is a list of atoms in AST too and worked by
accident, but `in: ~w(name email)` is a {:sigil_w, ...} node — matching a URL
value against it raised Protocol.UndefinedError at request time, past
compilation and past the codec tests. Normalise in the caller's module body
instead, and pin it with a test that actually uses the macro.
url_state_path/2 encoded whatever a caller merged in. A screen re-picking a
sort column after the current one is hidden hands over whatever column is
left, sortable or not — the URL then named a value decode/2 refuses, so the
address bar and the assigns disagreed and a reload showed something else.
Sanitise the merged state against the same whitelist and bounds before
encoding.
@timujinne

Copy link
Copy Markdown
ContributorAuthor

Third finding, from reviewing the first consumer

Reviewing the warehouse conversion (BeamLabEU/phoenix_kit_warehouse#11) surfaced a gap in this module, fixed here in 851d5f17.

url_state_path/2 encoded whatever a caller merged in, without checking it against the spec. Decoding validates; encoding did not. So a caller could put a value in the address bar that decode/2 then refuses — the link says one thing, the assigns hold another, and a reload shows a third.

It is not hypothetical. Warehouse's list screens re-pick the sort column when the current one is hidden, handing over whatever column is left — sortable or not. An unsortable pick was written to ?sort= and rejected on the way back in.

The merged state is now sanitised against the same whitelist and bounds as decoding, so the invariant is symmetric: the URL never carries a value the decoder would reject. Covered by a test.

That review also found two defects in warehouse itself (a sort reset bypassing the URL, and a lost query cache); both are fixed on that PR. Worth noting the shape of it — the module's own tests were green throughout, and both this gap and the earlier ~w() sigil bug only appeared once real consumers and a running app were involved.

url_state_path/2 merged onto the bookkeeping state map, so a LiveView that
set a declared param with assign/3 saw the superseded value come back in the
URL on the next patch — and a reload apply it. Warehouse hit this in all
seven lists: re-picking the sort column after the active one was hidden left
?sort= naming the hidden column.
Read the merge base back from the individual assigns instead, falling back to
the stored map. The freshest value wins however it was set, so the failure
mode is a URL that catches up rather than one that lies.
push_patch requires handle_params/3 to be exported, and exporting it is
exactly what makes a LiveView impossible to embed with live_render/3. One
requires what the other forbids, so until now an embeddable list simply could
not carry its state in the URL — phoenix_kit_projects has ten such LiveViews,
and more modules are heading the same way.
mode: :history never touches handle_params at all. The :handle_params stage
raises outright when the view has no router, so the hook goes on :handle_event
instead, and the compile-time stub is not injected. The browser owns the URL:
a JS hook reports the query on connect, rewrites the address bar when the
server pushes a new one, and reports popstate — which is what makes Back work
without a router. Only the query crosses the wire; the path stays client-side,
because an embedded LiveView does not know what page it is on.
The first load stays in mount/3, since there is no handle_params to hang it
on, so handle_url_state/2 serves changes only. The state is marked loaded at
mount so the client's connect report costs nothing when the URL held nothing.
@timujinne

Copy link
Copy Markdown
ContributorAuthor

Added: mode: :history, so embeddable LiveViews can carry state too (c9d78952)

The constraint written up in the description — push_patch needs handle_params/3 exported, and exporting it makes a LiveView un-embeddable — was originally recorded as a reason to leave phoenix_kit_projects alone. That module has ten LiveViews deliberately kept embeddable (dev_docs/embedding_audit.md, pinned by 43 live_isolated/3 tests), and other modules are heading the same way, so "embeddable lists cannot have shareable URLs" was not a limitation worth keeping.

mode: :history sidesteps the conflict by never touching handle_params:

  • the hook goes on :handle_event rather than :handle_params — the latter stage raises outright when the view has no router (Lifecycle.attach_hook/4);
  • the compile-time stub is not injected, since exporting the callback is the thing being avoided;
  • a JS hook (PhoenixKitUrlState, shipped in the core bundle) reports the query on connect, rewrites the address bar on a server push, and reports popstate — which is what makes Back and Forward work without a router;
  • push_url_state/3 applies the state itself; there is no round trip to bounce off.

Only the query crosses the wire. The path stays client-side deliberately: an embedded LiveView has no idea what page it is on.

One honest asymmetry, documented rather than hidden. With no handle_params, there is nothing to hang the first load on, so a :history LiveView keeps loading in mount/3 and handle_url_state/2 serves changes only. The state is marked loaded during on_mount, so the client's connect report costs an extra query only when the URL actually carried something.

:patch remains the default and is unchanged.

Verification

Verified against the first consumer (ProjectsLive, in the companion PR): the module compiles with --warnings-as-errors and function_exported?(mod, :handle_params, 3) is false — the invariant the whole mode exists to preserve, and the one :patch would have broken. Its 43 embedding tests are tagged :integration and need a PostgreSQL this environment does not have, so the export check stands in for them here; they will run in CI.

47 unit tests, credo --strict clean, and the JS bundle passes node --check.

Note for hosts: the hook ships in priv/static/assets/phoenix_kit.js, so an app that vendors that file needs mix phoenix_kit.update to pick it up before a :history LiveView will sync.

@ddon
ddon merged commit 105840f into BeamLabEU:mainAug 5, 2026
ddon pushed a commit that referenced this pull request Aug 5, 2026
Reviews the UrlState / V161-citext / V162-payment-option wave merged on
main, and fixes what it turned up.
Fix: `get_user_by_email_or_username_and_password/3` hand-rolled its case
folding as `fragment("LOWER(?)", u.username)`, which matches no index in
the chain. V161's whole premise is that comparison semantics come from
the column type, so with `username` now `citext` plain equality is both
correct and index-backed via `phoenix_kit_users_username_uidx`. This was
the only username lookup still sequentially scanning the users table, on
the one endpoint reachable without authenticating.
Fix: the `PhoenixKitUrlState` JS hook registered `handleEvent` on the
LiveSocket but only removed its `popstate` listener in `destroyed()`, so
each remount left another live callback behind.
Add: `test/phoenix_kit/migrations/v162_test.exs`. V162 shipped with no
test. Pins `ON DELETE SET NULL` in particular — that is the migration's
whole design decision, and a later refactor reaching for a plain
`references/2` would silently make it `RESTRICT` with nothing failing.
Also renames the V162 PR-draft doc out of `680-v161-…`, which named the
pre-renumber identity and collided with PR #680's own directory.
Full findings, and the two recorded-but-unfixed gaps, in
dev_docs/pull_requests/2026/680-682-post-merge-review/CLAUDE_REVIEW.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@timujinne
timujinne deleted the feature/url-state-search branch August 6, 2026 05:55
timujinne added a commit to timujinne/phoenix_kit that referenced this pull request Aug 10, 2026
The GLM reviewer pass on PR BeamLabEU#675 (COALESCE guard for the atomic
custom_fields merge/delete) was left untracked in a second, branch-name
mismatched directory. Filed under the PR's existing directory, whose slug
matches the head branch fix-custom-fields-atomic-merge, per the
one-directory-per-PR convention in CLAUDE.md. That puts it next to the
PR's CLAUDE_REVIEW.md; the two earlier GLM reviews (BeamLabEU#668, BeamLabEU#680) instead
sit in their own slug directories, which is drift worth not repeating.
.pi-subagents/ holds mission JSON and run transcripts written by the
external subagent harness; its reports land outside the repo, so the
directory is scratch and is now ignored rather than committed.
Sign up for freeto 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.

2 participants

@timujinne@ddon