diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79e9baf..dbd951f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.
## [Unreleased]
+### Added
+
+- **`ext.dusk.snap` now surfaces captured non-fatal render/build FlutterErrors in a `renderErrors` block, and `dusk:snap` prints a `⚠ N render error(s)` banner to stderr while stdout stays the pure snapshot.** A widget that throws at build time (a `ParentDataWidget` misuse such as `flex-1`/`Expanded` placed under a `Semantics`/`WAnchor` instead of directly inside a Flex, or an overflow) can render partially and stay invisible in the semantics snapshot, so an action against it silently no-ops with no signal to the agent. The snapshot payload now carries `renderErrors: {count, recent: [{type, message}], hint}` (populated from the existing `FlutterError.onError` capture buffer, omitted entirely when clean), so a broken screen is impossible to miss without separately calling `ext.dusk.exceptions`. Touches `lib/src/extensions/ext_snapshot.dart`, `lib/src/commands/dusk_snap_command.dart`; covered by `test/src/extensions/ext_snapshot_render_errors_test.dart`.
+
+### Changed
+
+- **`ext.dusk.navigate` now tries the consumer navigate adapter (`DuskPlugin.navigateAdapter`, e.g. `MagicRoute.to`) BEFORE `Navigator.pushNamed`.** On a Router-only stack (go_router / auto_route) `Navigator.onGenerateRoute` is null, so `Navigator.pushNamed` raised an asynchronous "no corresponding route" `FlutterError` on every navigate. Because the failure was async, the handler's try/catch could not suppress it, and it landed in the FlutterError buffer, now doubly visible via the new `renderErrors` snapshot block as a false positive. Adapter-first dispatch routes through the app's own router public API (the correct path for these apps) and skips the throwing `Navigator.pushNamed` entirely; it remains the fallback for apps with no registered adapter. Touches `lib/src/extensions/ext_navigation.dart`.
+
---
## [0.0.8] - 2026-06-17
diff --git a/doc/commands/dusk-snap.md b/doc/commands/dusk-snap.md
index 66cef3c..762fd45 100644
--- a/doc/commands/dusk-snap.md
+++ b/doc/commands/dusk-snap.md
@@ -47,7 +47,7 @@ The flag is parsed via `(ctx.input.option('includeEnrichers') as bool?) ?? false
## Returns
-The VM Service handler returns a JSON envelope `{ "snapshot": "" }`. The CLI unwraps the `snapshot` field and writes the raw YAML to stdout; when the field is missing the entire JSON object is dumped instead.
+The VM Service handler returns a JSON envelope `{ "snapshot": "", "groupId": "" }`. The CLI unwraps the `snapshot` field and writes the raw YAML to stdout; when the field is missing the entire JSON object is dumped instead.
**Success envelope (illustrative):**
@@ -62,6 +62,43 @@ The `typeable: true` sub-line marks the single surviving `textbox` node after th
When `--includeEnrichers` is set, each entry gains indented lines contributed by the registered enrichers (see [Enricher fragments](#enricher-fragments)).
+### Render errors
+
+When the `FlutterError.onError` capture buffer holds non-fatal render or build errors (for example a `ParentDataWidget` misuse such as `Expanded` placed outside a `Flex`, or a layout overflow), the response includes a `renderErrors` block:
+
+```json
+{
+ "snapshot": "",
+ "groupId": "snapshot-1700000000000",
+ "renderErrors": {
+ "count": 2,
+ "recent": [
+ { "type": "FlutterError", "message": "Incorrect use of ParentDataWidget." },
+ { "type": "FlutterError", "message": "A RenderFlex overflowed by 32 pixels." }
+ ],
+ "hint": "Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for full messages + stack traces."
+ }
+}
+```
+
+Fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `count` | int | Total number of captured render/build errors. May exceed `recent.length`. |
+| `recent` | array (max 3) | Most recent entries; each has `type` (string) and `message` (first line only). |
+| `hint` | string | Fixed advisory pointing to `dusk:exceptions` (CLI) / `dusk_exceptions` (MCP) for full detail. |
+
+The `renderErrors` block is **omitted entirely** when no errors are in the buffer. A clean screen produces the standard two-key envelope (`snapshot` + `groupId`).
+
+**CLI banner:** when `renderErrors` is present, `dusk:snap` prints a render-error banner to **stderr** (stdout stays the pure snapshot text, so tooling that captures only stdout is unaffected):
+
+```
+⚠ 2 render error(s) captured on this screen (run dusk:exceptions for full detail):
+ - FlutterError: Incorrect use of ParentDataWidget.
+ - FlutterError: A RenderFlex overflowed by 32 pixels.
+```
+
**Error envelope:**
The VM Service handler propagates errors as `ServiceExtensionResponse.error(extensionError, message)`. The CLI surfaces the exception via `ArtisanContext.callExtension` and exits with a non-zero status. Typical failure modes:
diff --git a/doc/mcp/tool-reference.md b/doc/mcp/tool-reference.md
index 4c32e37..376e6ae 100644
--- a/doc/mcp/tool-reference.md
+++ b/doc/mcp/tool-reference.md
@@ -880,9 +880,31 @@ carrying `typeable: true` when calling `dusk_type`.
### Returns
-Success: a YAML document where each node is annotated with a `[ref=e]` token, its role,
-label, actions, bounds, optional `overflow: true` and `typeable: true` sub-lines, and any
-enricher-contributed indented lines.
+Success: `{ "snapshot": "", "groupId": "" }`. The `snapshot` value is a YAML
+document where each node is annotated with a `[ref=e]` token, its role, label, actions,
+bounds, optional `overflow: true` and `typeable: true` sub-lines, and any enricher-contributed
+indented lines.
+
+When non-fatal render or build `FlutterError`s are in the capture buffer (for example a
+`ParentDataWidget` misuse or a layout overflow), a `renderErrors` block is added to the
+response:
+
+```json
+{
+ "snapshot": "...",
+ "groupId": "snapshot-1700000000000",
+ "renderErrors": {
+ "count": 1,
+ "recent": [ { "type": "FlutterError", "message": "Incorrect use of ParentDataWidget." } ],
+ "hint": "Run dusk:exceptions for full messages + stack traces."
+ }
+}
+```
+
+`renderErrors` is omitted entirely when the screen is clean. `recent` contains at most 3
+entries, each with `type` (string) and `message` (first line of the error). `count` reflects
+the full buffer depth, which may exceed 3. Use `dusk_exceptions` to retrieve full messages and
+stack traces.
### Example call
diff --git a/lib/src/commands/dusk_snap_command.dart b/lib/src/commands/dusk_snap_command.dart
index 76629ec..e20a6ac 100644
--- a/lib/src/commands/dusk_snap_command.dart
+++ b/lib/src/commands/dusk_snap_command.dart
@@ -37,6 +37,24 @@ class DuskSnapCommand extends ArtisanCommand {
'ext.dusk.snap',
params,
);
+ // Surface captured render/build FlutterErrors so a silently-broken widget
+ // (e.g. a ParentDataWidget misuse that makes a button render but ignore
+ // taps) is impossible to miss. This is a diagnostic, not snapshot payload,
+ // so it goes to stderr via ctx.output.error: stdout stays the pure snapshot
+ // for tooling that captures only the snapshot text. Full detail lives in
+ // dusk:exceptions (CLI) / dusk_exceptions (MCP).
+ final renderErrors = result['renderErrors'] as Map?;
+ if (renderErrors != null) {
+ final count = renderErrors['count'];
+ ctx.output.error('⚠ $count render error(s) captured on this screen '
+ '(run dusk:exceptions / dusk_exceptions for full detail):');
+ final recent = renderErrors['recent'] as List? ?? const [];
+ for (final e in recent) {
+ final entry = e as Map;
+ ctx.output.error(' - ${entry['type']}: ${entry['message']}');
+ }
+ }
+
final snapshot = result['snapshot'] as String? ?? jsonEncode(result);
ctx.output.writeln(snapshot);
return 0;
diff --git a/lib/src/dusk_artisan_provider.dart b/lib/src/dusk_artisan_provider.dart
index d516c66..4575c44 100644
--- a/lib/src/dusk_artisan_provider.dart
+++ b/lib/src/dusk_artisan_provider.dart
@@ -149,6 +149,13 @@ class DuskArtisanProvider extends ArtisanServiceProvider {
'signal only; call dusk_exceptions for the full non-fatal '
'error history including overflow details.\n'
'\n'
+ 'When non-fatal render/build FlutterErrors have been captured '
+ '(e.g. a ParentDataWidget misuse that makes a button render but '
+ 'ignore taps), the response adds a `renderErrors` block '
+ '(count + recent messages). Treat a non-zero count as the likely '
+ 'reason an action against a visible widget silently no-ops; call '
+ 'dusk_exceptions for the full message + stack.\n'
+ '\n'
'Usage:\n'
'- Call before any dusk_* action tool; refs become stale '
'after navigation, modal open/close, or significant widget '
diff --git a/lib/src/extensions/ext_navigation.dart b/lib/src/extensions/ext_navigation.dart
index dc5d620..ca35c6c 100644
--- a/lib/src/extensions/ext_navigation.dart
+++ b/lib/src/extensions/ext_navigation.dart
@@ -203,59 +203,56 @@ Future extDuskNavigateHandler(
// 2. Dismiss open modal overlays so navigation is unobstructed.
await dismissAllModals();
- // 3. Push the route. Try Navigator 1.0 (pushNamed via onGenerateRoute)
- // first; on failure (typically Navigator.onGenerateRoute is null
- // because the app uses a Router-based stack like go_router / auto_route),
- // fall back to the platform-channel route-information broadcast which
- // every Router-backed delegate listens to.
- final Element? root = WidgetsBinding.instance.rootElement;
+ // 3. Push the route. Prefer a consumer-registered navigate adapter
+ // (typically MagicRoute.to wired in host main.dart) when present: it
+ // dispatches through the app's own router public API (go_router /
+ // auto_route), which is the correct path for Router-based apps and
+ // avoids the spurious "no corresponding route" FlutterError that
+ // `Navigator.pushNamed` raises on a Router-only stack (its
+ // `onGenerateRoute` is null there, and the failure is asynchronous so a
+ // try/catch around the call cannot suppress it; it lands in the
+ // FlutterError buffer and pollutes ext.dusk.snap / ext.dusk.exceptions).
+ // `pushed = true` means dispatch attempted, NOT that the route was
+ // honored; the URL verify below is the source of truth.
bool pushed = false;
- if (root != null) {
- final NavigatorState? navigator = _findNavigator(root);
- if (navigator != null) {
- try {
- // Fire-and-forget. `Navigator.pushNamed` returns a Future that
- // completes when the pushed route is POPPED, not when it lands —
- // awaiting it would block this handler until the agent navigates
- // away, which deadlocks any test (and any test-like context) that
- // never pops. The push itself happens synchronously inside the
- // call; the post-dispatch endOfFrame ticks below guarantee the
- // new route is mounted before we URL-verify.
- unawaited(navigator.pushNamed(route));
- pushed = true;
- } catch (e) {
- // Navigator.onGenerateRoute null (go_router stack). Fall through to
- // the cross-router platform channel below.
- developer.log(
- '[fluttersdk_dusk] extDuskNavigateHandler: Navigator.pushNamed '
- 'failed for "$route" ($e); falling back to '
- 'SystemNavigator.routeInformationUpdated.',
- name: 'dusk',
- );
- }
+ final adapter = DuskPlugin.navigateAdapter;
+ if (adapter != null) {
+ try {
+ pushed = await adapter(route);
+ } catch (e) {
+ developer.log(
+ '[fluttersdk_dusk] extDuskNavigateHandler: navigateAdapter '
+ 'threw for "$route" ($e); falling back to Navigator / '
+ 'SystemNavigator.routeInformationUpdated.',
+ name: 'dusk',
+ );
}
}
+
+ // Fallback for apps WITHOUT a registered adapter: Navigator 1.0 pushNamed.
if (!pushed) {
- // Consumer-registered adapter (typically MagicRoute.to wired in
- // host main.dart). Cleanest dispatch path for app frameworks that
- // own their own router (Magic / GoRouter with custom
- // RouteInformationProvider), because the host pushes through the
- // router's public API instead of platform-channel broadcasts the
- // delegate may not be listening to. `pushed = true` means dispatch
- // attempted, NOT that the route was honored — URL verify below is
- // the source of truth (the adapter has no way to report whether
- // the router accepted or silently dropped the path).
- final adapter = DuskPlugin.navigateAdapter;
- if (adapter != null) {
- try {
- pushed = await adapter(route);
- } catch (e) {
- developer.log(
- '[fluttersdk_dusk] extDuskNavigateHandler: navigateAdapter '
- 'threw for "$route" ($e); falling back to '
- 'SystemNavigator.routeInformationUpdated.',
- name: 'dusk',
- );
+ final Element? root = WidgetsBinding.instance.rootElement;
+ if (root != null) {
+ final NavigatorState? navigator = _findNavigator(root);
+ if (navigator != null) {
+ try {
+ // Fire-and-forget. `Navigator.pushNamed` returns a Future that
+ // completes when the pushed route is POPPED, not when it lands.
+ // Awaiting it would block this handler until the agent navigates
+ // away, which deadlocks any test that never pops. The push itself
+ // happens synchronously inside the call; the post-dispatch
+ // endOfFrame ticks below guarantee the new route is mounted before
+ // we URL-verify.
+ unawaited(navigator.pushNamed(route));
+ pushed = true;
+ } catch (e) {
+ developer.log(
+ '[fluttersdk_dusk] extDuskNavigateHandler: Navigator.pushNamed '
+ 'failed for "$route" ($e); falling back to '
+ 'SystemNavigator.routeInformationUpdated.',
+ name: 'dusk',
+ );
+ }
}
}
}
diff --git a/lib/src/extensions/ext_snapshot.dart b/lib/src/extensions/ext_snapshot.dart
index 6af8a36..a60ed9a 100644
--- a/lib/src/extensions/ext_snapshot.dart
+++ b/lib/src/extensions/ext_snapshot.dart
@@ -8,6 +8,7 @@ import 'package:flutter/widgets.dart';
import 'package:fluttersdk_artisan/artisan.dart';
import 'package:fluttersdk_wind_diagnostics_contracts/fluttersdk_wind_diagnostics_contracts.dart';
+import '../dusk_error_capture.dart';
import '../dusk_plugin.dart';
import '../ref_registry.dart';
import '../utils/error_envelope.dart';
@@ -45,6 +46,18 @@ import '../utils/error_envelope.dart';
/// ```json
/// { "snapshot": "", "groupId": "snapshot-1700000000000" }
/// ```
+///
+/// When non-fatal render/build FlutterErrors have been captured (ParentDataWidget
+/// misuse, overflow, etc.), a `renderErrors` block is added so a silently-broken
+/// widget is visible without a separate `ext.dusk.exceptions` call. Omitted when
+/// clean:
+///
+/// ```json
+/// { "snapshot": "", "groupId": "...",
+/// "renderErrors": { "count": 1,
+/// "recent": [ { "type": "FlutterError", "message": "Incorrect use of ..." } ],
+/// "hint": "Run dusk:exceptions (CLI) or dusk_exceptions (MCP) for full messages + stack traces." } }
+/// ```
void registerSnapExtension() {
if (!kDebugMode) return;
@@ -125,9 +138,32 @@ Future