Skip to content

Commit

Permalink
feat: add experimental hydrate side effect (#62)
Browse files Browse the repository at this point in the history
Partially fixes #38
  • Loading branch information
GregoryConrad committed Jan 3, 2024
1 parent 4984053 commit 04dd82f
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 9 deletions.
43 changes: 43 additions & 0 deletions packages/rearch/lib/experimental.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,47 @@ extension ExperimentalSideEffects on SideEffectRegistrar {
return (() => state, (T newState) => state = newState);
});
}

/// A mechanism to persist changes made to some provided state.
/// Unlike [persist], [hydrate] allows you to pass in the state to persist,
/// if there is one, to enable easier composition with other side effects.
///
/// Defines a way to interact with a storage provider of your choice
/// through the [read] and [write] parameters.
/// - If [newData] is [Some], then [newData] will be persisted and
/// overwrite any existing persisted data.
/// - If [newData] is [None], then no changes will be made to the currently
/// persisted value (for when you don't have state to persist yet).
///
/// [read] is only called once; it is assumed that if [write] is successful,
/// then calling [read] again would reflect the new state that we already
/// have access to. Thus, calling [read] again is skipped as an optimization.
// NOTE: experimental because I am not sold on the Option<T> parameter.
AsyncValue<T> hydrate<T>(
Option<T> newData, {
required Future<T> Function() read,
required Future<void> Function(T) write,
}) {
final readFuture = use.callonce(read);
final readState = use.future(readFuture);
final (getPrevData, setPrevData) =
use.rawValueWrapper<Option<T>>(None<T>.new);
final (getWriteFuture, setWriteFuture) =
use.rawValueWrapper<Future<T>?>(() => null);

if (newData case Some(value: final data)) {
final dataChanged =
getPrevData().map((prevData) => data != prevData).unwrapOr(true);
if (dataChanged) {
setPrevData(Some(data));
setWriteFuture(() async {
await write(data);
return data;
}());
}
}

final writeState = use.nullableFuture(getWriteFuture());
return (writeState ?? readState).fillInPreviousData(readState.data);
}
}
61 changes: 52 additions & 9 deletions packages/rearch/lib/src/side_effects.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,45 @@ extension BuiltinSideEffects on SideEffectRegistrar {
/// unless you use something like [memo] to limit changes.
/// Or, if possible, it is even better to wrap the future in an entirely
/// new capsule (although this is not always possible).
AsyncValue<T> future<T>(Future<T> future) {
final asStream = use.memo(future.asStream, [future]);
return use.stream(asStream);
AsyncValue<T> future<T>(Future<T> future) => use.nullableFuture(future)!;

/// Consumes a [Stream] and watches the given [stream].
///
/// If the given stream changes between build calls, then the current
/// [StreamSubscription] will be disposed and recreated for the new stream.
/// Thus, it is important that the stream instance only changes when needed.
/// It is incorrect to create a stream in the same build as the [stream],
/// unless you use something like [memo] to limit changes.
/// Or, if possible, it is even better to wrap the stream in an entirely
/// new capsule (although this is not always possible).
AsyncValue<T> stream<T>(Stream<T> stream) => use.nullableStream(stream)!;

/// Consumes a nullable [Future] and watches the given [future].
///
/// Implemented by calling [Future.asStream] and forwarding calls
/// onto [nullableStream].
///
/// If the given future changes, then the current [StreamSubscription]
/// will be disposed and recreated for the new future.
/// Thus, it is important that the future instance only changes when needed.
/// It is incorrect to create a future in the same build as the [future],
/// unless you use something like [memo] to limit changes.
/// Or, if possible, it is even better to wrap the future in an entirely
/// new capsule (although this is not always possible).
///
/// This side effect also caches the data from the latest non-null [future],
/// passing it into [AsyncLoading.previousData] when the future switches and
/// [AsyncError.previousData] when a new future throws an exception.
/// To remove this cached data from the returned [AsyncValue],
/// you may call [AsyncValueConvenience.withoutPreviousData].
AsyncValue<T>? nullableFuture<T>(Future<T>? future) {
// NOTE: we convert to a stream here because we can cancel
// a stream subscription; there is no builtin way to cancel a future.
final asNullableStream = use.memo(() => future?.asStream(), [future]);
return use.nullableStream(asNullableStream);
}

/// Consumes a [Stream] and watches the given stream.
/// Consumes a nullable [Stream] and watches the given [stream].
///
/// If the given stream changes between build calls, then the current
/// [StreamSubscription] will be disposed and recreated for the new stream.
Expand All @@ -175,7 +208,13 @@ extension BuiltinSideEffects on SideEffectRegistrar {
/// unless you use something like [memo] to limit changes.
/// Or, if possible, it is even better to wrap the stream in an entirely
/// new capsule (although this is not always possible).
AsyncValue<T> stream<T>(Stream<T> stream) {
///
/// This side effect also caches the data from the latest non-null [stream],
/// passing it into [AsyncLoading.previousData] when the stream switches and
/// [AsyncError.previousData] when a new stream emits an exception.
/// To remove this cached data from the returned [AsyncValue],
/// you may call [AsyncValueConvenience.withoutPreviousData].
AsyncValue<T>? nullableStream<T>(Stream<T>? stream) {
final rebuild = use.rebuilder();
final (getValue, setValue) = use.rawValueWrapper<AsyncValue<T>>(
() => AsyncLoading<T>(None<T>()),
Expand All @@ -191,7 +230,7 @@ extension BuiltinSideEffects on SideEffectRegistrar {
if (needToInitializeState) {
setValue(AsyncLoading(getValue().data));
setSubscription(
stream.listen(
stream?.listen(
(data) {
setValue(AsyncData(data));
rebuild();
Expand All @@ -205,7 +244,7 @@ extension BuiltinSideEffects on SideEffectRegistrar {
);
}

return getValue();
return stream == null ? null : getValue();
}

/// A mechanism to persist changes made in state that manages its own state.
Expand All @@ -217,6 +256,10 @@ extension BuiltinSideEffects on SideEffectRegistrar {
/// [read] is only called once; it is assumed that if [write] is successful,
/// then calling [read] again would reflect the new state that we already
/// have access to. Thus, calling [read] again is skipped as an optimization.
///
/// See also: [hydrate], which will compose more nicely with side effects
/// that manage their own state by simply persisting their state.
/// [persist] acts like a [state] assembled with [hydrate].
(AsyncValue<T>, void Function(T)) persist<T>({
required Future<T> Function() read,
required Future<void> Function(T) write,
Expand Down Expand Up @@ -249,8 +292,8 @@ extension BuiltinSideEffects on SideEffectRegistrar {
final (getValue, setValue) =
use.rawValueWrapper<AsyncValue<T>?>(() => null);

// We convert to a stream here because we can cancel a stream subscription;
// there is no builtin way to cancel a future.
// NOTE: we convert to a stream here because we can cancel
// a stream subscription; there is no builtin way to cancel a future.
final (future, setFuture) = use.state<Future<T>?>(null);
final asStream = use.memo(() => future?.asStream(), [future]);

Expand Down
13 changes: 13 additions & 0 deletions packages/rearch/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,19 @@ extension AsyncValueConvenience<T> on AsyncValue<T> {
};
}

/// Fills in the [AsyncLoading.previousData] or [AsyncError.previousData] with
/// [None] so that there is no previous data whatsoever in the [AsyncValue].
/// This also means that [data] will only be [Some] when this is [AsyncData],
/// which can be useful when you want to erase any non-relevant previous data.
AsyncValue<T> withoutPreviousData() {
return switch (this) {
AsyncData() => this,
AsyncLoading() => AsyncLoading(None<T>()),
AsyncError(:final error, :final stackTrace) =>
AsyncError(error, stackTrace, None<T>()),
};
}

/// Maps an AsyncValue<T> into an AsyncValue<R> by applying
/// the given [mapper].
AsyncValue<R> map<R>(R Function(T) mapper) {
Expand Down
105 changes: 105 additions & 0 deletions packages/rearch/test/side_effects_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:rearch/experimental.dart';
import 'package:rearch/rearch.dart';
import 'package:test/test.dart';

Expand Down Expand Up @@ -42,6 +43,110 @@ void main() {
expect((getState() as AsyncError<int>).previousData, equals(const Some(0)));
});

test('hydrate', () async {
var writtenData = -1;
var shouldWriteError = false;
var writeCount = 0;

Future<int> read() async => writtenData;

Future<void> write(int newData) async {
await null;
writeCount++;
if (shouldWriteError) throw Exception();
writtenData = newData;
}

(AsyncValue<int>, void Function(int?)) testCapsule(
CapsuleHandle use,
) {
final (state, setState) = use.state<int?>(null);
final hydrateState = use.hydrate(
state != null ? Some(state) : const None<int>(),
read: read,
write: write,
);
return (hydrateState, setState);
}

final container = useContainer();
final setState = container.read(testCapsule).$2;

Future<void> setAndExpect({
required int? setStateTo,
required AsyncValue<int> expectInitialHydratedStateToEqual,
required int? expectNewHydratedStateToEqual, // null for AsyncError
}) async {
final initialWriteCount = writeCount;
final finalWriteCount = setStateTo != null && setStateTo != writtenData
? initialWriteCount + 1
: initialWriteCount;

setState(setStateTo);

expect(
container.read(testCapsule).$1,
equals(expectInitialHydratedStateToEqual),
);
expect(writeCount, equals(initialWriteCount));

await null;

if (expectNewHydratedStateToEqual == null) {
container.read(testCapsule).$1 as AsyncError; // should not throw
} else {
expect(
container.read(testCapsule).$1,
equals(AsyncData(expectNewHydratedStateToEqual)),
);
}
expect(writeCount, equals(finalWriteCount));
}

await setAndExpect(
setStateTo: null,
expectInitialHydratedStateToEqual: const AsyncLoading(None<int>()),
expectNewHydratedStateToEqual: -1,
);
await setAndExpect(
setStateTo: 0,
expectInitialHydratedStateToEqual: const AsyncLoading(Some(-1)),
expectNewHydratedStateToEqual: 0,
);
await setAndExpect(
setStateTo: null,
expectInitialHydratedStateToEqual: const AsyncData(0),
expectNewHydratedStateToEqual: 0,
);
await setAndExpect(
setStateTo: 1,
expectInitialHydratedStateToEqual: const AsyncLoading(Some(0)),
expectNewHydratedStateToEqual: 1,
);
await setAndExpect(
setStateTo: 1,
expectInitialHydratedStateToEqual: const AsyncData(1),
expectNewHydratedStateToEqual: 1,
);
await setAndExpect(
setStateTo: 2,
expectInitialHydratedStateToEqual: const AsyncLoading(Some(1)),
expectNewHydratedStateToEqual: 2,
);
shouldWriteError = true;
await setAndExpect(
setStateTo: 3,
expectInitialHydratedStateToEqual: const AsyncLoading(Some(2)),
expectNewHydratedStateToEqual: null,
);
shouldWriteError = false;
await setAndExpect(
setStateTo: 4,
expectInitialHydratedStateToEqual: const AsyncLoading(Some(2)),
expectNewHydratedStateToEqual: 4,
);
});

group('side effect transactions', () {
(
(int, void Function(int)),
Expand Down

0 comments on commit 04dd82f

Please sign in to comment.