I wanted to build a search modal for the pleasure of exploring the interaction. Bram Jetten’s Hotwire article supplied the inspiration: open an overlay, type a query, follow a result. I kept that interface idea and built an independent Phoenix LiveView companion app, with search while typing from the first version.

The companion source is public. The code and checks discussed here are at commit edafcdfc.

The app has twelve demonstration articles in PostgreSQL and a reading page for each one. It is separate from this blog. Searching for LiveView gives me this:

The running Search Lab app with its search dialog open and five results for LiveView.

The interesting part was deciding which interactions could wait for the server. Opening, closing and moving focus should feel immediate. Searching needs the database. That split worked well, until simulated latency exposed a smaller gap: the browser could already contain a new query while the server was still correctly answering the previous one.

Opening without a round trip

The modal is rendered with the page, initially carrying hidden. Opening runs LiveView JS commands, through phx-click, against elements that already exist. There is no application event to request the dialog’s HTML.

This is the complete opening command from SearchModal:

defp show_modal do
  JS.push_focus(to: "#open-search")
  |> JS.remove_attribute("hidden", to: "#search-modal")
  |> JS.set_attribute({"inert", ""}, to: "#page-content")
  |> JS.set_attribute({"aria-expanded", "true"}, to: "#open-search")
  |> JS.add_class("modal-open", to: "body")
  |> JS.focus(to: "#search-modal:not(.is-disconnected) #search-input")
  |> JS.focus(to: "#search-modal.is-disconnected #close-search")
end

It remembers the opener for focus restoration, reveals the overlay, makes the background inert and adds a body class whose CSS locks scrolling. Focus goes to the input when connected, or to Close when disconnected. LiveView’s JS attribute and class changes are aware of DOM patches, so a search response does not undo the open state.

Closing reverses those changes and calls JS.pop_focus(). Escape, the Close button and clicking outside the panel share that command. The selectors in open/0 and close/0 target only the relevant hidden or visible state; pressing Escape again after closing does not pop focus again.

The panel uses focus_wrap, role="dialog", an accessible name and aria-modal="true". In Chromium, Tab and Shift+Tab stay inside it. The input is type="text" with role="searchbox": in the browser experiment, a native search input cleared its value on Escape, whereas I wanted Escape to close the dialog and retain the query.

There is also cleanup on removal. Following a result navigates to a reading page, and phx-remove receives the closing commands directly so the outgoing modal releases background inertness and the scroll lock. Search completion never opens the dialog. A response arriving after I close it stays closed.

The form uses phx-change="search" and phx-submit="search". Its input has phx-debounce="250": typing waits for a 250 ms pause before sending a change. Enter can submit the current value sooner. LiveView owns that form event handling.

On the server, Content.normalize_query/1 trims the input and keeps at most 200 graphemes. Blank input returns no articles. For a nonblank query, pattern is a percent sign, the escaped query, then another percent sign. The database call in Content is:

Repo.all(
  from(a in Article,
    where:
      ilike(a.title, ^pattern) or ilike(a.summary, ^pattern) or ilike(a.body, ^pattern),
    order_by: [asc: a.title, asc: a.id],
    limit: 10
  ),
  timeout: 5_000
)

The ^pattern values are query parameters. Escaping wildcard characters is a separate concern: a parameterized query can still interpret % as a wildcard. I want someone searching for 100% to search for that literal percent sign. The helper is:

defp escape_like(query) do
  query
  |> String.replace("\\", "\\\\")
  |> String.replace("%", "\\%")
  |> String.replace("_", "\\_")
end

Backslash is escaped first. PostgreSQL’s pattern-matching reference describes %, _ and the default backslash escape, as well as ILIKE’s locale-dependent case-insensitive matching.

This searches title, summary and body, orders by title then id, and returns at most ten rows. It has no relevance ranking or accent folding. I have written about PostgreSQL full-text search before; this lab uses a simpler substring search. Twelve sample articles tell me whether the interaction works, not how this query will behave on a large catalogue.

Keeping the LiveView free to respond

The SQL runs in a task started with start_async/3, outside the LiveView process. Inside the nonblank branch, after extracting search_fn from the socket, the code in CatalogLive is:

socket
|> assign(:search_state, :loading)
|> start_async(:search, fn -> {generation, search_fn.(query)} end)

Each accepted search first calls cancel_async(:search), increments search_generation, clears the result stream and keeps the raw form value separate from the normalized query. Blank input takes the same invalidation path, then returns to :idle without starting another task.

There are overlapping protections here, and the version matters. This app locks LiveView 1.2.13. Its async API documentation says that when tasks share a name, the later start_async wins and the previous result is ignored. The implementation checks the task reference before dispatching the result to the application callback. Reusing :search therefore already protects that delivery path from an older task’s completion.

Cancellation alone behaves differently: it asks the old Elixir task to exit but leaves its registered reference in place. After clearing without starting a replacement, a cancellation exit or an already-sent result can therefore still reach the callback. Cancellation also does not establish that a PostgreSQL query already executing has stopped; that would need a separate experiment.

The successful callback also checks this condition (the body is omitted):

if generation == socket.assigns.search_generation and socket.assigns.search_state == :loading do

The loading-state guards are what preserve :idle after clearing: a successful result cannot populate it, and the exit callback cannot replace it with an error. After replacement, LiveView’s reference filtering rejects older task deliveries. Together, those checks suffice on this stable-name path in LiveView 1.2.13. I have kept the generation counter, but its comparison is redundant for correctness on this path.

One testing detail matters here. The state tests call handle_async/3 directly with old generations, bypassing LiveView’s reference filtering. They check the callback’s behavior; they do not establish that the generation comparison is necessary in the running application.

All of this concerns queries the server has received. That qualification is where the browser problem begins.

The query the server has not seen

With liveSocket.enableLatencySim(1000), changing LiveView to PostgreSQL exposed the revealing case: “5 results for LiveView” remained visible during debounce before the freshness guard was added. The companion README describes the experiment.

Those results were valid for the last search. They were wrong beside the current input. No amount of server-side cancellation can react to a keystroke whose event has not arrived yet.

Sequence showing new browser input, the 250 ms debounce, transport to the server, asynchronous search and a returned patch. The old response is hidden from the first input change.

The diagram shows ordering, not measured durations. If typing continues, the debounce window moves again. Transport and database execution add their own waits after it.

LiveView’s phx-change-loading and phx-submit-loading classes track the in-flight form event, but they cannot cover the earlier debounce interval. The hook’s pending state covers both debounce and transport while the raw input differs from the server echo. Throughout that interval, the CSS shows “Waiting to search…” and suppresses “Sending search…”.

“Sending search…” appears, for example, when I press Enter on an unchanged value: the raw input already matches the echo, so the transport-loading class can select that message without the hook’s pending state overriding it.

“Searching…” represents the asynchronous task’s loading state. In an instrumented browser run it appeared in the DOM between the loading and result patches, but no animation-frame sample captured it. That establishes a DOM transition, not that someone could perceive the message. Acknowledging the form event and finishing the SQL remain separate events, even when the patches arrive close together.

I want the visible response to agree with the field throughout those intervals, including when a patch for earlier input arrives.

A hook that only checks the input

The form echoes the raw accepted value in data-query-value={@form[:query].value} and attaches the colocated hook .CurrentQuery. The hook’s mounted() registers an input listener and checks immediately; updated() checks again after patches; destroyed() removes the listener. Here is its sync() method:

sync() {
  const value = this.input.value
  const pending = value !== this.el.dataset.queryValue
  if (pending) {
    this.el.dataset.inputState = value.trim() === "" ? "empty" : "pending"
  } else {
    delete this.el.dataset.inputState
  }
  const busy = pending ? value.trim() !== "" : this.el.dataset.searchState === "loading"
  this.el.querySelector("#search-output").setAttribute("aria-busy", String(busy))
},

The comparison is deliberately between two raw values. Searching for " PostgreSQL " uses "PostgreSQL" in SQL, but the echoed value must retain the spaces. Comparing the input to the normalized search query would leave a completed response hidden even though the server had handled that exact input. Retry preserves the raw form value for the same reason.

When the values differ, data-input-state becomes pending, or empty for whitespace-only input. CSS immediately hides the response and selects the appropriate instruction in the app stylesheet:

#search-form[data-input-state] .search-response,
#search-form[data-input-state] .transport-status { display: none; }
#search-form[data-input-state="pending"] .input-status { display: block; }
#search-form[data-input-state="empty"] .empty-status { display: block; }

When the echo matches again, the hook removes that attribute. The normal transport and server-state presentation takes over. Matching input does not itself mean the search is complete: the hook also uses data-search-state to maintain aria-busy while the task is loading.

That is the hook’s whole responsibility. It sends no events, schedules no debounce and performs no search. Visibility of the modal and focus remain in LiveView JS commands. The server still patches the form’s children, so this form intentionally has no phx-update="ignore". The hook updates presentation attributes and rechecks them after patches.

I like how small that browser responsibility is. It knows something the server cannot yet know, namely what is in the field right now, and uses it to avoid presenting an answer under the wrong query.

Checking the waits separately

I ran mix test on my Mac: 31 tests passed. mix precommit also succeeded, including the same 31-test suite. Those counts include the PostgreSQL-dependent tests. They are correctness checks, not search performance measurements.

The database tests exercise matching across all three fields, literal wildcards, ordering, the ten-result cap and repeatable seeds. Isolated LiveView tests use the production callbacks and templates with search tasks controlled by messages. They can hold a task open, replace it, observe its cancellation, or make it fail and retry. That gives deterministic checks without pretending to make PostgreSQL slow.

Browser behavior has a separate check: the Playwright scenario records 32 Chromium assertions against the running, seeded app. These cover focus, keyboard containment, closing, raw-input retention, real database results, no matches, disconnection, mobile layout and navigation cleanup. The latency cases include editing while an earlier change is in flight, clearing and closing before completion. They also check the waiting message during debounce and transport, and the sending message when submitting an unchanged value. The settling helper waits for the transport-loading classes to clear.

Animation-frame sampling in that scenario observed no visible response whose raw echo differed from the field. That is useful evidence for the scenario, not a proof that every possible race is absent.

There are useful limits to keep visible. enableLatencySim(1000) simulates transport delay; genuinely slow PostgreSQL execution remains to be tried. Other browsers and assistive technologies still need checking, so I would not call accessibility fully audited. The input’s HTML maxlength counts UTF-16 units while the server limit counts graphemes, which can give some Unicode input a shorter effective browser limit. The lab’s recorded dependency advisories also need review before any external deployment.

What I wanted from this small app was the feel of opening a search box and getting on with typing. The extra care ended up in the interval before the server could possibly help. I can let the database take its time, show an honest waiting state, and still close the dialog immediately. What I do not want is an answer for the word I have already replaced.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.