Skip to content

[Flutter] An onError listener that calls setState misses errors found while A2uiSurface mounts #3612

Closed

Description

@andrewkolos

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:

- 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:

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:

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:

/// 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

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

Metadata

Metadata

Assignees

Labels

P2The team intends to work on this in the near future, or at a lower priority.status: first-line-handled

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions