Skip to content

Commit e9f8183

Browse files
authored
feat: Add StateDebounceManager to common_client (#291)
## Summary Adds `StateDebounceManager` to `launchdarkly_common_client` per CSFDV2 CONNMODE section 3.5. Coalesces network availability, application lifecycle, and user-requested mode signals into a debounced reconciled snapshot delivered via a single-subscription `Stream<DebouncedState>`. Per spec, `identify` calls do not participate. The initial state is buffered on the stream during construction and delivered asynchronously to the first subscriber, mirroring the state-detector pattern. Subsequent reconciles fire after the configurable debounce window closes. `Duration.zero` bypasses the timer. ## Scope This PR ships only the debouncer primitive and its public exports from `common_client`. The Flutter SDK consumer wiring lands in a follow-up PR (#TBD) once this PR merges and `launchdarkly_common_client` is released. Split out of #281 to enable incremental merge / release. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > New isolated library code with broad unit tests; no production integration in this PR. > > **Overview** > Introduces **`StateDebounceManager`** in `launchdarkly_common_client` as a standalone FDv2 connection-mode debouncing primitive (CONNMODE §3.5). It coalesces **network availability**, **foreground lifecycle**, and **user-requested `FDv2ConnectionMode`** into a `DebouncedState` snapshot on a single-subscription stream; `identify` is explicitly out of scope. > > **`DebouncedState`** holds the three axes; setters no-op on unchanged values and reset a configurable debounce timer (or emit immediately when `debounceWindow` is `Duration.zero`). The initial state is buffered at construction and delivered asynchronously to the first subscriber; **`close()`** cancels pending timers and ignores further updates. > > Public API is exported from `launchdarkly_common_client.dart`. **`fake_async`** is added for tests covering timer coalescing, teardown, and zero-window behavior. SDK wiring is deferred to a follow-up PR. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bff223d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent fe8963f commit e9f8183

4 files changed

Lines changed: 460 additions & 0 deletions

File tree

packages/common_client/lib/launchdarkly_common_client.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export 'src/data_sources/fdv2/mode_resolution.dart'
7575
ModeResolutionEntry,
7676
resolveMode,
7777
flutterDefaultResolutionTable;
78+
export 'src/data_sources/fdv2/state_debounce_manager.dart'
79+
show DebouncedState, DebounceTimerFactory, StateDebounceManager;
7880
export 'src/data_sources/data_source_status.dart'
7981
show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState;
8082

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import 'dart:async';
2+
3+
import '../../fdv2_connection_mode.dart';
4+
5+
/// Snapshot of the desired state accumulated within a debounce window.
6+
///
7+
/// Each field is one of the axes that participate in debouncing per the
8+
/// FDv2 connection-mode resolution spec: network availability, application
9+
/// lifecycle, and the user-requested mode (when set via the public
10+
/// `setMode` API). `identify` calls intentionally do not participate.
11+
final class DebouncedState {
12+
final bool networkAvailable;
13+
final bool inForeground;
14+
final FDv2ConnectionMode? requestedMode;
15+
16+
static const _unset = Object();
17+
18+
const DebouncedState({
19+
required this.networkAvailable,
20+
required this.inForeground,
21+
required this.requestedMode,
22+
});
23+
24+
DebouncedState _copyWith({
25+
bool? networkAvailable,
26+
bool? inForeground,
27+
Object? requestedMode = _unset,
28+
}) {
29+
return DebouncedState(
30+
networkAvailable: networkAvailable ?? this.networkAvailable,
31+
inForeground: inForeground ?? this.inForeground,
32+
requestedMode: identical(requestedMode, _unset)
33+
? this.requestedMode
34+
: requestedMode as FDv2ConnectionMode?,
35+
);
36+
}
37+
}
38+
39+
/// Factory that produces a one-shot timer used to schedule the debounce
40+
/// fire. Exists primarily so tests can substitute a controllable
41+
/// implementation (e.g. via `fake_async`).
42+
typedef DebounceTimerFactory = Timer Function(
43+
Duration duration, void Function() callback);
44+
45+
Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb);
46+
47+
/// Debounces network availability, lifecycle, and user-requested mode
48+
/// signals into a reconciled snapshot delivered through [stream].
49+
///
50+
/// The initial state is added to [stream] during construction and is
51+
/// buffered until the first subscriber attaches.
52+
///
53+
/// Each `setX` call updates the relevant component of the pending state
54+
/// and resets the debounce timer. When the timer fires, the accumulated
55+
/// state is pushed onto [stream]. Per-setter early-return suppresses
56+
/// unchanged values; the consumer is responsible for deciding whether
57+
/// the resolved state requires action.
58+
///
59+
/// A [debounceWindow] of [Duration.zero] bypasses the timer entirely:
60+
/// state changes are pushed onto [stream] from inside the setter
61+
/// that produced them.
62+
///
63+
/// [stream] is single-subscription. Subscribe exactly once and
64+
/// cancel the subscription as part of the same teardown that calls
65+
/// [close].
66+
final class StateDebounceManager {
67+
final Duration _debounceWindow;
68+
final DebounceTimerFactory _timerFactory;
69+
final _controller = StreamController<DebouncedState>();
70+
71+
DebouncedState _pending;
72+
Timer? _timer;
73+
bool _closed = false;
74+
75+
StateDebounceManager({
76+
required DebouncedState initialState,
77+
required Duration debounceWindow,
78+
DebounceTimerFactory? timerFactory,
79+
}) : _pending = initialState,
80+
_debounceWindow = debounceWindow,
81+
_timerFactory = timerFactory ?? _defaultTimerFactory {
82+
_controller.add(initialState);
83+
}
84+
85+
/// Stream of debounced states. The initial state is buffered for the
86+
/// first subscriber and delivered on a microtask after subscription;
87+
/// subsequent reconciles fire after the debounce window closes.
88+
Stream<DebouncedState> get stream => _controller.stream;
89+
90+
void setNetworkAvailable(bool available) {
91+
if (_pending.networkAvailable == available) {
92+
return;
93+
}
94+
_pending = _pending._copyWith(networkAvailable: available);
95+
_scheduleOrFire();
96+
}
97+
98+
void setInForeground(bool inForeground) {
99+
if (_pending.inForeground == inForeground) {
100+
return;
101+
}
102+
_pending = _pending._copyWith(inForeground: inForeground);
103+
_scheduleOrFire();
104+
}
105+
106+
void setRequestedMode(FDv2ConnectionMode? mode) {
107+
if (_pending.requestedMode == mode) {
108+
return;
109+
}
110+
_pending = _pending._copyWith(requestedMode: mode);
111+
_scheduleOrFire();
112+
}
113+
114+
void close() {
115+
_closed = true;
116+
_timer?.cancel();
117+
_timer = null;
118+
_controller.close();
119+
}
120+
121+
void _scheduleOrFire() {
122+
if (_closed) {
123+
return;
124+
}
125+
if (_debounceWindow == Duration.zero) {
126+
_controller.add(_pending);
127+
return;
128+
}
129+
_timer?.cancel();
130+
_timer = _timerFactory(_debounceWindow, _onTimer);
131+
}
132+
133+
void _onTimer() {
134+
_timer = null;
135+
if (_closed) {
136+
return;
137+
}
138+
_controller.add(_pending);
139+
}
140+
}

packages/common_client/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ dev_dependencies:
1818
test: ^1.24.3
1919
lints: ^3.0.0
2020
mocktail: ^1.0.1
21+
fake_async: ^1.3.1

0 commit comments

Comments
 (0)