Skip to content

Commit 921853d

Browse files
committed
release: 0.4.0
1 parent 816a752 commit 921853d

7 files changed

Lines changed: 458 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.4.0
2+
3+
- Adds optimistic updates.
4+
15
## 0.3.0
26

37
- Adds `cancelableAsyncOp` to `Trent` class, allowing for the cancellation of optional async operations via `void reset({bool cancelInFlightAsyncOps = true})` or `cancelInFlightAsyncOps`. This helps prevent state leaking across sessions.

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
- Ease of use:
1818
- Built-in dependency injection and service locators.
19+
- Built-in optimistic update functionality.
1920
- Boasts simple `Alerter` and `Digester` widgets for managing UI layer reactively.
2021
- Fine-grained control:
2122
- Includes special shorthand `watch<T>`, `watchMap<T, S>`, and `get<T>` functions for reduced-boilerplate managing of UI layer.
@@ -438,6 +439,91 @@ class CalculatorTrent extends Trent<CalculatorStates> {
438439
}
439440
```
440441

442+
### Optimistic Updates
443+
444+
Optimistic updates let you immediately reflect a change in your UI before an async operation (like a network request) completes. This makes your app feel faster and more responsive. If the async operation fails, the optimistic update can be reverted.
445+
446+
Trent provides a built-in API for optimistic updates via the `optimisticUpdate<T>(...)` method, available on any Trent instance. Optimistic updates are tracked by tag, can be explicitly accepted or rejected, and are automatically cleaned up on `reset()`.
447+
448+
#### Tags
449+
A tag is a string that identifies the optimistic update. If you run multiple optimistic updates with the same tag, only the latest effect for that tag is present; previous effects are fully reverted before the new one is applied. This ensures repeated or colliding updates don't stack up and cause state confusion. If you don't provide a tag, Trent will generate a universally unique tag for you.
450+
451+
```dart
452+
// In your Trent subclass:
453+
optimisticUpdate<int>(
454+
tag: "counter",
455+
forward: (state, value) => state.copyWith(value: state.value + value),
456+
reverse: (state, value) => state.copyWith(value: state.value - value),
457+
).execute(5); // Immediately applies +5 optimistically
458+
```
459+
460+
#### Resolving Optimistic Updates
461+
There are three main ways to resolve an optimistic update:
462+
463+
1. **Accept**: Call `accept()` on the attempt to confirm the optimistic update (e.g., after a successful async operation). This makes the update permanent and removes it from the pending list.
464+
2. **Accept As**: Call `acceptAs(newValue)` to accept the update but with a new value (runs revert, then applies the new value as a new optimistic update).
465+
3. **Reject**: Call `reject()` to revert the optimistic update (e.g., if the async operation fails). This will undo the effect and remove it from the pending list.
466+
467+
All of these methods are available on the returned `OptimisticAttempt`:
468+
469+
```dart
470+
final attempt = optimisticUpdate<int>(
471+
tag: "counter",
472+
forward: (state, value) => state.copyWith(value: state.value + value),
473+
reverse: (state, value) => state.copyWith(value: state.value - value),
474+
);
475+
attempt.execute(10); // Apply +10 optimistically
476+
477+
// Later, after async completes:
478+
attempt.accept(); // Accepts and finalizes
479+
// or
480+
attempt.reject(); // Reverts the update
481+
// or
482+
attempt.acceptAs(42); // Reverts +10, then applies +42
483+
```
484+
485+
Optimistic updates are async-safe: if you run multiple with the same tag before accepting, acceptingAs, or rejecting, only the latest is kept and previous ones are reverted before the new one is applied. This prevents flooding/collision issues.
486+
487+
If you always resolve each optimistic attempt (by calling `accept()`, `acceptAs(...)`, or `reject()` on every attempt you create), you will not leak memory or state. The additional cleanup methods (like `rejectAllUnresolvedOptimisticUpdates`) are provided as extra safety for cases where you might forget to resolve, or for bulk cleanup after network failures, app suspends, or other edge cases.
488+
489+
#### Cleanup
490+
491+
To prevent memory leaks or stale updates, Trent provides a cleanup method:
492+
493+
- `rejectAllUnresolvedOptimisticUpdates({Duration? olderThan})`: Rejects all unresolved optimistic updates, or only those older than a given duration. This is useful for cleaning up after network failures, app suspends, or just to ensure your state is fresh.
494+
495+
```dart
496+
// Reject all unresolved optimistic updates
497+
trent.rejectAllUnresolvedOptimisticUpdates();
498+
499+
// Reject only those older than 30 seconds
500+
trent.rejectAllUnresolvedOptimisticUpdates(olderThan: Duration(seconds: 30));
501+
```
502+
503+
Optimistic updates are also automatically cleaned up on a Trent's `reset(...)` being called.
504+
505+
**Example:**
506+
507+
```dart
508+
final attempt = optimisticUpdate<int>(
509+
tag: "saveDraft",
510+
forward: (state, value) => state.copyWith(value: value),
511+
reverse: (state, value) => state.copyWith(value: state.value - value),
512+
);
513+
attempt.execute(123);
514+
515+
// If the save fails after 10 seconds
516+
Future.delayed(Duration(seconds: 10), () {
517+
attempt.reject();
518+
});
519+
520+
// Or, to clean up all stale attempts after a while
521+
trent.rejectAllUnresolvedOptimisticUpdates(olderThan: Duration(seconds: 10));
522+
```
523+
524+
This system ensures your UI remains responsive, your state stays consistent, and you have full control over optimistic updates and their lifecycle.
525+
526+
441527
## How to Use
442528

443529
### 1. Define Your State Types

example/pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ packages:
278278
path: ".."
279279
relative: true
280280
source: path
281-
version: "0.1.1"
281+
version: "0.3.0"
282282
typed_data:
283283
dependency: transitive
284284
description:

lib/src/logic/optimism.dart

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,57 @@
11
import 'package:trent/trent.dart';
2+
import 'package:meta/meta.dart';
23

34
class OptimisticAttempt<TState extends EquatableCopyable<TState>, TValue> {
4-
final TState Function(TState, TValue) forward;
5-
final TState Function(TState, TValue) reverse;
6-
final Trent<TState> trent;
5+
final TState Function(TState, TValue) _forward;
6+
final TState Function(TState, TValue) _reverse;
7+
final Trent<TState> _trent;
78
final String tag;
8-
final String compositeKey;
9+
final String _compositeKey;
910

1011
bool _started = false;
1112
bool _finished = false;
1213

1314
late TValue? _value;
1415

16+
DateTime? _createdAt;
17+
1518
OptimisticAttempt({
16-
required this.trent,
19+
required Trent<TState> trent,
1720
required this.tag,
18-
required this.forward,
19-
required this.reverse,
20-
}) : compositeKey = "${trent.runtimeType}_$tag";
21+
required TState Function(TState, TValue) forward,
22+
required TState Function(TState, TValue) reverse,
23+
}) : _trent = trent,
24+
_reverse = reverse,
25+
_forward = forward,
26+
_compositeKey = "${trent.runtimeType}_$tag";
2127

2228
/// Call this to apply the optimistic update with a value.
2329
void execute([TValue? value]) {
2430
if (_started) return;
2531
_started = true;
2632
_value = value;
33+
_createdAt = DateTime.now();
2734

2835
// Revert previous attempt with the same tag if it exists and is not this
29-
final prev = registry[compositeKey];
36+
final prev = registry[_compositeKey];
3037
if (prev != null && prev != this && prev._started && !prev._finished) {
3138
final typedPrev = prev as OptimisticAttempt<TState, TValue>;
32-
trent.emit(typedPrev.reverse(trent.state, typedPrev._value as TValue));
39+
_trent.emit(typedPrev._reverse(_trent.state, typedPrev._value as TValue));
3340
typedPrev._finish();
3441
}
3542

36-
trent.emit(forward(trent.state, value as TValue));
37-
registry[compositeKey] = this;
43+
_trent.emit(_forward(_trent.state, value as TValue));
44+
registry[_compositeKey] = this;
3845
}
3946

4047
/// Accept the optimistic update with a new value (runs reverse then forward with new value).
4148
void acceptAs(TValue value) {
4249
if (!_started || _finished) return;
4350
if (_isLatest()) {
4451
// revert optimistic
45-
trent.emit(reverse(trent.state, _value as TValue));
52+
_trent.emit(_reverse(_trent.state, _value as TValue));
4653
// apply new value
47-
trent.emit(forward(trent.state, value));
54+
_trent.emit(_forward(_trent.state, value));
4855
_finish();
4956
}
5057
}
@@ -53,7 +60,7 @@ class OptimisticAttempt<TState extends EquatableCopyable<TState>, TValue> {
5360
void reject() {
5461
if (!_started || _finished) return;
5562
if (_isLatest()) {
56-
trent.emit(reverse(trent.state, _value as TValue));
63+
_trent.emit(_reverse(_trent.state, _value as TValue));
5764
_finish();
5865
}
5966
}
@@ -68,10 +75,18 @@ class OptimisticAttempt<TState extends EquatableCopyable<TState>, TValue> {
6875

6976
void _finish() {
7077
_finished = true;
71-
registry.remove(compositeKey);
78+
registry.remove(_compositeKey);
7279
}
7380

74-
bool _isLatest() => registry[compositeKey] == this;
81+
bool _isLatest() => registry[_compositeKey] == this;
7582

7683
static final Map<String, OptimisticAttempt> registry = {};
84+
85+
bool get isFinished => _finished;
86+
87+
/// For internal use by Trent only. Not for public consumption.
88+
DateTime? get createdAtForTrent => _createdAt;
89+
90+
@visibleForTesting
91+
set createdAtForTest(DateTime dt) => _createdAt = dt;
7792
}

lib/src/logic/trent.dart

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ abstract class Trents<Base> extends ChangeNotifier {
6060
///
6161
/// All last states are cleared.
6262
///
63-
/// Optionally, cancels all in-flight async ops and resets the session token (further ensuring in-flight async ops get suppressed).
63+
/// Also cancels all in-flight async ops and resets the session token (further ensuring in-flight async ops get suppressed).
64+
/// Additionally, all unresolved optimistic attempts are rejected and cleared.
6465
void reset({bool cancelInFlightAsyncOps = true}) {
6566
if (cancelInFlightAsyncOps) {
6667
for (var op in _ops) {
@@ -69,6 +70,14 @@ abstract class Trents<Base> extends ChangeNotifier {
6970
_ops.clear();
7071
_sessionToken = const Uuid().v4();
7172
}
73+
// --- Reject and clear all unresolved optimistic attempts ---
74+
final optimisticRegistry = OptimisticAttempt.registry;
75+
for (final attempt in optimisticRegistry.values.toList()) {
76+
// Only reject if not finished; _finished is private, so use public API
77+
attempt.reject();
78+
}
79+
optimisticRegistry.clear();
80+
// --- END NEW ---
7281
clearAllExes();
7382
emit(_initialState);
7483
_updateLastState(_initialState);
@@ -89,7 +98,8 @@ abstract class Trents<Base> extends ChangeNotifier {
8998
/// This ensures no "leakage" of async operations that are not cancelled
9099
/// across Trent resets, and the result is wrapped in an [AsyncCompleted]
91100
/// to safely distinguish between completed and cancelled/stale executions.
92-
Future<AsyncCompleted<T>> cancelableAsyncOp<T>(Future<T> Function() work) async {
101+
Future<AsyncCompleted<T>> cancelableAsyncOp<T>(
102+
Future<T> Function() work) async {
93103
final captured = _sessionToken;
94104
final op = CancelableOperation<T>.fromFuture(work());
95105
_ops.add(op);
@@ -150,15 +160,18 @@ abstract class Trents<Base> extends ChangeNotifier {
150160
/// to return to D(value: 10) instead of D(value: some_value_you_must_define).
151161
Option<T> getExStateAs<T extends Base>() {
152162
return _lastStates[T] != null
153-
? _lastStates[T]!.match(some: (v) => Option.some(v as T), none: () => Option<T>.none())
163+
? _lastStates[T]!.match(
164+
some: (v) => Option.some(v as T), none: () => Option<T>.none())
154165
: Option<T>.none();
155166
}
156167

157168
/// Retrieve the current state as a specific type.
158169
///
159170
/// Will return None if the current state is not of the specified type.
160171
Option<T> getCurrStateAs<T extends Base>() {
161-
return _state.runtimeType == T ? Option.some(_state as T) : Option<T>.none();
172+
return _state.runtimeType == T
173+
? Option.some(_state as T)
174+
: Option<T>.none();
162175
}
163176

164177
/// Dispose of the Trent.
@@ -183,7 +196,8 @@ abstract class Copyable<T> {
183196
}
184197

185198
/// A generic Trent that manages state transitions.
186-
abstract class Trent<Base extends EquatableCopyable<Base>> extends Trents<Base> {
199+
abstract class Trent<Base extends EquatableCopyable<Base>>
200+
extends Trents<Base> {
187201
Trent(super.state);
188202

189203
/// Optimistic update helper, available on all Trent instances.
@@ -203,4 +217,29 @@ abstract class Trent<Base extends EquatableCopyable<Base>> extends Trents<Base>
203217
// Do NOT register the attempt here; registration happens in execute().
204218
return attempt;
205219
}
220+
221+
/// Rejects all unresolved optimistic attempts. If [olderThan] is supplied,
222+
/// only attempts pending longer than [olderThan] are rejected.
223+
void rejectAllUnresolvedOptimisticUpdates({Duration? olderThan}) {
224+
final now = DateTime.now();
225+
final optimisticRegistry = OptimisticAttempt.registry;
226+
for (final attempt in optimisticRegistry.values.toList()) {
227+
if (olderThan == null) {
228+
attempt.reject();
229+
} else {
230+
final created = attempt.createdAtForTrent;
231+
if (created != null && now.difference(created) > olderThan) {
232+
attempt.reject();
233+
}
234+
}
235+
}
236+
if (olderThan == null) {
237+
optimisticRegistry.clear();
238+
} else {
239+
// Remove only those that were rejected above
240+
optimisticRegistry.removeWhere((_, attempt) =>
241+
attempt.createdAtForTrent != null &&
242+
now.difference(attempt.createdAtForTrent!) > olderThan);
243+
}
244+
}
206245
}

pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: trent
22
description: A Flutter package for simple, scalable, and reactive state management with built-in dependency injection and efficient stream-based state handling.
3-
version: 0.3.0
3+
version: 0.4.0
44

55
homepage: https://matthewtrent.me
66
repository: https://github.com/mattrltrent/trent
@@ -20,6 +20,7 @@ dependencies:
2020
get_it: ^8.0.3
2121
async: ^2.12.0
2222
uuid: ^4.5.1
23+
meta: ^1.16.0
2324

2425
dev_dependencies:
2526
flutter_test:

0 commit comments

Comments
 (0)