flutter / flutter/flutter

[semantics] Assertion `!childSemantics.renderObject._needsLayout` fails in `_RenderObjectSemantics._collectChildMergeUpAndSiblingGroup` when a layout-dirty render object is reachable from `visitChildrenForSemantics`

Open
#191,188 1 comment 0 reactions 1 assignee Claimed by @chunhtai View on GitHub
a: accessibility c: crash framework has reproducible steps P2 team-accessibility triaged-accessibility
Dominant language
Dart
Stars
179k
Forks
31.1k
PR merge metrics
PR metrics pending

Description

## Steps to reproduce

1. `flutter create --template=package semantics_repro`
2. Save the code sample below as `test/semantics_layout_dirty_test.dart`.
3. Run `flutter test test/semantics_layout_dirty_test.dart`.

Both tests fail. No Material/Cupertino dependency and no platform is involved; the only requirement is that semantics is enabled (`tester.ensureSemantics()`), which is also what an app does when a screen reader is on.

## Expected results

`PipelineOwner.flushSemantics` copes with a render object that is still marked as needing layout, the way it already does for the nodes it picks up from `_nodesNeedingSemanticsUpdate` (see the analysis below). The tests complete without a framework assertion.

## Actual results

`PipelineOwner.flushSemantics` throws:

```
'package:flutter/src/rendering/object.dart': Failed assertion: line 6017 pos 14:
'!childSemantics.renderObject._needsLayout': is not true.
```

(`object.dart:6017` on master `875be1c8953`; the same assertion is at `object.dart:5994` on stable 3.44.9.)

The assertion has no message, and the accompanying text tells the user to file a framework bug, so an app hitting it has nothing actionable to go on.

Two independent ways to get there, both in the code sample:

* **Test 1** — a render object stops laying its child out, the child is later marked as needing layout, and nothing lays it out again. The child is still returned by the default `visitChildrenForSemantics`, so semantics compilation walks into it and asserts.
* **Test 2** — the same, plus a subtree that is *newly admitted* to the semantics tree (`visitChildrenForSemantics` starts returning a child) while that subtree is layout-dirty. The assertion fires twice here: once for the middle render object at step 2, once for the leaf at step 3.

### Analysis

`flushSemantics` is already defensive about exactly this state for the nodes it starts from — `object.dart:1468` filters them:

```dart
final List nodesToProcess =
_nodesNeedingSemanticsUpdate
.where((RenderObject object) => !object._needsLayout && object.owner == this)
.toList()
```

but one level down, the same condition is a hard assertion instead (`object.dart:6017`):

```dart
for (final _RenderObjectSemantics childSemantics in _getNonBlockedChildren()) {
assert(!childSemantics.renderObject._needsLayout);
```

So a layout-dirty render object is a tolerated state when it is the root of a semantics update, and a crash when it is reached as a child during the same flush. Skipping such children (or deferring their subtree until they are laid out) would make the two paths consistent.

Both lines were introduced by #161195 ("Introduce caching mechanism during compile semantics tree"), which first shipped in 3.32.0.

The render objects in the sample are deliberately minimal, and I am aware that "do not lay a child out, but keep exposing it to `visitChildrenForSemantics`" is a questionable thing for a render object to do — the framework's own render objects (e.g. `RenderOffstage`) always pair the two. But that pairing is nowhere stated as a contract in the `visitChildrenForSemantics` / `markNeedsLayout` documentation, and the failure mode is an unmessaged internal assertion rather than a diagnostic. If the invariant is intended, an assertion with an explanatory message (and a doc note on `visitChildrenForSemantics`) would be a good outcome for this issue too.

## Code sample

test/semantics_layout_dirty_test.dart

```dart
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

/// A render object that independently controls whether it lays its child out
/// and whether it exposes the child via [visitChildrenForSemantics].
class Gate extends SingleChildRenderObjectWidget {
const Gate({
super.key,
required this.layoutChild,
this.includeInSemantics = true,
this.boundary = false,
required super.child,
});

final bool layoutChild;
final bool includeInSemantics;
final bool boundary;

@override
RenderObject createRenderObject(BuildContext context) => RenderGate(
layoutChild: layoutChild,
includeInSemantics: includeInSemantics,
boundary: boundary,
);

@override
void updateRenderObject(BuildContext context, RenderGate renderObject) {
renderObject
..layoutChild = layoutChild
..includeInSemantics = includeInSemantics;
}
}

class RenderGate extends RenderBox with RenderObjectWithChildMixin {
RenderGate({
required bool layoutChild,
required bool includeInSemantics,
required bool boundary,
}) : _layoutChild = layoutChild,
_includeInSemantics = includeInSemantics,
_boundary = boundary;

bool _layoutChild;
bool _includeInSemantics;
final bool _boundary;

set layoutChild(bool value) {
if (_layoutChild == value) {
return;
}
_layoutChild = value;
markNeedsLayout();
}

set includeInSemantics(bool value) {
if (_includeInSemantics == value) {
return;
}
_includeInSemantics = value;
markNeedsSemanticsUpdate();
}

@override
void performLayout() {
size = constraints.biggest;
final RenderBox? child = this.child;
if (_layoutChild && child != null) {
child.layout(BoxConstraints.loose(constraints.biggest), parentUsesSize: true);
}
}

@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
final RenderBox? child = this.child;
if (_includeInSemantics && child != null) {
visitor(child);
}
}

@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = _boundary;
}

@override
void paint(PaintingContext context, Offset offset) {}
}

void main() {
testWidgets('a semantics child that its parent stops laying out', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();

Widget app({required bool layoutChild}) {
return Directionality(
textDirection: TextDirection.ltr,
child: Gate(
layoutChild: layoutChild,
child: Semantics(container: true, child: const SizedBox(width: 100, height: 100)),
),
);
}

// 1. Everything is laid out and part of the semantics tree.
await tester.pumpWidget(app(layoutChild: true));
final RenderGate gate = tester.renderObject(find.byType(Gate));
final RenderBox child = gate.child!;

// 2. The gate stops laying its child out, and the child is marked as
// needing layout. Nothing lays it out again, but it is still visited by
// `visitChildrenForSemantics`.
await tester.pumpWidget(app(layoutChild: false));
child.markNeedsLayout();
await tester.pump(); // <-- assertion

expect(child.debugNeedsLayout, isTrue);
handle.dispose();
});

testWidgets('a layout-dirty subtree that is newly admitted to the semantics tree', (
WidgetTester tester,
) async {
final SemanticsHandle handle = tester.ensureSemantics();

Widget app({required bool layoutMid, required bool includeLeaf}) {
return Directionality(
textDirection: TextDirection.ltr,
child: Gate(
layoutChild: layoutMid,
boundary: true,
child: Gate(
layoutChild: true,
includeInSemantics: includeLeaf,
child: Semantics(container: true, child: const SizedBox(width: 100, height: 100)),
),
),
);
}

// 1. Everything is laid out; the leaf is kept out of the semantics tree.
await tester.pumpWidget(app(layoutMid: true, includeLeaf: false));

final List gates = tester.renderObjectList(find.byType(Gate)).toList();
final RenderGate boundary = gates.first;
final RenderGate mid = gates.last;
final RenderBox leaf = mid.child!;

// 2. The outer gate stops laying the middle gate out, then both the middle
// gate and the leaf are marked as needing layout.
await tester.pumpWidget(app(layoutMid: false, includeLeaf: false));
mid.markNeedsLayout();
leaf.markNeedsLayout();
await tester.pump(); // <-- assertion (the middle gate is dirty)

expect(boundary.debugNeedsLayout, isFalse);
expect(mid.debugNeedsLayout, isTrue);
expect(leaf.debugNeedsLayout, isTrue);

// 3. The middle gate exposes the layout-dirty leaf to the semantics tree.
await tester.pumpWidget(app(layoutMid: false, includeLeaf: true)); // <-- assertion

handle.dispose();
});
}
```

## Screenshots or Video

Not applicable.

## Logs

flutter test output (master, framework revision 875be1c895)

```console
$ flutter test test/semantics_layout_dirty_test.dart
00:00 +0: loading .../semantics_repro/test/semantics_layout_dirty_test.dart
00:00 +0: a semantics child that its parent stops laying out
══╡ EXCEPTION CAUGHT BY SCHEDULER LIBRARY ╞═════════════════════════════════════════════════════════
The following assertion was thrown during a scheduler callback:
'package:flutter/src/rendering/object.dart': Failed assertion: line 6017 pos 14:
'!childSemantics.renderObject._needsLayout': is not true.

Either the assertion indicates an error in the framework itself, or we should provide substantially
more information in this error message to help you determine and fix the underlying cause.
In either case, please report this assertion by filing a bug on GitHub:
https://github.com/flutter/flutter/issues/new?template=02_bug.yml

When the exception was thrown, this was the stack:
#2 _RenderObjectSemantics._collectChildMergeUpAndSiblingGroup (package:flutter/src/rendering/object.dart:6017:14)
#3 _RenderObjectSemantics.updateChildren (package:flutter/src/rendering/object.dart:5843:50)
#4 _RenderObjectSemantics._didUpdateParentData (package:flutter/src/rendering/object.dart:6094:5)
#5 _RenderObjectSemantics._collectChildMergeUpAndSiblingGroup (package:flutter/src/rendering/object.dart:6018:22)
#6 _RenderObjectSemantics.updateChildren (package:flutter/src/rendering/object.dart:5843:50)
#7 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1489:25)
#8 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1653:15)
#9 AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:2470:35)
#10 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:558:5)
#11 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1430:15)
#12 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1345:9)
#13 AutomatedTestWidgetsFlutterBinding.pump. (package:flutter_test/src/binding.dart:2286:9)
#15 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#16 AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:2275:27)
#17 WidgetTester.pump. (package:flutter_test/src/widget_tester.dart:652:53)
#19 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#20 WidgetTester.pump (package:flutter_test/src/widget_tester.dart:652:27)
#21 main. (.../semantics_repro/test/semantics_layout_dirty_test.dart:115:18)

#22 testWidgets.. (package:flutter_test/src/widget_tester.dart:192:15)

#23 TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1978:5)

(elided 5 frames from class _AssertionError, dart:async, and package:stack_trace)
════════════════════════════════════════════════════════════════════════════════════════════════════
00:00 +0 -1: a semantics child that its parent stops laying out [E]
Test failed. See exception logs above.
The test description was: a semantics child that its parent stops laying out

00:00 +0 -1: a layout-dirty subtree that is newly admitted to the semantics tree
══╡ EXCEPTION CAUGHT BY SCHEDULER LIBRARY ╞═════════════════════════════════════════════════════════
The following assertion was thrown during a scheduler callback:
'package:flutter/src/rendering/object.dart': Failed assertion: line 6017 pos 14:
'!childSemantics.renderObject._needsLayout': is not true.

[...same message...]

When the exception was thrown, this was the stack:
#2 _RenderObjectSemantics._collectChildMergeUpAndSiblingGroup (package:flutter/src/rendering/object.dart:6017:14)
#3 _RenderObjectSemantics.updateChildren (package:flutter/src/rendering/object.dart:5843:50)
#4 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1489:25)
#5 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1653:15)
#6 AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:2470:35)
#7 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:558:5)
#8 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1430:15)
#9 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1345:9)
#10 AutomatedTestWidgetsFlutterBinding.pump. (package:flutter_test/src/binding.dart:2286:9)
#12 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#13 AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:2275:27)
#14 WidgetTester.pump. (package:flutter_test/src/widget_tester.dart:652:53)
#16 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#17 WidgetTester.pump (package:flutter_test/src/widget_tester.dart:652:27)
#18 main. (.../semantics_repro/test/semantics_layout_dirty_test.dart:154:18)

[...]
════════════════════════════════════════════════════════════════════════════════════════════════════
══╡ EXCEPTION CAUGHT BY SCHEDULER LIBRARY ╞═════════════════════════════════════════════════════════
The following assertion was thrown during a scheduler callback:
'package:flutter/src/rendering/object.dart': Failed assertion: line 6017 pos 14:
'!childSemantics.renderObject._needsLayout': is not true.

[...same message...]

When the exception was thrown, this was the stack:
#2 _RenderObjectSemantics._collectChildMergeUpAndSiblingGroup (package:flutter/src/rendering/object.dart:6017:14)
#3 _RenderObjectSemantics.updateChildren (package:flutter/src/rendering/object.dart:5843:50)
#4 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1489:25)
#5 PipelineOwner.flushSemantics (package:flutter/src/rendering/object.dart:1653:15)
#6 AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:2470:35)
#7 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:558:5)
#8 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1430:15)
#9 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1345:9)
#10 AutomatedTestWidgetsFlutterBinding.pump. (package:flutter_test/src/binding.dart:2286:9)
#12 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#13 AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:2275:27)
#14 WidgetTester.pumpWidget. (package:flutter_test/src/widget_tester.dart:598:22)
#16 TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#17 WidgetTester.pumpWidget (package:flutter_test/src/widget_tester.dart:595:27)
#18 main. (.../semantics_repro/test/semantics_layout_dirty_test.dart:161:18)

[...]
════════════════════════════════════════════════════════════════════════════════════════════════════
00:00 +0 -2: a layout-dirty subtree that is newly admitted to the semantics tree [E]
Test failed. See exception logs above.

00:00 +0 -2: Some tests failed.
```

## Flutter Doctor output

Doctor output — master (reproduces)

```console
[!] Flutter (Channel master, 3.48.0-1.0.pre-232, on macOS 26.5.2 25F84 darwin-arm64, locale en-JP)
• Flutter version 3.48.0-1.0.pre-232 on channel master
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 875be1c895 (11 hours ago), 2026-08-16 15:02:02 +0000
• Engine revision 875be1c895
• Dart version 3.14.0 (build 3.14.0-134.0.dev)
• DevTools version 2.61.0-dev.0

[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 26.6)
[✓] Chrome - develop for the web
[✓] Connected device (2 available)
[✓] Network resources
```

Doctor output — stable 3.44.9 (also reproduces)

```console
[✓] Flutter (Channel stable, 3.44.9, on Ubuntu 24.04.4 LTS 6.18.5-fc-v20, locale en_US)
• Flutter version 3.44.9 on channel stable
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 6b182d2c75 (11 days ago), 2026-08-05 10:04:07 -0700
• Engine revision 5a2a6a42cc
• Dart version 3.12.2
• DevTools version 2.57.0
```

## Analysis and a candidate mitigation

Line numbers are against master `875be1c8953`.

Nothing establishes the invariant the assertion assumes: `visitChildrenForSemantics` defaults to
`visitChildren`, so a render object that skips laying a child out keeps handing it to the semantics
phase unless it overrides both. Flutter's own render objects do (`RenderOffstage`, lazy viewport
children), but that pairing isn't documented as a requirement. Elsewhere the pipeline doesn't rely on
it: `_paintWithContext` (`:3501`) skips render objects that still need layout, and `flushSemantics`
already filters them out of `_nodesNeedingSemanticsUpdate` (`:1468`) and the geometry pass (`:1512`)
— silently dropped as an update root, fatal one level down as a child. Recovery is in place either
way: `layout` (`:2923`) calls `markNeedsSemanticsUpdate()` after `performLayout()`, so a subtree laid
out again rejoins the semantics tree.

So the semantics phase can do what the paint phase does:

```diff
List<_RenderObjectSemantics> _getNonBlockedChildren() {
final result = <_RenderObjectSemantics>[];
renderObject.visitChildrenForSemantics((RenderObject renderChild) {
+ // Skipped during layout, so it has no up-to-date geometry and can't
+ // contribute to semantics. Mirrors `_paintWithContext`.
+ if (renderChild._needsLayout) {
+ return;
+ }
if (renderChild._semantics.isBlockingPreviousSibling) {
@@
for (final _RenderObjectSemantics childSemantics in _getNonBlockedChildren()) {
- assert(!childSemantics.renderObject._needsLayout);
childSemantics._didUpdateParentData(effectiveChildParentData);
```

The filter goes in `_getNonBlockedChildren()` so `debugCheckForParentData` (`:5721`) walks the same
set; the result is indistinguishable from a parent that doesn't return the child. Both tests in this
issue pass, as do `test/semantics`, `test/rendering`, `test/widgets` and `test/material` (bar 4
golden diffs in `icons_test.dart` that fail identically on a clean checkout).

But I suspect this isn't the fix you want. If the invariant is meant to be a contract, it needs
documenting on `visitChildrenForSemantics`, an assertion message naming the offending render object,
and a decision about release builds, where `semanticBounds` is read from a render object with no
valid size — that last part needs skipping anyway. Which direction do you want? Happy to write
regression tests and send a PR either way.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.