Skip to content

Rebuild a semantics fragment when its sibling conflict changes - #190431

Merged
auto-submit[bot] merged 3 commits into
flutter:masterfrom
CanArslanDev:semantics-attach-stale-children
Oct 3, 2026
Merged

auto-submit[bot] merged 3 commits into
flutter:masterfrom
CanArslanDev:semantics-attach-stale-children

Conversation

@CanArslanDev

@CanArslanDev CanArslanDev commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

shouldFormSemanticsNode ends in return parentData!.explicitChildNodes || _hasSiblingConflict;. When the parent data half changes, _didUpdateParentData calls markNeedsBuild, because whether a fragment forms a node may have changed. When the sibling conflict half changes, nothing does. markSiblingConfigurationConflict just assigns the flag and returns.

That gap is enough to corrupt the semantics tree. A fragment that stops forming its own node keeps built == true and keeps its cachedSemanticsNode. While it is out of the tree an ancestor can take its children back, and its child list is never refreshed because _buildSemantics short circuits on built. When the fragment forms a node again it is handed straight back into the tree still holding children that now belong to somebody else, and attaching it attaches them too. The result is a SemanticsNode that is attached but has no parent, which is what trips assert(!child.attached) in _replaceChildren.

There are three earlier branches in shouldFormSemanticsNode (isSemanticBoundary, isRoot, contributesToSemanticsTree). I went looking for the same gap in those and could not find one, because everything that feeds them goes through markNeedsUpdate, which nulls parent data on its way up and so ends at markNeedsBuild anyway. I would not swear to that the way I would to the paragraph above, so if you know of a path I am not seeing, I would rather hear it now.

Outside debug builds this looks worse than an assertion. The orphan still passes the node.attached check used when building childrenInTraversalOrder, so its id goes to the engine as a child that is not reachable, which is the shape of the Failed to update ui::AXTree, error: N will not be in the tree and is not the new root that @davidhicks980 reported in #187198. I should be clear that this part is me reading the code and not something I measured. I could not find a way to drive VoiceOver from a test, so I have not confirmed that the release mode failure is this and only this.

OverlayPortal runs into this reliably because showing an overlay child creates and removes a sibling conflict for the anchors around it, so node formation flips back and forth while the traversal graft moves nodes between subtrees.

The change marks the fragment as needing a build when its sibling conflict flag actually changes across a pass. It has to be a before and after comparison rather than a check inside markSiblingConfigurationConflict, because _marksConflictsInMergeGroup deliberately resets every fragment to false first and that reset is load bearing: configToMergeUp reads shouldFormSemanticsNode, so leaving the previous pass's flag in place while recomputing changes the result. The snapshot is taken in the existing loop just before the reset, and the comparison rides along with the loop that marks the conflicts, so there is no extra pass over mergeGroup. Both directions are marked, because losing a conflict changes node formation just as much as gaining one.

This runs during the parent data phase of flushSemantics, before the build loop, so the fragment is rebuilt in the same flush.

Why not repair the child list instead

The first version of this PR fixed the stale child list at the point of the theft, and guarded attach the way detach already is. Both work, and the test passes with either. @chunhtai pointed out that the fragment should have known its children were taken and marked itself, which turned out to be right, so this version drops both in favour of fixing the invalidation. Happy to add the attach guard back as hardening if you want it, since detach is still the only one of the ten methods in semantics.dart that walk _children that checks whether the child is still its own, but it is a separate concern from this bug.

While I was checking that number I found I had got it wrong earlier. I said eight places here and on #187198. It is ten, and the two I missed, _redepthChildren and _updateChildrenMergeFlags, both assert on child.owner, so a stale entry can trip there as well.

I did earlier report that invalidating was not possible because markNeedsBuild, RenderObject.markNeedsSemanticsUpdate and the flushSemantics exit assert all reject being dirtied during the flush. That was true of invalidating at the moment of the theft, which is in the middle of the build phase. Invalidating at the moment the decision changes is a different place and does not have that problem.

Test

The regression test goes in the existing Semantics group in overlay_portal_test.dart. It needs two portal anchors before it will fail, because the fragment whose conflict changes belongs to the sibling anchor rather than the one being toggled. Taking the sibling out makes it pass on unpatched master. It fails on master with '!child.attached': is not true and passes with this change.

One thing worth flagging: the test has no semantics expectations of its own, so its only failure mode is that framework assertion. Happy to add some if you would rather it pinned the resulting tree down.

test/semantics, test/rendering, test/widgets and test/material come out at 16171 passing with nothing failing.

Fixes #189902

One request on that: @madenvel reported a computeChildGeometry assertion variant in a comment on that issue, and closing it through this PR would bury that report. Could that be split into its own issue? I have not been able to reproduce it, it is described as Linux and AT-SPI, and I do not know whether this change covers it. I said earlier that I expected it to survive any fix in this area, but that was about the two patches this version replaced, and the reasoning does not carry over. Rebuilding the fragment refreshes its geometry too, so it is genuinely an open question now rather than a no.

Partially addresses #187198. The assertions reported there go away. I said I would expect the AXTree failure to go away as well, but when I tried to check that I could not back it up. I recorded every semantics update sent to the engine in a widget test and checked it against the rules the desktop bridge applies. Neither of @davidhicks980's samples produces a bad update on current master, with or without this change. The shape from #182444 (Tooltips in a ListView, and the MenuAnchor + Tooltip one reported there) does, with the exact same will not be in the tree and is not the new root error, and it does so with or without this change too. So that error has at least one cause this PR does not touch, and I can't tell which one was being hit in #187198. The menu item positioning problem also reported there is separate and this does not touch it.

For the record, #182604 turned out not to be the same defect, even though it sits downstream of the same refactor. It was fixed by #192139 on the engine side, where setChildrenInHitTestOrder was overwriting the traversal parent that OverlayPortal had set. I had said on the two issues above that all three came down to one thing, which was wrong.

Pre-launch Checklist

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) labels Aug 3, 2026
@google-cla

google-cla Bot commented Aug 3, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies SemanticsNode.attach to only attach child nodes if their parent is the current node, preventing stale children from being attached. It also adds a regression test to verify this behavior when toggling an overlay child. The review feedback suggests refactoring the test to avoid potential LateInitializationErrors from late final variables and utilizing addTearDown for the automatic disposal of SemanticsTester.

Comment on lines +3260 to +3296
late final OverlayEntry entry;
addTearDown(() {
entry.remove();
entry.dispose();
});

// A second anchor is required: the node that goes stale belongs to the
// sibling, not to the portal being toggled.
final Widget sibling = OverlayPortal.overlayChildLayoutBuilder(
controller: siblingController,
overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) =>
const SizedBox.shrink(),
child: Semantics(explicitChildNodes: true, child: const Text('sibling')),
);

Widget portal = OverlayPortal.overlayChildLayoutBuilder(
controller: controller,
overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) =>
const Align(alignment: Alignment.topLeft, child: Text('overlay child')),
child: Semantics(explicitChildNodes: true, child: const Text('anchor')),
);
portal = Overlay.wrap(child: ExcludeSemantics(child: portal));
portal = SizedBox(width: 200, height: 100, child: portal);

await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: <OverlayEntry>[
entry = OverlayEntry(
builder: (BuildContext context) =>
Semantics(container: true, child: Column(children: <Widget>[sibling, portal])),
),
],
),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Declaring entry as late final and registering addTearDown before it is initialized can lead to a LateInitializationError if pumpWidget or any preceding step fails. This masks the actual test failure and makes debugging harder.

Instead, define the widgets first, initialize entry directly, register its teardown, and then pass it to pumpWidget.

      // A second anchor is required: the node that goes stale belongs to the
      // sibling, not to the portal being toggled.
      final Widget sibling = OverlayPortal.overlayChildLayoutBuilder(
        controller: siblingController,
        overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) =>
            const SizedBox.shrink(),
        child: Semantics(explicitChildNodes: true, child: const Text('sibling')),
      );

      Widget portal = OverlayPortal.overlayChildLayoutBuilder(
        controller: controller,
        overlayChildBuilder: (BuildContext context, OverlayChildLayoutInfo info) =>
            const Align(alignment: Alignment.topLeft, child: Text('overlay child')),
        child: Semantics(explicitChildNodes: true, child: const Text('anchor')),
      );
      portal = Overlay.wrap(child: ExcludeSemantics(child: portal));
      portal = SizedBox(width: 200, height: 100, child: portal);

      final OverlayEntry entry = OverlayEntry(
        builder: (BuildContext context) =>
            Semantics(container: true, child: Column(children: <Widget>[sibling, portal])),
      );
      addTearDown(() {
        entry.remove();
        entry.dispose();
      });

      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: Overlay(
            initialEntries: <OverlayEntry>[entry],
          ),
        ),
      );

testWidgets('toggling an overlay child does not leave a parentless attached semantics node', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent test pollution and properly dispose of SemanticsTester even if an assertion fails, register its disposal using addTearDown immediately after creation.

Suggested change
final semantics = SemanticsTester(tester);
final semantics = SemanticsTester(tester);
addTearDown(semantics.dispose);

expect(find.text('overlay child'), findsNothing);
}

semantics.dispose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This manual call to semantics.dispose() can be removed since it is now handled automatically by addTearDown registered at the start of the test.

@CanArslanDev
CanArslanDev force-pushed the semantics-attach-stale-children branch from 9f57f06 to 5b05eeb Compare August 3, 2026 08:19
@CanArslanDev

Copy link
Copy Markdown
Contributor Author

Leaving the test as is for now, since both patterns the bot flagged are what the rest of this file already does. late final OverlayEntry entry; with the teardown registered before assignment is used by 8 other tests in overlay_portal_test.dart and nothing uses the alternative, and all 5 tests that create a SemanticsTester there dispose it manually at the end rather than through addTearDown.

The point about disposal not running when an expectation fails is fair though, so happy to switch to addTearDown if a reviewer would rather have that, or to change all of them in a separate PR if the file should move to that style.

@Piinks
Piinks requested review from QuncCccccc, chunhtai, hannah-hyj and victorsanni and removed request for hannah-hyj August 4, 2026 22:16
@CanArslanDev CanArslanDev changed the title Do not re-attach stale semantics children Repair stale semantics child lists when a child is stolen Aug 5, 2026
victorsanni
victorsanni previously approved these changes Aug 5, 2026
final SemanticsNode oldParent = child.parent!;
final List<SemanticsNode>? oldParentChildren = oldParent._children;
if (oldParentChildren != null && oldParentChildren.contains(child)) {
oldParent._children = List<SemanticsNode>.of(oldParentChildren)..remove(child);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel right, in this case the oldParent should updateWith a new list of children that exclude the child. do you know why this is not the case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and the short answer is that it does exactly that in the common case, just not in the one that breaks.

I instrumented the repro to log every updateWith and every steal. Trimmed to the interesting part, with the portal shown, hidden, then shown again:

UPDATEWITH: node#3 children=[2]
STEAL:      node#2 taken from node#1 by node#3   (node#1.attached=true,  node#1._children=[2])
UPDATEWITH: node#1 children=[3, 4]          <- node#1 rebuilds, list no longer has node#2

UPDATEWITH: node#1 children=[2]
STEAL:      node#2 taken from node#3 by node#1   (node#3.attached=false, node#3._children=[2])
SHORTCIRCUIT: RenderSemanticsAnnotations built=true, cached=node#3, its _children=[2] -> no updateWith
UPDATEWITH: node#1 children=[3, 6]          <- node#3 goes back in still holding [2]

The first steal is the case you describe. node#1 loses node#2 and is rebuilt in the same pass, so it calls updateWith with a list that excludes it and everything stays consistent.

The second steal is the problem. The victim is node#3, which by then is detached and cached. Its _RenderObjectSemantics still has built == true, so when the portal is shown again _buildSemantics short circuits at object.dart:6225 and never reaches _produceSemanticsNode, _buildSemanticsSubtree or updateWith. node#3 is handed straight back to node#1 as a child while still holding _children = [2], and attaching it attaches node#2, which now belongs to node#1.

Nothing marks that _RenderObjectSemantics as needing a build when its semantics child is taken. markNeedsBuild is only driven by config, geometry and parentData, and there is no back reference from a SemanticsNode to the _RenderObjectSemantics that cached it.

I did try adding that back reference and calling markNeedsBuild at the moment of the steal, which I think is what you are pointing at. It does not work, because the steal always happens inside flushSemantics and the framework refuses to be dirtied from there in three places:

  • markNeedsBuild() leaves the flush with a dirty node, so debugCheckForBuilds trips assert(node.built)
  • RenderObject.markNeedsSemanticsUpdate() asserts !attached || !owner!._debugDoingSemantics
  • PipelineOwner.flushSemantics asserts _nodesNeedingSemanticsUpdate.isEmpty on the way out

So the repair has to be synchronous, which is why this fixes the list rather than scheduling a rebuild.

If you would rather see the cached fragment rebuild instead, I am happy to try, but it looks like that means relaxing one of those three invariants or invalidating before the flush starts, perhaps in the OverlayPortal teardown path. Happy to go whichever way you prefer.

@chunhtai chunhtai Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing marks that _RenderObjectSemantics as needing a build when its semantics child is taken. markNeedsBuild is only driven by config, geometry and parentData

I think this is the problem, the victim renderobject (node3) must have knew its children got taken away (because it is not painting it), and it should have markNeedsSemanticsUpdate when that happen

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, and I was wrong about it not being possible. I have rewritten the PR to do what you asked.

shouldFormSemanticsNode reads two things:

return parentData!.explicitChildNodes || _hasSiblingConflict;

The parent data half is invalidated. _didUpdateParentData calls markNeedsBuild with the comment "Parent data changes may result in node formation changes". The sibling conflict half is not. markSiblingConfigurationConflict assigns the flag and returns, so a fragment can stop forming a node, keep built == true and keep its cachedSemanticsNode, and never find out that its children were taken while it was out of the tree.

So the render object does know, exactly as you said. It just does not act on it.

The reason my earlier answer said this was impossible is that I was trying to invalidate at the moment of the theft, which is in the middle of the build phase, where all three of those asserts fire. markSiblingConfigurationConflict is reached from updateChildren during the parent data phase, before the build loop, so marking there gets rebuilt in the same flush and none of them fire. My mistake.

One wrinkle worth mentioning, because it cost me a round. The obvious version, an early return in markSiblingConfigurationConflict when the value is unchanged, does not work. _marksConflictsInMergeGroup resets every fragment to false before recomputing, and that reset is load bearing: configToMergeUp returns null when shouldFormSemanticsNode is true, so leaving the previous pass's flag in place while recomputing makes fragments get skipped and everything merges up. It cost me six semantics tests before I understood why. The version in the PR snapshots the flags before the pass and compares after, leaving the existing sequence untouched.

The two changes from the previous revision are gone. Repairing the stale child list and guarding attach both worked, but they were treating the symptom, and this is the cause. I am happy to add the attach guard back as hardening if you want it, since six of the eight places that walk _children still assume the list is clean, but that is a separate concern from this bug and I would rather not muddy the PR with it.

test/semantics, test/rendering, test/widgets and test/material all pass with this. The regression test is unchanged.

@chunhtai
chunhtai self-requested a review August 19, 2026 22:01
@victorsanni
victorsanni self-requested a review August 21, 2026 22:16
@CanArslanDev CanArslanDev changed the title Repair stale semantics child lists when a child is stolen Rebuild a semantics fragment when its sibling conflict changes Aug 30, 2026
@CanArslanDev
CanArslanDev force-pushed the semantics-attach-stale-children branch from d4c2589 to 2896c58 Compare August 30, 2026 14:58
@github-actions github-actions Bot removed the a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) label Aug 30, 2026

void _marksConflictsInMergeGroup(List<_SemanticsFragment> mergeGroup, {bool isMergeUp = false}) {
final wasConflicting = <_SemanticsFragment>{
for (final _SemanticsFragment fragment in mergeGroup)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a separate loop for this? can we record existing Conflicting in the loop on line 6549

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The snapshot is taken in the existing loop now, right before the reset. Nothing in that loop touches another fragment's _hasSiblingConflict, the markSiblingConfigurationConflict(true) calls all happen after it, so reading it there gives the same value the separate pass gave.

I also folded the comparison into the loop that marks the conflicts rather than walking mergeGroup a third time. Anything in hasSiblingConflict that was not already in the snapshot is newly conflicting, and whatever is left in the snapshot after removing those stopped conflicting. Both of those need the mark, since losing a conflict changes shouldFormSemanticsNode the same way gaining one does.

testWidgets('toggling an overlay child does not leave a parentless attached semantics node', (
WidgetTester tester,
) async {
final semantics = SemanticsTester(tester);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not used in the test, and should be removed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. It was doing even less than it looked like, testWidgets has semanticsEnabled on by default and SemanticsTester only wraps tester.ensureSemantics(), so it was not enabling anything either.

Worth saying out loud that this leaves the test with no semantics expectations of its own, so the only thing that can fail it is the assertion in _replaceChildren. Happy to add some if you would rather it pinned the resulting tree down instead of relying on that.


late final OverlayEntry entry;
addTearDown(() {
entry.remove();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove shouldn't be here, it will be inserted and removed by the overlay widget when it init and dispose. It is not the responsibility of owner's of the overlayentry to call remove

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, and I took entry.dispose() and the late final with it rather than just the remove().

OverlayState does not override dispose, and nothing sets entry._overlay back to null except OverlayEntry.remove(), so leaving the dispose() behind on its own trips 'An OverlayEntry must first be removed from the Overlay before dispose is called.' at overlay.dart:278. Dropping the whole teardown is the clean version, the entry goes away with the Overlay.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to what I said here. The entry does not go away with the Overlay, nothing ever disposes it, and the leak tracking shard caught it. Windows framework_tests_widgets_leak_tracking is red on this PR because of that. _WrappingOverlayState itself calls remove() and then dispose(), so whoever creates the entry really does have to do both.

I switched the test to Overlay.wrap, which owns its entry, so there is nothing left to clean up and still no remove() in the test. It passes with LEAK_TRACKING=true and still fails without the fix.

shouldFormSemanticsNode ends in parentData.explicitChildNodes ||
_hasSiblingConflict. A change to the first calls markNeedsBuild through
_didUpdateParentData; a change to the second did nothing, so a fragment
could stop forming a node, keep built and cachedSemanticsNode, have its
children taken by an ancestor while it was out of the tree, and then be
handed back in still holding them. Attaching it then attached children
belonging to another parent.

The previous flags are snapshotted at the top of the existing loop
rather than compared inside markSiblingConfigurationConflict, because
_marksConflictsInMergeGroup resets every fragment to false before
recomputing and configToMergeUp reads shouldFormSemanticsNode, so that
reset has to stay in place. Both directions are marked, since losing a
conflict changes node formation as much as gaining one.
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Sep 28, 2026
@victorsanni victorsanni added the CICD Run CI/CD label Sep 28, 2026
The OverlayEntry built inline in initialEntries was never disposed, which
the leak tracking shard reports. Overlay.wrap owns its entry, so the test no
longer has one to clean up.

Also moves the explanation next to the show and hide loop it describes.
@CanArslanDev
CanArslanDev dismissed stale reviews from victorsanni and chunhtai via c550880 October 2, 2026 17:14
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Oct 2, 2026
@CanArslanDev

Copy link
Copy Markdown
Contributor Author

@chunhtai @victorsanni sorry about this, the push dismissed both of your approvals and took the CICD label off. The only thing that changed since you approved is the test: it uses Overlay.wrap now so it stops leaking an OverlayEntry (that was the red leak tracking shard), and the comment moved next to the loop. object.dart is untouched. Could you take another look when you get a chance?

@victorsanni victorsanni added the CICD Run CI/CD label Oct 2, 2026

@chunhtai chunhtai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chunhtai chunhtai added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 2, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into flutter:master with commit 15ddda0 Oct 3, 2026
27 of 28 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SemanticsNode._replaceChildren may cause inconsistent parent/children pointers, and eventually asserts

3 participants