Skip to content

Actionability gate

View raw .md

The actionability gate is the precondition check tapir runs before it dispatches a pointer or key event at a widget.

On this page

Overview#

A refused gesture reads like this:

text
Widget ref=e5 is not actionable: not enabled

The pointer verbs (tap, hover, drag, dblclick, right_click, triple_click) and the text verbs (type, fill) run the gate BEFORE synthesising the pointer or key event. clear, focus, scroll, press_key, select_option and set_checkbox skip it. It lives in lib/src/utils/actionability_gate.dart and is invoked through ensureActionable(entry, ref: ...). The gate guards an agent against firing a gesture against a widget that cannot accept it: disabled, collapsed, off-screen, animating, or covered by another widget.

A failed gate throws a typed TapirActionabilityException whose message is re-emitted by the VM-Service handler as ServiceExtensionResponse.error(extensionError, exception.message). Agents parse the failure-reason substring to decide whether to re-snap, re-find, scroll, wait, or abort.

Precondition chain#

The gate evaluates its preconditions in a fixed order (a defunct preflight, then five ordered checks). The order is FROZEN: agents branch on the failure-reason substring, so adding, removing, or reordering checks is a breaking change.

  1. Defunct (preflight); the entry's Element may have been deactivated by parent rebuild, route pop, or list-item recycle since the snapshot minted the ref. findRenderObject() returns null, or the framework throws on the inactive element. This guard runs before the five ordered checks below. Failure reasons: defunct (element no longer attached to a render object) or defunct (element no longer mounted). The agent's recovery is to re-snap.
  2. Enabled; the entry's SemanticsNode is non-null AND its flagsCollection.isEnabled is Tristate.isFalse. Tristate.none (no enabled flag set, e.g. plain Text) and Tristate.isTrue both pass. The gate only fails when the framework has explicitly marked the widget disabled. Synthetic entries without a captured SemanticsNode (for example, find_by_text results) pass through this check untouched.
  3. Zero-area rect; the entry's rect.width == 0 || rect.height == 0. A zero-area rect cannot receive a pointer event at rect.center and almost always indicates the widget has been collapsed or detached between snapshot and action.
  4. Off-viewport; the entry's rect does not intersect the active FlutterView's visible logical viewport (recomputed every call from WidgetsBinding.instance.platformDispatcher.views.firstOrNull so window resizes between actions are honoured). Visible means the view minus its viewInsets: the app keeps laying out under a soft keyboard, but a pointer there never reaches it. When the rect's centre lies outside the visible viewport the gate first attempts RenderObject.showOnScreen to bring the element into view, then re-checks; it fails only when scroll-into-view cannot place the target inside the viewport. Skipped gracefully when no FlutterView is attached (headless test harnesses, multi-view race).
  5. Stable; the entry's bounding box, sampled from the live RenderBox before and after one frame, has not drifted by more than 0.5 logical pixels on any side. Animated widgets (sliding sheets, expanding tiles, page transitions) fail this gate so the agent waits for the animation to settle before retrying. Both samples are live, so a layout change that finished before the gate ran (the soft keyboard opening, the step 3 auto-scroll) does not trip this check. Opt out via checkStable: false.
  6. Receives events; a hit-test at rect.center on the active view confirms the entry's render object (or a descendant) appears in the hit-test path. If the topmost target is anything else, an overlay, modal scrim, or stacked widget is swallowing the pointer. The thrown reason carries the obscurer's runtimeType. A graceful degradation accepts the action when the hit-test path contains only a root RenderView / _ReusableRenderView (Flutter Web's debug compositor sometimes pipes hit-tests through a snapshot view that does not mirror the live element subtree). Opt out via checkReceivesEvents: false.

Live-rect dispatch (post-gate, pre-dispatch)#

After the gate passes and BEFORE the pointer is synthesised, every pointer verb (tap, hover, drag start + end endpoints, dblclick, right_click, triple_click) re-resolves the target's CURRENT bounding rect via dispatchRectOf(entry) and dispatches at that live centre, falling back to the cached entry.rect.center only when the live rect is null (a sliver, a detached / unsized render object, or a synthetic test entry). dispatchRectOf reuses the same _liveRectOf(entry.element) helper the stable check (step 4) measures, guarded by the renderObject.attached precondition Flutter's localToGlobal asserts.

One exception: tap can retarget a merged semantics boundary. When exactly one labelled control owns the merged label and a hit test at its centre reaches it, the pointer goes to that control instead of the live centre (the response carries retargeted). It is the default; retarget: false (--no-retarget on the CLI) taps the live centre. hover, dblclick, right_click, triple_click and drag always use the live centre. See tapir:tap.

This is purely additive to the FROZEN gate: it runs after the gate passes and before dispatch, so it touches neither the evaluation order nor any failure-reason substring. It fixes the false-success class where a host rebuilt the target into a shifted slot between snapshot and action: the Element / RenderObject identity (and thus SemanticsNode.id) is retained across a same-type-and-key rebuild, so the live rect is valid and the pointer lands on the moved target instead of its stale gate-time position. A residual TOCTOU window remains between the live-rect re-resolve and the dispatch itself (the smallest achievable without a frame lock); the effect block on every ext.tapir.tap response reports whether the tap produced an observable effect, so the residual window is visible rather than silent.

What the gate could not prove#

The gate throws on a precondition it can DISPROVE. Step 5 has a third outcome it cannot: the hit-test sometimes fails to answer at all. On Flutter Web's debug build that is routine, because DWDS pipes hit-tests through a snapshot view that does not mirror the live element subtree, so the path comes back carrying only the root render view.

Breaking every valid tap on that artefact would be the worse failure, so the gate proceeds. It used to proceed silently, and a clean pass was then indistinguishable from a confirmed one: that is how a fill printed a green tick four times onto a row covered by a pinned footer, with nothing in any response to say the check had not run.

The response carries a checks block whenever step 5 did not confirm. The gated verbs that emit it are tap, hover, drag, dblclick, right_click, triple_click and type; fill runs the gate through its type step but copies only effect into its own response:

json
{
  "ref": "e7",
  "checks": {
    "receivesEvents": "indeterminate",
    "why": "hit-test returned only the root render view",
    "overlapCandidates": ["RenderPhysicalShape@0,712,390x88"],
    "hint": "The gate could not confirm the target receives the event, so this action may have landed on something else. Read the `effect` block before trusting it."
  }
}
receivesEvents Meaning
absent Confirmed. The hit-test reached the target or a descendant.
indeterminate The hit-test could not answer. The action was dispatched anyway.
skipped The caller passed checkReceivesEvents: false.

overlapCandidates is a rect scan run only on the indeterminate path: render objects that overlap the target and paint after it, capped at five. It is advisory, not a verdict. An overlap is not proof of occlusion, since a transparent layer overlaps and swallows nothing, which is exactly why the hit-test exists and why these are candidates.

The block is absent on the healthy path, so its presence is the signal.

Failure reason substrings#

The thrown message has the shape Widget ref=$ref is not actionable: $reason. Agents perform substring matches against $reason to branch their recovery. The substring list is FROZEN:

Reason substring Trips when Suggested agent recovery
defunct (...) findRenderObject() returns null OR Element is in _ElementLifecycle.defunct lifecycle state Re-snap; the widget was deactivated.
not enabled flagsCollection.isEnabled == Tristate.isFalse Re-snap; the widget may enable later.
zero rect rect.width == 0 || rect.height == 0 Re-snap or re-find; layout has shifted.
off-viewport rect does not overlap the visible viewport (view minus viewInsets) even after showOnScreen + one frame tapir_scroll with intoView: true, then retry.
not stable live rect drifted > 0.5 logical pixels on any side across one frame tapir_wait_for_network_idle or settle delay.
obscured by hit-test at rect.center resolves to a non-descendant render object first Dismiss the obscurer (modal, scrim, overlay).

The off-viewport reason carries the rect and viewport (off-viewport (rect=..., viewport=...)); the not-stable reason carries the maximum side delta (not stable (rect changed by X.Xpx)); the obscured reason carries the obscurer's runtime type (obscured by other widget (top=...)). Match on the leading substring shown above, not the trailing detail.

Opt-out flags#

ensureActionable exposes two opt-out parameters; both default to true to match Playwright's actionability semantics:

dart
Future<ActionabilityReport> ensureActionable(
  RefEntry entry, {
  required String ref,
  bool checkStable = true,
  bool checkReceivesEvents = true,
});
Flag Disables When to opt out
checkStable: false the stable precondition (4) widget tests that fabricate synthetic RefEntry rects which do not match the live render-object geometry.
checkReceivesEvents: false the receives-events precondition (5) the same widget-test scenarios, plus environments where the platform compositor swallows hit-tests.

The pointer verbs, type and fill expose the same two flags as checkStable and checkReceivesEvents inputs (see tool-reference.md); both default to true.

Intentional gate skips#

Some action handlers do NOT route through the actionability gate. These skips are deliberate and listed here so contributors do not add the gate "for symmetry":

  • scroll; operates on the parent Scrollable rather than the ref target. Gating the ref would refuse scrolls against widgets that are off-viewport, which is exactly the scenario tapir_scroll is meant to fix.
  • select_option; calls the dropdown's onChanged callback directly instead of tapping, so no pointer event leaves the VM and there is nothing for the gate to check.
  • press_key; targets the currently focused widget, not a ref. The gate contract requires a RefEntry; the focused widget may not have a token.

Cross-package implications#

The gated actions share a common error shape. TapirActionabilityException is caught by the VM-Service handler and re-emitted via ServiceExtensionResponse.error(extensionError, wrapErrorDetail(...)). The wire error detail is a JSON string with a message field (the flat message, reason included) and an envelope field (a machine-readable type, widget_path and suggestions). Substring matching on the reason still works because the reason sits verbatim inside message; consuming agents (Claude Code, Cursor, Windsurf via the MCP tool surface) branch on it.

The contract guarantee is:

  • The reason substrings are frozen and listed above.
  • The evaluation order is frozen so substring branching stays deterministic.
  • The exception is always typed; the wire format is always the JSON string described above.
  • Agents re-snap or re-find on failure. The gate never silently retries; the cost of a silent retry on an animating or obscured widget is a flaky test the agent cannot diagnose.

A change to any of these guarantees requires a coordinated bump across tapir (this package), magic (whose MagicTapirIntegration adds enrichers to the snapshots that mint the refs the gate guards) and wind_kit (whose diagnostics reach the same snapshots through wind_kit_diagnostics_contracts). Treat the gate's public contract as load-bearing across the ecosystem.

  • tool-reference.md: the checkStable and checkReceivesEvents inputs and the effect block of every gated verb.
  • frame-production.md: why a hidden window or tab produces no frames for the stable check to sample.
  • magic-integration.md: the Magic enrichers that annotate the refs the gate guards.
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.