Repository navigation
Description
Steps to reproduce
flutter create --platforms web bugand replacelib/main.dartwith the code sample below (no packages beyond the SDK).flutter build web --release, servebuild/web(e.g.python3 -m http.server --directory build/web 8765) and open it in Chrome. (flutter run -d chromeshows the debug assertion instead.)- 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. - Click Open form.
- 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, fromRenderObject.getTransformTo(rendering/object.dart:3687, theowner!); - 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)
- Clicking Save gives DOM focus to the button's
<flt-semantics role="button">. When its child becomes a spinner, the spinner'sSemanticsRole.loadingSpinneris merged into the button's node (semantics/semantics.dart:6808), so the engine role changes button → loadingSpinner.SemanticsObject._updateRole()(enginesemantics/semantics.dart:2314-2358) creates a new DOM element and removes the old one without moving focus →focusoutwithrelatedTarget: null,activeElement: <body>→ViewFocusBindingemitsViewFocusEvent(unfocused). (A button that stays enabled is re-focused by the engine because the merged node is stillisFocused, which hides the bug — henceonPressed: nullwhile loading, the common pattern.) - The pop with
transitionDuration: Duration.zerocompletes synchronously; the poppedOverlayEntryis 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. - In that frame,
flushSemantics→ engineupdateSemanticscreates the list route'sSemanticRoute, whose one-time post-update callback (semantics/route.dart:26-48, run fromEngineSemanticsOwner._finalizeTree,semantics.dart:3156) focuses the first focusable DOM node of the route →focusin→ViewFocusEvent(focused), delivered synchronously, still insidedrawFrame, beforeBuildOwner.finalizeTree()unmounts the deactivated elements. _ViewState.didChangeViewFocus(widgets/view.dart:254) runsReadingOrderTraversalPolicy.findFirstFocus, which readsFocusNode.rectfor every node it sorts (focus_traversal.dart:1399→focus_manager.dart:862). The popped route'sFocusScopeNodeis still in the focus tree (its_FocusState.dispose, which detaches it, only runs at unmount), its element is inactive and its render object detached →getTransformTodereferencesowner!.
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
suryaKommana2662 commentedon Oct 9, 2026
@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 elementassertion.The stack trace points to
FocusNode.rectduringReadingOrderTraversalPolicytraversal, called from_ViewState.didChangeViewFocuswhile the frame is being processed. routing to thewfor further investigation.Flutetr Doctor Output
flutter-zl commentedon Oct 21, 2026
MaterialApp.routersample with zero-duration pages, semantics on at startup, and the lastFlutterErrorshown in a bottom bar. Release build.Null check operator used on a null valuefromRenderObject.getTransformToviadidChangeViewFocus. A debug build showsCannot get renderObject of inactive elementinstead. Fixed: no error, the list is shown.--dart-define=WORKAROUND=true, which keeps the button's semantics role while the spinner shows, and the error goes away.Keep overlay focus nodes out of the web View focus sort