Emitted state not received by CubitBuilder #52
Comments
Hi @saschaernst 👋 In the example you've provided, when you import 'package:flutter/material.dart';
import 'package:flutter_cubit/flutter_cubit.dart';
import 'package:cubit/cubit.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CubitProvider(
create: (_) => TestCubit()..init(),
child: MyHomePage(),
),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: CubitBuilder<TestCubit, String>(
builder: (context, state) {
return Text(
'$state',
style: Theme.of(context).textTheme.headline4,
);
},
),
),
);
}
}
class TestCubit extends Cubit<String> {
TestCubit() : super("Waiting...");
void init() => emit("It works!!!");
@override
void onTransition(Transition<String> transition) {
print(transition);
super.onTransition(transition);
}
} You can set a breakpoint or add a print statement inside of CubitBuilder and you'll see that the transition occurs before CubitBuilder has been built. If you really want to guarantee CubitBuilder sees the initial state for 1 frame (not sure why you would) then you can call init via a postFrameCallback like: import 'package:flutter/material.dart';
import 'package:flutter_cubit/flutter_cubit.dart';
import 'package:cubit/cubit.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CubitProvider(
create: (_) => TestCubit(),
child: MyHomePage(),
),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.cubit<TestCubit>().init();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: CubitBuilder<TestCubit, String>(
builder: (context, state) {
return Text(
'$state',
style: Theme.of(context).textTheme.headline4,
);
},
),
),
);
}
}
class TestCubit extends Cubit<String> {
TestCubit() : super("Waiting...");
void init() => emit("It works!!!");
@override
void onTransition(Transition<String> transition) {
print(transition);
super.onTransition(transition);
}
} In any case, the change will happen so fast you won't most likely won't see it. Let me know if that helps clarify things. If you have outstanding questions it would be beneficial for you to provide a more realistic sample to illustrate what you're trying to accomplish, thanks! 👍 |
As I mentioned in my description, the edge case is the situation when you are awaiting a call before the emit which can be synchronous or asynchronous (i.e. reading from repo that might have cached data in memory or has to read it from disk first). Example works when called for the first time, every other times it gets in trouble as described above: String foo;
Future<String> getString() async {
if(foo == null) {
foo = await doSomethingToGetAStringAsync();
}
return foo;
}
When I don't have an await call at all, the state change happens first and CubitBuilder shows the new state when being build, that's fine. Only in the first case the Builder is able to read the first state, then the emit happens but CubitBuilder is not notified about the second as it has no subscriptions yet. There seems to be a gap between running the builder for the first time and having it wired up correctly in exactly that case. |
The addPostFrameCallback fixes the problem, but feels like a hack for a problem, that should not exist in the first place |
@saschaernst I'm not sure I understand. If your repo has cached the state and returns synchronously the CubitBuilder will still build with the cached state (it will just skip the initial loading state). |
No, it doesn't. If the init method containing the emit awaits a method which in fact doesn't do anything async (i.e. data is already cached), the weird behaviour shows up. I updated my example code to show the following:
So the builder reads the initial state, but cannot receive the new one when it is emitted later. If you have no await in the init call at all, in fact the builder gets build after the emit and it directly reads the new state 'works'. |
Check out this example: import 'package:flutter/material.dart';
import 'package:flutter_cubit/flutter_cubit.dart';
import 'package:cubit/cubit.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CubitProvider(
create: (context) => TestCubit(Repository())..fetch(),
child: MyHomePage(),
),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: CubitBuilder<TestCubit, Data>(
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${state.value}',
style: Theme.of(context).textTheme.headline4,
),
Text(
'last updates: ${state.lastUpdated}',
style: Theme.of(context).textTheme.caption,
),
],
),
);
},
),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.refresh),
onPressed: () => context.cubit<TestCubit>().fetch(),
),
);
}
}
class TestCubit extends Cubit<Data> {
TestCubit(this._repository) : super(Data('waiting'));
final Repository _repository;
Future<void> fetch() async {
final data = await _repository.getData();
emit(data);
}
}
class Repository {
Data _cachedData;
Future<Data> getData() async {
if (_cachedData != null) return _cachedData = _cachedData.copyWith();
await Future<void>.delayed(const Duration(seconds: 1));
return _cachedData = Data('it works');
}
}
class Data {
Data(this.value, [DateTime lastUpdated])
: lastUpdated = lastUpdated ?? DateTime.now();
final String value;
final DateTime lastUpdated;
Data copyWith({String value}) {
return Data(value ?? this.value, DateTime.now());
}
} The first load happens from "network" and subsequent requests (triggered by tapping the floating action button) are read from "cache". You can see the last updated timestamp change which indicates that the CubitBuilder is in fact rebuilding. Let me know if that helps 👍 |
As long as you always have this delay it surely works, but in real code you would not have an asynchronous delay of any kind (network, disk access, etc) on every call, as it would be retrieved from cache from the second time on, but only the first one. Please have a look at my example method. It is only really asynchronous on the first call. Now imagine the following situation:
|
My example doesn’t always have a delay. My repository method is also only awaiting on the first request. Subsequent requests return the data from cache. The CubitBuilder will always receive the latest state. I think the issue you’re facing is if your cubit emits a state |
I will not use a stateless widget anymore, as calling the init within its build method probably messes with the flutter system. I am just wondering why I have to use addPostFrameCallback in initState or didChangeDependencies. // answering your last comment In the meantime I came to believe that it is not a cubit bug, but some weird edgecase of streams. I take your fix thankfully and move on |
You will run into my troubles if you would navigate to another page with a cubit reading from the same repo, trust me. Manually calling init works as the Builder had time to set up properly |
My getData doesn’t always have a one second delay. If cachedData is not null it is returned immediately with no delay. You shouldn’t need to override didChangeDependencies in this case and you also shouldn’t need addPostFrameCallback. The latest example I shared demonstrates how I would handle this. I can add a second page 👍 |
Sorry, I misread your code. yes, try the second page, please |
I've added a details page 👍 import 'package:flutter/material.dart';
import 'package:flutter_cubit/flutter_cubit.dart';
import 'package:cubit/cubit.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CubitProvider(
create: (context) => TestCubit(Repository())..fetch(),
child: MaterialApp(
home: MyHomePage(),
),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: CubitBuilder<TestCubit, Data>(
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${state.value}',
style: Theme.of(context).textTheme.headline4,
),
Text(
'last updates: ${state.lastUpdated}',
style: Theme.of(context).textTheme.caption,
),
FlatButton(
child: Text('Details'),
onPressed: () {
Navigator.of(context).push<void>(MyDetailsPage.route());
},
)
],
),
);
},
),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.refresh),
heroTag: 'fab1',
onPressed: () => context.cubit<TestCubit>().fetch(),
),
);
}
}
class MyDetailsPage extends StatelessWidget {
static Route route() {
return MaterialPageRoute<void>(builder: (_) => MyDetailsPage());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Details')),
body: Center(
child: CubitBuilder<TestCubit, Data>(
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${state.value}',
style: Theme.of(context).textTheme.headline4,
),
Text(
'last updates: ${state.lastUpdated}',
style: Theme.of(context).textTheme.caption,
),
],
),
);
},
),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.refresh),
heroTag: 'fab2',
onPressed: () => context.cubit<TestCubit>().fetch(),
),
);
}
}
class TestCubit extends Cubit<Data> {
TestCubit(this._repository) : super(Data('waiting'));
final Repository _repository;
Future<void> fetch() async {
final data = await _repository.getData();
emit(data);
}
}
class Repository {
Data _cachedData;
Future<Data> getData() async {
if (_cachedData != null) return _cachedData = _cachedData.copyWith();
await Future<void>.delayed(const Duration(seconds: 1));
return _cachedData = Data('it works');
}
}
class Data {
Data(this.value, [DateTime lastUpdated])
: lastUpdated = lastUpdated ?? DateTime.now();
final String value;
final DateTime lastUpdated;
Data copyWith({String value}) {
return Data(value ?? this.value, DateTime.now());
}
} |
You are using the same cubit instance for both pages, right? That will obviously work. My problem is with multiple cubit instances (one per page, no matter if of the same type or not) using the same repo. The second cubit instance will be in trouble |
@saschaernst I found the issue and am working on pushing out a fix 👍 |
@saschaernst I've released |
Works for me, you are my hero! |
Awesome! Thanks so much for reporting this and providing detailed reproduction steps, I really appreciate it 🙏 |
It was an honour to help you out. When can we expect the final release of 0.2.0? |
There are some other things I need to sort out for 0.2.0 but hopefully by next week 👍 |
Describe the bug
When emitting a state after awaiting a call that was itself not asynchronous (i.e. reading from a repo that has the requested data already cached in memory and does not need to read it from disk again), the new state is not received by a listening CubitBuilder instance.
Hint:
When I follow the emit call in the problematic case, the _AsyncBroadcastStreamController has no subscriptions, which I guess is a problem... There is probably a racing condition when setting these things up.
When not using any await, the new state is the first thing the CubitBuilder gets, never seeing the initial state in the first place. When awaiting a really asynchronous operation, both states are received as expected.
To Reproduce
here is a minimal piece of code to demonstrate the problem:
Expected behavior
The emitted state should be received by the CubitBuilder instance no matter if the call leading up to the emit is synchronous or awaiting something synchronous or asynchronous
The text was updated successfully, but these errors were encountered: