> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userpilot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Autocapture hierarchy changes in 1.2.0

> Understand how Flutter SDK 1.2.0 changed captured hierarchies and accessibility fields, why it is faster, and which targeting rules you need to rebuild.

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.

<Info>
  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.
</Info>

## 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.

| | Before 1.2.0 | 1.2.0 and later |
| - | - | - |
| Calculated from | position among the rows currently built | the row's own item index in the list |
| Row 3, list scrolled to top | `attr__index="3"` | `attr__index="3"` |
| Row 3, scrolled down two rows | `attr__index="1"` | `attr__index="3"` |
| Between scroll positions | changes as the viewport moves | identical at every scroll position |

<Warning>
  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.
</Warning>

### 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.

| | Before 1.2.0 | 1.2.0 and later |
| - | - | - |
| Calculated from | the first label found anywhere in the segment's subtree | the label declared on that element |
| Result | one child's label reported on every ancestor | each element reports its own label, or none |
| Between interactions | varied with which rows a lazy list had built | depends only on the element |

### 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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Info>
  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.
</Info>

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:

| Work per interaction | Before 1.2.0 | 1.2.0 and later |
| - | - | - |
| Element tree traversal | the whole tree, on every touch | only the touched path |
| `hierarchy` string | built for every candidate considered | built once, for the matched widget |
| Widget classification | repeated for shared ancestors | computed once per element |
| Accessibility metadata | one subtree walk per hit-test element | at most one, and none when the nearest ancestor already declares the value |

Measured as element visits per interaction in the SDK's regression tests, for a control inside a container holding many other widgets:

| Tap target | Subtree walks before | Subtree walks after |
| - | - | - |
| Control inside a 200-descendant container | 4 | 0–1 |
| Control inside a 50-descendant container | 4 | 0–1 |
| Leaf control, such as a button | 0 | 0 |

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.

| Screen | Before 1.2.0 | 1.2.0 and later |
| - | - | - |
| 400-row screen | 71.8 ms per tap | 23.2 ms per tap |
| Growth with row count | cost rose as rows were added | flat |

<Note>
  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.
</Note>

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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

See [Tuning maxHierarchyDepth](/docs/developer/installation/mobile/flutter/auto-capture#tuning-maxhierarchydepth) for the configuration itself.

## Recommended actions

<Warning>
  **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.
</Warning>

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

```dart theme={null}
Semantics(
  identifier: 'checkout.submit',
  label: 'Complete purchase',
  child: ElevatedButton(
    onPressed: _submit,
    child: const Text('Buy now'),
  ),
);
```

### FAQs

<AccordionGroup>
  <Accordion title="Do I have to rebuild every rule?" defaultOpen="false">
    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.
  </Accordion>

  <Accordion title="Why is a performance change also a breaking change?" defaultOpen="false">
    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.
  </Accordion>

  <Accordion title="How do I tell whether a rule stopped matching because of this?" defaultOpen="false">
    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.
  </Accordion>

  <Accordion title="Will lowering maxHierarchyDepth make capture faster?" defaultOpen="false">
    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.
  </Accordion>

  <Accordion title="Does redaction still work the same way?" defaultOpen="false">
    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](/docs/developer/installation/mobile/flutter/auto-capture#privacy-controls).
  </Accordion>
</AccordionGroup>

### Related pages

<Card title="Auto Capture" icon="wand-magic-sparkles" href="/docs/developer/installation/mobile/flutter/auto-capture">
  Configuration options, wire metadata keys, the `hierarchy` format, and privacy controls.
</Card>

<Card title="Flutter SDK release notes" icon="tag" href="/docs/developer/installation/mobile/flutter/mobile-flutter-release-notes">
  Full release history, including every change shipped in 1.2.0.
</Card>

<Frame>
  [For any questions or concerns please reach out to **support@userpilot.com**](mailto:support@userpilot.com)
</Frame>
