When Dragons Go Missing: A Tale of URL Parameter Parsing in Phoenix LiveView

A debugging story about array parameters, pagination, and why choosing the right parsing function matters.

The Quest Begins

One day, Marci, an intrepid Elixir developer, was building an admin panel for a Monster Bestiary. Everything was working smoothly until adventurers started reporting a bizarre bug: when they filtered by multiple monster types and tried to paginate through results, some of their selected filters mysteriously vanished.

Adventurers would select both “Dragons” and “Goblins”, set their page size to 15, and when they clicked “Next Page” – poof! – suddenly only “Goblins” remained in their filters. The Dragons had vanished without a trace.

The Mysterious Disappearing Act

Here’s what adventurers were experiencing:

  1. Select “Dragons” and “Goblins” monster types
  2. URL becomes: /admin/bestiary?monster_types[]=dragons&monster_types[]=goblins&page=1&per_page=15
  3. Click “Next Page”
  4. URL becomes: /admin/bestiary?monster_types[]=goblins&page=2&per_page=15
  5. Only Goblin monsters shown on page 2

The “Dragons” filter was being dropped during pagination – completely unexpected behavior that left adventurers confused and frustrated.

Reproducing the Mystery

Marci knew the first step in any good debugging quest was to reproduce the problem reliably. She wrote a test that would fail until the mystery was solved:

test "pagination preserves monster type filters when navigating between pages", %{
  conn: conn,
  bestiary: bestiary
} do
  # Create monsters for pagination testing
  create_monsters_for_pagination(bestiary, 10, :dragon)
  create_monsters_for_pagination(bestiary, 10, :goblin)
  create_monsters_for_pagination(bestiary, 5, :undead)

  # Start with Dragons and Goblins selected, per_page=5
  {:ok, view, _html} = live(conn, ~p"/admin/bestiary/#{bestiary}?per_page=5")

  # Filter to Dragons and Goblins only
  view
  |> element("#filter_form")
  |> render_change(%{filter: %{monster_types: ["dragons", "goblins"]}})

  # Should show 5 monsters (first page of Dragons + Goblins)
  assert_pagination_state(view, 5, "1-5 of 20")

  # Verify we have both Dragons and Goblins visible
  dragon_count = count_monsters_by_type(view, "Fire Drake")
  goblin_count = count_monsters_by_type(view, "Cave Goblin")
  assert dragon_count + goblin_count == 5

  # Test pagination through all pages
  for page <- 2..4 do
    # Go to next page
    view |> element("#pagination-next") |> render_click()

    # Should show next 5 monsters and maintain the same filters
    expected_range = "#{(page - 1) * 5 + 1}-#{min(page * 5, 20)} of 20"
    assert_pagination_state(view, 5, expected_range)

    # Verify we still have both Dragons and Goblins visible
    dragon_count = count_monsters_by_type(view, "Fire Drake")
    goblin_count = count_monsters_by_type(view, "Cave Goblin")
    assert dragon_count + goblin_count == 5

    # Verify the filter pills still show Dragons and Goblins as selected
    assert_monster_types_selected(view, ["dragons", "goblins"])
    assert_monster_types_not_selected(view, ["undead"])
  end
end

defp count_monsters_by_type(view, type_text) do
  render(view)
  |> Floki.find("[data-test-monster-log-block]")
  |> Enum.count(fn element ->
    Floki.text(element) =~ type_text
  end)
end

Marci’s failing test captured exactly what the adventurers were experiencing. The test would be her guide – as long as it failed, the mystery remained unsolved.

The Quest for Answers

Prepared with a reliable reproduction case, Marci began investigating. Her initial hunches led her down several false paths:

False Lead #1: Monster Filter Logic
Maybe the database query for filtering monsters was somehow broken? But testing showed that filtering worked perfectly – it was only during pagination that monsters disappeared.

False Lead #2: Form Submission Issues
Perhaps the monster type selection form wasn’t submitting array parameters correctly? But inspection of the form data showed it was submitting as she expected: monster_types[]=dragons&monster_types[]=goblins.

False Lead #3: LiveView State Management
Could this be a complex state management issue where the LiveView was losing track of selected filters? But the state seemed fine – it was the URL parameters that were wrong.

The symptoms were particularly misleading:

  • ✅ Single monster type filters worked
  • ❌ Multiple monster type filters broke during pagination
  • ✅ Pagination worked on pages without array filters

This made it seem like a complex interaction between the filtering system and pagination logic, when the real culprit was hiding in a much simpler place.

Tracking the Beast

Marci decided to trace what happens step-by-step when adventurers use pagination. The bug had to be somewhere in this flow:

When the page first loads (working correctly):

  • User visits: /admin/bestiary?monster_types[]=dragons&monster_types[]=goblins&page=1
  • LiveView’s handle_params/3 receives these URL parameters
  • handle_params/3 calls Pagination.build/3 with the URL and params
  • Database query runs and returns both dragons and goblins
  • Page displays correctly with both monster types

When “Next Page” is clicked (where things go wrong):

  • User clicks “Next Page” button
  • Pagination component needs to build a new URL for page 2
  • merge_url_params/2 tries to combine existing filters with new page number
  • Something goes wrong here – dragons disappear from the URL
  • New URL becomes: /admin/bestiary?monster_types[]=goblins&page=2
  • LiveView navigates to this incorrect URL
  • handle_params/3 runs again, but now only sees goblins in the params
  • Database query only returns goblins, dragons are gone

Since the filtering logic worked fine on the initial page load, the issue was likely to be in the Pagination.build/3 function – specifically in how it was handling URL parameters.

Marci focused on the pagination component’s URL building logic. Here’s the relevant part of the beast:

  def build(query, url, params, opts \ []) do
    pagination_params = params(params)
    preload = Keyword.get(opts, :preload, [])

    {records, records_count, page_count, {page_range_from, page_range_to}} =
      query_and_count(query, pagination_params, preload)

    links = links(url, pagination_params, page_count)

    {records,
     %{
       records_count: records_count,
       per_page: pagination_params.per_page,
       
       links: links
     }}
  end

defp links(url, %{page: page, per_page: per_page}, page_count) do
  next_page =
    if page + 1 <= page_count do
      url
      |> merge_url_params(%{"per_page" => per_page, "page" => page + 1})
      |> URI.to_string()
    end

  prev_page =
    if page > 1 do
      url
      |> merge_url_params(%{"per_page" => per_page, "page" => page - 1})
      |> URI.to_string()
    else
      nil
    end

  # … rest of the function
end

The merge_url_params/2 function was responsible for taking the current URL (with all its monster filters) and adding new pagination parameters. If dragons were disappearing, this function was the prime suspect.

defp merge_url_params(url, params) do
  uri = URI.parse(url)
  query = URI.decode_query(uri.query || "")  # ← The culprit!
  new_query = Map.merge(query, params)
  %{uri | query: URI.encode_query(new_query)}
end

When Marci tested this function with the actual URL parameters from the failing test, the problem became clear:

# Input URL query: 
"monster_types[]=dragons&monster_types[]=goblins&page=1&per_page=15"

# What URI.decode_query/3 returns:
URI.decode_query("monster_types[]=dragons&monster_types[]=goblins&page=1&per_page=15")
# => %{"monster_types[]" => "goblins", "page" => "1", "per_page" => "15"}

The dragons were being overwritten! URI.decode_query/3 only keeps the last value for repeated keys. When it encountered multiple monster_types[] parameters, it threw away everything except the final “goblins” value.

The Root Cause: Choosing the Wrong Spell

The issue lay in the fundamental difference between two similar-looking Elixir functions:

URI.decode_query/3 – General Purpose URL Parsing:

URI.decode_query("monster_types[]=dragons&monster_types[]=goblins")
# Returns: %{"monster_types[]" => "goblins"}
# ❌ Only keeps the last value, loses all others!

Plug.Conn.Query.decode/3 – HTML Form Array Parsing:

Plug.Conn.Query.decode("monster_types[]=dragons&monster_types[]=goblins")
# Returns: %{"monster_types[]" => ["dragons", "goblins"]}
# ✅ Correctly handles arrays, preserves all values!

URI.decode_query/3 is designed for general URL parsing where duplicate keys are unusual. Plug.Conn.Query.decode/4 is specifically designed for HTML form submissions, which commonly use array parameters like monster_types[]. It’s the same function Phoenix uses internally when processing form data.

Taming the Beast

The solution was elegant – just two lines of code, but using the right spell for the job:

# Before:
defp merge_url_params(url, params) do
  uri = URI.parse(url)
  query = URI.decode_query(uri.query || "") # ❌
  new_query = Map.merge(query, params)
  %{uri | query: URI.encode_query(new_query)} # ❌
end

# After:
defp merge_url_params(url, params) do
  uri = URI.parse(url)
  query = Plug.Conn.Query.decode(uri.query || "")  # ✅ Decodes arrays
  new_query = Map.merge(query, params)
  %{uri | query: Plug.Conn.Query.encode(new_query)}  # ✅ Encodes arrays
end

With this fix in place, Marci ran her test and success! With the test passing, and manual verification in the browser confirming that dragons were no longer disappearing when adventurers paginated through their bestiary, Marci was confident in her fix.

  • ✅ All 4 pages of monsters displayed correctly
  • ✅ Both Dragons and Goblins remained visible throughout pagination
  • ✅ Filter pills stayed consistently selected
  • ✅ The mystical “6-10 of 20” pagination range appeared exactly as expected

The Adventurer’s Journal

Before declaring victory, Marci verified the fix wouldn’t break existing functionality. The beauty of the solution was that Plug.Conn.Query.decode/4 is actually a superset of URI.decode_query/3 – it handles everything the old function did, plus array parameters.

This meant:

  • All existing pagination with simple key-value parameters worked identically
  • The fix was completely backward compatible
  • The pagination component was now more robust for future array parameter use cases
  • All other tests continued to pass

Back in her workshop, Marci reflected on the adventure. What had started as a complex filtering mystery turned out to be a simple case of using the wrong parsing function. The disappearing dragons had led her down paths involving database queries and state management, but the real culprit was hiding in plain sight – a two-line URL parsing function.

She realized that her reproduction test had been invaluable, not just for confirming the bug existed, but as a compass throughout the debugging journey. Every hypothesis she tested could be immediately validated or rejected by running that single test.

Most importantly, she’d discovered that in the Elixir ecosystem, seemingly similar functions can have subtle but crucial differences. URI.decode_query/3 and Plug.Conn.Query.decode/4 looked almost identical, but one was designed for general URLs while the other understood HTML form conventions. Since her data came from HTML forms with array parameters, she needed the form-aware version.

The fix was just two lines of code, but the debugging journey highlighted the importance of understanding the subtle differences between similar-looking functions and tracing problems back to their actual source rather than chasing symptoms.

Sometimes the dragons aren’t really missing – they’re just hiding in the URL parser.

Happy coding! 🎉

Similar Posts