-
-
Notifications
You must be signed in to change notification settings - Fork 23
Fix/overlay portal semantics crash #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // The regression guard for `tool/patches/0001-semantics-attach-stale-children.patch`. | ||
| // | ||
| // Nothing in DPIP is under test here — this is the framework, reproduced at the | ||
| // smallest shape that reaches the bug. It lives in the suite because the patch | ||
| // has to be kept honest by something that runs on every machine and in CI, and | ||
| // a test that fails on an unpatched SDK does that without a check script: the | ||
| // patch is applied by `require_mise`, so a run that skipped it fails here. | ||
| // | ||
| // When upstream fixes flutter#189902 and mise.toml moves to a version that | ||
| // carries the fix, the patch stops applying (and says so, loudly) — this test | ||
| // should then pass on its own. Keep it. It is the thing that says the fix is | ||
| // really in. | ||
| // | ||
| // Shape credit: the reduction in the upstream issue. Each of the three oddities | ||
| // below is load-bearing; drop any one and the assert does not fire. | ||
| import 'package:flutter/gestures.dart'; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
|
|
||
| /// Two tooltips, one of which is rigged to reach the bug. | ||
| /// | ||
| /// Every [Tooltip] is an [OverlayPortal] — `RawTooltip` is built on | ||
| /// `OverlayPortal.overlayChildLayoutBuilder` — which is why an app that never | ||
| /// names `OverlayPortal` (DPIP does not) still hits an OverlayPortal defect. | ||
| class _TwoTooltips extends StatelessWidget { | ||
| const _TwoTooltips(); | ||
|
|
||
| /// A border, so the two rows do not collapse into one render object and the | ||
| /// semantics subtree has an interior node to lose track of. | ||
| Widget _decorate(Widget child) => DecoratedBox( | ||
| decoration: BoxDecoration(border: Border.all()), | ||
| child: child, | ||
| ); | ||
|
|
||
| Widget _tooltip(String label) => Tooltip( | ||
| message: '$label tooltip', | ||
| // `explicitChildNodes` is what stops the child folding into the tooltip's | ||
| // own node, so there is a separate SemanticsNode available to be stolen. | ||
| child: Semantics(explicitChildNodes: true, child: Text('$label text')), | ||
| ); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| Widget bad = _tooltip('bad'); | ||
| // The second Overlay is what lets one node end up parented under a subtree | ||
| // that a later pass rebuilds from the bottom up — the "stealing". | ||
| bad = Overlay.wrap(child: ExcludeSemantics(child: bad)); | ||
| // A tight box: it stops the enclosing node being rebuilt on the pass that | ||
| // would otherwise refresh the stale `_children` list and hide the bug. | ||
| bad = SizedBox(width: 200, height: 100, child: bad); | ||
|
|
||
| return Dialog( | ||
| child: Column( | ||
| spacing: 20, | ||
| children: [_decorate(_tooltip('good')), _decorate(bad)], | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| void main() { | ||
| testWidgets('a tooltip shown and dismissed twice keeps the semantics tree ' | ||
| 'consistent (flutter#189902)', (tester) async { | ||
| // Semantics is only built when something asks for it — a screen reader on | ||
| // a device, this handle in a test. Without it `flushSemantics` does no work | ||
| // and the bug cannot be reached. | ||
| final handle = tester.ensureSemantics(); | ||
|
|
||
| await tester.pumpWidget( | ||
| const MaterialApp(home: Scaffold(body: _TwoTooltips())), | ||
| ); | ||
|
|
||
| final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); | ||
| await gesture.addPointer(location: Offset.zero); | ||
| addTearDown(gesture.removePointer); | ||
|
|
||
| final target = tester.getCenter(find.text('bad text')); | ||
| const away = Offset(5, 5); | ||
|
|
||
| // Twice is the whole test. The first show/dismiss leaves a SemanticsNode | ||
| // with `attached == true` and `parent == null`; the second dismiss walks | ||
| // into it and trips `assert(!child.attached)` in `_replaceChildren`. | ||
| for (var pass = 1; pass <= 2; pass++) { | ||
| await gesture.moveTo(target); | ||
| await tester.pump(); | ||
| await tester.pump(const Duration(seconds: 1)); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| await gesture.moveTo(away); | ||
| await tester.pump(); | ||
| await tester.pump(const Duration(seconds: 1)); | ||
| await tester.pumpAndSettle(); | ||
| } | ||
|
|
||
| // Disposed in the body, not `addTearDown`: the binding's own end-of-test | ||
| // check for leaked handles runs before tear-downs do. | ||
| handle.dispose(); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| #!/usr/bin/env bash | ||
| # Applies tool/patches/*.patch to the pinned Flutter SDK. | ||
| # | ||
| # Called from `require_mise` in tool/dev/_lib.sh, so every script that reaches | ||
| # the toolchain has already run it. Nobody runs this by hand. | ||
| # | ||
| # Patching the SDK is the thing this repo is otherwise most careful *not* to | ||
| # do — AGENTS.md → Toolchain exists because a build off the wrong SDK announces | ||
| # nothing. A patched SDK is a wrong SDK by that definition, so the same rule | ||
| # applies to the patch: it lives in the repo, it is applied by a script every | ||
| # entry point calls, and it is never left to a machine to have remembered. The | ||
| # alternative is one laptop where the bug is fixed and a CI runner where it is | ||
| # not, which is exactly the failure the pin was bought to prevent. | ||
| # | ||
| # Each patch is one of three states, and only one of them is silent: | ||
| # | ||
| # applies cleanly → apply it, and say so | ||
| # already applied → nothing to do, say nothing | ||
| # neither → stop. The SDK moved out from under the patch, which | ||
| # usually means a Flutter bump. Somebody has to decide | ||
| # whether upstream fixed it; a skipped patch would let | ||
| # the bug back in without a word. | ||
| set -euo pipefail | ||
|
|
||
| root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | ||
| patch_dir="$root/tool/patches" | ||
|
|
||
| [[ -d $patch_dir ]] || exit 0 | ||
| shopt -s nullglob | ||
| patches=("$patch_dir"/*.patch) | ||
| ((${#patches[@]})) || exit 0 | ||
|
|
||
| # `mise where`, not the resolved binary's parent: this wants the SDK root that | ||
| # `packages/flutter/...` hangs off, and require_mise has already established | ||
| # that mise owns it. | ||
| sdk="$(cd "$root" && mise where flutter 2>/dev/null || true)" | ||
| if [[ -z $sdk || ! -d $sdk ]]; then | ||
| printf '\n Cannot locate the pinned Flutter SDK to patch it.\n' >&2 | ||
| printf ' Run `mise install` from %s.\n\n' "$root" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| for p in "${patches[@]}"; do | ||
| name="$(basename "$p")" | ||
|
|
||
| # --forward alone is not enough to tell "applies" from "already applied": | ||
| # both refuse, with different messages. A reverse dry-run answers it | ||
| # directly — a patch that can be undone is a patch that is already in. | ||
| if patch -p1 -d "$sdk" --forward --dry-run --silent <"$p" >/dev/null 2>&1; then | ||
| patch -p1 -d "$sdk" --forward --silent <"$p" | ||
| printf ' \033[33m●\033[0m patched SDK: %s\n' "$name" >&2 | ||
| elif patch -p1 -d "$sdk" --reverse --dry-run --silent <"$p" >/dev/null 2>&1; then | ||
|
Comment on lines
+49
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| : # already applied | ||
| else | ||
| cat >&2 <<PATCHFAIL | ||
|
|
||
| tool/patches/$name no longer applies to the pinned SDK. | ||
|
|
||
| SDK: $sdk | ||
|
|
||
| The patch is pinned to one Flutter version and the pin has moved. Read the | ||
| header of the patch — it names the upstream issue. If that issue is fixed in | ||
| this version, delete the patch; its regression test stays and should pass on | ||
| its own. If it is not fixed, re-cut the patch against the new source. | ||
|
|
||
| PATCHFAIL | ||
| exit 1 | ||
| fi | ||
| done | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| Guard SemanticsNode.attach() against a stale child list. | ||
|
|
||
| flutter#189902 https://github.com/flutter/flutter/issues/189902 (open) | ||
| cut against flutter 3.47.0 | ||
|
|
||
| `detach()` documents that `_children` is allowed to be stale — it may hold | ||
| nodes that have since been re-parented — and skips those: | ||
|
|
||
| if (child.parent == this) { child.detach(); } | ||
|
|
||
| `attach()` walks the same list with no such guard, so it re-attaches a node | ||
| that now belongs to somebody else. That produces a node with `attached == true` | ||
| and `parent == null`, and the next `_replaceChildren` trips | ||
|
|
||
| 'package:flutter/src/semantics/semantics.dart': line 3013 | ||
| Failed assertion: '!child.attached': is not true. | ||
|
|
||
| Every `Tooltip` is an `OverlayPortal` (`RawTooltip` is built on | ||
| `OverlayPortal.overlayChildLayoutBuilder`), so showing and dismissing one | ||
| twice is enough to reach it — which is what DPIP hit. Related, all open and | ||
| all traced to `fccfa978a97` "Reland: Refactor OverlayPortal semantics", in | ||
| stable since 3.41.0: flutter#187198, flutter#182604. | ||
|
|
||
| The assert is debug-only, but the inconsistent parent/child pointers it | ||
| catches are real in release too, where they silently mis-shape the tree a | ||
| screen reader walks. | ||
|
|
||
| `test/shared/semantics_overlay_portal_test.dart` fails without this patch. | ||
| That test — not a check script — is what keeps the patch honest: when upstream | ||
| lands a fix and mise.toml moves off 3.47.0, this patch stops applying and | ||
| tool/internal/apply_sdk_patches.sh fails loudly. Delete it then, and the test | ||
| should stay green on its own. | ||
|
|
||
| --- a/packages/flutter/lib/src/semantics/semantics.dart 2026-09-01 13:49:30 | ||
| +++ b/packages/flutter/lib/src/semantics/semantics.dart 2026-09-01 13:49:30 | ||
| @@ -3263,7 +3263,12 @@ | ||
| } | ||
| if (_children != null) { | ||
| for (final SemanticsNode child in _children!) { | ||
| - child.attach(owner); | ||
| + // The list of children may be stale and may contain nodes that have | ||
| + // been assigned to a different parent. Mirrors the guard `detach()` | ||
| + // already has. See https://github.com/flutter/flutter/issues/189902 | ||
| + if (child.parent == this) { | ||
| + child.attach(owner); | ||
| + } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
由於
apply_sdk_patches.sh會在每次透過require_mise初始化工具鏈時執行,且針對每個補丁都會進行兩次patch --dry-run檢查,隨著補丁數量增加,可能會導致開發者指令的啟動延遲。建議可以透過檢查tool/patches/目錄的內容雜湊(hash)來實作快取機制,若雜湊未變動則跳過檢查。