The external-app runner is not limited to this package's reference components. Use it to prove that an interaction in your already-started LiveView application keeps the same browser-owned state across a real acknowledged patch.
This guide is for application authors. To add a reference component to this package,
use BUILDING_COMPONENTS.md instead.
Start with ownership, not phx-update="ignore"
Choose the smallest reconciliation boundary that matches who creates and updates the DOM:
| Client responsibility | LiveView boundary | Contract evidence |
|---|---|---|
| Builds the container's children, such as an SVG seat map or chart | phx-update="ignore" on that container | selected child node_identity remains same |
| Writes attributes on otherwise server-owned DOM | JS.ignore_attributes/1 for only those attributes | exact attribute values survive |
| Opens native popover/dialog state while server still owns content | keep the subtree patchable; protect only reflected client-owned attributes when needed | popover or complete dialog vector plus identity/attributes |
| Only observes server/browser state | no ownership override | probe the state that must survive |
Whole-subtree ignore is not a generic protection helper. It transfers ownership of
all descendants away from LiveView. Use it only when the client really builds and
owns those descendants. See KERNEL.md and CONTRACT_ISLAND.md for the underlying
rules.
Build one independent contract
A useful contract has four parts:
- Subject — the patch-relevant interaction boundary.
- Activation — a real click, hover, or deterministic scroll that reaches the browser state you intend to preserve.
- Patch proof — a click or restricted
Enter/Spacepress plus a subject-local attribute whose value changes only when the LiveView patch arrives. - Expectations — named, reviewable state observed before and after that patch.
Use stable application-owned selectors. Prefer IDs or explicit data-* attributes
over styling classes or DOM-position selectors. The acknowledgment must be inside the
subject, but probe targets may be elsewhere when the interaction genuinely spans
elements.
Never use a timer as patch proof. Add a deterministic revision, selected-state, mode, or other server-rendered attribute whose change acknowledges the exact patch under test.
Four proven application patterns
Client-built SVG or canvas island
Seat maps and canvas builders commonly mount into an empty server-rendered container and create SVG descendants in a hook. Activate the hook, trigger a sibling patch, and retain the exact client-created child object:
{
"schema_version": 1,
"id": "seat-map-svg-survives-sibling-patch",
"route": "/e2e/seat-map",
"subject": {"selector": "#seat-map"},
"activate": [{"type": "click", "selector": "#choose-seat"}],
"patch": {
"trigger": {"type": "click", "selector": "#change-mode"},
"ack": {"type": "attribute_change", "selector": "#seat-map", "attribute": "data-mode"}
},
"expectations": [
{"probe": "node_identity", "selector": "#seat-map svg", "after": "same"}
]
}Identity survival proves that the selected DOM object was not replaced. It does not prove that the island's content is correct or that subtree ownership was assigned correctly; retain product-specific assertions in the application test suite.
ScrollSpy or navigation hook
For a hook-owned navigation island, combine identity with the exact hook-written attribute. Put the patch trigger somewhere that remains genuinely clickable without changing the scroll position being tested:
{
"probe": "attribute",
"selector": "#section-nav [data-target=\"billing\"]",
"attribute": "aria-current",
"before": {"value": "true"},
"after": {"value": "true"}
}The activation can be {"type":"scroll","selector":"#billing"}. The runner uses
real browser scrolling and waits for all declared before-state expectations to agree
in one polling round before taking identity baselines.
Hover-owned tooltip during a keyboard-triggered patch
Hover the tooltip, then trigger a server patch with a real key press so the pointer does not move:
{
"activate": [{"type": "hover", "selector": "#rate-tip-trigger"}],
"patch": {
"trigger": {"type": "press", "selector": "#published-filter", "key": "Enter"},
"ack": {"type": "attribute_change", "selector": "main", "attribute": "data-filter-rev"}
},
"expectations": [
{"probe": "popover", "selector": "#rate-tip", "before": {"open": true}, "after": {"open": true}}
]
}Press targets must receive exact focus before keyboard dispatch. The runner does not fall back to a synthetic click.
Server-owned subtree with client-written attributes
When LiveView must continue patching descendants, do not wrap the subtree in
phx-update="ignore". Protect only the client-written attributes and assert their
exact values with the attribute probe. null means the attribute must be absent.
Run one contract or a directory suite
Single-contract mode preserves the original v1 interface:
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contract test/interaction_contracts/seat_map.json \
--out tmp/seat-map-result.json
Suite mode is a deterministic batch convenience for independent contracts:
mix live_interaction_contracts.check \
--url http://127.0.0.1:4000 \
--contracts test/interaction_contracts \
--out-dir tmp/interaction-contract-results
It reads only first-level regular *.json files in lexical filename order. It does
not recurse, accept symlink entries or leaf directories, define dependencies, share
browser contexts, or compose contracts. Ancestor path aliases are canonicalized for
input/output safety. Every contract keeps its own schema-v1 result file. Suite exit
reduction is error (3), then inconclusive (2), then fail (1), otherwise all
pass (0).
Fresh browser contexts isolate cookies, storage, pages, and DOM state. They do not reset the running application's database, processes, or external side effects. Contracts must be independently executable, and the caller owns application fixture setup/reset between contracts when a route mutates shared state.
The runner preflights every contract before launching a browser. Use
--validate-only to run that preflight explicitly. Give --out-dir a dedicated
generated directory: mapped stale results are invalidated, and an unexpected old
*.result.json is rejected rather than silently uploaded as current evidence.
Keep product assertions in the product
Portable contracts should assert browser and patch mechanics with named probes. Business workflow, data authorization, visual layout, text, and application-specific semantics belong in the application's own ExUnit or Playwright tests. Do not turn a portable contract into arbitrary JavaScript just to replace those tests.
The two layers are complementary:
- declarative contract: did the browser-owned interaction state survive the patch?
- application test: did the user-visible workflow produce the correct business result?