From eb7776bab7b181fcd10bddaf96393ac59215b36a Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Tue, 27 Oct 2026 02:51:32 +0700 Subject: [PATCH] fix: widgets on a kept-alive page that is not showing get no outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sliver list does not paint a kept-alive child outside its viewport, such as another tab of a TabBarView, and gives it a zero paint transform. Its global position then comes out NaN, and a NaN rect passes Rect.overlaps, so every on-screen check took it as visible. On the Chat App demo (six kept-alive tabs) the Oversized Images crosshair started on an image of another tab, the bar said "Showing Image · 1 of 61" with 61 counting every tab, and the outline's label sat at the screen's bottom edge. Read on an iPhone 12: every image of the tabs not shown had a NaN rect. getGlobalRect returns null for a rect that is not finite, as it does for one it cannot measure; its 19 callers already skip null, so those widgets get no highlight, marker or step. SimpleStructuralDetector measured with localToGlobal directly and now goes through getGlobalRect. Three tests build a TabBarView with kept-alive pages: getGlobalRect, the painter detector and a custom detector each leave the page that is not showing out, and each fails without the fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ CLAUDE.md | 4 +- doc/release_notes/1.0.0.md | 7 +++ .../models/simple_structural_detector.dart | 7 +-- lib/src/utils/widget_location.dart | 8 ++- .../custom_painter_detector_test.dart | 45 +++++++++++++++++ .../simple_structural_detector_test.dart | 41 +++++++++++++++ test/utils/widget_location_test.dart | 50 ++++++++++++++++++- 8 files changed, 159 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d10136..07b4ddff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ list every change. - Ancestor chains, fix hints and the card's Where row name each widget's own `file:line`. A widget type built in several places reported the first place Sleuth saw. +- Widgets on a kept-alive page that is not showing, such as another tab + of a `TabBarView`, get no outline or marker. Their position read as NaN, + so stepping through a card's widgets could start on one, say "Showing", + and count every tab's widgets. ## 0.38.0 diff --git a/CLAUDE.md b/CLAUDE.md index b690c429..283ecbc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Runtime performance diagnostics package for Flutter mobile apps. 21 detectors ac ```bash # Always use fvm for all Flutter/Dart commands -fvm flutter test --exclude-tags benchmark # Default run (~5,099 tests; wall-clock benchmarks excluded) +fvm flutter test --exclude-tags benchmark # Default run (~5,102 tests; wall-clock benchmarks excluded) fvm flutter test --tags benchmark --concurrency=1 # Wall-clock benchmarks, serial (44 tests) fvm flutter test test/detectors/ # Run detector tests only fvm flutter analyze # Static analysis (must be 0 issues) @@ -81,7 +81,7 @@ test/ **v1.0.0 API**: `lib/sleuth.dart` exports with `show` lists only (62 names + `Sleuth`); `lib/validation.dart` holds `EvidenceTier`, `DetectorMetadata`(+Provider), `BracketSpec`, `ComponentMetadata`(+Provider), `ProfileCaptureSchema`, `PollTimings`, `StreamResourcePollResult`, `kSleuthPackageVersion` and Repaint/MemoryPressure/Startup/TrackedResource/ImageMemory/GpuPressure/FrameTiming detectors, all `@experimental` (example `analysis_options.yaml` ignores `experimental_member_use`); 15 internal names hidden (tests import `src/`). `StartupTracker` (`lib/src/startup/`) owns startup state (`metrics`, `dartEntryMonotonicUs`, `enrichWithVmData`); `ActiveController.current` / `release` replace `Sleuth._controller` / `notifyControllerDisposed`. Capture statics on `Sleuth` `@experimental`, test statics `@visibleForTesting`. `RouteSession.end()` / `countScanCycle()` `@internal`. Theme: 29 unread tokens + `confidenceColor`/`effortColor`/`sourceAccentColor` removed. Growing enums documented. `test/validation/public_api_test.dart` pins names against `test/validation/_fixtures/public_api_1_0_0.txt` (80 names); CI `api-diff` job: `dart pub unpack` newest 1.x, drop `lib/validation.dart` from both trees, `dart_apitool` 0.23.2 `--version-check-mode onlyBreakingChanges` (fails only a breaking change without a major bump; `fully` also failed minor changes) (added enum value = minor `CF02`; removing an `@experimental` top-level is still breaking), skips before 1.0.0 is on pub.dev, fails on a pub.dev error. API table `doc/api_1_0_0.md`. -**v1.0.0 overlay**: header run label ("Profile · VM", dot filled with VM / amber ring without) + `MoreMenu` (Startup, Rebuild stats, Hidden issues, Encyclopedia, Guide, Copy snapshot, Theme Auto/Light/Dark); severity chips lead the status row (turning one off is refused when no card would remain; the frame rate and the chipless row label are semantics containers); Rebuild stats reads "Off" unless `SleuthController.countsRouteRebuilds` (profile + `enableDeepDebugInstrumentation`); 320 px min width; no footer. `IssueCard`: `SleuthSeverityIcon` (light presets outline warning/OK via `edgeOf`), meta line (category · confidence or Debug timing tag · "seen X of Y scans, label" · effects, Jank/New tags), crosshair 48 px. Expanded: Measured row with `MeasurementGauge` (scale 1.25 × the higher line, 100 for a percent; a value past it fills the track with two slanted cuts; a debug rate critical on its latest window, `HeldRate.latestRate` → `extraTraceArgs.latestRatePerSec`, reads "Critical: the latest second went over the line" instead of the held note), Where (`file:line` per element) / Route / When / Evidence ("... Sleuth rates it possible, since ...") / Check in rows, Copy details and the Ask AI prompt add `measuredSentence` (`copyDetailsText`), Fix with code blocks, buttons Show on screen / Learn more / Ask AI / Copy details / Hide / Not useful, Why it rebuilds (`DebugSnapshot.forcedRebuildsOf`). `status_lines.dart`: Starting / Warming up / No VM link / No issues found / VM link lost / stopped retrying (no VM-lost toast), one debug banner. Highlight flow: crosshair → `selectHighlightForIssue` (`HighlightLookup` found/notFound/pending, no category fallback) → card shrinks to `SleuthHighlightBar` ("Showing X" / "X is off screen", Back to issues, close; edge away from the widget, shield for a double tap, `highlightBarAtTopNotifier` for the label); outline only (markers = "Mark flagged widgets" toggle), in the target issue's severity (`highlightTargetSeverity`), not painted while the widget is off screen; an issue with an `occurrenceId` takes only its element's highlight, a name match skips list-tagged highlights; with the bar up Escape leaves an app dialog or sheet to the app; controller collects highlights while `_highlightTarget` / pending. List cards (`SleuthController.listsWidgets`: always_repaint_painter, missing_repaint_boundary, layout_bottleneck, uncached_images, expensive_gpu_nodes) step through highlights tagged `WidgetHighlight.stableId` + element `occurrenceId`: first on screen, `highlightStepNotifier` (shown place frozen between Next/lookup, refreshed on 0/1/many count change), `nextHighlightStep` (off screen → widget now at its place), `canStepNext`; bar shows "· 1 of 3" + Next and a second row only with Next (GlobalKey keeps Back to issues), shields its old rect when it moves or shrinks. Card list: each card sits in `_CardSlot`, which keeps its `IssueCard` widget while `IssueCard.sameInputsAs` holds (every constructor field; issues by `utils/issue_fingerprint.dart` `issueFingerprint` + `occurrenceId`, callbacks by presence; `issue_card_test` reads the constructor to pin the list) and `_cardEpoch` stays (moves on severity filter / hidden-key change, a change of the shown `(listKey, occurrenceId)` set, `didUpdateWidget`, `didChangeDependencies`, `reassemble`: what `_relatedLinkOf` reads while a card builds); kept cards' callbacks act on the row's latest report (`latest()`). `OverlayUiState.schemaVersion` 2: `loadJson` maps a version 1 hide key without a widget name to an any-widget key (`legacyHideKey`, `base|*` / `base|*!critical`), which `isHiddenBy` / `hideKeysOf` / the Hidden page match for every widget of that id. The Hidden page labels keys with `hideKeyLabel` (`id · Widget`, `id · every widget`, critical suffix left out). Jank tag: `SleuthController.heldJankCauses()` (issues a verdict named in the last `FrameTimingDetector.jankWindow`, recorded by a `verdictNotifier` listener on the controller's monotonic clock or `clockOverrideForTest`, cleared on a route change beside `markRouteEpoch`, so the hold survives closing the dashboard), shown only while a jank symptom is reported; the card arms one timer for `nextExpiry`. Toasts in inverted theme colours (`toastColorsOf`). Example `ext.sleuthDemo.overlay` actions `menu` / `notUseful`. The 1.0 card replaced 0.37 internals named in the v0.37.0 paragraphs below: the frozen zone is `_frozen` (`List`, built by `applyFreezeZone` and `_expandInZone`) in place of `_expandedIndices` / `_orderSnapshot` / `_repointExpansions`; the status row's `_SeverityChip`s replace `_IssuesSummaryBar` / `_SeverityChipPill`; the meta line replaces `_badgeLine` / `_inlineBadgesWidth`; `_LeadTrailLine` is gone. Example `FileSleuthStateStore` writes under `overlayStateDirectory` (Android `files` beside `code_cache`, iOS `Library/Application Support` beside `tmp`; installs empty `code_cache`). +**v1.0.0 overlay**: header run label ("Profile · VM", dot filled with VM / amber ring without) + `MoreMenu` (Startup, Rebuild stats, Hidden issues, Encyclopedia, Guide, Copy snapshot, Theme Auto/Light/Dark); severity chips lead the status row (turning one off is refused when no card would remain; the frame rate and the chipless row label are semantics containers); Rebuild stats reads "Off" unless `SleuthController.countsRouteRebuilds` (profile + `enableDeepDebugInstrumentation`); 320 px min width; no footer. `IssueCard`: `SleuthSeverityIcon` (light presets outline warning/OK via `edgeOf`), meta line (category · confidence or Debug timing tag · "seen X of Y scans, label" · effects, Jank/New tags), crosshair 48 px. Expanded: Measured row with `MeasurementGauge` (scale 1.25 × the higher line, 100 for a percent; a value past it fills the track with two slanted cuts; a debug rate critical on its latest window, `HeldRate.latestRate` → `extraTraceArgs.latestRatePerSec`, reads "Critical: the latest second went over the line" instead of the held note), Where (`file:line` per element) / Route / When / Evidence ("... Sleuth rates it possible, since ...") / Check in rows, Copy details and the Ask AI prompt add `measuredSentence` (`copyDetailsText`), Fix with code blocks, buttons Show on screen / Learn more / Ask AI / Copy details / Hide / Not useful, Why it rebuilds (`DebugSnapshot.forcedRebuildsOf`). `status_lines.dart`: Starting / Warming up / No VM link / No issues found / VM link lost / stopped retrying (no VM-lost toast), one debug banner. Highlight flow: crosshair → `selectHighlightForIssue` (`HighlightLookup` found/notFound/pending, no category fallback) → card shrinks to `SleuthHighlightBar` ("Showing X" / "X is off screen", Back to issues, close; edge away from the widget, shield for a double tap, `highlightBarAtTopNotifier` for the label); outline only (markers = "Mark flagged widgets" toggle), in the target issue's severity (`highlightTargetSeverity`), not painted while the widget is off screen; an issue with an `occurrenceId` takes only its element's highlight, a name match skips list-tagged highlights; with the bar up Escape leaves an app dialog or sheet to the app; controller collects highlights while `_highlightTarget` / pending. List cards (`SleuthController.listsWidgets`: always_repaint_painter, missing_repaint_boundary, layout_bottleneck, uncached_images, expensive_gpu_nodes) step through highlights tagged `WidgetHighlight.stableId` + element `occurrenceId`: first on screen, `highlightStepNotifier` (shown place frozen between Next/lookup, refreshed on 0/1/many count change), `nextHighlightStep` (off screen → widget now at its place), `canStepNext`; bar shows "· 1 of 3" + Next and a second row only with Next (GlobalKey keeps Back to issues), shields its old rect when it moves or shrinks. Card list: each card sits in `_CardSlot`, which keeps its `IssueCard` widget while `IssueCard.sameInputsAs` holds (every constructor field; issues by `utils/issue_fingerprint.dart` `issueFingerprint` + `occurrenceId`, callbacks by presence; `issue_card_test` reads the constructor to pin the list) and `_cardEpoch` stays (moves on severity filter / hidden-key change, a change of the shown `(listKey, occurrenceId)` set, `didUpdateWidget`, `didChangeDependencies`, `reassemble`: what `_relatedLinkOf` reads while a card builds); kept cards' callbacks act on the row's latest report (`latest()`). `OverlayUiState.schemaVersion` 2: `loadJson` maps a version 1 hide key without a widget name to an any-widget key (`legacyHideKey`, `base|*` / `base|*!critical`), which `isHiddenBy` / `hideKeysOf` / the Hidden page match for every widget of that id. The Hidden page labels keys with `hideKeyLabel` (`id · Widget`, `id · every widget`, critical suffix left out). Jank tag: `SleuthController.heldJankCauses()` (issues a verdict named in the last `FrameTimingDetector.jankWindow`, recorded by a `verdictNotifier` listener on the controller's monotonic clock or `clockOverrideForTest`, cleared on a route change beside `markRouteEpoch`, so the hold survives closing the dashboard), shown only while a jank symptom is reported; the card arms one timer for `nextExpiry`. Toasts in inverted theme colours (`toastColorsOf`). Example `ext.sleuthDemo.overlay` actions `menu` / `notUseful`. `getGlobalRect` returns null for a non-finite rect (a kept-alive page that is not painted, such as another `TabBarView` tab, maps to NaN), so those widgets get no highlight; `SimpleStructuralDetector` uses it too. The 1.0 card replaced 0.37 internals named in the v0.37.0 paragraphs below: the frozen zone is `_frozen` (`List`, built by `applyFreezeZone` and `_expandInZone`) in place of `_expandedIndices` / `_orderSnapshot` / `_repointExpansions`; the status row's `_SeverityChip`s replace `_IssuesSummaryBar` / `_SeverityChipPill`; the meta line replaces `_badgeLine` / `_inlineBadgesWidth`; `_LeadTrailLine` is gone. Example `FileSleuthStateStore` writes under `overlayStateDirectory` (Android `files` beside `code_cache`, iOS `Library/Application Support` beside `tmp`; installs empty `code_cache`). **v0.38.0**: detectors-only release on top of 0.37.0. Jank is a missed deadline with Sleuth's own start delay excluded, the VM timeline comes from the `Timeline` stream outside capture mode, new `event_loop_stall`, `material_ui` / `cupertino_ui` support, GC counted once per collection and young-only, `heap_growing` floor leg, 12 more runtimeVerified pairs (27 across 19 stable ids). Sidecar `sleuth_mcp` 0.9.0. The paragraphs below hold the detail per area. diff --git a/doc/release_notes/1.0.0.md b/doc/release_notes/1.0.0.md index 87ce42df..8148669f 100644 --- a/doc/release_notes/1.0.0.md +++ b/doc/release_notes/1.0.0.md @@ -356,6 +356,13 @@ The full list of changes in sleuth 1.0.0. [CHANGELOG.md](../../CHANGELOG.md) has outlined in its place. When the widget is not on screen a toast says "Couldn't find on screen. It may be scrolled away or not built yet.", and no outline appears later. +- Widgets on a kept-alive page that is not showing, such as another tab + of a `TabBarView`, get no outline, marker or step. Flutter does not + paint such a page, so its widgets' global position came out NaN, which + passed every on-screen check. Stepping through Oversized Images on a + tabbed screen could start on another tab's image, say "Showing Image" + and count the images of every tab. `getGlobalRect` now returns null + for a rect that is not finite, as for one it cannot measure. ### Theme diff --git a/lib/src/models/simple_structural_detector.dart b/lib/src/models/simple_structural_detector.dart index 75c1995a..67170768 100644 --- a/lib/src/models/simple_structural_detector.dart +++ b/lib/src/models/simple_structural_detector.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import '../utils/widget_location.dart' show getGlobalRect; import 'base_detector.dart'; import 'issue_measurement.dart'; import 'performance_issue.dart'; @@ -190,11 +191,11 @@ abstract class SimpleStructuralDetector extends BaseDetector { ); final ro = element.renderObject; - if (ro is RenderBox && ro.hasSize && ro.attached) { - final offset = ro.localToGlobal(Offset.zero); + final rect = ro != null && ro.attached ? getGlobalRect(ro) : null; + if (ro != null && rect != null) { _highlights.add( WidgetHighlight( - rect: offset & ro.size, + rect: rect, renderObject: ro, occurrenceId: identityHashCode(element), widgetName: widgetName, diff --git a/lib/src/utils/widget_location.dart b/lib/src/utils/widget_location.dart index e44d5c2f..05483011 100644 --- a/lib/src/utils/widget_location.dart +++ b/lib/src/utils/widget_location.dart @@ -129,10 +129,16 @@ String? extractPackageNameFromElement(Element element) { } /// Get the global bounding rect for a RenderObject, or null if unavailable. +/// +/// Also null for a box that is not painted. A sliver list gives a kept-alive +/// child outside its viewport (a `TabBarView` page that is not showing) a +/// zero paint transform, so its global position comes out NaN, and a NaN +/// rect passes every `overlaps` check. Rect? getGlobalRect(RenderObject ro) { try { if (ro is RenderBox && ro.hasSize) { - return ro.localToGlobal(Offset.zero) & ro.size; + final rect = ro.localToGlobal(Offset.zero) & ro.size; + return rect.isFinite ? rect : null; } } catch (e, s) { assert(() { diff --git a/test/detectors/custom_painter_detector_test.dart b/test/detectors/custom_painter_detector_test.dart index 0b4b20bd..9a20afc6 100644 --- a/test/detectors/custom_painter_detector_test.dart +++ b/test/detectors/custom_painter_detector_test.dart @@ -98,6 +98,29 @@ void main() { expect(detector.issues.first.title, contains('2 found')); }); + testWidgets('a kept-alive tab page that is not showing gets no ' + 'highlight', (tester) async { + final tabs = TabController(length: 2, vsync: const TestVSync()); + addTearDown(tabs.dispose); + await tester.pumpWidget( + MaterialApp( + home: TabBarView( + controller: tabs, + children: const [_KeptAlivePainterPage(), _KeptAlivePainterPage()], + ), + ), + ); + tabs.index = 1; + await tester.pumpAndSettle(); + detector.scanTree(tester.element(find.byType(MaterialApp))); + + // Both painters are reported, but only the shown page's has a place + // on screen to outline. + expect(detector.issues, isNotEmpty); + expect(detector.highlights, hasLength(1)); + expect(detector.highlights.single.rect.isFinite, isTrue); + }); + testWidgets('highlights produced per always-repaint painter', ( tester, ) async { @@ -694,6 +717,28 @@ void main() { } /// Painter that always returns true from shouldRepaint(self). +class _KeptAlivePainterPage extends StatefulWidget { + const _KeptAlivePainterPage(); + + @override + State<_KeptAlivePainterPage> createState() => _KeptAlivePainterPageState(); +} + +class _KeptAlivePainterPageState extends State<_KeptAlivePainterPage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return CustomPaint( + painter: _AlwaysRepaintPainter(), + child: const SizedBox.expand(), + ); + } +} + class _AlwaysRepaintPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) {} diff --git a/test/models/simple_structural_detector_test.dart b/test/models/simple_structural_detector_test.dart index 39087afb..e9a12207 100644 --- a/test/models/simple_structural_detector_test.dart +++ b/test/models/simple_structural_detector_test.dart @@ -101,6 +101,28 @@ void main() { expect(detector.issues.single.measurement, measured); }); + testWidgets('a kept-alive tab page that is not showing reports without ' + 'a highlight', (tester) async { + final tabs = TabController(length: 2, vsync: const TestVSync()); + addTearDown(tabs.dispose); + await tester.pumpWidget( + MaterialApp( + home: TabBarView( + controller: tabs, + children: const [_KeptAlivePlaceholder(), _KeptAlivePlaceholder()], + ), + ), + ); + tabs.index = 1; + await tester.pumpAndSettle(); + final detector = _PlaceholderDetector(); + detector.scanTree(tester.element(find.byType(MaterialApp))); + + expect(detector.issues, hasLength(2)); + expect(detector.highlights, hasLength(1)); + expect(detector.highlights.single.rect.isFinite, isTrue); + }); + testWidgets('issues and highlights clear between scans', (tester) async { await tester.pumpWidget( const Directionality( @@ -269,3 +291,22 @@ class _ThrowingDetector extends SimpleStructuralDetector { super.finalizeScan(); } } + +class _KeptAlivePlaceholder extends StatefulWidget { + const _KeptAlivePlaceholder(); + + @override + State<_KeptAlivePlaceholder> createState() => _KeptAlivePlaceholderState(); +} + +class _KeptAlivePlaceholderState extends State<_KeptAlivePlaceholder> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return const Placeholder(); + } +} diff --git a/test/utils/widget_location_test.dart b/test/utils/widget_location_test.dart index a3673a5d..9d3b717a 100644 --- a/test/utils/widget_location_test.dart +++ b/test/utils/widget_location_test.dart @@ -2,7 +2,7 @@ // (fires only on subclassing). Remove when analyzer-server recognizes the // implement-only kind. // ignore_for_file: deprecated_member_use -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/utils/widget_location.dart'; @@ -191,6 +191,35 @@ void main() { final rect = getGlobalRect(_FakeRenderObject()); expect(rect, isNull); }); + + testWidgets('returns null for a kept-alive tab page that is not showing', ( + tester, + ) async { + final tabs = TabController(length: 2, vsync: const TestVSync()); + addTearDown(tabs.dispose); + await tester.pumpWidget( + MaterialApp( + home: TabBarView( + controller: tabs, + children: const [ + _KeptAlivePage(key: Key('page-0')), + _KeptAlivePage(key: Key('page-1')), + ], + ), + ), + ); + tabs.index = 1; + await tester.pumpAndSettle(); + + // The first page stays built, but its list no longer paints it, so + // its global position would come out NaN. + final hidden = tester.renderObject( + find.byKey(const Key('page-0'), skipOffstage: false), + ); + final shown = tester.renderObject(find.byKey(const Key('page-1'))); + expect(getGlobalRect(hidden), isNull); + expect(getGlobalRect(shown), isNotNull); + }); }); } @@ -259,3 +288,22 @@ class _FakeRenderObject extends RenderObject { @override Rect get semanticBounds => Rect.zero; } + +class _KeptAlivePage extends StatefulWidget { + const _KeptAlivePage({super.key}); + + @override + State<_KeptAlivePage> createState() => _KeptAlivePageState(); +} + +class _KeptAlivePageState extends State<_KeptAlivePage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return const SizedBox.expand(); + } +}