Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/flutter_hooks/lib/src/hooks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ part 'scroll_controller.dart';
part 'search_controller.dart';
part 'tab_controller.dart';
part 'text_controller.dart';
part 'throttled.dart';
part 'transformation_controller.dart';
part 'tree_sliver_controller.dart';
part 'widget_states_controller.dart';
Expand Down
42 changes: 42 additions & 0 deletions packages/flutter_hooks/lib/src/throttled.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
part of 'hooks.dart';

/// widget ignore updates accordingly after a specified [duration] duration.
///
/// Example:
/// ```dart
/// String userInput = ''; // Your input value
///
/// // Create a throttle callback
/// final throttle = useThrottle(duration: const Duration(milliseconds: 500));
/// // Assume a fetch method fetchData(String query) exists
/// Button(onPressed: () => throttle(() => fetchData(userInput)));
/// ```
void Function(VoidCallback callback) useThrottle({
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be named useThrottled to match `useDebounced

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated with 4ebb7f7

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the API

I'd like something that matches the useDebounced API, for consistency

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think useDebounced has big difference of usage.
useThrottled should be callback method to use.

Duration duration = const Duration(milliseconds: 500),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value seems arbitrary. I'd rather not have a default value and make the parameter required

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated with 4ebb7f7

}) {
final throttler = useMemoized(() => _Throttler(duration), [duration]);
return throttler.run;
}

class _Throttler {
_Throttler(this.duration)
: assert(
0 < duration.inMilliseconds,
'duration must be greater than 0ms',
);

final Duration duration;

Timer? _timer;

bool get _isRunning => _timer != null;

void run(VoidCallback callback) {
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
Comment on lines +30 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider edge case handling for invalid durations.

The current implementation doesn't handle edge cases like Duration.zero or negative durations, which could lead to unexpected behavior.

 void run(VoidCallback callback) {
+  if (duration <= Duration.zero) {
+    callback();
+    return;
+  }
+  
   if (!_isRunning) {
     _timer = Timer(duration, () {
       _timer = null;
     });
     callback();
   }
 }

For zero or negative durations, the callback should execute immediately without throttling.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void run(VoidCallback callback) {
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
void run(VoidCallback callback) {
if (duration <= Duration.zero) {
callback();
return;
}
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
🤖 Prompt for AI Agents
In packages/flutter_hooks/lib/src/throttled.dart around lines 30 to 37, the run
method does not handle zero or negative Duration values, which can cause
unexpected behavior. Modify the method to check if the duration is zero or
negative; if so, execute the callback immediately without setting a timer or
throttling. Otherwise, proceed with the existing throttling logic.

}
73 changes: 73 additions & 0 deletions packages/flutter_hooks/test/use_throttle_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('useThrottle', () {
testWidgets('no update when tapping multiple times', (tester) async {
await tester.runAsync<void>(() async {
await tester.pumpWidget(const _UseThrottleTestWidget());

final text = find.byType(GestureDetector);
expect(find.text('1'), findsOneWidget);

await tester.tap(text);
await tester.pump();

expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);
});
});

testWidgets('update number after duration', (tester) async {
await tester.runAsync<void>(() async {
await tester.pumpWidget(const _UseThrottleTestWidget());

final text = find.byType(GestureDetector);
expect(find.text('1'), findsOneWidget);

await tester.pumpAndSettle(_duration);
await Future<void>.delayed(_duration);

await tester.tap(text);
await tester.pump();

expect(find.text('2'), findsOneWidget);
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve test to better validate throttle reset behavior.

The current test doesn't fully validate that the throttle properly resets. It only tests a single tap after waiting, not that subsequent taps can occur after the throttle period.

 testWidgets('update number after duration', (tester) async {
   await tester.runAsync<void>(() async {
     await tester.pumpWidget(const _UseThrottleTestWidget());

     final text = find.byType(GestureDetector);
     expect(find.text('1'), findsOneWidget);

+    // First tap should work immediately
+    await tester.tap(text);
+    await tester.pump();
+    expect(find.text('2'), findsOneWidget);
+
+    // Wait for throttle duration to pass
     await tester.pumpAndSettle(_duration);
     await Future<void>.delayed(_duration);

+    // Next tap should work after throttle resets
     await tester.tap(text);
     await tester.pump();
-    expect(find.text('2'), findsOneWidget);
+    expect(find.text('3'), findsOneWidget);
   });
 });
🤖 Prompt for AI Agents
In packages/flutter_hooks/test/use_throttle_test.dart around lines 33 to 48, the
test only verifies a single tap after the throttle duration but does not confirm
that the throttle resets to allow subsequent taps. Modify the test to include
multiple taps separated by the throttle duration, verifying that the widget
updates correctly each time, thereby ensuring the throttle reset behavior is
properly validated.

});
}

class _UseThrottleTestWidget extends HookWidget {
const _UseThrottleTestWidget();

@override
Widget build(BuildContext context) {
final textNumber = useState(1);
final throttle = useThrottle();

void updateText() {
textNumber.value++;
}

return MaterialApp(
home: GestureDetector(
onTap: () => throttle(updateText),
child: Text(textNumber.value.toString()),
),
);
}
}

const _duration = Duration(milliseconds: 500);