ReactiveX / rxdart

The Reactive Extensions for Dart
http://reactivex.io
Apache License 2.0
3.35k stars 272 forks source link

shareValue() not behaving as expected #452

Open feinstein opened 4 years ago

feinstein commented 4 years ago

Sorry if this maybe is a question, but I don't think that shareValue() is behaving as it was expected to (or at least how I understood it was supposed to work, which in this case it could be something to add to the docs).

I have this code (adapted here for simplicity) on my library:

static final StreamController<FirebaseUser> _userReloadedStreamController = 
    StreamController<FirebaseUser>.broadcast();
static Stream<FirebaseUser> get onUserReloaded => _userReloadedStreamController.stream;
...
onAuthStateChangedOrReloaded = 
    Rx.merge([FirebaseAuth.instance.onAuthStateChanged, onUserReloaded]).shareValue();

Where I am returning a Stream that is supposed to merge 2 streams and every time someone listens to it, they will be able to receive the last emitted value. FirebaseAuth.instance.onAuthStateChanged has an implementation that resembles a Behavior Subject, every time there's a new subscriptions it emits the last value, but it doesn't use RxDart.

I was testing this inside an App where there's just one subscriber at first, when this subscriber is first created things work fine, but then the subscriber is destroyed and when a new subscriber comes the Stream stops behaving normally, there are only 1 or 0 subscribers at any given time. Here are some logs to illustrate better what's going on:

This is the Widget I am using for testing:

class Wrapper extends StatefulWidget {
  Wrapper() {
    print('created');
  }

  @override
  _WrapperState createState() => _WrapperState();
}

class _WrapperState extends State<Wrapper> {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<FirebaseUser>(
        stream: FirebaseAuth.instance.onAuthStateChanged,
        builder: (context, snapshot) {
          print(snapshot.connectionState);
          print('snapshot.data(user): ${snapshot.data}');
          return Container();
        });
  }

  @override
  void initState() {
    super.initState();
    print('init');
  }

  @override
  void dispose() {
    print('disposed');
    super.dispose();
  }
}

It just logs the different states of the Widget.

Using the stream: FirebaseAuth.instance.onAuthStateChanged These are the logs that I get when Wrapper is created and destroyed multiple times:

I/flutter (21763): created I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): null I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): null I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): null I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): null I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser')

Basically, it goes from ConnectionState.waiting to ConnectionState.active as expected.

Now, these are the logs that I get when I use onAuthStateChangedOrReloaded (the one using shareValue()) instead:

I/flutter (21763): created I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): null I/flutter (21763): ConnectionState.active I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): ConnectionState.done I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed I/flutter (21763): init I/flutter (21763): ConnectionState.waiting I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): ConnectionState.done I/flutter (21763): snapshot.data(user): FirebaseUser(Instance of 'PlatformUser') I/flutter (21763): disposed

As you can see, the stream created by shareValue() works as expected on the first subscription, than later it goes instantaneously from ConnectionState.waiting to ConnectionState.done.

Is this the expected behavior? I couldn't find it documented anywhere. Should I be doing something differently?

frankpepermans commented 4 years ago

Which version of rxdart are you using?

brianegan commented 4 years ago

Hey there -- that's actually the expected behavior of shareValue: It will start listening to the source stream when first listened to, then shut everything down when there are no more subscribers. I've tried to document it as part of the extension method: https://pub.dev/documentation/rxdart/latest/rx/ConnectableStreamExtensions/shareValue.html

This method is uses the refCount operator under the hood, which comes from Rx: https://pub.dev/documentation/rxdart/latest/rx/ConnectableStreamExtensions/shareValue.html

However, despite the fact that this is a standard Rx operator and is useful for some cases, many folks are surprised by / do not want this behavior, and want the Stream to keep working after the final subscription is cancelled.

Therefore, I've thought about implemented the operators asBroadcastValueStream and asBroadcastValueStreamSeeded. These would convert any Stream into a broadcast ValueStream, but would not do the reference counting that shareValue is doing, nor would it replay the latest event to the listener (similar to how asBroadcastStream does not replay values).

Would that work / be helpfu?

feinstein commented 4 years ago

Hmm I think I misunderstood what "shutsdown" means on the docs. I thought it would shutdown until someone subscribes again. Still what the logs show me is that the stream didn't completely shutdown, it emits a value and then closes, if it was truly shut down shouldn't it just don't emit at all?

I was using asBroadcastStream, but I changed to shareValue because I want to replay the latest event on each new listener.

Are there any ways of having a broadcast stream that replays the latest value?

brianegan commented 4 years ago

Thanks for the feedback, I'll try to rewrite those docs so they're easier to understand. In this case, since we don't have an operator fits your use case perfectly, you need to manage a BehaviorSubjector ConnectableStream to achieve this.

In your case, my recommendation would be like this:

class StreamContainer {
  final StreamController<FirebaseUser> _userReloadedStreamController = 
    StreamController<FirebaseUser>.broadcast();
  Stream<FirebaseUser> get onUserReloaded => _userReloadedStreamController.stream;
  Stream _onAuthStateChangedOrReloaded;
  StreamSubscription _connectionSubscription;

  Stream get onAuthStateChangedOrReloaded  => _onAuthStateChangedOrReloaded;

  void init() {
    // Manually Create the "Published" Stream using `publishValue` instead of `shareValue`
     _onAuthStateChangedOrReloaded = Rx.merge([FirebaseAuth.instance.onAuthStateChanged, onUserReloaded]).publishValue();

    // Ask the "Published" stream to "connect" to the underlying Merged Stream and start listening for values. Also store the subscription so we can cancel it later.
    _ connectionSubscription = _onAuthStateChangedOrReloaded.connect();
  }

  void dispose() {
    // When you're done using the Stream entirely, call the dispose method on this class which will cancel the underlying subscription to Rx.merge
    _connectionSubscription?.cancel();
  } 
}

Does that work for ya?

feinstein commented 4 years ago

Managing the stream was my plan B, I am just a bit disappointed there isn't an operator for something as simple as this.

I will try to adapt your example as my class is actually made of a bunch of static fields so it's easier for the users, but harder to control initialization and disposal, thanks Brian.

feinstein commented 4 years ago

@brianegan as far I have read Publish/Connect doesn't buffer the last value, so is your recommendation buffering it?

feinstein commented 4 years ago

Never mind, I can see it does buffer it in the docs, I guess other Rx Implementations don't buffer it, which confused me.

brianegan commented 4 years ago

Yah, there are three types of publish (Connectable Streams in general can be confusing): publish (no replay), publishValue (replay latest value), publishReplay (replay all values up to a max).

I am just a bit disappointed there isn't an operator for something as simple as this.

This is a tough call -- it's hard to have an operator for all of these different combinatorial cases without making a huge API. This might be a good time for an operator / extension you maintain in-house for now?

feinstein commented 4 years ago

For my particular use case I think I will just do a publishValue()..connect();, as the stream is supposed to live for the entire duration of the App and be initialized as soon as it starts, so no need to ever cancel it.

I understand your point regarding a big API, I just thought "a buferred broadcast stream" was a common call, as the difference between a single subscription stream and a broadcast stream (besides the obvious one) is that broadcast streams don't buffer values, so this new operator could remove the only missing feature from broadcast streams that single subscription streams have.

feinstein commented 4 years ago

@brianegan I am getting an error while trying to run some tests, here's my code:

  static Stream<FirebaseUser> _onAuthStateChangedOrReloaded =
      _mergeWithOnUserReloaded(_auth.onAuthStateChanged);

  static Stream<FirebaseUser> _mergeWithOnUserReloaded(Stream<FirebaseUser> stream) {
    return Rx.merge([stream, onUserReloaded]).publishValue()..connect();
  }

And here are the tests that are breaking:

test('Reloads emits the new User in onAuthStateChangedOrReloaded', () async {
  expect(FirebaseUserReloader.onAuthStateChangedOrReloaded,
      emitsInOrder([mockOldUser, mockNewUser]));
  await FirebaseUserReloader.reloadCurrentUser();
});

test('Current user is emmited when subscribing to onAuthStateChangedOrReloaded', () {
  expect(FirebaseUserReloader.onAuthStateChangedOrReloaded, emits(mockOldUser));
});

And these are the errors:

dart:core Object.noSuchMethod package:async/src/stream_queue.dart 472:19 StreamQueue._pause package:async/src/stream_queue.dart 434:7 StreamQueue._updateRequests package:async/src/stream_queue.dart 514:5 StreamQueue._addResult package:async/src/stream_queue.dart 484:9 StreamQueue._ensureListening. dart:async _EventSinkWrapper.add package:rxdart/src/transformers/start_with.dart 18:17 _StartWithStreamSink.add dart:async _StreamSinkWrapper.add package:rxdart/src/transformers/start_with.dart 59:13 _StartWithStreamSink._safeAddFirstEvent package:rxdart/src/transformers/start_with.dart 40:13 _StartWithStreamSink.onListen package:rxdart/src/utils/forwarding_stream.dart 18:22 forwardStream. dart:async _BoundSinkStream.listen package:rxdart/src/streams/defer.dart 37:18 DeferStream.listen dart:async StreamView.listen package:async/src/stream_queue.dart 483:31 StreamQueue._ensureListening package:async/src/stream_queue.dart 542:7 StreamQueue._addRequest package:async/src/stream_queue.dart 299:5 StreamQueue.startTransaction package:test_api expect package:flutter_test/src/widget_tester.dart 234:3 expect test\firebase_user_stream_test.dart 70:7 main.. ===== asynchronous gap =========================== dart:async _BoundSinkStream.listen package:rxdart/src/streams/defer.dart 37:18 DeferStream.listen dart:async StreamView.listen package:async/src/stream_queue.dart 483:31 StreamQueue._ensureListening package:async/src/stream_queue.dart 542:7 StreamQueue._addRequest package:async/src/stream_queue.dart 299:5 StreamQueue.startTransaction package:test_api expect package:flutter_test/src/widget_tester.dart 234:3 expect test\firebase_user_stream_test.dart 70:7 main..

NoSuchMethodError: The method 'pause' was called on null. Receiver: null Tried calling: pause()

I think dart stream tests use a StreamQueue and it's trying to pause this Connectable Stream, but Connectable Streams don't actually allow pausing, is this correct? How do we test them then?

hoc081098 commented 4 years ago

Using expectAsync1:

FirebaseUserReloader.onAuthStateChangedOrReloaded.listen(
  expectAsync1((user) => expect(user, mockOldUser))
);
feinstein commented 4 years ago

Thanks @hoc081098, but how can I use emitsInOrder with expectAsync1?

feinstein commented 4 years ago

I tried this:

test('Reloads emits the new User in onAuthStateChangedOrReloaded', () async {
  FirebaseUserReloader.onAuthStateChangedOrReloaded.listen(expectAsync1(
      (user) => expect(user, emitsInOrder([mockOldUser, mockNewUser]))));

  await FirebaseUserReloader.reloadCurrentUser();
});

And I got this:

package:test_api                                       expect
package:flutter_test/src/widget_tester.dart 234:3      expect
test\firebase_user_stream_test.dart 71:21              main.<fn>.<fn>.<fn>
dart:async                                             _EventSinkWrapper.add
package:rxdart/src/transformers/start_with.dart 18:17  _StartWithStreamSink.add
dart:async                                             _StreamSinkWrapper.add
package:rxdart/src/transformers/start_with.dart 59:13  _StartWithStreamSink._safeAddFirstEvent
package:rxdart/src/transformers/start_with.dart 40:13  _StartWithStreamSink.onListen
package:rxdart/src/utils/forwarding_stream.dart 18:22  forwardStream.<fn>
dart:async                                             _BoundSinkStream.listen
package:rxdart/src/streams/defer.dart 37:18            DeferStream.listen
dart:async                                             StreamView.listen
test\firebase_user_stream_test.dart 70:57              main.<fn>.<fn>
===== asynchronous gap ===========================
dart:async                                             _BoundSinkStream.listen
package:rxdart/src/streams/defer.dart 37:18            DeferStream.listen
dart:async                                             StreamView.listen
test\firebase_user_stream_test.dart 70:57              main.<fn>.<fn>
Callback called more times than expected (1).
dart:async                                             _EventSinkWrapper.add
package:rxdart/src/transformers/start_with.dart 18:17  _StartWithStreamSink.add
dart:async                                             _BroadcastStreamController.add
package:rxdart/src/subjects/subject.dart 141:17        Subject._add
package:rxdart/src/subjects/subject.dart 135:5         Subject.add
===== asynchronous gap ===========================
dart:async                                             _BoundSinkStream.listen
package:rxdart/src/streams/defer.dart 37:18            DeferStream.listen
dart:async                                             StreamView.listen
test\firebase_user_stream_test.dart 70:57              main.<fn>.<fn>

Expected: should do the following in order:
          * emit an event that MockFirebaseUser:<MockFirebaseUser>
          * emit an event that MockFirebaseUser:<MockFirebaseUser>
  Actual: MockFirebaseUser:<MockFirebaseUser>
   Which: was not a Stream or a StreamQueue

As far as I understand the stream will be disconnected from emitsInOrder by expectAsync1, so I have no idea on how to make them work together.

Sorry if I am abusing guys, I am pretty lost on this one, I can't find any references online on how to test this.

hoc081098 commented 4 years ago

Trying like this

final expected = [mockOldUser, mockNewUser];
var i = 0;

stream.listen(
  expectAsync1(
    (user) => expect(user, expected[i++]),
    count: expected.length,
  )
);

Sent from my Redmi 7A using FastHub

feinstein commented 4 years ago

Thanks @hoc081098 it works! It's tricky to test those things, I was looking at RxDart tests and I could only find expectLater and it doesn't work.

feinstein commented 4 years ago

FYI the tests are interacting between each other because the steam subscription wasn't closed, so I am adding this modification now.

feinstein commented 4 years ago

@brianegan I was looking at this behavior a bit deeper and I found something curious, if you look into my first post you can see that the StreamBuilder shows a ConnectionState.done for the shareValue() stream, but it still can reconnect to that same stream again, get the last value and complete with a ConnectionState.done. Is this the expected behavior? I thought once the number of subscribers gets to 0 no one can subscribe anymore and get any values from it.

brianegan commented 4 years ago

I understand your point regarding a big API, I just thought "a buferred broadcast stream" was a common call, as the difference between a single subscription stream and a broadcast stream (besides the obvious one) is that broadcast streams don't buffer values, so this new operator could remove the only missing feature from broadcast streams that single subscription streams have.

Agreed, thats why I'd thought about adding asBroadcastValueStream / asBroadcastValueStreamSeeded to do just that, and I'm specifically thinking of integrating with Flutter for these operators.

In my head, these operators would convert a normal Stream into a Broadcast Stream that allows you to read the latest value, but it would NOT replay the latest value the way shareValue does, nor would it shut down after the number of listeners goes to 0. In fact, I think that's actually best when working with StreamBuilder widgets.

An example might look like this:

final onAuthStateChangedOrReloaded = 
    Rx.merge([FirebaseAuth.instance.onAuthStateChanged, onUserReloaded]).asBroadcastValueStream();

// Later in your Widgets
StreamBuilder(
  initialData: onAuthStateChangedOrReloaded.value,
  stream: onAuthStateChangedOrReloaded,
  builder: (context, snapshot) => Text('${snapshot.data}'),
);

Basically, the asBroadcastValueStream would not replay the value, but you could still provide the latest captured value to the StreamBuilder widget's initialData property. This is actually better IMO than replaying the value, because it requires Flutter only build the Widget once. With replays, the StreamBuilder actually has to build twice (once with no data, then once the first data event is delivered by the Stream).

What do you think about that approach?

brianegan commented 4 years ago

I was looking at this behavior a bit deeper and I found something curious, if you look into my first post you can see that the StreamBuilder shows a ConnectionState.done for the shareValue() stream, but it still can reconnect to that same stream again, get the last value and complete with a ConnectionState.done. Is this the expected behavior? I thought once the number of subscribers gets to 0 no one can subscribe anymore and get any values from it.

Sounds fishy indeed. I'll write some tests to see what's going on there -- thanks for the info!

feinstein commented 4 years ago

I discovered a bug on StreamBuilder while investigating this, so I made a sample project to report it. You can use it for testing this if you want, but you will have to change my library's reference on pubspec.yaml to firebase_user_stream: 1.0.0-alpha.2 as this is the version that shows this behavior. Check the readme for some quick instructions on how to trigger it.

feinstein commented 4 years ago

What do you think about that approach?

I like it a lot for StreamBuilder, makes sense.

jonsamwell commented 4 years ago

@brianegan I think a replay observable that was long lived and didn't use refCount would be a valuable addition.

In regards to the stream builder building twice even if passed a type of stream where the value is stored so it can be access synchronously I create the package https://pub.dev/packages/flutter_rx_stream_builder a while ago.

hoc081098 commented 4 years ago

Oh, I'm working on my branch: https://github.com/hoc081098/rxdart/tree/hoc081098/value_subject to introduce ValueSubject :)

feinstein commented 4 years ago

@brianegan I filed a bug here on StreamBuilder, but they complained my sample uses external libraries. I am having a hard time reproducing this using the standard Stream library, if you could give me some insights on this I would appreciate it.

rich-j commented 4 years ago

@feinstein Your initial problem description describes an issue with re-subscribing that sounds similar to what we've encountered. We use an Rx.defer pattern as I describe in this post https://github.com/ReactiveX/rxdart/issues/455#issuecomment-622561853.

feinstein commented 4 years ago

@rich-j thanks, I will take a look, but .publishValue()..connect(); is working for my use case.

rullyalves commented 2 years ago

in a project I participate, I ended up running into this same problem, but in my use case, prevented from canceling the StreamSubscription returned by ConnectableStream.connect();

I will create an extension and add it to rxdart with a PR.

follow the example:


extension ValueExtensions<T> on Stream<T> {

  Stream<T> asValueStream() {
    StreamSubscription<T>? subscription;

    final stream = doOnDone((){
       // When source stream done, cancel [ConnectableStream] subscription
       subscription?.cancel();
    })
    .publishValue();

    // Intructs the [ConnectableStream to begin emiting items]
    subscription = stream.connect();

    return stream;
  }

  Stream<T> asValueSeededStream(T seedValue) {
    StreamSubscription<T>? subscription;

    final stream = doOnDone((){
       // When source stream done, cancel [ConnectableStream] subscription
       subscription?.cancel();
    })
    .publishValueSeeded(seedValue);

    // Intructs the [ConnectableStream to begin emiting items]
    subscription = stream.connect();

    return stream;
  }

 Stream<T> asReplayStream({int? maxSize}) {
    StreamSubscription<T>? subscription;

    final stream = doOnDone((){
       // When source stream done, cancel [ConnectableStream] subscription
       subscription?.cancel();
    })
    .publishReplay(maxSize: maxSize);

    // Intructs the [ConnectableStream to begin emiting items]
    subscription = stream.connect();

    return stream;
  }

 Stream<T> asBroadcast() {
    StreamSubscription<T>? subscription;

    final stream = doOnDone((){
       // When source stream done, cancel [ConnectableStream] subscription
       subscription?.cancel();
    })
    .publish();

    // Intructs the [ConnectableStream to begin emiting items]
    subscription = stream.connect();

    return stream;
  }

}
lukepighetti commented 2 years ago

asBroadcastValueStream would be very much appreciated

rullyalves commented 2 years ago

It can be done, I would like to open a PR, but I would like the opinion of the repository maintainers as well.

Azbesciak commented 2 years ago

Guys, I came there from many years of experience in rxjs and java's reactor project... and this lib is so strange to me... BehaviorSubject which is canceled after last subscription - what is a purpose of a seed then? I usually use the BehaviorSubject as a value container, to notiffy all listeners - now and whenever - that value is changed, but also the current value. Now the question is on the listener side, whether this subject is still valid or canceled. What if canceled because there are no listeners any more, what then I supposed to do? Lie down and cry?

frankpepermans commented 2 years ago

@Azbesciak Can you create a minimal example?

Always keep in mind that rxdart is built on top of the Dart streams, which can cause differences with other Rx implementations, but the following code:

void main() async {
  final s = BehaviorSubject.seeded(1);
  late StreamSubscription ss;

  onListen(int subscriberId) => (int event) => print('subscriberId: $subscriberId, value: $event');
  onDone(int subscriberId) => () {
    ss.cancel();
    print('subscriberId: $subscriberId canceled');
  };

  ss = s.listen(onListen(1), onDone: onDone(1));

  // add all data
  s.add(2);
  s.add(3);
  s.add(4);
  s.close();

  await Future.delayed(const Duration(seconds: 1));
  ss = s.listen(onListen(2), onDone: onDone(2));
  await Future.delayed(const Duration(seconds: 1));
  ss = s.listen(onListen(3), onDone: onDone(3));
  await Future.delayed(const Duration(seconds: 1));
  ss = s.listen(onListen(4), onDone: onDone(4));
}

gives:

subscriberId: 1, value: 1
subscriberId: 1, value: 2
subscriberId: 1, value: 3
subscriberId: 1, value: 4
subscriberId: 1 canceled
subscriberId: 2, value: 4
subscriberId: 2 canceled
subscriberId: 3, value: 4
subscriberId: 3 canceled
subscriberId: 4, value: 4
subscriberId: 4 canceled
Azbesciak commented 2 years ago

I need to admit it was my mental shortcut, I thought about shareValue operator on the stream. I have something like stream of a stream, with flatten value output, like below

_input = BehaviorSubject<Stream<T?>>.seeded(seed.asStream());
output = _input
    .switchMap(
        (v) => v.asBroadcastStream(),
     )
     .asBroadcastStream()
     .shareValue()

If you subscribe once, everything is ok, but if you unsubscribe and then subscribe, you won't receive any value later. It does not come through shareValue.

I needed to add dummy listen on output (so I have some kind of memory leak...), and then it works.

frankpepermans commented 2 years ago

That's because each new subscription calls shareValue over again, which promotes the Stream to a behavior subject, but then it still needs a first event played out.

Azbesciak commented 2 years ago

But this output is a instance field, which is returned later to multiple consumers. Also I have checked and there is a cancel event called on it, just once. Any later subscription does not change it - called on output.

See https://github.com/Azbesciak/Stocker/blob/main/lib/preferences/watchable_preferences.dart#L7-L18 please

frankpepermans commented 2 years ago

Then maybe try a full code example that fails?

I kinda adapted yours to:

void main() async {
  final input = BehaviorSubject<Stream>.seeded(Stream.fromIterable([1, 2, 3]));
  final output = input
      .switchMap((v) => v.asBroadcastStream())
      .asBroadcastStream()
      .shareValue();
  late StreamSubscription ss;

  print('first');
  ss = output.listen((event) {
    print(event);

    if (event == 3) {
      ss.cancel();
      print('canceled first');
    }
  });

  await Future.delayed(const Duration(seconds: 1));

  print('second');
  output.listen(print);
}

which does:

first
1
2
3
canceled first
second
3
frankpepermans commented 2 years ago

If you can isolate the issue in a standalone example that you can copy/paste here, then I'll gladly run it and take another look

Azbesciak commented 2 years ago

Please try this out

test('how shareValue works', () async {
    final input =
        BehaviorSubject<Stream>.seeded(Stream.fromIterable([1, 2, 3]));
    final output = input
        .switchMap((v) => v.asBroadcastStream())
        .asBroadcastStream()
        .shareValue();
    late StreamSubscription ss;

    print('first');
    ss = output.listen((event) {
      print('listen 1: ${event}');

      if (event == 3) {
        ss.cancel();
        print('canceled first');
      }
    });

    await Future.delayed(const Duration(seconds: 1));
    print('second');
    output.listen((v) => print('listen 2: ${v}'));
    input.add(Stream.value(4));
    input.add(Stream.value(5));
    input.add(Stream.value(6));
    output.listen((v) => print('listen 3: ${v}'));
    await Future.delayed(const Duration(seconds: 1));
  });

the result is

first
listen 1: 1
listen 1: 2
listen 1: 3
canceled first
second
listen 2: 3
listen 3: 3

If you comment out ss.cancel you would get this

first
listen 1: 1
listen 1: 2
listen 1: 3
canceled first
second
listen 2: 3
listen 3: 3
listen 1: 4
listen 2: 4
listen 3: 4
listen 1: 5
listen 2: 5
listen 3: 5
listen 1: 6
listen 2: 6
listen 3: 6

My conclusion: after the first cancel nothing later impacts the input stream If you would add another

output.listen((event) {});

before the sub and after the print('first'), it would also work without commenting on the cancelation.

frankpepermans commented 2 years ago

Ok so to minify it:

void main() async {
  final input = BehaviorSubject<int>.seeded(3);
  final output = input.publishValue().refCount(); // is the same as input.shareValue()
  late StreamSubscription ss;

  print('first');
  ss = output.listen((event) {
    print('listen 1: ${event}');

    if (event == 3) {
      //ss.cancel();
      print('canceled first');
    }
  });

  await Future.delayed(const Duration(seconds: 1));
  print('second');
  output.listen((v) => print('listen 2: ${v}'));
  input.add(4);
  input.add(5);
  input.add(6);
  output.listen((v) => print('listen 3: ${v}'));
  await Future.delayed(const Duration(seconds: 1));
}

The culprit is refCount in your case, as it indeed shuts down the inner subscription when nothing else is listening.

It's literally been ages, but I assume this was done by design, as otherwise the inner subscription would never actually cancel.

But indeed this is a bug with shareValue

frankpepermans commented 2 years ago

Need to check if this is at all correct, but refCount could be updated to:

@override
  R refCount() {
    if (_canReuse(_ConnectableStreamUse.refCount)) {
      return _subject as R;
    }

    late StreamSubscription<T> subscription;

    _subject.onListen = () {
      subscription = _connection;

      _connection._source.onDone(subscription.cancel);
    };

    return _subject as R;
  }
AlexDochioiu commented 2 years ago

Just adding a comment as well to reiterate the same concerns, hoping someone would fix them. The behaviour of shareValue just makes no sense. Issue 1: If I use it, I'd like to be able to detach/re-attach listeners without cancelling the underlying flow. So, if there is an active subscription, I'd like to attach another listener. If there's no subscription, I'd like it to create one from scratch. (aka when all listeners go away and I attach a listener I want it to behave the same way as the first time when I attached a listener) Issue 2: If the underlying stream gets cancelled, then it should be properly cancelled. This means the app should crash if I attach a listener to it. This way we won't waste pointless time trying to figure out why the hell we get only one emission and then a Done event. That to me indicates that it turns into a cold stream instead of cancelling it. Issue 3: Documentation is not clear enough. It does not make it clear that once the underlying stream is cancelled, it will behave like a cold flow emitting only the last emission before cancelling.

frankpepermans commented 2 years ago

I could take a look, but will be a while since I'm on vacation atm, Stream.multi might be a better candidate for this going forward

hoc081098 commented 2 years ago

@AlexDochioiu refCount will cancel upstream subscription if the number of subscriptions goes down to zero. That is expected behavior.

AlexDochioiu commented 2 years ago

@hoc081098 I did not mention refCount anywhere and that was on purpose. refCount is an implementation detail which means that there's no requirement that shareValue has to use it.

hoc081098 commented 2 years ago

You can use publishValueSeeded() and connect()

class ShareValueWithScope<T> {
    final Stream<T> _upstream;
    final T _seeded;

    StreamSubscription<T>? _sub;
    ValueConnectableStream<T>? _s;

    ValueStream<T> get valueStream => _s!;

    ShareValueWithScope(
        final Stream<T> Function() factory,
        this._seeded,
    ) : _upstream = Rx.defer(factory, reusable: true);

   void startSharing() {
       _sub?.cancel();
       _s = upstream.publishValueSeeded(seeded);
       _sub = _s.connect();
   }

   void stopSharing() {
       _sub.cancel();
       _sub = null;
   }

And use

final scope = ShareValueWithScope<int>(
    () => Stream.periodic(1000, (int i) => i),
    0
);

scope.startSharing();

scope.valueStream.listen(print);
scope.valueStream.listen(print);

scope.stopSharing();
AlexDochioiu commented 2 years ago

My personal expectation (desire) is that shareValue should behave pretty much the same as StateFlow in android coroutines.

AlexDochioiu commented 2 years ago

@hoc081098 I am not here seeking a solution since I already fixed my problem. I mostly want to reiterate that the behaviour seems odd and see if there's a chance to improve on it.

hoc081098 commented 2 years ago

@hoc081098 I am not here seeking a solution since I already fixed my problem. I mostly want to reiterate that the behaviour seems odd and see if there's a chance to improve on it.

The main issue is that we cannot listen to non-broadcast Dart Stream twice. It is not identical when comparing Dart Stream vs other reactive streams such as RxJava's, RxJs's, RxSwift's Observable or Kotlinx Coroutines Flow

frankpepermans commented 2 years ago

Idd Dart streams are a bit of their own thing, making some rx features super tricky to implement. Still, and I'm actually a bit out of the loop regarding rxdart, but a Stream.multi might actually help here, no?

lukepighetti commented 1 year ago

Ran into this again today. Still no way to disable ref counting on shareValueSeeded()?

Here's my escape hatch. Use at own risk:

final $selectedList = $selectedListIdStream
    .flatMap((it) => it?.let($rtdb.watchList) ?? Stream.value(<GroceryItem>[]))
    .distinctUnique()
    .shareValueSeeded([]).keepAlive();

extension HackJobValueStreamExtensions<T> on ValueStream<T> {
  ValueStream<T> keepAlive() {
    listen((_) {});
    return this;
  }
}
hoc081098 commented 1 year ago

@lukepighetti this is the same as ValueStream<...> stream = publishValueSeeded(...)..connect()

Sent from my 2201117TG using FastHub