Skip to content

[web] Semantics node map inconsistent after GlobalKey reparent that removes a later sibling #194420

Closed

Description

@Icosa2050

Steps to reproduce

In Flutter web debug mode, a valid same-frame GlobalKey reparent that also removes a later sibling throws Bad state: Semantics node map was inconsistent after update. The reproduction uses only Flutter widgets and the SDK integration_test binding (no app code, custom RenderObjects, manually assigned node IDs or animations).

  1. In an ordinary Flutter project with flutter_test and integration_test SDK dev dependencies, save the code sample below as integration_test/repro_test.dart.
  2. Add test_driver/integration_test.dart:
    import 'package:integration_test/integration_test_driver.dart';
    Future<void> main() => integrationDriver();
  3. Start a ChromeDriver matching your Chrome on port 4444 (we used Chrome for Testing / ChromeDriver 155.0.8059.79), then run:
    flutter drive -d web-server --headless --browser-name=chrome \
      --driver-port=4444 --target=integration_test/repro_test.dart \
      --driver=test_driver/integration_test.dart
  4. Controls: repeat with --dart-define=REPRO_MODE=sibling-first and --dart-define=REPRO_MODE=fresh-child. Add --profile to run without debug assertions.

Expected results

Moving the GlobalKey child to its new parent and removing its old sibling leaves a consistent engine semantics node map.

Actual results

Debug fails every time (2/2 fresh Chrome sessions); both controls pass; --profile passes (the consistency check is inside an assert). The framework tree changes from 0 → 1 → 2 → [3 retained, 4 removed] to 0 → 5 → 6 → 3, but the engine map keeps orphan node 4.

Suspected cause (source reading, not a verified bisect): in the web engine, SemanticsObject._visitDepthFirstInTraversalOrderCanSkipSubtree calls child!._visitDepthFirstInTraversalOrder(searchSubtree) (lib/_engine/engine/semantics/semantics.dart, around line 2651 in 3.47.5). That helper stops the whole traversal when the callback returns false, instead of skipping only that subtree. _finalizeTree returns false for a retained/reparented node, so later removed siblings at sufficient depth are never visited and stay in the map. That fits the sibling-first control passing. The pruning runs outside the assert, so profile/release builds likely keep the stale node too (only the check is debug-only). The code appears to come from #177069 (commit 16ae36ce20bbbe0543e70b2be5564f2dd595f06d, related issue #175180); this is a candidate, not a bisect result.

Code sample

Code sample
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
// No Nebrivo imports, database, PDF, Riverpod, routes, or animation.
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  testWidgets('reparent a child while removing its old siblings', (tester) async {
    final handle = tester.ensureSemantics();
    final retainedKey = GlobalKey();
    const mode = String.fromEnvironment('REPRO_MODE', defaultValue: 'reparent');
    final changed = ValueNotifier(false);
    Widget node(String label, Widget child, {Key? key}) => Semantics(
      key: key, container: true, explicitChildNodes: true,
      label: label, child: child,
    );
    try {
      await tester.pumpWidget(Directionality(
        textDirection: TextDirection.ltr,
        child: ValueListenableBuilder<bool>(
          valueListenable: changed,
          builder: (context, ready, _) {
            final retained = node('retained', const SizedBox(width: 20, height: 20),
              key: mode == 'fresh-child' ? const ValueKey('retained') : retainedKey);
            final removed = node('removed sibling', const SizedBox(width: 20, height: 20));
            return ready
              ? node('new outer', node('new middle', retained), key: const ValueKey('new'))
              : node('old outer', node('old middle', Column(children: [
                  if (mode == 'sibling-first') removed,
                  retained,
                  if (mode != 'sibling-first') removed,
                ])), key: const ValueKey('old'));
          },
        ),
      ));
      debugPrint('REPRO: before reparent');
      debugDumpSemanticsTree(DebugSemanticsDumpOrder.traversalOrder);
      changed.value = true;
      await tester.pump();
      debugPrint('REPRO: after reparent');
      debugDumpSemanticsTree(DebugSemanticsDumpOrder.traversalOrder);
      // Leave any framework error with the binding so the driver records the
      // original engine stack and fails. Never suppress a semantics failure.
      expect(find.bySemanticsLabel('retained'), findsOneWidget);
    } finally {
      handle.dispose();
      changed.dispose();
    }
  });
}

Logs

Logs
══╡ EXCEPTION CAUGHT BY SCHEDULER LIBRARY ╞══════════════════════
The following StateError was thrown during a scheduler callback:
Bad state: Semantics node map was inconsistent after update:

BEFORE: The semantics node map is consistent:
  Nodes in tree:
    0: [1]
    1: [2]
    2: [3, 4]
    3: []
    4: []
  Nodes in map: [0, 1, 2, 3, 4]


AFTER: The semantics node map is inconsistent:
  Nodes in tree:
    0: [5]
    5: [6]
    6: [3]
    3: []
  Nodes in map: [0, 3, 4, 5, 6]

When the exception was thrown, this was the stack:
dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 274:3       throw_
lib/_engine/engine/semantics/semantics.dart 3281:9                                <fn>
lib/_engine/engine/semantics/semantics.dart 3330:14                               updateSemantics
lib/_engine/engine/window.dart 109:5                                              updateSemantics
package:flutter_test/src/window.dart 1276:5                                       updateSemantics
package:flutter/src/rendering/view.dart 384:5                                     updateSemantics
package:flutter/src/rendering/binding.dart 268:48                                 <fn>
package:flutter/src/semantics/semantics.dart 5078:22                              sendSemanticsUpdate
package:flutter/src/rendering/object.dart 1651:7                                  flushSemantics
package:flutter/src/rendering/object.dart 1653:14                                 flushSemantics
package:flutter/src/rendering/binding.dart 699:7                                  drawFrame
package:flutter/src/widgets/binding.dart 1573:13                                  drawFrame
package:flutter/src/rendering/binding.dart 558:5                                  [_handlePersistentFrameCallback]

Flutter Doctor output

Versions
  • Flutter 3.47.5, framework 6a19cca56475dbfba1478ee68d7bd0c2ef891da1, engine af7e796e161ae0bb1ff0758c71a7105418bd9ded, Dart 3.13.4: debug repro fails; sibling-first control and profile pass.
  • Flutter 3.47.7, framework abaf9c5237, engine deb287481e, Dart 3.13.5: debug repro fails; sibling-first and fresh-child controls pass.
  • Host macOS arm64; Chrome / ChromeDriver for Testing 155.0.8059.79, headless web-server device.

Activity

  1. saurabh-mirajkar commented on Oct 15, 2026

    @saurabh-mirajkar
    Contributor

    @Icosa2050 Thanks for the report. I reproduced this on Flutter 3.47.5 and 3.47.7 on macOS with Chrome 155.0.8059.41 and ChromeDriver 155.0.8059.79.

    In debug mode, the default reparent case throws Bad state: Semantics node map was inconsistent after update on both versions. The sibling-first and fresh-child control cases pass. In profile mode, all tests pass on both versions.

    Routing this to the team for further evaluation.

  2. added
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    and removed
    in triagePresently being triaged by the triage team
    on Oct 15, 2026
  3. flutter-zl commented on Oct 21, 2026

    @flutter-zl
    Contributor

    Demo 12: #194056, https://flutter-demo-12-before.web.app

    The reporter's MaterialApp.router sample, release build, last FlutterError in the bottom bar.

    1. Click Open form, then Save. The button becomes a spinner and the form pops after 300 ms.
    2. Bug: the bottom bar and console show Null check operator used on a null value from didChangeViewFocus. Expected: no error.
    3. Control: rebuild with --dart-define=WORKAROUND=true and the error goes away.
  4. added
    triaged-webTriaged by Web platform team
    P2Important issues not at the top of the work list
    on Oct 21, 2026
  5. hannah-hyj commented on Oct 21, 2026

    @hannah-hyj
    Member

    From triage: we should look into if this is caused by framework sending a wrong semantics updates or if web side is handling he semantics updates wrongly.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)triaged-accessibilityTriaged by Framework Accessibility team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions