Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts priv combobox-spike app.exs
Raw

priv/combobox-spike/app.exs

#!/usr/bin/env elixir
# Combobox integration spike (test target).
#
# Composition spike: Delegation (native popover listbox shell) × Cursor
# (client-owned active descendant over server-rendered children) × Channel
# (versioned async query/result exchange) × LiveView patching × ARIA × IME.
#
# The staleness question is made DETERMINISTIC with a server-side query/release
# gate: `query` records a versioned pending result set; `release` renders one.
# This lets the driver force out-of-order delivery (release v2, then release v1)
# with no timing race. `auto_release` (default true) makes ordinary typing render
# immediately; the driver disables it only for the stale-ordering tests. The
# `guard` flag toggles the server-side monotonic guard so the spike can measure
# construction-vs-convention directly.
#
# Run: elixir app.exs (http://127.0.0.1:4127)
Mix.install([
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.1"},
{:bandit, "~> 1.7"},
{:jason, "~> 1.4"}
])
Application.put_env(:phoenix, :json_library, Jason)
Application.put_env(:cb, CBWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4127],
server: true,
adapter: Bandit.PhoenixAdapter,
live_view: [signing_salt: "combobox-spike-salt"],
secret_key_base: String.duplicate("combobox-spike-secret-key!", 4),
pubsub_server: CB.PubSub,
check_origin: false,
debug_errors: true
)
defmodule CBWeb.Layout do
use Phoenix.Component
@phoenix_js File.read!(Application.app_dir(:phoenix, "priv/static/phoenix.min.js"))
@lv_js File.read!(Application.app_dir(:phoenix_live_view, "priv/static/phoenix_live_view.min.js"))
def render("root.html", assigns) do
assigns = assign(assigns, phoenix_js: @phoenix_js, lv_js: @lv_js)
~H"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
<title>Combobox integration spike</title>
<script><%= Phoenix.HTML.raw(@phoenix_js) %></script>
<script><%= Phoenix.HTML.raw(@lv_js) %></script>
<script>
window.__cb = { lastV: 0, active: null, staleSeen: [], appliedToStale: [] };
window.__log = [];
// --- CHANNEL: versioned query emission + staleness detection ---
// --- CURSOR: aria-activedescendant over server-rendered options ---
// Coordinated on the input; focus never leaves the input.
const Channel = {
mounted() {
this.composing = false;
this.el.addEventListener("input", () => this.onInput());
this.el.addEventListener("keydown", (e) => this.onKey(e));
this.el.addEventListener("compositionstart", () => { this.composing = true; window.__log.push({ev:"compositionstart"}); });
this.el.addEventListener("compositionend", () => { this.composing = false; window.__log.push({ev:"compositionend"}); this.emit(); });
window.__cbEmit = (q) => { this.el.value = q; this.emit(); };
window.__cbReproject = () => this.reproject();
},
list() { return document.getElementById("cb-list"); },
items() { const l = this.list(); return l ? [...l.querySelectorAll("[data-item]")] : []; },
itemIds() { return this.items().map((n) => n.getAttribute("data-item")); },
onInput() {
if (this.composing) { window.__log.push({ev:"input-suppressed-during-composition", val:this.el.value}); return; }
this.emit();
},
emit() {
window.__cb.lastV += 1;
const v = window.__cb.lastV;
window.__log.push({ev:"emit", v, q:this.el.value});
this.pushEvent("query", { q: this.el.value, v });
},
onKey(e) {
const ids = this.itemIds();
if (!ids.length) return;
let i = ids.indexOf(window.__cb.active);
if (e.key === "ArrowDown") { e.preventDefault(); i = i < 0 ? 0 : Math.min(ids.length - 1, i + 1); window.__cb.active = ids[i]; this.reproject(); }
else if (e.key === "ArrowUp") { e.preventDefault(); i = i <= 0 ? 0 : i - 1; window.__cb.active = ids[i]; this.reproject(); }
},
// Re-apply active-descendant to whatever nodes currently carry the logical
// ids; detect staleness by comparing the rendered version to lastV.
reproject() {
const l = this.list();
if (!l) { this.el.removeAttribute("aria-activedescendant"); return; }
const renderedV = parseInt(l.getAttribute("data-version") || "0", 10);
const stale = renderedV < window.__cb.lastV;
l.setAttribute("data-stale", stale ? "true" : "false");
if (stale && !window.__cb.staleSeen.includes(renderedV)) window.__cb.staleSeen.push(renderedV);
const ids = this.itemIds();
if (window.__cb.active && !ids.includes(window.__cb.active)) {
window.__cb.active = null; // COLLAPSE POLICY: clear (no highlight) when active removed
window.__log.push({ev:"collapse", renderedV});
}
this.items().forEach((n) => n.setAttribute("data-active", n.getAttribute("data-item") === window.__cb.active ? "true" : "false"));
const activeNode = window.__cb.active ? l.querySelector(`[data-item="${window.__cb.active}"]`) : null;
if (activeNode) this.el.setAttribute("aria-activedescendant", activeNode.id);
else this.el.removeAttribute("aria-activedescendant");
// record if a keyboard action ever applied against a stale set
if (stale && window.__cb.active) window.__cb.appliedToStale.push({renderedV, active: window.__cb.active});
},
};
const CursorList = {
openShell() {
// Openness is browser-owned (top layer). The `popover` attribute is
// static + server-rendered (constant, never a state), so morphdom does
// not strip itunlike a client-added attribute.
try { if (this.el.showPopover && !this.el.matches(":popover-open")) this.el.showPopover(); } catch {}
},
mounted() { this.openShell(); window.__cbReproject && window.__cbReproject(); },
updated() { window.__log.push({ev:"list-updated", v: this.el.getAttribute("data-version")}); window.__cbReproject && window.__cbReproject(); },
};
window.addEventListener("DOMContentLoaded", () => {
const csrf = document.querySelector("meta[name='csrf-token']").getAttribute("content");
const ls = new LiveView.LiveSocket("/live", Phoenix.Socket, { hooks: { Channel, CursorList }, params: { _csrf_token: csrf } });
ls.connect();
window.liveSocket = ls;
});
</script>
</head>
<body><%= @inner_content %></body>
</html>
"""
end
end
defmodule CBWeb.MainLive do
use Phoenix.LiveView
@pool ~w(apple apricot avocado banana blueberry blackberry cherry cranberry date)
def mount(_p, _s, socket) do
{:ok,
assign(socket,
version_seen: 0,
rendered_version: 0,
query: "",
results: [],
guard: true,
auto_release: true,
pending: %{},
stale_drops: 0
)}
end
defp filter(q) do
q = String.trim(q)
if q == "", do: [], else: Enum.filter(@pool, &String.contains?(&1, q))
end
def render(assigns) do
~H"""
<main>
<p>
rendered_version {@rendered_version} · version_seen {@version_seen} ·
guard {to_string(@guard)} · auto_release {to_string(@auto_release)} ·
stale_drops {@stale_drops}
</p>
<input id="cb-input" phx-hook="Channel" role="combobox"
aria-controls="cb-list" aria-expanded={to_string(@results != [])}
autocomplete="off" />
<ul id="cb-list" phx-hook="CursorList" role="listbox" popover="manual"
data-version={@rendered_version} data-stale="false"
style="border:1px solid #999;min-height:20px;margin:0">
<li :for={r <- @results} id={"opt-" <> r} data-item={r} role="option">{r}</li>
</ul>
<div id="dbg-query">{@query}</div>
<div id="dbg-count">{length(@results)}</div>
<%!-- test instrumentation --%>
<button id="toggle-guard" phx-click="toggle_guard">guard</button>
<button id="toggle-auto" phx-click="toggle_auto">auto</button>
</main>
"""
end
# A query: compute results, mark pending, bump the highest version seen.
# If auto_release, render immediately (ordinary typing path).
def handle_event("query", %{"q" => q, "v" => v}, socket) do
v = to_int(v)
results = filter(q)
socket =
socket
|> assign(version_seen: max(socket.assigns.version_seen, v))
|> update(:pending, &Map.put(&1, v, {q, results}))
if socket.assigns.auto_release, do: {:noreply, do_release(socket, v)}, else: {:noreply, socket}
end
# Render a previously-queued version. The gate the driver uses to force order.
def handle_event("release", %{"v" => v}, socket), do: {:noreply, do_release(socket, to_int(v))}
def handle_event("toggle_guard", _, s), do: {:noreply, assign(s, guard: not s.assigns.guard)}
def handle_event("toggle_auto", _, s), do: {:noreply, assign(s, auto_release: not s.assigns.auto_release)}
defp do_release(socket, v) do
case Map.fetch(socket.assigns.pending, v) do
{:ok, {q, results}} ->
# Monotonic guard: refuse to render results for a superseded version.
# This is where "prevention by construction" lives.
if socket.assigns.guard and v < socket.assigns.version_seen do
update(socket, :stale_drops, &(&1 + 1))
else
assign(socket, rendered_version: v, query: q, results: results)
end
:error ->
socket
end
end
defp to_int(v) when is_integer(v), do: v
defp to_int(v) when is_binary(v), do: String.to_integer(v)
end
defmodule CBWeb.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug :put_root_layout, html: {CBWeb.Layout, :root}
end
scope "/" do
pipe_through :browser
live "/", CBWeb.MainLive
end
end
defmodule CBWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :cb
@session_options [store: :cookie, key: "_cb_key", signing_salt: "combobox", same_site: "Lax"]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options
plug CBWeb.Router
end
{:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: CB.PubSub}, CBWeb.Endpoint], strategy: :one_for_one)
IO.puts("VERSIONS phoenix=#{Application.spec(:phoenix, :vsn)} live_view=#{Application.spec(:phoenix_live_view, :vsn)} elixir=#{System.version()} otp=#{System.otp_release()}")
IO.puts("READY http://127.0.0.1:4127")
Process.sleep(:infinity)