Users of a Flutter release build see a grey box where a crashed widget should be, yet the crash reporter shows nothing; what is missing, and how do you fix it?
answer
- the grey box was caught, not uncaught
- reportError runs before ErrorWidget.builder
- default onError only prints
- last assignment wins; chain the previous
- context names the widget that failed
basics
~20 sThe grey box means the framework caught a build exception and sent it to FlutterError.onError, whose default only prints. Reporting wired only to uncaught errors never sees it; forward FlutterError.onError to the reporter, then fix the build.
solid answer
~40 sA grey box in release is the default `ErrorWidget`: a widget's `build` threw, the element caught it, called `FlutterError.reportError`, then swapped in `ErrorWidget.builder(details)`. Because the framework caught it, the error never reaches `PlatformDispatcher.instance.onError` or a zone guard. It went to `FlutterError.onError`, which by default only prints. So check `FlutterError.onError` first. It may never have been set, a later `FlutterError.onError = ...` in some module may have replaced the reporter's handler, or the handler may drop errors. Set it once in `main()` before `runApp`, call `presentError`, and forward `exception`, `stack`, `library` and the `context` text. Chain any previous handler. Then use the report's context ("building ProductCard") to find the throw, usually release-only data hitting a `!` or a cast. Finally, give release users a cheap `ErrorWidget.builder` fallback.
code
dart · 19 linesimport 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void installFrameworkErrorForwarding(
void Function(Object error, StackTrace? stack, Map<String, String> tags) send,
) {
final FlutterExceptionHandler? previous = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
send(details.exception, details.stack, <String, String>{
'library': details.library ?? 'unknown',
'context': details.context?.toString() ?? '',
'silent': '${details.silent}',
});
if (previous != null && previous != FlutterError.presentError) {
previous(details);
}
};
}go deeper
Recognise the grey box as the release form of the red error screen, which means a widget's build threw.
Explain that the framework caught this error, so only FlutterError.onError saw it, and why the default handler only prints.
Audit the hook wiring (unset, overwritten, filtered or not yet ready), fix it with chaining, and use the forwarded context to find the data bug.
Define how caught and uncaught errors are counted and alerted per release, and who owns the global error hooks in a multi-team app.
## Reading the symptom A **grey box** where a widget should be is the release (and profile) look of the default **`ErrorWidget`**. It proves three things: 1. A widget-building callback threw: a `build` method, a `LayoutBuilder` builder, or a list item builder. 2. The framework **caught** the exception. Nothing crashed, and the rest of the screen still works. 3. Before swapping in the replacement, the element called `FlutterError.reportError(details)`, so **`FlutterError.onError` ran with the full details**. "The reporter shows nothing" therefore means the problem is on the `FlutterError.onError` side, not somewhere deep in the app. ## Why the reporter never saw it | Cause | How it happens | How to spot it | |---|---|---| | hook never set | only `PlatformDispatcher.instance.onError` or a zone guard was wired; both see **uncaught** errors, and this one was caught | `FlutterError.onError` is still the default presenter | | hook overwritten | a later `FlutterError.onError = ...` in a feature module or debug tool replaced the reporter's handler; it is one static field, and the last assignment wins | search the codebase for assignments | | hook filters it out | the handler drops errors by `library`, by `silent`, or by a sampling rule | read the handler | | reporter not ready | errors thrown before the reporter finished initialising are lost | the order of work in `main()` | The device log (`flutter logs`, or the platform log viewer) usually still has the default dump, because `presentError` prints outside debug too, just without the diagnostics tree. ## Fixing the wiring 1. In `main()`, before `runApp`, initialise the reporter and then set `FlutterError.onError`. 2. In the handler, call `FlutterError.presentError(details)` first to keep console and IDE output. 3. Forward `details.exception` and `details.stack`, plus `details.library` and `details.context?.toString()`. In release, `informationCollector` adds less, because the framework attaches the failing element's debug creator only in debug mode, so the context text matters. 4. If another handler might already be installed, **chain it** instead of replacing it: keep `final previous = FlutterError.onError;` and call `previous?.call(details)`. 5. Keep `PlatformDispatcher.instance.onError` for the uncaught class, returning `true` once the error is recorded. 6. Add a check to code review: exactly one place assigns these global hooks. ## Finding the real bug - The report's **context** reads like "building ProductCard(...)", which names the widget whose build threw. - The **exception type** usually points at data: a `!` on a null the test data never had, a `RangeError` on an empty list, a failed `as` cast on a JSON field. - Check what changed in the **data**, not only in the code. A release that passed every test can still meet a server response, a locale or a stored value the tests never used, and the grey box appears only for the users who have it. - Group reports by the context text. One widget failing for many users is a data or contract problem; many widgets failing at once usually points to something shared, such as a theme or a localisation lookup. - Reproduce with the same data in **profile mode** if you need release-like behaviour with a readable stack, or in debug to get the red screen and the full diagnostics tree. - If release stack traces are obfuscated, symbolicate them with the symbols saved at build time. That process belongs to the release build set-up. ## Improving what users see The default grey box is honest but ugly. Assign **`ErrorWidget.builder`** in release to a cheap fallback that fits the design, such as a neutral panel saying the section could not load. The API doc stresses doing as little work as possible there: it runs right after an exception thrown mid-build, when the surrounding tree and the `BuildOwner` may be fragile. So use no providers, no network calls and no animation. Keep the default in debug so developers still see the message. ## What to monitor afterwards - The count of framework-caught errors per release, separate from uncaught ones. The two hooks make this easy, since each can tag its source. - Grey boxes are **non-fatal** from the process's point of view, but they are real user-visible failures, so do not filter them out as noise. - `silent` errors, such as image loading failures, are a separate choice. Forward them at a lower severity or drop them, but decide on purpose.
- Why would a reporter wired only through a zone guard or PlatformDispatcher.instance.onError miss this error?Both see errors nobody caught. A build exception is caught by the element's rebuild code, reported through `FlutterError.reportError`, and replaced with an `ErrorWidget`, so it never becomes an uncaught error. Only `FlutterError.onError` sees it.
- Why does the report from a release build carry less context than the same error in debug?In debug the framework attaches the failing element's debug creator to the details, and `dumpErrorToConsole` renders a full diagnostics tree. In release the information collector adds less and the console gets only the label and stack. Forwarding `library` and the `context` text is what still tells you which widget was building.
- What should a custom ErrorWidget.builder avoid?Anything that can fail or do real work: reading providers or inherited state that may be mid-rebuild, starting network calls, or animating. It runs right after an exception inside build, so the API doc recommends the simplest leaf-like widget that tolerates any constraints.
saying these in an interview costs you the question
- A grey box means the app crashed and the reporter should have a fatal crash
- A zone guard alone catches build exceptions before the framework does
- Assigning FlutterError.onError twice runs both handlers
- Release builds skip FlutterError.onError to save work
- A rich ErrorWidget.builder that reads app state is a safe fallback