The panel that closed itself

Projects
Elixir, Liveview
July 11, 2026

Popsicle Boat’s composer grew a cross-posting picker: a post lives in one interest, and an “Also show in” panel of checkboxes lets it appear in others. Sixty-odd checkboxes is a lot of form, so the panel went inside a <details> element — the browser’s own disclosure widget, free with every HTML parser since 2020.

<details class="post-form__also-interests">
  <summary><%= t("Also show in") %></summary>
  <div class="post-form__also-interests-options">
    <%= for interest <- @also_interest_options do %>
      ...

I found the bug while using the picker for real — making the discussion thread for the previous post here. Open the panel, check a box, and the panel snaps shut. Open it again, check another box, shut again. Collapsed, it gives no sign anything is selected at all.

The bug #

The last panel bug on this blog was a race — three timers and a duplicate event, reproducible twice in many runs. This one is the opposite: perfectly deterministic, wrong every single time, and visible the moment you know where to look.

A <details> element’s open attribute is written by the browser when the user clicks the summary. The server never hears about it. Meanwhile the composer is a LiveView form with live validation — every keystroke and every checkbox change makes a round trip, the server re-renders, and the patcher re-syncs the DOM to the server’s HTML. The server’s HTML has no open attribute, so the patcher, doing its job faithfully, removes it. Typing a single letter in the title field also closed the panel. The user opens; the server, knowing nothing, “corrects.”

In the race post, the DOM patcher was the obvious suspect and came back innocent. This time it did it — and it still wasn’t wrong. The markup put a piece of state where the server couldn’t see it, and whatever the server can’t see, the patcher deletes. Server-rendered UIs have a custody rule: every bit of state needs exactly one owner, and it had better be the side that renders.

The fix #

Give the boolean to the server. The summary became a button that flips an assign, and the panel’s visibility now comes from the render, so re-renders preserve it instead of destroying it:

<button type="button" phx-click="toggle_also_interests"
        aria-expanded={to_string(@also_picker_open?)}>
  <%= t("Also show in") %>
  <%= if names != [] do %>
    <span><%= Enum.join(names, ", ") %></span>
  <% end %>
</button>
<div hidden={!@also_picker_open?}>
  ... the checkboxes ...
</div>

Two details in the details replacement. The checkbox list is hidden when collapsed, not conditionally rendered — inputs that leave the DOM leave the form, and a reader who collapses the panel before submitting shouldn’t silently lose their picks. And the collapsed row now lists the chosen interests by name, which turns the bug report’s second complaint — no sign of what’s selected — into the feature that was missing all along.

The panel stays open now.

Discuss this post aboard Popsicle Boat →