Skip to content

[web] Keep the semantics tree usable when an update lists a child that was never sent - #195837

Open
hannah-hyj wants to merge 1 commit into
flutter:masterfrom
hannah-hyj:fix-193235-web-semantics-resilience
Open

hannah-hyj wants to merge 1 commit into
flutter:masterfrom
hannah-hyj:fix-193235-web-semantics-resilience

Conversation

@hannah-hyj

Copy link
Copy Markdown
Member

fix #193235.

When a SemanticsUpdate contains a parent whose child list references an id that has never been sent to the engine, SemanticsObject.updateChildren() dereferenced owner._semanticsTree[childId]! and threw Unexpected null value. Because _finalizeTree() was only called on the success path, the owner stayed in SemanticsUpdatePhase.updating with the pending attachment and detachment lists populated, so every subsequent update failed as well and the tree stayed broken for the rest of the session. Disabling and re-enabling semantics did not help either: reset() walked the (now inconsistent) child lists through _finalizeTree() and threw from there.

This change makes the engine tolerate such an update instead of poisoning itself:

  • SemanticsObject._lookUpChild replaces the bare ! look-ups in updateChildren (render order and hit-test order) and recomputeChildrenAdjustment. In debug mode it asserts with a message that names the parent and the missing child; in release mode the missing child is skipped and the rest of the update is applied.
  • The two depth-first visitors already asserted on a missing child; they now also skip it so that the detachment walk in _finalizeTree() cannot throw on a stale child list.
  • EngineSemanticsOwner.updateSemantics runs _finalizeTree() in a finally block, and clears each node's dirty fields in a finally block, so the owner always returns to the idle phase with empty attachment and detachment lists even if the update throws.
  • EngineSemanticsOwner.reset() no longer detaches every node and walks their child lists; it disposes each object directly, runs the one-time post-update callbacks, and clears all owner state in a finally block so that disabling semantics is always a clean slate.

The framework-side trigger observed in production (a parent that stops merging its descendants without sending them) is removed separately in #195829. This change makes sure that any remaining or future producer of such an update degrades to a missing node in release builds and a descriptive assertion in debug builds, rather than a permanently broken accessibility tree.

Tests: three new tests in semantics_test.dart under "update that lists a child that was never sent". On master, all three fail, and the failure also cascades into unrelated later tests in the same file ("semantics owner update phases" reports the owner stuck in updating; "SemanticRole Sets id..." reports "Semantics node map was inconsistent"), which is exactly the poisoning this change prevents.

Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.

List which issues are fixed by this PR. You must list at least one issue. An issue is not required if the PR fixes something trivial like a typo.

If you had to change anything in the flutter/tests repo, include a link to the migration guide as per the breaking change policy.

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

…t was never sent

Defense in depth for flutter#193235.

When a SemanticsUpdate contains a parent whose child list references an id
that has never been sent to the engine, `SemanticsObject.updateChildren()`
dereferenced `owner._semanticsTree[childId]!` and threw `Unexpected null
value`. Because `_finalizeTree()` was only called on the success path, the
owner stayed in `SemanticsUpdatePhase.updating` with the pending attachment
and detachment lists populated, so every subsequent update failed as well
and the tree stayed broken for the rest of the session. Disabling and
re-enabling semantics did not help either: `reset()` walked the (now
inconsistent) child lists through `_finalizeTree()` and threw from there.

This change makes the engine tolerate such an update instead of poisoning
itself:

* `SemanticsObject._lookUpChild` replaces the bare `!` look-ups in
  `updateChildren` (render order and hit-test order) and
  `recomputeChildrenAdjustment`. In debug mode it asserts with a message
  that names the parent and the missing child; in release mode the missing
  child is skipped and the rest of the update is applied.
* The two depth-first visitors already asserted on a missing child; they
  now also skip it so that the detachment walk in `_finalizeTree()` cannot
  throw on a stale child list.
* `EngineSemanticsOwner.updateSemantics` runs `_finalizeTree()` in a
  `finally` block, and clears each node's dirty fields in a `finally` block,
  so the owner always returns to the idle phase with empty attachment and
  detachment lists even if the update throws.
* `EngineSemanticsOwner.reset()` no longer detaches every node and walks
  their child lists; it disposes each object directly, runs the one-time
  post-update callbacks, and clears all owner state in a `finally` block so
  that disabling semantics is always a clean slate.

The framework-side trigger observed in production (a parent that stops
merging its descendants without sending them) is removed separately in
flutter#195829. This change makes sure that
any remaining or future producer of such an update degrades to a missing
node in release builds and a descriptive assertion in debug builds, rather
than a permanently broken accessibility tree.

Tests: three new tests in `semantics_test.dart` under "update that lists a
child that was never sent". On master, all three fail, and the failure also
cascades into unrelated later tests in the same file ("semantics owner
update phases" reports the owner stuck in `updating`; "SemanticRole Sets
id..." reports "Semantics node map was inconsistent"), which is exactly the
poisoning this change prevents.
@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) platform-web Web applications specifically team-web Owned by Web platform team labels Nov 2, 2026
@hannah-hyj
hannah-hyj requested a review from flutter-zl November 2, 2026 22:56
@hannah-hyj
hannah-hyj marked this pull request as ready for review November 2, 2026 22:56

@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 robustifies the web engine's semantics update and reset processes to prevent the engine from getting stuck in an inconsistent state when the framework sends incomplete updates (e.g., listing a child that was never sent). It introduces safe child lookup with debug assertions, wraps the update phase in try-finally blocks to guarantee tree finalization, and refactors reset() to safely dispose of objects and clear state. Feedback on the changes highlights a potential risk in executing _oneTimePostUpdateCallbacks during reset() after disposing of the semantics objects, and suggests copying _semanticsTree.values to a list before iterating to avoid a ConcurrentModificationError during disposal.

Comment on lines +3512 to +3518
for (final SemanticsObject object in _semanticsTree.values) {
object.dispose();
}
_phase = SemanticsUpdatePhase.postUpdate;
for (final ui.VoidCallback callback in _oneTimePostUpdateCallbacks) {
callback();
}

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.

high

Executing _oneTimePostUpdateCallbacks during reset() is risky and likely incorrect. These callbacks (such as requesting focus or sending accessibility announcements) are registered during an update and expect a valid, active semantics tree. Since all SemanticsObjects have just been disposed, running these callbacks can lead to exceptions (e.g., trying to access or operate on disposed elements) or unwanted side effects (e.g., announcing messages when semantics is being disabled). In the original implementation, these callbacks were simply cleared without being executed.\n\nAdditionally, iterating directly over _semanticsTree.values while calling dispose() carries a risk of ConcurrentModificationError if any disposal logic or side effects modify the map. Copying the values to a list using .toList() before iterating is a safer, defensive programming practice.

      for (final SemanticsObject object in _semanticsTree.values.toList()) {\n        object.dispose();\n      }

@flutter-zl flutter-zl 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.

Thanks! The finalize-in-finally and the reset() rewrite look good.

I built release demos with this PR's semantics.dart patched into the web SDK. They use raw updateSemantics calls, like the repro on #193235:
- Before: https://flutter-demo-93-before.web.app
- After: https://flutter-demo-93-after.web.app

An empty root listing a missing child, step 1, is fixed. A root that already has children and adds a missing id, step 2, still throws Null check operator used on a null value in release.

After _lookUpChild skips the missing id, childrenInRenderOrder.length < childCount, but updateChildren still iterates to childCount in while (newIndex < childCount) and for (int i = childCount - 1; ...). Using childrenInRenderOrder.length for minLength, the trivial-case check, and both loops should fix it.

The new tests don't reach this because the debug assert in _lookUpChild fires first. Collecting missing ids and asserting after _finalizeTree() would let the tests cover the release path, and it would also stop a debug throw from leaving later nodes in the same update with dirty fields.

@justinmc justinmc added the CICD Run CI/CD label Nov 6, 2026
@flutter-dashboard

Copy link
Copy Markdown

This pull request is not mergeable in its current state, likely because of a merge conflict. Pre-submit CI jobs were not triggered. Pushing a new commit to this branch that resolves the issue will result in pre-submit jobs being scheduled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

3 participants