Skip to content

feat: make wait assignable so one instance can vary its delay - #9

Closed
xsahil03x wants to merge 2 commits into
masterfrom
feat/dynamic-wait
Closed

feat: make wait assignable so one instance can vary its delay#9
xsahil03x wants to merge 2 commits into
masterfrom
feat/dynamic-wait

Conversation

@xsahil03x

@xsahil03x xsahil03x commented Aug 11, 2026

Copy link
Copy Markdown
Member

Why

wait is fixed at construction, so varying the delay means one Debounce per delay. stream_chat_flutter's SearchDebouncer does exactly that, and documents it:

Backed by two Debounce functions — one per delay — because a Debounce's wait is fixed at construction. Each call runs the debouncer for the matching query length and cancels the other so only a single search is scheduled.

Two instances, manually cancelled against each other, with isPending OR-ing the pair. Swift solves the same feature with SearchDebouncePolicy.interval(forQueryLength:).

What

wait becomes a readable and assignable property on Debounce and Throttle. A new value takes effect from the next call.

debouncedSearch.wait = Duration(milliseconds: query.length <= 2 ? 500 : 300);
debouncedSearch([query]);

SearchDebouncer collapses to one instance with no cross-cancellation:

void call(String query) {
  _search.wait = _policy.delayFor(query.length);
  _search([query]);
}

bool get isActive => _search.isPending;

Why not a waitBuilder callback

That was the first version of this PR, and it was wrong. The callback received the same List<Object?>? / Map<Symbol, Object?>? that Function.apply uses, so every use site had to null-assert and cast its way back to a type it already knew:

waitBuilder: (args, _) => (args!.first! as int).toDuration()

That is the untyped call path leaking straight into a brand new API. A typed builder — (String query) => ... — needs no casts, but it only exists once Debounce itself is generic, so it belongs after that work rather than before it.

The caller already holds the value in typed form, so there is no reason to hand it back to them. The property is also a smaller surface: one member, readable as well as assignable, rather than a parameter threaded through two constructors, two extension methods and two top-level functions.

Two behaviours that needed care

A shrinking wait has to re-arm the pending timer. It was armed for the previous, longer wait, so the call would fire late — 500ms after the first call rather than 200ms after the second. Growing the wait already self-corrected through the existing restart path in _timerExpired, so only the shrink case needed the fix; the grow test documents the behaviour rather than guarding new code.

Throttle must keep maxWait equal to wait. maxWait is always raised to at least wait, so passing Duration.zero pins it to whatever wait currently is. Left at the constructed value, a throttle whose wait was lowered fired half as often as asked — measured as 2 invocations instead of 4 over a 200ms tight loop.

Compatibility

Additive and non-breaking. Nothing changes unless wait is assigned — all 39 pre-existing tests pass untouched.

Verification

dart format --set-exit-if-changed ., dart analyze --fatal-infos ., 46 tests under randomized ordering, coverage 100% (116/116), dart pub publish --dry-run clean.

Each new behaviour is mutation-checked — removing the timer re-arm fails only the shrink test, and reverting the maxWait pin fails only the throttle test.

🤖 Generated with Claude Code

`wait` was fixed at construction, so varying the delay meant one Debounce
per delay. stream_chat_flutter's SearchDebouncer does exactly that — two
instances cancelled against each other, with isPending OR-ing the pair —
and says so in a comment.

`waitBuilder` receives the same arguments as the debounced function and
returns the wait for that call:

    debounce(
      onSearch,
      const Duration(milliseconds: 300),
      waitBuilder: (args, _) => policy.delayFor(args!.first! as String),
    );

Shrinking the wait has to re-arm the pending timer, which was armed for
the previous, longer one — otherwise the call fires late. Growing it
already self-corrected through the existing restart path.

Throttle keeps maxWait equal to wait. Since maxWait is always raised to
at least wait, passing zero pins it to whatever the builder last
returned; leaving it at the constructed wait made a throttle with a
shrinking wait fire half as often as asked.

Behaviour is unchanged when no waitBuilder is given: the resolver returns
early and every existing test passes untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (0bb40e4) to head (c9c00df).

Additional details and impacted files
@@            Coverage Diff            @@
##            master        #9   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         4           
  Lines          100       116   +16     
=========================================
+ Hits           100       116   +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The waitBuilder callback received the same List<Object?>/Map<Symbol,
Object?> that Function.apply does, so every use site had to null-assert
and cast its way back to the type it already knew:

    waitBuilder: (args, _) => (args!.first! as int).toDuration()

That is the untyped call path leaking into a brand new API. A typed
builder — (String query) => ... — needs no casts at all, but it only
exists once Debounce itself is generic, so it belongs after that work
rather than before it.

The caller already holds the value in typed form, so nothing needs to
hand it back to them:

    debouncedSearch.wait = query.length <= 2 ? 500.ms : 300.ms;
    debouncedSearch([query]);

Smaller surface too: one property, readable as well as assignable,
instead of a parameter on four constructors and factories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xsahil03x xsahil03x changed the title feat: derive the wait from the call arguments feat: make wait assignable so one instance can vary its delay Aug 11, 2026
@xsahil03x xsahil03x closed this Aug 11, 2026
@xsahil03x
xsahil03x deleted the feat/dynamic-wait branch August 11, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant