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 cursor-spike app.exs
Raw

priv/cursor-spike/app.exs

#!/usr/bin/env elixir
# Cursor replace-with-same-id spike (test target).
#
# Tests the UNPROVEN client-owned tier: can client-owned coordination state
# (active item / highlight / roving tabindex / aria-activedescendant) survive when
# the DOM NODE is replaced but the logical item id stays the same?
#
# Prior audits never exercised this: LiveView preserved the STRONGER condition
# (same id AND same node). Here we deliberately force node replacement while
# keeping logical ids, via techniques whose actual replacement behavior the driver
# verifies with a JS expando.
#
# Forced-replacement techniques exposed:
# mode=tag : render each item as <li> or <button> — tag swap forces morphdom
# to replace the node in-place (same id, new node, one patch).
# mode=wrap : toggle an extra wrapper element around the list, changing the
# tree shape so children are rebuilt.
# container swap: swap the LIST CONTAINER's tag (<ul>/<ol>) — replaces the node
# that carries the Cursor hook, to test hook-instance loss.
# :if remove : remove then re-render the active item (same id returns later).
#
# Run: elixir app.exs (http://127.0.0.1:4126)
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(:cs, CSWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4126],
server: true,
adapter: Bandit.PhoenixAdapter,
live_view: [signing_salt: "cursor-spike-salt"],
secret_key_base: String.duplicate("cursor-spike-secret-key!", 4),
pubsub_server: CS.PubSub,
check_origin: false,
debug_errors: true
)
defmodule CSWeb.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>Cursor rebind spike</title>
<script><%= Phoenix.HTML.raw(@phoenix_js) %></script>
<script><%= Phoenix.HTML.raw(@lv_js) %></script>
<script>
// ---- The Cursor: client-owned coordination state over server-rendered
// identity-bearing children. This is the ONLY place JS owns state. ----
//
// Design choice under test: cursor state (the active LOGICAL id) is held
// BOTH in the hook instance AND in a module-level store keyed by the
// container's logical id. The module-level store is the explicit userland
// machinery that could let the cursor survive replacement of the hook's
// own node. We record which mechanism actually did the rebinding.
window.__cursorStore = {}; // containerId -> {activeId, typeahead}
window.__cursorLog = [];
const log = (m) => window.__cursorLog.push(m);
const Cursor = {
mounted() {
this.cid = this.el.id;
const saved = window.__cursorStore[this.cid];
if (saved) {
// rebind from external store (survives hook-instance loss)
this.active = saved.activeId;
this.rebindSource = "external-store";
log({ev: "mounted-rebind", cid: this.cid, active: this.active});
} else {
// fresh init: first item
this.active = this.firstItemId();
this.rebindSource = "fresh";
log({ev: "mounted-fresh", cid: this.cid, active: this.active});
}
this.project();
this.el.addEventListener("keydown", (e) => this.onKey(e));
},
updated() {
// node persisted (or its children changed) — re-project by logical id.
log({ev: "updated", cid: this.cid, active: this.active});
this.project();
},
destroyed() { log({ev: "destroyed", cid: this.cid}); },
itemIds() { return [...this.el.querySelectorAll("[data-item]")].map((n) => n.getAttribute("data-item")); },
firstItemId() { return this.itemIds()[0] || null; },
nodeFor(id) { return this.el.querySelector('[data-item="' + id + '"]'); },
// Re-apply cursor presentation to whatever nodes currently carry the
// logical ids. This is the "rebind by logical id" operation.
project() {
const ids = this.itemIds();
if (this.active && !ids.includes(this.active)) {
// active logical id no longer present: deterministic collapse
this.active = ids[0] || null;
log({ev: "collapse", cid: this.cid, active: this.active});
}
this.el.querySelectorAll("[data-item]").forEach((n) => {
const on = n.getAttribute("data-item") === this.active;
n.setAttribute("data-active", on ? "true" : "false");
n.setAttribute("tabindex", on ? "0" : "-1");
});
if (this.active) this.el.setAttribute("aria-activedescendant", this.domIdFor(this.active) || "");
else this.el.removeAttribute("aria-activedescendant");
window.__cursorStore[this.cid] = {activeId: this.active};
},
domIdFor(logicalId) { const n = this.nodeFor(logicalId); return n ? n.id : null; },
move(dir) {
const ids = this.itemIds();
const i = ids.indexOf(this.active);
const ni = Math.max(0, Math.min(ids.length - 1, i + dir));
this.active = ids[ni];
this.project();
const n = this.nodeFor(this.active);
if (n) n.focus();
},
onKey(e) {
if (e.key === "ArrowDown") { e.preventDefault(); this.move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); this.move(-1); }
}
};
window.addEventListener("DOMContentLoaded", () => {
const csrf = document.querySelector("meta[name='csrf-token']").getAttribute("content");
const ls = new LiveView.LiveSocket("/live", Phoenix.Socket, { hooks: {Cursor}, params: {_csrf_token: csrf} });
ls.connect();
window.liveSocket = ls;
});
</script>
</head>
<body><%= @inner_content %></body>
</html>
"""
end
end
defmodule CSWeb.MainLive do
use Phoenix.LiveView
@items ~w(alpha bravo charlie delta echo)
def mount(_p, _s, socket) do
{:ok, assign(socket, items: @items, rev: 0, item_tag: "li", container_tag: "ul", wrap: false, hide: nil)}
end
def render(assigns) do
~H"""
<main>
<p>rev {@rev} · item_tag {@item_tag} · container_tag {@container_tag} · wrap {to_string(@wrap)} · hide {to_string(@hide)}</p>
<%!-- optional wrapper changes tree shape (mode=wrap) --%>
<div :if={@wrap} id="wrapper">
<.list items={@items} rev={@rev} item_tag={@item_tag} container_tag={@container_tag} hide={@hide} />
</div>
<.list :if={not @wrap} items={@items} rev={@rev} item_tag={@item_tag} container_tag={@container_tag} hide={@hide} />
<button id="c-content" phx-click="content">content</button>
<button id="c-item-tag" phx-click="item_tag">swap item tag</button>
<button id="c-container-tag" phx-click="container_tag">swap container tag</button>
<button id="c-wrap" phx-click="wrap">toggle wrap</button>
<button id="c-hide-charlie" phx-click="hide" phx-value-id="charlie">hide charlie</button>
<button id="c-show-all" phx-click="show_all">show all</button>
</main>
"""
end
# The list container carries the Cursor hook. Items are identity-bearing children
# with BOTH a DOM id and a stable logical data-item id.
def list(assigns) do
~H"""
<.dynamic_container id="clist" tag={@container_tag}>
<%= for it <- @items, it != @hide do %>
<.dynamic_item tag={@item_tag} logical={it} rev={@rev} />
<% end %>
</.dynamic_container>
"""
end
def dynamic_container(%{tag: "ul"} = assigns) do
~H"""
<ul id={@id} phx-hook="Cursor" role="listbox" tabindex="0">{render_slot(@inner_block)}</ul>
"""
end
def dynamic_container(%{tag: "ol"} = assigns) do
~H"""
<ol id={@id} phx-hook="Cursor" role="listbox" tabindex="0">{render_slot(@inner_block)}</ol>
"""
end
def dynamic_item(%{tag: "li"} = assigns) do
~H"""
<li id={"it-" <> @logical} data-item={@logical} role="option">{@logical} v{@rev}</li>
"""
end
def dynamic_item(%{tag: "button"} = assigns) do
~H"""
<button id={"it-" <> @logical} data-item={@logical} role="option">{@logical} v{@rev}</button>
"""
end
def handle_event("content", _, s), do: {:noreply, assign(s, rev: s.assigns.rev + 1)}
def handle_event("item_tag", _, s), do: {:noreply, assign(s, item_tag: swap(s.assigns.item_tag, "li", "button"))}
def handle_event("container_tag", _, s), do: {:noreply, assign(s, container_tag: swap(s.assigns.container_tag, "ul", "ol"))}
def handle_event("wrap", _, s), do: {:noreply, assign(s, wrap: not s.assigns.wrap)}
def handle_event("hide", %{"id" => id}, s), do: {:noreply, assign(s, hide: id)}
def handle_event("show_all", _, s), do: {:noreply, assign(s, hide: nil)}
defp swap(cur, a, b), do: if(cur == a, do: b, else: a)
end
defmodule CSWeb.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: {CSWeb.Layout, :root}
end
scope "/" do
pipe_through :browser
live "/", CSWeb.MainLive
end
end
defmodule CSWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :cs
@session_options [store: :cookie, key: "_cs_key", signing_salt: "cursor", same_site: "Lax"]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options
plug CSWeb.Router
end
{:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: CS.PubSub}, CSWeb.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:4126")
Process.sleep(:infinity)