Repository navigation
[Flutter] An onError listener that calls setState misses errors found while A2uiSurface mounts #3612
Description
The a2ui_flutter README tells every app that an onError listener which calls setState (to show the error in the UI, say) must defer it to the next frame:
a2ui/dart/a2ui_flutter/README.md
Lines 105 to 106 in 409ded2
| - An `onError` listener can run while an `A2uiSurface` builds, so a | |
| `setState` it causes must wait for the next frame. |
Without that workaround, if there is an error in the agent's first message, the listener's setState will be cancelled.
To better understand, consider the usual flow the agent creates a surface and fills it in one go, and the app then puts an A2uiSurface for it on screen. If one of those components is broken (e.g. an unknown type), the adapter finds the error while the widget mounts, the onError listener's setState runs during build, which Flutter does not allow. The banner never appears.
Motivation. I noticed this when reviewing agent-drafted code for #3094. It wrapped every update of its error log in a runOutsideBuild helper that pushes the call to a post-frame callback, which struck me as a smelly workaround.
Cause
A2uiSurface builds its NodeResolver in initState:
a2ui/dart/a2ui_flutter/lib/src/a2ui_surface.dart
Lines 56 to 70 in 409ded2
| void _attach() { | |
| _resolver = NodeResolver<ComponentImplementation>(widget.surface); | |
| _unsubscribe = listenAfterFirst(_resolver.rootNode, (_) => signalChanged()); | |
| } | |
| void _detach() { | |
| _unsubscribe(); | |
| _resolver.dispose(); | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _attach(); | |
| } |
The resolver delivers what it found synchronously when its update ends, so the listener runs inside the build:
a2ui/dart/a2ui_core/lib/src/resolution/node_resolver.dart
Lines 198 to 220 in 409ded2
| void _runUpdate(void Function() update) { | |
| if (_disposed) { | |
| return; | |
| } | |
| _updateDepth++; | |
| try { | |
| batch(update); | |
| } catch (_) { | |
| _discardPendingErrors(); | |
| _pendingWarnings.clear(); | |
| _pendingSurfaceWarnings.clear(); | |
| rethrow; | |
| } finally { | |
| _updateDepth--; | |
| if (_updateDepth == 0) { | |
| if (_disposeRequested) { | |
| dispose(); | |
| } else { | |
| _flushErrors(); | |
| } | |
| } | |
| } | |
| } |
Flutter's "setState() called during build" assertion is then caught and logged by EventNotifier, so nothing visible fails:
a2ui/dart/a2ui_core/lib/src/primitives/event_notifier.dart
Lines 53 to 67 in 409ded2
| /// Emits an event to all subscribed listeners. | |
| void emit(T event) { | |
| if (_subscriptions.isEmpty) return; | |
| // Iterate over a copy to allow listeners to unsubscribe themselves. | |
| for (final _CallbackSubscription<T> sub in List.of(_subscriptions)) { | |
| try { | |
| final void Function(T event)? listener = sub._listener; | |
| if (listener != null) { | |
| listener(event); | |
| } | |
| } catch (e, st) { | |
| _logger.severe('Error in EventNotifier listener', e, st); | |
| } | |
| } | |
| } |
The same happens each time a widget is built again for an existing surface, such as when a list scrolls back to it. Fixing this lets the gallery delete runOutsideBuild and the README drop its note.
Repro (widget test, agent-generated)
testWidgets('onError listener can show an error found at mount', (tester) async {
final processor = MessageProcessor<ComponentImplementation>(
catalogs: [basicCatalog()],
defaultVersion: A2uiProtocolVersion.v0_9,
);
processor.processMessages(
AgentToRendererMessagePayload.fromJson([
{
'version': 'v0.9',
'createSurface': {'surfaceId': 's', 'catalogId': basicCatalog().id},
},
{
'version': 'v0.9',
'updateComponents': {
'surfaceId': 's',
'components': [
{'id': 'root', 'component': 'Bogus'},
],
},
},
], protocolVersion: A2uiProtocolVersion.v0_9),
);
await tester.pumpWidget(
MaterialApp(home: _ErrorCount(processor.model.getSurface('s')!)),
);
await tester.pump();
expect(find.text('errors: 1'), findsOneWidget); // Fails: 'errors: 0'.
});
class _ErrorCount extends StatefulWidget {
const _ErrorCount(this.surface);
final SurfaceModel<ComponentImplementation> surface;
@override
State<_ErrorCount> createState() => _ErrorCountState();
}
class _ErrorCountState extends State<_ErrorCount> {
int _errors = 0;
@override
void initState() {
super.initState();
widget.surface.onError.subscribe((_) => setState(() => _errors++));
}
@override
Widget build(BuildContext context) => Scaffold(
body: Column(
children: [
Text('errors: $_errors'),
Expanded(child: A2uiSurface(surface: widget.surface)),
],
),
);
}Suggestion, Errors and warnings should reach their listeners after the call that found them returns, outside the build, so a listener can call setState directly.
🧑 Human-generated
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity