Skip to main content
Flutter SDK 1.2.0 changes how a captured interaction describes the widget you tapped. This page is the reference for what changed, why the implementation was rewritten, and what you need to do about it.

Overview

Every captured interaction carries a hierarchy string — the path of widgets from the element you touched up to the screen — plus a set of accessibility fields. Userpilot matches your targeting rules against those values, so how they are calculated decides how consistently a rule fires. In 1.2.0 three of those values are calculated from a different basis. The previous implementation derived them by traversing broadly: up to the root of the widget tree and down through an element’s entire subtree, then picking the first candidate it found. That approach made two things true at once — the result depended on what Flutter had built at that moment, and the work grew with the size of the screen. The new implementation derives each value from the element itself and its nearest relatives, with explicit limits, following how Flutter actually builds and reuses widgets. The values are consistent between interactions and cost less to produce — but they are different values, so rules built against the previous calculation need rebuilding.

Use cases

  • You have targeting rules on a mobile Flutter experience that fire inconsistently, or stopped firing after upgrading.
  • You are upgrading from 1.1.0 or earlier and need to know what to re-verify.
  • You want to understand why interaction capture got cheaper, and what still costs.

Why the calculation changed

Flutter builds the interface lazily and reuses widgets aggressively. A ListView only builds the rows near the viewport and discards the rest; widgets are rebuilt and re-parented as state changes. That is what makes Flutter fast, and it has a consequence for anything that inspects the tree. Any value derived from what currently exists in the tree inherits that volatility. The same element, inspected at two moments, sits in a differently-shaped tree — so a calculation based on traversing that tree returns two different answers, without anything in your app having changed. The 1.2.0 implementation follows a different rule: derive each value from what the widget itself declares, not from what happens to surround it at that instant. Where a broader search is genuinely needed, it is bounded explicitly so the cost cannot grow with the screen.
This is why the change is worth the rule rebuild. A value calculated from declared properties stays the same across scroll positions, rebuilds, and screen sizes, so a rule recorded once keeps matching.

What changed

Three values are calculated differently.

Sibling index inside lazy lists

attr__index is a widget’s position among its siblings. For a row of a ListView.builder or SliverList, the previous implementation counted the row’s position among the children the list had built at that moment — and a lazy list only builds the rows near the viewport. The new implementation reads the item index the list itself maintains for that row, which does not depend on what is currently built.
The two calculations agree at the top of a list, because the built window starts at row 0 there. A rule recorded in that state matches, then stops matching once the list has been scrolled. If a mobile rule works during testing but not in production, this is the first thing to check.

Accessibility label on hierarchy segments

attr__desc carries the accessibility label of a hierarchy segment. The previous implementation searched each segment’s whole subtree for a label, so a label declared on one child was reported on every ancestor above it — a checkbox labelled “Accept terms” produced Viewport:attr__desc="Accept terms", because the checkbox was inside the viewport’s subtree at that moment. The new implementation reads the label declared on that element only.

Accessibility fields in the payload

accessibility_label, accessibility_identifier, accessibility_hint, accessibility_value and view_tag were calculated by collecting every Semantics widget above the element up to the root, plus every Semantics in its subtree, and then taking the first match found. The breadth of that search meant a Semantics far away in the tree could supply the value, and view_tag was taken from a descendant’s tagForChildren rather than from the ancestor whose children that property describes. Each field now resolves to the nearest declared source, with both directions limited:
1

Ancestors first, nearest outwards

This is where a Semantics widget you wrote yourself sits, so your own value takes precedence. The search is bounded by maxHierarchyDepth — the same window hierarchy describes.
2

Then the element's own subtree, only for fields still unresolved

Some Flutter widgets declare their Semantics inside themselves rather than above. A Slider(label: 'Volume') declares that label below the Slider, so an upward-only search would not see it.
3

Bounded by an element count, not a depth

The subtree search stops at the first Semantics on each branch, as soon as every field is resolved, or after a fixed number of elements. It is limited by how many elements it visits rather than how deep it goes, because a container’s cost comes from how many children it holds rather than how deeply they nest.
Wrapping a control in your own Semantics makes that value authoritative, regardless of what the widget declares internally. That is the most reliable way to give an element a stable identity for targeting.
Only Semantics widgets are read. Controls that declare semantics on their render object instead — a Slider’s numeric value, a Checkbox’s checked state — do not populate these fields. Checked and selected state arrive separately as is_checked and selected_index.

Performance

What changed

Resolving one interaction used to repeat work. A single touch produces several hit-test elements, and each one climbs the widget tree looking for a widget the SDK can describe — so several arrive at the same widget and recompute the same answer, and all but one result is discarded. 1.2.0 removes that repetition, and skips work whose outcome is already known: Measured as element visits per interaction in the SDK’s regression tests, for a control inside a container holding many other widgets: The remaining walk disappears when the control’s nearest Semantics ancestor already declares its label, which is the normal case in an instrumented app.

Measured effect

The change that mattered most is that the cost stopped growing with the screen. Building a hierarchy previously searched each ancestor’s whole subtree for an accessibility label, so the work multiplied by both the depth of the hierarchy and the number of widgets on screen.
The second row is the important one. Interaction capture runs on pointer-down, so every scroll paid a tap’s price — which is why this presented as jank while scrolling a long list rather than as a delay when tapping a button. Removing the growth matters more than the single-tap figure.
Measurements come from the SDK’s profiling screens on a 400-row feed. Elapsed time on your own screens depends on the device and the shape of your widget tree, so for a figure that describes your app, run your build in profile mode with Flutter DevTools and record a scroll through your longest list.

What maxHierarchyDepth now bounds

maxHierarchyDepth limits how many ancestors a captured hierarchy includes. In 1.2.0 it also bounds the upward search for accessibility fields, so the two stay consistent: a Semantics widget further above the element than this setting is not consulted for either. Does lowering it make capture faster? Not meaningfully, and less than it used to. It does less work — fewer ancestor steps per interaction, and the SDK’s internal search budgets are derived from this setting, so they shrink with it. But that work is now a small fixed amount, roughly one step per level. This changed with 1.2.0. Depth used to multiply the expensive part: each ancestor’s subtree was searched for an accessibility label, so halving the depth halved a cost that dominated capture. That search is gone, and with it depth’s leverage — which is why lowering this setting is no longer presented as a performance recommendation. What it does change substantially is captured data. A shallower window can stop before the ancestor that tells two repeated rows apart, and since 1.2.0 it can also drop an accessibility label a rule depends on. Choose it for the data you need to target on, then verify:
1

Confirm repeated rows stay distinguishable

Tap two different rows of the same list and check they report different hierarchy values. If they match, the window is too shallow and the rows cannot be targeted separately.
2

Confirm the accessibility fields you rely on still arrive

If a rule matches accessibility_label or accessibility_identifier, check the value is still present after lowering the setting.
See Tuning maxHierarchyDepth for the configuration itself.
If you own targeting rules, rebuild them against the new values. Rules recorded before 1.2.0 may match a hierarchy or accessibility value the SDK no longer produces. A rule in that state does not error — it stops matching, so you see an experience stop firing rather than a warning.
1

List the mobile rules that target Flutter elements

Focus on rules whose element path contains a ListView, SliverList, or Viewport segment, and rules that match an attr__desc or accessibility value. Those are the three values calculated differently.
2

Re-record each one against a build using 1.2.0

Capture the element again from the running app rather than editing the stored value by hand. Re-recording is the only way to be certain you have the value the SDK now emits.
3

Verify a lazy-list rule at more than one scroll position

Tap the target row at the top of the list, then scroll and tap it again. Both interactions must report the same attr__index.
4

Confirm two different rows still report different hierarchies

If two rows of the same list produce identical hierarchies, they are treated as one element and cannot be targeted separately. If that happens, raise maxHierarchyDepth until they differ.
5

Give critical targets an explicit identity

For any element an important experience depends on, wrap it in a Semantics widget with a stable identifier. That value comes from your code rather than from the shape of the widget tree, so it survives layout changes and future SDK versions.

FAQs

No. Only rules that match one of the three values calculated differently: attr__index on a lazy-list segment, attr__desc, or one of the accessibility fields. Rules matching a ValueKey, a Semantics.identifier, or a widget type are unaffected.
The two came from the same rewrite. The previous values were produced by traversing the tree broadly, which is both what made them vary between interactions and what made the work grow with the screen. Deriving them from what each widget declares fixes the cost and changes the value at the same time — and because Userpilot matches your rules against that value, changing it is breaking for saved rules even though no code in your app has to change.
A rule affected by this change stops matching entirely rather than matching intermittently. If an experience fired inconsistently before upgrading and does not fire at all after, it was built on a value that depended on scroll position. Re-record it.
It reduces the number of ancestors written into each payload, and in 1.2.0 it also shortens the upward accessibility search. It does not bound the subtree search, which is limited by an element count instead. Treat it as a trade-off against captured data rather than a free performance setting, and after changing it confirm that repeated rows still report different hierarchies.
Yes, and it takes precedence over everything on this page. Under UserpilotRedactText the hierarchy structure is preserved but text and accessibility attributes are omitted, and enableInteractionAccessibilityLabelCapture: false removes the accessibility fields entirely. See Privacy Controls.

Auto Capture

Configuration options, wire metadata keys, the hierarchy format, and privacy controls.

Flutter SDK release notes

Full release history, including every change shipped in 1.2.0.