Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose InheritedWidget subscription cancelation #33213

Closed
wants to merge 7 commits into from

Conversation

rrousselGit
Copy link
Contributor

@rrousselGit rrousselGit commented May 22, 2019

Description

This PR exposes an overridable method to let the InheritedElement know that a subscription has been canceled.

My plan with this change is to make a custom InheritedElement that could be combined with a listenable object (Stream/Listenable) – and subscribe to that object only if there's at least one widget that would be notified by notifyDependents.

This is something currently not possible because while we know when the listening starts, we currently don't know when it ends.

Related Issues

Tests

  • removeDependencies + hasDependencies: done in inherited_test.dart

Checklist

Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes ([x]). This will ensure a smooth and quick review process.

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I signed the [CLA].
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Does your PR require Flutter developers to manually update their apps to accommodate your change?

  • No, this is not a breaking change.

@goderbauer goderbauer added the framework flutter/packages/flutter repository. See also f: labels. label May 23, 2019
Copy link
Contributor

@HansMuller HansMuller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a safe change to me. This is a sensitive part of the implementation; I'd like @Hixie to look at it as well.

/// Called by [dependent] to stop listening to this [InheritedElement].
///
/// Subclasses can override this method to cancel potential side-effects
/// related to subscriptions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a good place to mention hasDependencies. I assume the use case we're enabling is checking hasDependencies after calling super.removeDependencies().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, done. Is that what you had in mind?

@@ -4266,6 +4266,7 @@ class InheritedElement extends ProxyElement {
/// created with [inheritFromWidgetOfExactType].
/// * [setDependencies], which sets dependencies value for a dependent
/// element.
/// * [removeDependencies], which unsets the value for a dependent element.
/// * [notifyDependent], which can be overridden to use a dependent's
/// dependencies value to decide if the dependent needs to be rebuilt.
/// * [InheritedModel], which is an example of a class that uses this method
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these lists of "see also" links is getting out of hand. We don't need to mention every method in each one. We should clean it up so that only the most relevant ones are linked to each time.

@Hixie
Copy link
Contributor

Hixie commented Jun 6, 2019

This change is scary, but I can't think of a case off-hand that it would break...

@Hixie
Copy link
Contributor

Hixie commented Jun 6, 2019

That said, it probably doesn't do what you want. For example, if you have a child that registers with inherited widgets A and B in build 1, then A makes it dirty and the second time it only registers with A, B won't hear about it until the widget goes away entirely, which might be never.

So I probably wouldn't recommend going down this path. It's better to just vend the actual stream and let the stream do the logic.

@rrousselGit
Copy link
Contributor Author

B won't hear about it until the widget goes away entirely, which might be never.

Indeed, but that's because the dépendent is actually still depending on B, such that if B makes it dirty, the dependent will still rebuild even if it doesn't call inheritFromWidgetOfExactType(B) anymore.

So thats excepted

@rrousselGit
Copy link
Contributor Author

@Hixie so, is this something that can potentially be merged?

I don't mind it being refused, but a feature for a package of mine may be implemented differently depending on if can get merged or not.

The changes described here are not critical, but it'd serve as a solution for #30062

@Hixie
Copy link
Contributor

Hixie commented Jun 14, 2019

Indeed, but that's because the dépendent is actually still depending on B, such that if B makes it dirty, the dependent will still rebuild even if it doesn't call inheritFromWidgetOfExactType(B) anymore.

But if you use the "vend a Stream" solution, B would unregister from the stream and so things would be more efficient.

I'm torn on this one. On the one hand, I can see it being useful. On the other hand, it's a few extra cycles even in cases where we don't use it, and you only need a few thousand such "it's only a few extra cycles" before it really adds up. There's also the tradeoff between doing it the efficient way by vending a stream and the reality that doing that is somewhat convoluted and not as clean.

Have you considered making a Builder widget that looks up the stream from an InheritedWidget and does the subscribe/unsubscribe? Maybe that would be clean enough?

@rrousselGit
Copy link
Contributor Author

I'm torn on this one.

Sorry, I don't understand this paragraph.

Have you considered making a Builder widget that looks up the stream from an InheritedWidget and does the subscribe/unsubscribe? Maybe that would be clean enough?

Yes, but that is not compatible with what provider does.

One of the key feature behind provider is that it allows listening to any kind of object just by using Provider.of.

That both makes it easier to use (a single API), safer (can't forget to subscribe), and malleable (can be used inside didChangeDependencies).


An alternative solution is to expose a set of event listeners for the different widgets life-cycle on Element – like a add/removeUnmountListener.

But I think that would be a lot more dangerous than what this PR does.

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jun 27, 2019

Any update? I'm getting quite a few requests about that.

I'm definitely convinced that listening to complex objects on the inherited widget instead of the consumer is a good idea.

This allows many powerful things that wouldn't be possible otherwise. AnimatedTheme is one example.

@Hixie
Copy link
Contributor

Hixie commented Jul 1, 2019

"I'm torn on this one" is an English idiom meaning that I am struggling to make a decision because I see good arguments both for and against.

Maybe provider could be changed to do the subscribing/unsubscribing?

The more I think about this the more I think we probably shouldn't do it, because it adds a few cycles to every operation involving inherited widgets, and it isn't strictly adding anything that can't already be done, and it isn't really a complete solution to the problem (since it only handles some "unsubscribes").

Not sure what you mean about AnimatedTheme. Why is it not possible? We do it today, no?

@rrousselGit
Copy link
Contributor Author

(I hope that I do not sound harsh or too insisting)

But if you use the "vend a Stream" solution, B would unregister from the stream and so things would be more efficient.

Not sure what you mean about AnimatedTheme. Why is it not possible? We do it today, no?

These two quotes are related.

provider voluntarily works like Theme because it makes the API a lot easier to use and composable.
With Theme, consumers don't need an AnimatedBuilder to listen to the object.
But still, replacing Theme by AnimatedTheme is enough to make virtually all widgets that depend on ThemeData to play the animation.

provider does that, but instead of ThemeData, any object will do.

That works great, but one of the requirement is that the "vend a Stream" is not an option (unless I missed something?).
I'd prefer to cancel my plans for #30062 and other similar requests than to lose the simplicity of the current API.

because it adds a few cycles to every operation involving inherited widgets

I do not understand what this means.
The proposed changes shouldn't have any impact on existing InheritedWidgets.

it isn't really a complete solution to the problem

Is it because of #12992?
If so, then I disagree.

The use-case behind these changes is to do extra work when all listeners are removed (usually when a Route is unmounted).
Due to the nature of InheritedWidgets, there will rarely be only one widget listening to an InheritedWidget (otherwise an InheritedWidget is probably not useful). Therefore #12992 would rarely have an impact.

And even in the event where it does have an impact, then there's a simple workaround to that specific issue.

Instead of:

if (foo) {
  return Text(Foo.of(context));
} else {
  return Text(Bar.of(context));
}

we can have:

if (foo) {
  return Builder(
    key: const Key('foo'),
    builder: (context) => Text(Foo.of(context)),
  );
} else {
  return Builder(
    key: const Key('bar'),
    builder: (context) => Text(Bar.of(context)),
  );
}

@Hixie
Copy link
Contributor

Hixie commented Jul 18, 2019

The proposed changes shouldn't have any impact on existing InheritedWidgets.

At a very minimum it adds a polymorphic call to removeDependencies. This is a few cycles. We have to be strict about not adding a few cycles because otherwise everyone has their few cycles they can add and suddenly we have a LOT of cycles that aren't strictly necessary, and things get slow.

#12992 is what I was referring to by "not complete", yes.

I don't really see why you need this for the AnimatedTheme-equivalent behaviour. AnimatedTheme just builds a new Theme each frame. Why doesn't that work for provider?

@rrousselGit
Copy link
Contributor Author

This isn't to implement an AnimatedTheme.
It's instead to implement a "maintainState"-like parameter, while still being compatible with AnimatedTheme. Such that we have our AnimatedTheme, but when not used anymore, it's GCed.

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 18, 2019

In case this wasn't clear enough, a concrete example is:

With provider, it's not rare to have many Inheritedwidgets above Navigator.
These can vary from a simple immutable data, to an object that listens a firestore stream.

There's no problem with the former, but for the latter we do want to both lazily create the object and dispose it when it's not needed anymore.

@Hixie
Copy link
Contributor

Hixie commented Jul 19, 2019

For the problem of having a Listenable which behaves differently with zero listeners than non-zero listeners, I would expose the Listenable on the InheritedWidget, and then use an AnimationBuilder to register the callbacks:

   return AnimationBuilder(
     animation: MyNotifierSource.of(context),
     builder: (...) { ... },
   );

Similarly if it's a ValueListenable I'd use a ValueListenableBuilder. You can do similar things with a stream, e.g. using StreamBuilder.

I don't see why the inherited widget itself needs to do track if you have callbacks.

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 19, 2019

I don't see why the inherited widget itself needs to do track if you have callbacks.

For the same reasons behind Theme.of not returning a ValueListenable<ThemeData>.
That heavily simplify the API.

Also:

  • It guards peoples against forgetting to listen to the Listenable. There's nothing preventing the following for instance:

    Widget build(context) {
      ValueListenable<T> foo = Something.of(context);
      // we forgot to use `AnimatedBuilder`
      return Text(foo.value.toString());
    }

    But that's unlikely to be desired as it won't rebuild when the value change.
    If .of returns T instead of ValueListenable<T>, then that issue is avoided entirely.

  • It makes very easy to listen to an observable object outside of build thanks to didChangeDependencies.
    The typical:

    class _FooState extends State<Foo> {
      Bar bar;
      ValueListenable<int> listenable;
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        ValueListenable<int> listenable = Something.of(context);
        if (listenable != this.listenable) {
          this.listenable?.removeListener(listener);
          this.listenable = listenable;
          listenable.addListener(listener);
          // the listener is not called immediatly, so we compute the state manually first
          bar = Bar(listenable.value);
        }
      }
    
      void listener() {
        setState(() {
          bar = Bar(listenable.value);
        });
      }
    
      @override
      void dispose() {
        super.dispose();
        listenable.removeListener(listener);
      }
    }

    becomes:

    class _FooState extends State<Foo> {
      Bar bar;
      int previous;
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        int value = Something.of(context);
        if (value != previous) {
          previous = value;
          bar = Bar(value);
        }
      }
    }

    This, too, removes a whole range of potential errors. And it also make the code more readable by removing the "unnecessary redundant bits".

  • It makes the listening process works for everything. There's no need to learn both AnimatedBuilder, StreamBuilder, FutureBuilder, ...
    .of(context) works for everything.

  • This is compatible with immutability. Animation/ValueListenable are mutable even if their .value property contains immutable objects.
    This can cause issues with asynchronous event handlers. There's no guarantee that the value hasn't changed between the build that created the event handler, and the time where the .value is used.

    By having Provider.of do the listening, then the value returned is completely immutable. It is therefore guaranteed that there's no undesired mutation that happened in the meantime.

@Hixie
Copy link
Contributor

Hixie commented Jul 20, 2019

For the same reasons behind Theme.of not returning a ValueListenable<ThemeData>.

But Theme doesn't expose a Stream or Listenable, and AnimatedTheme doesn't avoid animating if there's no subscribers. So I don't think that's the same thing at all.

  • It guards peoples against forgetting to listen to the Listenable.

You can avoid this by using an API where you only get access to the data if you subscribe (e.g. Stream) if that's a concern.

  • It makes very easy to listen to an observable object outside of build thanks to didChangeDependencies.

In the same way that a widget like AnimatedBuilder can expose a build method, it can expose callbacks for didChangeDependencies and dispose (though at that point, why not just do the listening directly), or, for the case you have, it could expose a transform callback:

  return ValueListenableWithTransformBuilder<int, Bar>(
    valueListenable: Something.of(context),
    transform: (int value) => Bar(value),
    build: (BuildContext context, Bar value) {
      // ...
    },
  );
  • It makes the listening process works for everything. There's no need to learn both AnimatedBuilder, StreamBuilder, FutureBuilder, ... .of(context) works for everything.

I'm not really convinced that hiding details like this is a good thing. It just makes it harder to figure out why things break when they break.

Also, it doesn't actually work for everything, as we noted earlier in this thread. It fails to unsubscribe promptly in some cases.

  • This is compatible with immutability. Animation/ValueListenable are mutable even if their .value property contains immutable objects.

You don't have to use these objects, if you need different properties. They're just existence proofs.

@rrousselGit
Copy link
Contributor Author

There's nothing as simple as returning T instead of Anything<T>.

Using ValueLisrenable here is just the easiest example available. All other objects comes with their own complexity.
For example Stream is problematic, because in most cases the first event is emitted the next frame. So we have to deal with a blank frame, even when we have our data.

Then we'll typically have to use rxdart, for BehaviorSubject (otherwise our app is likely bugged). Which adds another dependency, and with a big learning curve at that.


I'm not really convinced that hiding details like this is a good thing. It just makes it harder to figure out why things break when they break.

Why would it break?

Also, it doesn't actually work for everything, as we noted earlier in this thread. It fails to unsubscribe promptly in some cases.

While that issue is interesting, it is technically unrelated to these changes.
It's a separate problem that, if this were to big merged, would be fixed by having removeDependencies called when a build stops calling .of

Right now, there's technically still a listener. So it is logical for removeDepenencies to not be called, and for hasDependencies to stay true:

ValueListenableWithTransformBuilder

That's a lot more complex.
This would require a new Widget in the framework, for all objects, and for all situations.

And this approach will get stuck when creating our Bar from two providers instead of one.
Because that ValueListenableWithTransformBuilder<int> will work for one and only one dependency, while didChangeDependencies can handle as many as desired.


In any case, provider have always been doing that.
It's simple and similar to how many widgets from the framework works. Be it Theme.of or Scrollable.of, the listening is performed on the parent, not the descendant.

Changing that would invalidate nearly the entirely library, and reintroduce bugs that were made voluntarily harder.

And even then, that is still incompatible with the desired feature that is a maintainState variable on the widget that crates the object.

For a maintainState to work using your suggestion, it is necessary to override the addListener-like methods, which is outside of the scope of provider.
provider is made to help manipulating existing classes, not to implement its own alternatives.

@Hixie
Copy link
Contributor

Hixie commented Jul 20, 2019

We don't seem to be really convincing each other. I hear what you are saying, it just doesn't seem like a compelling enough reason to change the framework. If you think that I'm missing something, I recommend writing some demo apps using the style you want; we can see what it would look like to do them in styles possible today, and see how compelling the difference is.

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 30, 2019

A demo will take a bit of time to make since I have other priorities.
But in the mean-time, I have another argument:

Having the InheritedWidget do the listening instead of the consumer of that information absorbs the performance issue of classes such as ChangeNotifier.

The documentation is pretty clear:

ChangeNotifier is optimized for small numbers (one or two) of listeners. It is O(N) for adding and removing listeners and O(N²) for dispatching notifications (where N is the number of listeners).

By having InheritedWidgets do the listening, we'll stay in that "one or two listeners" situations for the entire life of our ChangeNotifier.

If provider changed to listen the ChangeNotifier on the consumer instead of the InheritedWidget, then that would mean we'd end up with dozens if not hundreds of listeners as the app grows.

@rrousselGit
Copy link
Contributor Author

@Hixie I've been thinking: is hasDependencies alone reasonable?
With my use-case, I could replace removeDependencies by a post-frame callback.

@jtlapp
Copy link

jtlapp commented Sep 1, 2019

Having the InheritedWidget do the listening instead of the consumer of that information absorbs the performance issue of classes such as ChangeNotifier.

What about implementing your own Listenable?

In appears that in the Flutter framework itself, the dependents are the keys of a HashMap, and the framework iterates over these keys marking each for rebuilding. That's O(n), which is better than ChangeNotifier's O(n^2) for dispatching notifications.

ChangeNotifier appears to notify at O(n^2) because while it's iterating over the listeners in one list, it's first verifying the listeners in another list. That reminds me of working off of an immutable copy to protect data across threads, especially in closures, though I don't know why ChangeNotifier is doing it in this case.

So I'm not seeing why provider couldn't maintain its own list at O(1) for adding and removing listeners and O(n) for dispatching notifications. Mind you, I'm working with very little understanding here! [edited]

@jtlapp
Copy link

jtlapp commented Sep 1, 2019

Within a single thread, I can't see any reason for a Listenable to be O(n^2) unless it's allowing listener callbacks to add and remove other listeners.

@Hixie
Copy link
Contributor

Hixie commented Sep 1, 2019

cc @gspencergoog who has been working on fixing the O(N) thing

@rrousselGit
Copy link
Contributor Author

@Hixie I think you missed my question here #33213 (comment):

Is hasDependencies alone reasonable? With my use-case, I could replace removeDependencies by a post-frame callback


What about implementing your own Listenable?

Reimplementing Listenable is outside of the scope of provider. The library is just an util for inherited widgets.
It just pass existing objects. It should not create new types of objects.

In any case, it's not just about O(N) vs O(N²).
Not having to deal with listeners (and as such forgetting to remove a listener) is pretty significant too.
And having only one listener allows supporting streams without having to broadcast them. That's important to correctly support async* functions.

@jtlapp
Copy link

jtlapp commented Sep 2, 2019

@Hixie wrote:

why not just do the listening directly

@rrousselGit replied:

Having the InheritedWidget do the listening instead of the consumer of that information absorbs the performance issue of classes such as ChangeNotifier.
[...]
ChangeNotifier is optimized for small numbers (one or two) of listeners. It is O(N) for adding and removing listeners and O(N²) for dispatching notifications (where N is the number of listeners).
[...]
If provider changed to listen the ChangeNotifier on the consumer instead of the InheritedWidget, then that would mean we'd end up with dozens if not hundreds of listeners as the app grows.

I responded with a way to listen without using ChangeNotifier and without getting the O(N^2) performance you expressed worry about.

I didn't mean that would create a new public Listenable class or even a Listenable class at all, just that you would do the listening. I pointed to how Flutter was "listening" to its dependencies by using a HashMap as an example of what could be done instead.

EDIT: Rereading now I see that you would instead have the "consumer" do the listening. My reading was that provider could listen to the data source and then call setState() on the consumers. Maybe I read that incorrectly.

@rrousselGit
Copy link
Contributor Author

Rereading now I see that you would instead have the "consumer" do the listening. My reading was that provider could listen to the data source and then call setState() on the consumers. Maybe I read that incorrectly.

That'd be equivalent to reimplementing InheritedWidget, which is not possible as a third party package since it requires changes inside Element.

The only way we can make this work without that PR is to stop supporting Provider.of(context).

@jtlapp
Copy link

jtlapp commented Sep 2, 2019

That'd be equivalent to reimplementing InheritedWidget, which is not possible as a third party package since it requires changes inside Element.

I stand corrected. It sounds like the unsubscribe belongs on InheritedWidget.

@jtlapp
Copy link

jtlapp commented Sep 13, 2019

I have a question for you @Hixie. I'm reading this issue as Flutter having a memory leak but the leak apparently not being bad enough to fix, perhaps because the memory would normally free on page change, which presumably happens often enough.

QUESTION: Assume inheritFromWidgetOfExactType() subscribes a descendant widget to InheritedWidget state changes. When the descendant widget rebuilds, it actually creates a new widget which must also subscribe to state changes of the InheritedWidget. Are the prior instances of these descendant widgets unsubscribed? Or does every widget that ever subscribed remain subscribed for the life of the InheritedWidget, whether still in use or not? I imagine that those that remain subscribed have to be tested for being dead, lest they rebuild.

@rrousselGit
Copy link
Contributor Author

I can answer that for hixie:

It's not the widgets that subscribes to an InheritedWidget, but its Element instead.
The Element stays subscribed to the InheritedWidget until it is "deactivated", which happens when:

  • the associated widget is removed from the widget tree
  • the widget is moved in the tree (using GlobalKey)

On the other hand, the subscription is not cleared if the widget's build method stops calling inheritFromWidgetOfExactType.

@jtlapp
Copy link

jtlapp commented Sep 13, 2019

On the other hand, the subscription is not cleared if the widget's build method stops calling inheritFromWidgetOfExactType.

Thank you @rrousselGit! You've prevented me from expressing a misunderstanding in the article I'm writing. It sounds like a widget need only subscribe on its first build to forever thereafter be subscribed, even if it subsequently calls ancestorWidgetOfExactType. That is, subsequent to the first call, inheritFromWidgetOfExactType and ancestorWidgetOfExactType only serve to retrieve the ancestor widget, not to also establish or decline to establish a subscription.

@Hixie
Copy link
Contributor

Hixie commented Sep 15, 2019

@Hixie I've been thinking: is hasDependencies alone reasonable?
With my use-case, I could replace removeDependencies by a post-frame callback.

I don't understand what this means.

I think we should close this PR, as per #33213 (comment), until we have a stronger reason to make any changes to the framework for this.

@goderbauer
Copy link
Member

I am going to close this PR as per the previous comment.

@szotp
Copy link

szotp commented Feb 27, 2020

Maybe this PR should be reconsidered:

  1. Provider package is popular enough that it probably deserves these few cycles.
  2. notifyListeners is still O(N²).
  3. Stream based solution would be significantly harder than inheriting - probably broadcast stream with some logic to pause/resume it.
  4. Since setDependencies, updateDependencies, getDependencies already exists (presumably to support InheritedModel which hardly anyone uses), adding removeDependencies & hasDependencies would only slightly increase API surface while making things consistent.
  5. InheritedWidget is a very cool idea, imagine if we had to setup individual listeners for theme, language, screen width, etc. Those two methods would give us a chance to experiment a bit and maybe make it even better.

@rrousselGit
Copy link
Contributor Author

I definitely agree with @szotp.

I find it surprising that we have methods for getDependencies & co, which are called a lot. But removeDependencies is rejected, when it would be called only when an Element is deactivated (and it is fairly rare).

Currently we may be able to work around it by overriding setDependencies to track everything. But that is a lot of overhead for something that would be very easy to fix on the framework side.

@rrousselGit
Copy link
Contributor Author

@Hixie is this completely out of question, or could opening a design doc and making some example potentially convince the team?

@Hixie
Copy link
Contributor

Hixie commented Feb 28, 2020

My recommendation would be to file an issue that describes the problem, ideally using sample code that demonstrates the problem. Until there is agreement that there is a real problem worth solving here, there's not much point us talking about what the solutions should be.

You can write such an issue on GitHub or as a design doc, whatever is easiest for you. The point is just to separate the "problem" stage from the "solution" stage. It may be that someone can come up with a much better solution to the problem once they understand the problem fully.

@rrousselGit
Copy link
Contributor Author

The issue already exists #30062

This is not about a "problem", in the sense that it is impossible to do today.
This is about improvement, to make it easier to solve such thing

@szotp
Copy link

szotp commented Feb 29, 2020

A more general problem would be:
I want an InheritedWidget that knows if it is being inherited.

I will create an issue with better description.

@Hixie
Copy link
Contributor

Hixie commented Mar 1, 2020

"I want X" isn't a problem.
"It is hard to do Y" is a problem, especially if you demonstrate it with code that is indeed difficult to write or understand.
"Doing Y involves a lot of boilerplate" is a problem, especially if you can demonstrate that doing it in two cases involves a lot of identical code.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 1, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants