Actionability gate
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:
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.
- Defunct (preflight); the entry's
Elementmay 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)ordefunct (element no longer mounted). The agent's recovery is to re-snap. - Enabled; the entry's
SemanticsNodeis non-null AND itsflagsCollection.isEnabledisTristate.isFalse.Tristate.none(no enabled flag set, e.g. plainText) andTristate.isTrueboth pass. The gate only fails when the framework has explicitly marked the widget disabled. Synthetic entries without a capturedSemanticsNode(for example,find_by_textresults) pass through this check untouched. - Zero-area rect; the entry's
rect.width == 0 || rect.height == 0. A zero-area rect cannot receive a pointer event atrect.centerand almost always indicates the widget has been collapsed or detached between snapshot and action. - Off-viewport; the entry's rect does not intersect the active
FlutterView's visible logical viewport (recomputed every call fromWidgetsBinding.instance.platformDispatcher.views.firstOrNullso window resizes between actions are honoured). Visible means the view minus itsviewInsets: 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 attemptsRenderObject.showOnScreento 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 noFlutterViewis attached (headless test harnesses, multi-view race). - Stable; the entry's bounding box, sampled from the
live
RenderBoxbefore 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 viacheckStable: false. - Receives events; a hit-test at
rect.centeron 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'sruntimeType. A graceful degradation accepts the action when the hit-test path contains only a rootRenderView/_ReusableRenderView(Flutter Web's debug compositor sometimes pipes hit-tests through a snapshot view that does not mirror the live element subtree). Opt out viacheckReceivesEvents: 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:
{
"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:
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 parentScrollablerather than the ref target. Gating the ref would refuse scrolls against widgets that are off-viewport, which is exactly the scenariotapir_scrollis meant to fix.select_option; calls the dropdown'sonChangedcallback 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 aRefEntry; 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.
Related Documentation#
- tool-reference.md: the
checkStableandcheckReceivesEventsinputs and theeffectblock 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.