Skip to content

[web] Focus traversal from _ViewState.didChangeViewFocus reads the FocusScopeNode of a route popped in the same frame (semantics enabled): "Cannot get renderObject of inactive element" #194056

Open

Description

@Igao06

Steps to reproduce

  1. flutter create --platforms web bug and replace lib/main.dart with the code sample below (no packages beyond the SDK).
  2. flutter build web --release, serve build/web (e.g. python3 -m http.server --directory build/web 8765) and open it in Chrome. (flutter run -d chrome shows the debug assertion instead.)
  3. Enable the semantics tree the way a screen reader does: run document.querySelector('flt-semantics-placeholder').click() in DevTools, or Tab to the "Enable accessibility" placeholder and activate it.
  4. Click Open form.
  5. Click Save. The button's child becomes a CircularProgressIndicator; ~300 ms later the app pops back to List.

Each of these is required (verified in isolation): the semantics tree is on; the popped route has a zero-duration transition; the app uses MaterialApp.router (MaterialApp(home:) hides it, see below); the page is wrapped in Semantics(scopesRoute: true, explicitChildNodes: true), as MaterialPageRoute.buildPage does; the button is disabled while loading (onPressed: null). Typing in the TextField is not needed.

Expected results

No exception. The form pops and the list is shown.

Actual results

Every time, one error is reported through FlutterError.onError while dispatching WidgetsBindingObserver.didChangeViewFocus:

  • release: Null check operator used on a null value, from RenderObject.getTransformTo (rendering/object.dart:3687, the owner!);
  • debug: Cannot get renderObject of inactive element. … The findRenderObject() method was called for the following element: _FocusScopeWithExternalFocusNode.

The app keeps working; the error is caught in WidgetsBinding.handleViewFocusChanged.

Mechanism (Flutter 3.44.0 sources)

  1. Clicking Save gives DOM focus to the button's <flt-semantics role="button">. When its child becomes a spinner, the spinner's SemanticsRole.loadingSpinner is merged into the button's node (semantics/semantics.dart:6808), so the engine role changes button → loadingSpinner. SemanticsObject._updateRole() (engine semantics/semantics.dart:2314-2358) creates a new DOM element and removes the old one without moving focus → focusout with relatedTarget: null, activeElement: <body> → ViewFocusBinding emits ViewFocusEvent(unfocused). (A button that stays enabled is re-focused by the engine because the merged node is still isFocused, which hides the bug — hence onPressed: null while loading, the common pattern.)
  2. The pop with transitionDuration: Duration.zero completes synchronously; the popped OverlayEntry is removed in a post-frame callback, so in the next frame the list route comes back onstage and the form route's elements are deactivated in the same build.
  3. In that frame, flushSemantics → engine updateSemantics creates the list route's SemanticRoute, whose one-time post-update callback (semantics/route.dart:26-48, run from EngineSemanticsOwner._finalizeTree, semantics.dart:3156) focuses the first focusable DOM node of the route → focusin → ViewFocusEvent(focused), delivered synchronously, still inside drawFrame, before BuildOwner.finalizeTree() unmounts the deactivated elements.
  4. _ViewState.didChangeViewFocus (widgets/view.dart:254) runs ReadingOrderTraversalPolicy.findFirstFocus, which reads FocusNode.rect for every node it sorts (focus_traversal.dart:1399 → focus_manager.dart:862). The popped route's FocusScopeNode is still in the focus tree (its _FocusState.dispose, which detaches it, only runs at unmount), its element is inactive and its render object detached → getTransformTo dereferences owner!.

MaterialApp(home:) does not show it because it wraps the Navigator in FocusScope('Navigator Scope') (widgets/app.dart:1692), and _sortAllDescendants uses _getDescendantsWithoutExpandingScope, which does not descend into nested scopes, so the route scopes are never measured. With MaterialApp.router they are. Replacing the route instead of popping (both pages attached in the same frame) does not throw either.

This looks like the same unguarded FocusNode.rect read from didChangeViewFocus as #193287 / #191508 (RenderBox was not laid out on a just-mounted overlay) and #186562 (null directionality), with a different precondition: an inactive element whose render object is already detached.

Possible fixes: skip nodes whose element is not mounted / whose render object is not attached when sorting (or guard FocusNode.rect), and/or defer the traversal in didChangeViewFocus when it is called during drawFrame; on the engine side, transfer focus to the replacement element in _updateRole() when the removed element is document.activeElement, and/or run the route default focus outside the semantics update.

Workaround

Keep the merged node a button while loading, so the engine does not swap the element:

FilledButton(
  onPressed: saving ? null : save,
  child: saving
      ? Semantics(
          label: 'Save',
          excludeSemantics: true,
          child: const SizedBox.square(
            dimension: 20,
            child: CircularProgressIndicator(strokeWidth: 2),
          ),
        )
      : const Text('Save'),
)

Measured: the node stays role="button" aria-disabled="true", DOM focus never leaves the Flutter view, no error. (The sample applies it with --dart-define=WORKAROUND=true.)

Code sample

Code sample
import 'package:flutter/material.dart';

/// Build with `--dart-define=WORKAROUND=true` to apply the workaround
/// (keep the button's semantics role while the spinner is shown).
const bool kWorkaround = bool.fromEnvironment('WORKAROUND');

void main() => runApp(const ReproApp());

class ReproApp extends StatefulWidget {
  const ReproApp({super.key});

  @override
  State<ReproApp> createState() => _ReproAppState();
}

class _ReproAppState extends State<ReproApp> {
  final AppRouterDelegate _delegate = AppRouterDelegate();

  @override
  void dispose() {
    _delegate.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // MaterialApp.router: no 'Navigator Scope' FocusScope between the View's
    // scope and the routes (MaterialApp(home:) adds one and hides the bug).
    return MaterialApp.router(routerDelegate: _delegate);
  }
}

/// A page with a zero-duration transition (a common "no transition" page).
class NoTransitionPage<T> extends Page<T> {
  const NoTransitionPage({required this.child, super.key, super.name});

  final Widget child;

  @override
  Route<T> createRoute(BuildContext context) {
    return PageRouteBuilder<T>(
      settings: this,
      transitionDuration: Duration.zero,
      reverseTransitionDuration: Duration.zero,
      // Same wrapper as MaterialPageRoute.buildPage (and other routing packages):
      // it is what makes the web engine create a route node for the page.
      pageBuilder: (_, _, _) =>
          Semantics(scopesRoute: true, explicitChildNodes: true, child: child),
    );
  }
}

class AppRouterDelegate extends RouterDelegate<Object>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<Object> {
  @override
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  bool _formOpen = false;

  void openForm() {
    _formOpen = true;
    notifyListeners();
  }

  void closeForm() {
    _formOpen = false;
    notifyListeners();
  }

  @override
  Future<void> setNewRoutePath(Object configuration) async {}

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: <Page<void>>[
        NoTransitionPage<void>(
          key: const ValueKey<String>('list'),
          name: '/',
          child: ListScreen(onOpenForm: openForm),
        ),
        if (_formOpen)
          NoTransitionPage<void>(
            key: const ValueKey<String>('form'),
            name: '/form',
            child: FormScreen(onSaved: closeForm),
          ),
      ],
      onDidRemovePage: (Page<Object?> page) {
        if (page.key == const ValueKey<String>('form')) {
          _formOpen = false;
        }
      },
    );
  }
}

class ListScreen extends StatelessWidget {
  const ListScreen({required this.onOpenForm, super.key});

  final VoidCallback onOpenForm;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('List')),
      body: Center(
        child: FilledButton(
          onPressed: onOpenForm,
          child: const Text('Open form'),
        ),
      ),
    );
  }
}

class FormScreen extends StatefulWidget {
  const FormScreen({required this.onSaved, super.key});

  final VoidCallback onSaved;

  @override
  State<FormScreen> createState() => _FormScreenState();
}

class _FormScreenState extends State<FormScreen> {
  bool _saving = false;

  Future<void> _save() async {
    setState(() => _saving = true);
    // Simulates a remote write; then pops back to the list.
    await Future<void>.delayed(const Duration(milliseconds: 300));
    if (!mounted) {
      return;
    }
    widget.onSaved();
  }

  @override
  Widget build(BuildContext context) {
    Widget spinner = const SizedBox.square(
      dimension: 20,
      child: CircularProgressIndicator(strokeWidth: 2),
    );
    if (kWorkaround) {
      // Keeps the merged semantics node a button: no role swap in the engine.
      spinner = Semantics(
        label: 'Save',
        excludeSemantics: true,
        child: spinner,
      );
    }
    return Scaffold(
      appBar: AppBar(title: const Text('Form')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: <Widget>[
            const TextField(decoration: InputDecoration(labelText: 'Name')),
            const SizedBox(height: 16),
            FilledButton(
              // Disabled while saving (common pattern): the button's FocusNode
              // can no longer hold focus, so the engine does not re-focus the
              // swapped DOM element and document focus stays on <body>.
              onPressed: _saving ? null : _save,
              child: _saving ? spinner : const Text('Save'),
            ),
          ],
        ),
      ),
    );
  }
}

Screenshots or Video

N/A (console output below).

Logs

Logs

Release (flutter build web --release --source-maps, Chrome 134), mapped frames:

Null check operator used on a null value
RenderObject.getTransformTo                packages/flutter/lib/src/rendering/object.dart:3687
FocusNode.rect                             packages/flutter/lib/src/widgets/focus_manager.dart:862
ReadingOrderTraversalPolicy.sortDescendants packages/flutter/lib/src/widgets/focus_traversal.dart:1399
FocusTraversalPolicy._sortAllDescendants   packages/flutter/lib/src/widgets/focus_traversal.dart:1709
FocusTraversalPolicy._findInitialFocus     packages/flutter/lib/src/widgets/focus_traversal.dart:335, 327
_ViewState.didChangeViewFocus              packages/flutter/lib/src/widgets/view.dart:254
WidgetsBinding.handleViewFocusChanged      packages/flutter/lib/src/widgets/binding.dart:1355
invoke1                                    lib/_engine/engine/platform_dispatcher.dart:1715

Debug (flutter build web --debug):

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════
The following assertion was thrown while dispatching notifications for
WidgetsBindingObserver.didChangeViewFocus:
Cannot get renderObject of inactive element.
In order for an element to have a valid renderObject, it must be active, which means it is part of
the tree.
Instead, this element is in the _ElementLifecycle.inactive state.
If you called this method from a State object, consider guarding it with State.mounted.
The findRenderObject() method was called for the following element:
  _FocusScopeWithExternalFocusNode

DOM focus events around the pop (node-8 = Save button, node-5 = "List" heading):

focusin  flt-semantics#flt-semantic-node-8[role=button] "Save"
focusout flt-semantics#flt-semantic-node-8  relatedTarget=null  activeElement=body   <- role swap removed the element
focusin  h2#flt-semantic-node-5 "List"      relatedTarget=null                        <- engine route default focus; error follows

Flutter Doctor output

Doctor output
[!] Flutter (Channel [user-branch], 3.44.0, on Ubuntu 22.04.5 LTS (WSL2))
    • Framework revision 559ffa3f75 (2026-05-15), Engine 4c525dac5e, Dart 3.12.0, DevTools 2.57.0
[✓] Chrome - develop for the web (Google Chrome 134.0.6998.35)
[✓] Connected device (2 available)
[✓] Network resources

Activity

  1. suryaKommana2662 commented on Oct 9, 2026

    @suryaKommana2662
    Contributor

    @Igao06 Thanks for the report. I was able to reproduce the issue locally in Chrome. After enabling the semantics tree and following the reported steps, I observed the Cannot get renderObject of inactive element assertion.

    The stack trace points to FocusNode.rect during ReadingOrderTraversalPolicy traversal, called from _ViewState.didChangeViewFocus while the frame is being processed. routing to the w for further investigation.

    _error_dumper_web.dart:15 ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
    The following assertion was thrown while dispatching notifications for
    WidgetsBindingObserver.didChangeViewFocus:
    Cannot get renderObject of inactive element.
    In order for an element to have a valid renderObject, it must be active, which means it is part of
    the tree.
    Instead, this element is in the _ElementLifecycle.inactive state.
    If you called this method from a State object, consider guarding it with State.mounted.
    The findRenderObject() method was called for the following element:
      _FocusScopeWithExternalFocusNode
    
    When the exception was thrown, this was the stack:
    dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/errors.dart 274:3       throw_
    package:flutter/src/widgets/framework.dart 4912:9                                 <fn>
    package:flutter/src/widgets/framework.dart 4924:14                                findRenderObject
    package:flutter/src/widgets/focus_manager.dart 868:33                             get rect
    package:flutter/src/widgets/focus_traversal.dart 1399:18                          new
    package:flutter/src/widgets/focus_traversal.dart 1580:43                          <fn>
    package:flutter/src/widgets/focus_traversal.dart 1579:41                          sort
    package:flutter/src/widgets/focus_traversal.dart 1709:7                           sortDescendants
    package:flutter/src/widgets/focus_traversal.dart 514:55                           _sortAllDescendants
    package:flutter/src/widgets/focus_traversal.dart 338:9                            [_findInitialFocus]
    package:flutter/src/widgets/focus_traversal.dart 299:12                           findFirstFocus
    package:flutter/src/widgets/view.dart 254:33                                      didChangeViewFocus
    package:flutter/src/widgets/binding.dart 1355:17                                  handleViewFocusChanged
    dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/operations.dart 118:77  tear
    lib/_engine/engine/platform_dispatcher.dart 1748:5                                invoke1
    lib/_engine/engine/platform_dispatcher.dart 206:5                                 invokeOnViewFocusChange
    dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/operations.dart 118:77  tear
    lib/_engine/engine/platform_dispatcher/view_focus_binding.dart 109:23             [_handleFocusChange]
    lib/_engine/engine/platform_dispatcher/view_focus_binding.dart 54:5               <fn>
    dart-sdk/lib/async/zone.dart 1034:54                                              runUnary
    dart-sdk/lib/async/zone.dart 989:26                                               <fn>
    dart-sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart 218:27    _callDartFunctionFast1
    dart-sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart 78:15     ret
    lib/_engine/engine/dom.dart 518:8                                                 DomElement.focusWithoutScroll
    lib/_engine/engine/semantics/label_and_value.dart 101:17                          focusAsRouteDefault
    lib/_engine/engine/semantics/label_and_value.dart 601:5                           focusAsRouteDefault
    lib/_engine/engine/semantics/heading.dart 59:5                                    focusAsRouteDefault
    lib/_engine/engine/semantics/route.dart 45:37                                     <fn>
    lib/_engine/engine/semantics/semantics.dart 2594:49                               [_visitDepthFirstInTraversalOrder]
    lib/_engine/engine/semantics/semantics.dart 2616:17                               [_visitDepthFirstInTraversalOrder]
    lib/_engine/engine/semantics/semantics.dart 2616:17                               [_visitDepthFirstInTraversalOrder]
    lib/_engine/engine/semantics/semantics.dart 2590:5                                visitDepthFirstInTraversalOrder
    lib/_engine/engine/semantics/route.dart 37:5                                      [_setDefaultFocus]
    lib/_engine/engine/semantics/route.dart 32:7                                      <fn>
    lib/_engine/engine/semantics/semantics.dart 3157:11                               [_finalizeTree]
    lib/_engine/engine/semantics/semantics.dart 3270:5                                updateSemantics
    lib/_engine/engine/window.dart 109: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]
    dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/operations.dart 118:77  tear
    package:flutter/src/scheduler/binding.dart 1430:7                                 [_invokeFrameCallback]
    package:flutter/src/scheduler/binding.dart 1345:9                                 handleDrawFrame
    package:flutter/src/scheduler/binding.dart 1198:5                                 [_handleDrawFrame]
    dart-sdk/lib/_internal/js_dev_runtime/private/ddc_runtime/operations.dart 118:77  tear
    lib/_engine/engine/platform_dispatcher.dart 1733:5                                invoke
    lib/_engine/engine/platform_dispatcher.dart 268:5                                 invokeOnDrawFrame
    lib/_engine/engine/frame_service.dart 209:32                                      [_renderFrame]
    lib/_engine/engine/frame_service.dart 117:9                                       <fn>
    dart-sdk/lib/async/zone.dart 1034:54                                              runUnary
    dart-sdk/lib/async/zone.dart 989:26                                               <fn>
    dart-sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart 218:27    _callDartFunctionFast1
    dart-sdk/lib/_internal/js_dev_runtime/patch/js_allow_interop_patch.dart 78:15     ret
    ════════════════════════════════════════════════════════════════════════════════════════════════════

    Flutetr Doctor Output

    [✓] Flutter (Channel stable, 3.47.7, on macOS 27.0 26A428 darwin-arm64, locale en-IN) [269ms]
        • Flutter version 3.47.7 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision abaf9c5237 (15 hours ago), 2026-10-08 09:51:21 -0700
        • Engine revision deb287481e
        • Dart version 3.13.5
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,379ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.0.0
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 27.0) [738ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 27A266a
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [4ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [5.9s]
        • iPhone 18 Pro (mobile) • D80F2104-E911-4251-BEDD-E67D9B7EBF58 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-27-0 (simulator)
        • macOS (desktop)        • macos                                • darwin-arm64   • macOS 27.0 26A428 darwin-arm64
        • Chrome (web)           • chrome                               • web-javascript • Google Chrome 155.0.8059.39
    
    [✓] Network resources [765ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    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 9, 2026
  3. flutter-zl commented on Oct 21, 2026

    @flutter-zl
    Contributor
    • Demo: https://flutter-demo-12-before.web.app, The reporter's MaterialApp.router sample with zero-duration pages, semantics on at startup, and the last FlutterError shown in a bottom bar. Release build.
      • Steps: click Open form, then Save. The button turns into a spinner and the form pops after 300 ms.
      • Bug: the bottom bar and console show Null check operator used on a null value from RenderObject.getTransformTo via didChangeViewFocus. A debug build shows Cannot get renderObject of inactive element instead. Fixed: no error, the list is shown.
      • Control: rebuild with --dart-define=WORKAROUND=true, which keeps the button's semantics role while the spinner shows, 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. added
    team-webOwned by Web platform team
    triaged-webTriaged by Web platform team
    and removed
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Oct 21, 2026
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 onteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions