Repository navigation
Rebuild a semantics fragment when its sibling conflict changes - #190431
auto-submit[bot] merged 3 commits into
Conversation
|
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. |
There was a problem hiding this comment.
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.
| 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])), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
| expect(find.text('overlay child'), findsNothing); | ||
| } | ||
|
|
||
| semantics.dispose(); |
9f57f06 to
5b05eeb
Compare
|
Leaving the test as is for now, since both patterns the bot flagged are what the rest of this file already does. The point about disposal not running when an expectation fails is fair though, so happy to switch to |
| 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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, sodebugCheckForBuildstripsassert(node.built)RenderObject.markNeedsSemanticsUpdate()asserts!attached || !owner!._debugDoingSemanticsPipelineOwner.flushSemanticsasserts_nodesNeedingSemanticsUpdate.isEmptyon 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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
d4c2589 to
2896c58
Compare
|
|
||
| void _marksConflictsInMergeGroup(List<_SemanticsFragment> mergeGroup, {bool isMergeUp = false}) { | ||
| final wasConflicting = <_SemanticsFragment>{ | ||
| for (final _SemanticsFragment fragment in mergeGroup) |
There was a problem hiding this comment.
do we need a separate loop for this? can we record existing Conflicting in the loop on line 6549
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
this is not used in the test, and should be removed
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
2896c58 to
65e4783
Compare
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.
c550880
|
@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 |
shouldFormSemanticsNodeends inreturn parentData!.explicitChildNodes || _hasSiblingConflict;. When the parent data half changes,_didUpdateParentDatacallsmarkNeedsBuild, because whether a fragment forms a node may have changed. When the sibling conflict half changes, nothing does.markSiblingConfigurationConflictjust assigns the flag and returns.That gap is enough to corrupt the semantics tree. A fragment that stops forming its own node keeps
built == trueand keeps itscachedSemanticsNode. While it is out of the tree an ancestor can take its children back, and its child list is never refreshed because_buildSemanticsshort circuits onbuilt. 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 aSemanticsNodethat is attached but has no parent, which is what tripsassert(!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 throughmarkNeedsUpdate, which nulls parent data on its way up and so ends atmarkNeedsBuildanyway. 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.attachedcheck used when buildingchildrenInTraversalOrder, so its id goes to the engine as a child that is not reachable, which is the shape of theFailed to update ui::AXTree, error: N will not be in the tree and is not the new rootthat @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_marksConflictsInMergeGroupdeliberately resets every fragment to false first and that reset is load bearing:configToMergeUpreadsshouldFormSemanticsNode, 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 overmergeGroup. 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
attachthe waydetachalready 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 theattachguard back as hardening if you want it, sincedetachis still the only one of the ten methods insemantics.dartthat walk_childrenthat 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,
_redepthChildrenand_updateChildrenMergeFlags, both assert onchild.owner, so a stale entry can trip there as well.I did earlier report that invalidating was not possible because
markNeedsBuild,RenderObject.markNeedsSemanticsUpdateand theflushSemanticsexit 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
Semanticsgroup inoverlay_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 trueand 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/widgetsandtest/materialcome out at 16171 passing with nothing failing.Fixes #189902
One request on that: @madenvel reported a
computeChildGeometryassertion 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 rooterror, 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
setChildrenInHitTestOrderwas overwriting the traversal parent thatOverlayPortalhad set. I had said on the two issues above that all three came down to one thing, which was wrong.Pre-launch Checklist
///).