#!/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
or — 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 (/) — 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"""
Cursor rebind spike
<%= @inner_content %>
"""
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"""
rev {@rev} · item_tag {@item_tag} · container_tag {@container_tag} · wrap {to_string(@wrap)} · hide {to_string(@hide)}
<%!-- optional wrapper changes tree shape (mode=wrap) --%>
<.list items={@items} rev={@rev} item_tag={@item_tag} container_tag={@container_tag} hide={@hide} />
<.list :if={not @wrap} items={@items} rev={@rev} item_tag={@item_tag} container_tag={@container_tag} hide={@hide} />
content
swap item tag
swap container tag
toggle wrap
hide charlie
show all
"""
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 %>
"""
end
def dynamic_container(%{tag: "ul"} = assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
def dynamic_container(%{tag: "ol"} = assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
def dynamic_item(%{tag: "li"} = assigns) do
~H"""
@logical} data-item={@logical} role="option">{@logical} v{@rev}
"""
end
def dynamic_item(%{tag: "button"} = assigns) do
~H"""
@logical} data-item={@logical} role="option">{@logical} v{@rev}
"""
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)