Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<FrozenCard>`, 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<FrozenCard>`, 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.

Expand Down
7 changes: 7 additions & 0 deletions doc/release_notes/1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <widget> 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

Expand Down
7 changes: 4 additions & 3 deletions lib/src/models/simple_structural_detector.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion lib/src/utils/widget_location.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(() {
Expand Down
45 changes: 45 additions & 0 deletions test/detectors/custom_painter_detector_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {}
Expand Down
41 changes: 41 additions & 0 deletions test/models/simple_structural_detector_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}
}
Loading
Loading