Skip to content
This repository has been archived by the owner on May 13, 2023. It is now read-only.

stream() does not work properly with nested StreamBuilders #44

Closed
akram-95 opened this issue Aug 20, 2021 · 21 comments
Closed

stream() does not work properly with nested StreamBuilders #44

akram-95 opened this issue Aug 20, 2021 · 21 comments
Labels
realtime This issue or pull request is related to realtime

Comments

@akram-95
Copy link

Bug report

The Streambuilder doesn't update in real-time , it just do that when I add a new row to the table but when I update a row in the table , it doesn't update

Screenshots

Upload.from.GitHub.for.iOS.MOV
@akram-95 akram-95 added the bug Something isn't working label Aug 20, 2021
@dshukertjr
Copy link
Member

@akram-95

Thanks for opening this issue. Please do follow the issue template so that we can reproduce this issue on our end. Update events seem to work on my end. If you could share a minimal code snippet to reproduce this issue, that would be great.

@akram-95
Copy link
Author

here is the code of that

StreamBuilder<List<Map<String, dynamic>>>(
              stream: DatabaseHelper()
                  .client
                  .from("Questions")
                  .stream()
                  .order("timestamp")
                  .execute(),
              builder: (context, questions) {
                if (!questions.hasData) {
                  return const Center(child: CupertinoActivityIndicator());
                }
                List<Question> questionsList =
                    questions.data!.map((e) => Question.fromJson(e)).toList();

                return Expanded(
                  flex: 1,
                  child: ListView.builder(
                    shrinkWrap: true,
                    physics: const AlwaysScrollableScrollPhysics(),
                    controller: questionsAnswersController.scrollController,
                    itemCount: questionsList.length,
                    itemBuilder: (_, index) {
                      return StreamBuilder<List<Map<String, dynamic>>>(
                        stream: DatabaseHelper()
                            .client
                            .from(
                                "IndividualProfile:userId=eq.${questionsList[index].authorId}")
                            .stream()
                            .order("timestamp")
                            .limit(1)
                            .execute(),
                        builder: (context, profile) {
                          if (!profile.hasData) {
                            return const Center(child: SizedBox());
                          }
                          if (profile.hasError || profile.error != null) {
                            return const Text("Text");
                          }
                          if (profile.data!.isEmpty) {
                            return Container();
                          }

                          final dataProfile = profile.data!.first;
                          IndividualProfile individualProfile =
                              IndividualProfile.fromJson(dataProfile);
                          return widgetCard(
                              individualProfile, questionsList[index]);
                        },
                      );
                    },
                  ),
                );
              }),

@dshukertjr
Copy link
Member

dshukertjr commented Aug 21, 2021

@akram-95
Okay, I am able to reproduce it. I haven't been able to dig all the way down to the cause of this, but my guess is that it is related to this characteristics of Supabase

While the client may join any number of topics on any number of channels, the client may only hold a single subscription for each unique topic at any given time. When attempting to create a duplicate subscription, the server will close the existing channel, log a warning, and spawn a new channel for the topic. The client will have their channel.onClose callbacks fired for the existing channel, and the new channel join will have its receive hooks processed as normal.
From supabase-js readme.md

When the outer StreamBuilder rebuilds, the inner StreamBuilders are quickly rebuilt again, causing the sockets to quickly disconnect and reconnect again.

You can probably avoid this from happening if you could combine the two Streams together to make sure this quick reconnection does not happen. I'm thinking something like the following. Note that this code is just a quick example that I wrote up, so might not work, but hopefully you can understand the idea.

This not only solves the problem you are facing, it gets rid of the flash that you experience whenever the parent widget rebuilds!

class Example extends StatefulWidget {
  const Example({Key? key}) : super(key: key);

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

class _ExampleState extends State<Example> {
  late StreamSubscription _questionsListener;

  // Stores the StreamSubscription where the userId is the key of the map
  final Map<String, StreamSubscription> _profileSubscriptions = {};

  // Stores the profile data of users where the key is their uid
  final Map<String, Profile> _profiles = {};

  final List<Question> _questions = [];

  @override
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (context, index) {
      final question = _questions[index];
      return ListTile(
        title: Text(question.title),
        subtitle: Text(_profiles[question.authorId].name),
      );
    });
  }

  void _loadData() {
    _questionsListener =
        supabase.from('questions').stream().execute().listen((questions) {
      _questions.addAll(Question.fromEvent(questions));

      for (final question in questions) {
        // Make sure only create the listener if a listener for the user has not been created yet
        if (_profileSubscriptions[question['authorId']] == null) {
          _profileSubscriptions[question['authorId']] = supabase
              .from('IndividualProfile:userId=eq.${question['authorId']}')
              .stream()
              .execute()
              .listen((event) {
            final profile = Profile.fromEvent(event);
            _profiles[profile.uid] = profile;
            setState(() {});
          });
        }
        setState(() {});
      }
    });
  }

  @override
  void initState() {
    _loadData();
    super.initState();
  }

  @override
  void dispose() {
    _questionsListener.cancel();
    for (final profileListener in _profileSubscriptions.values) {
      profileListener.cancel();
    }
    super.dispose();
  }
}

@dshukertjr
Copy link
Member

Will dig deeper into this issue and share anything that I find here in the meanwhile!

@akram-95
Copy link
Author

@dshukertjr Thank you so much for your support , but unfortunately it doesn't work and I did notice that when I update an array of varchar , it's not empty, it has the value [archa]
From where does it come ?

@akram-95
Copy link
Author

when i update the array of char stars , in the result of execution it's true but in the listen of stream, i get the value [archa]

image

@dshukertjr
Copy link
Member

@akram-95

Could you please share your table schema as well as the code you are using to produce the above log?

@akram-95
Copy link
Author

@akram-95
Copy link
Author

@dshukertjr On the table the array stars is empty and when I update it , it becomes an new user Id when I want to listen to that I get [archa]

@akram-95
Copy link
Author

the code for updating a Question
image
the code for listening to changes in the table Questions
image

@dshukertjr
Copy link
Member

dshukertjr commented Aug 21, 2021

@akram-95

When you share your code, it is better to share it as text rather than image. That way other people can just copy and paste!

In the first code block, could you share what you are assigning to newQuestionValue?

Also, is the reason why you have stars and likes etc... in Questions table because that is how you used to do it in Firebase? Typically in SQL database, you do it like this? With this approach, you can utilize row level security as well. With your current setup, I think it's impossible or at least pretty hard to make it completely secure with row level security. You always face malicious user wiping the star or like data.

@akram-95
Copy link
Author

i used stars and likes as an array for every Question , that's the only reason , i did it always like that using firebase
that's the code for the first Block

  Future setLikeQuestionBySupabase(
      Question question, likeArt likeart, String followerId) async {
    var result = await client
        .from("Questions")
        .select()
        .eq("questionId", question.questionId)
        .single()
        .execute();

    if (result.error != null) {
      return ProcessState.failed;
    }

    Question newQuestionValue = Question.fromJson(result.toJson()["data"]);

    if (likeart == likeArt.star) {
      newQuestionValue.dislikes.removeWhere((element) => element == followerId);
      newQuestionValue.likes.removeWhere((element) => element == followerId);
      if (newQuestionValue.stars.contains(followerId)) {
        newQuestionValue.stars.removeWhere((element) => element == followerId);
      } else {
        newQuestionValue.stars.add(followerId);
      }
    }
    if (likeart == likeArt.like) {
      newQuestionValue.stars.removeWhere((element) => element == followerId);
      newQuestionValue.dislikes.removeWhere((element) => element == followerId);
      if (newQuestionValue.likes.contains(followerId)) {
        newQuestionValue.likes.removeWhere((element) => element == followerId);
      } else {
        newQuestionValue.likes.add(followerId);
      }
    }
    if (likeart == likeArt.dislike) {
      newQuestionValue.likes.removeWhere((element) => element == followerId);
      newQuestionValue.stars.removeWhere((element) => element == followerId);
      if (newQuestionValue.dislikes.contains(followerId)) {
        newQuestionValue.dislikes
            .removeWhere((element) => element == followerId);
      } else {
        newQuestionValue.dislikes.add(followerId);
      }
    }

    result = await client
        .from("Questions")
        .update(newQuestionValue.toJson())
        .eq("questionId", newQuestionValue.questionId)
        .execute();
    print(result.data);
    if (result.error != null) {
      return ProcessState.failed;
    }

    return ProcessState.successful;
  }

@dshukertjr
Copy link
Member

@akram-95
Thanks for sharing the code! I found a bug within realtime-dart that is causing the array value not being returned correctly and returning ['archa'] instead. Have to go to bed for now, but will fix it first thing in the morning! Thanks for the patients!

@akram-95
Copy link
Author

akram-95 commented Aug 21, 2021

@dshukertjr Nice to hear
Good night and thank you for your support

@dshukertjr
Copy link
Member

@akram-95
With the latest update, you should be able to receive array values correctly. Thanks for your patients!

@akram-95
Copy link
Author

@dshukertjr Oh yeah
It works now perfectly, thank you 🙏

@dshukertjr
Copy link
Member

Realtime seems to close on its own when StreamBuilder is quickly rebuilt as stated here, so will keep this issue opened until we can properly fix that issue.

@dshukertjr dshukertjr changed the title Streambuilder update just by using delete or add stream() does not work properly with nested StreamBuilders Oct 24, 2021
@elhe26
Copy link

elhe26 commented Nov 29, 2021

Realtime seems to close on its own when StreamBuilder is quickly rebuilt as stated here, so will keep this issue opened until we can properly fix that issue.

Any updates?

@dshukertjr
Copy link
Member

Hi @elhe26
We have been busy adding WALRUS support to the realtime client, so there has not been any updates on this issue, but did you try the workaround? It is generally not a very good idea to nest StreamBuilders anyway in my opinion because every time the parent rebuilds, the child has to rebuild again and has to start from ConnectionState.waiting.

@bdlukaa
Copy link
Contributor

bdlukaa commented Dec 1, 2021

stream() should not be called directly on the build function. When it's rebuilt, the stream is executed every time the screen is rebuilt (since you're calling the function on build time). The stream() will also rebuilt when you hot realod, so this is not an issue with the current stream() implementation.

What you can do is:

class Example extends StatefulWidget {
  const Example({Key? key}) : super(key: key);

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

class _ExampleState extends State<Example> {
  late Stream stream1;
  late Stream stream2;

  @override
  void initState() {
    super.initState();
    stream1 = DatabaseHelper()
        .client
        .from("Questions")
        .stream()
        .order("timestamp")
        .execute();
    stream2 = DatabaseHelper()
        .client
        .from("IndividualProfile:userId=eq.${questionsList[index].authorId}")
        .stream()
        .order("timestamp")
        .limit(1)
        .execute();
  }

  @override
  void dispose() {
    // there isn't such thing as disposing streams. this is handled internally
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (context, index) {
      final question = _questions[index];
      StreamBuilder<List<Map<String, dynamic>>>(
          stream: stream1,
          builder: (context, questions) {
            if (!questions.hasData) {
              return const Center(child: CupertinoActivityIndicator());
            }
            List<Question> questionsList =
                questions.data!.map((e) => Question.fromJson(e)).toList();

            return Expanded(
              flex: 1,
              child: ListView.builder(
                shrinkWrap: true,
                physics: const AlwaysScrollableScrollPhysics(),
                controller: questionsAnswersController.scrollController,
                itemCount: questionsList.length,
                itemBuilder: (_, index) {
                  return StreamBuilder<List<Map<String, dynamic>>>(
                    stream: stream2,
                    builder: (context, profile) {
                      if (!profile.hasData) {
                        return const Center(child: SizedBox());
                      }
                      if (profile.hasError || profile.error != null) {
                        return const Text("Text");
                      }
                      if (profile.data!.isEmpty) {
                        return Container();
                      }

                      final dataProfile = profile.data!.first;
                      IndividualProfile individualProfile =
                          IndividualProfile.fromJson(dataProfile);
                      return widgetCard(
                          individualProfile, questionsList[index]);
                    },
                  );
                },
              ),
            );
          });
    });
  }
}

Can this be closed?

@bdlukaa bdlukaa closed this as completed Dec 1, 2021
@bdlukaa bdlukaa reopened this Dec 1, 2021
@bdlukaa
Copy link
Contributor

bdlukaa commented Apr 17, 2022

Closing as solved!

@bdlukaa bdlukaa closed this as completed Apr 17, 2022
@bdlukaa bdlukaa removed the bug Something isn't working label Apr 17, 2022
@bdlukaa bdlukaa added the realtime This issue or pull request is related to realtime label Apr 17, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
realtime This issue or pull request is related to realtime
Projects
None yet
Development

No branches or pull requests

4 participants