Overview
Every captured interaction carries ahierarchy 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. AListView 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.
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.
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:
Ancestors first, nearest outwards
Semantics widget you wrote yourself sits, so your own value takes precedence. The search is bounded by maxHierarchyDepth — the same window hierarchy describes.Then the element's own subtree, only for fields still unresolved
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.Bounded by an element count, not a depth
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.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.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: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.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:
Confirm repeated rows stay distinguishable
hierarchy values. If they match, the window is too shallow and the rows cannot be targeted separately.Confirm the accessibility fields you rely on still arrive
accessibility_label or accessibility_identifier, check the value is still present after lowering the setting.Recommended actions
List the mobile rules that target Flutter elements
ListView, SliverList, or Viewport segment, and rules that match an attr__desc or accessibility value. Those are the three values calculated differently.Re-record each one against a build using 1.2.0
Verify a lazy-list rule at more than one scroll position
attr__index.Confirm two different rows still report different hierarchies
maxHierarchyDepth until they differ.Give critical targets an explicit identity
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
Do I have to rebuild every rule?
Do I have to rebuild every rule?
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.Why is a performance change also a breaking change?
Why is a performance change also a breaking change?
How do I tell whether a rule stopped matching because of this?
How do I tell whether a rule stopped matching because of this?
Will lowering maxHierarchyDepth make capture faster?
Will lowering maxHierarchyDepth make capture faster?
Does redaction still work the same way?
Does redaction still work the same way?
UserpilotRedactText the hierarchy structure is preserved but text and accessibility attributes are omitted, and enableInteractionAccessibilityLabelCapture: false removes the accessibility fields entirely. See Privacy Controls.Related pages
Auto Capture
hierarchy format, and privacy controls.