Files
plezy/test/widgets/card_inflation_budget_test.dart
edde746 11f7fd766d perf(library): cut scroll jank in card grids and hub rows
Profile traces showed 100-370ms UI-thread frames while scrolling the
library screen, dominated by rebuilding and inflating media cards.

- Gate per-card focus/pointer chrome on input mode: FocusableWrapper
  skips the scale/border wrappers and creates its AnimationController
  lazily outside keyboard mode, and ClickableCursor plus the card tap
  region only build MouseRegion/InkWell machinery on desktop - TV and
  touch use a bare GestureDetector. Hub cards also drop their outer
  gesture wrapper outside keyboard mode; the card's own tap region
  always won the gesture arena anyway.

- Memoize sliver children (SliverChildMemo): browse/collections grids
  and hub rows return identical widget instances for unchanged items,
  so delegate swaps from pagination, watch-state, and deletion
  setStates no longer rebuild every realized card inside layout. The
  browse tab prunes the memo in lockstep with focus-node eviction so a
  cached card can never resurrect a disposed FocusNode.

- Budget fresh inflation (CardInflationBudget): while a scrollable is
  moving in pointer/touch mode at most one new card inflates per frame,
  the rest render as SkeletonMediaCard and upgrade on following frames.
  Hub rows also stop pre-inflating 250px of off-screen cards on entry.

Device traces: worst frame 373ms -> 103ms, per-card build 3.6ms ->
2.4ms median; remaining row-entry work is spread across frames.
2026-07-05 02:23:42 +02:00

63 lines
1.8 KiB
Dart

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/card_inflation_budget.dart';
class _UpgradeHost extends StatefulWidget {
const _UpgradeHost();
@override
State<_UpgradeHost> createState() => _UpgradeHostState();
}
class _UpgradeHostState extends State<_UpgradeHost> with SkeletonUpgradeScheduler {
int builds = 0;
int pendingSkeletons = 2;
@override
Widget build(BuildContext context) {
builds++;
if (pendingSkeletons > 0) {
pendingSkeletons--;
scheduleSkeletonUpgrade();
}
return const SizedBox();
}
}
void main() {
setUp(CardInflationBudget.reset);
testWidgets('budget grants maxPerFrame slots and resets on the next frame', (tester) async {
await tester.pumpWidget(const SizedBox());
for (var i = 0; i < CardInflationBudget.maxPerFrame; i++) {
expect(CardInflationBudget.tryTake(), isTrue);
}
expect(CardInflationBudget.tryTake(), isFalse);
// In production tryTake only runs during builds, where a frame is in
// flight; here the takes happened between frames, so schedule one for
// the post-frame reset to ride on.
tester.binding.scheduleFrame();
await tester.pump();
expect(CardInflationBudget.tryTake(), isTrue);
});
testWidgets('skeleton upgrade chain re-arms per frame and stops when drained', (tester) async {
await tester.pumpWidget(const _UpgradeHost());
final state = tester.state<_UpgradeHostState>(find.byType(_UpgradeHost));
expect(state.builds, 1);
// Each pump runs the post-frame setState, upgrading one pending skeleton.
await tester.pump();
expect(state.builds, 2);
await tester.pump();
expect(state.builds, 3);
// Drained: no reschedule, no further rebuilds.
await tester.pump();
expect(state.builds, 3);
});
}