Files
plezy/test/focus/focusable_wrapper_test.dart
T
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

68 lines
2.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
/// The wrapper's focus chrome (scale Transform + border AnimatedContainer)
/// must only exist in keyboard/d-pad mode: on touch it is pure dead weight
/// multiplied by every card in a grid (see library scroll jank).
void main() {
Finder chromeIn(Type type) =>
find.descendant(of: find.byType(FocusableWrapper), matching: find.byType(type));
Widget buildWrapper() => Scaffold(
body: FocusableWrapper(
onSelect: () {},
child: const SizedBox(width: 10, height: 10),
),
);
testWidgets('pointer mode builds no focus chrome around the child', (tester) async {
await tester.pumpWidget(MaterialApp(home: buildWrapper()));
expect(chromeIn(Transform), findsNothing);
expect(chromeIn(AnimatedContainer), findsNothing);
// The Focus node stays mounted so d-pad traversal finds the card the
// moment keyboard mode activates.
expect(chromeIn(Focus), findsWidgets);
});
testWidgets('keyboard mode builds the scale/border chrome', (tester) async {
await tester.pumpWidget(InputModeTracker(child: MaterialApp(home: buildWrapper())));
// A navigation key press flips the tracker into keyboard mode.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(chromeIn(Transform), findsOneWidget);
expect(chromeIn(AnimatedContainer), findsOneWidget);
});
testWidgets('focusing in pointer mode works without a pre-built controller', (tester) async {
final node = FocusNode(debugLabel: 'card');
addTearDown(node.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableWrapper(
focusNode: node,
onSelect: () {},
child: const SizedBox(width: 10, height: 10),
),
),
),
);
// The AnimationController is created lazily on first focus; gaining and
// losing focus in pointer mode must not throw.
node.requestFocus();
await tester.pump();
node.unfocus();
await tester.pump();
expect(tester.takeException(), isNull);
});
}