Skip to content

Commit

Permalink
Add warning when LocalizationsDelegate.load() is async (#342)
Browse files Browse the repository at this point in the history
  • Loading branch information
passsy authored Apr 5, 2024
1 parent ce440e5 commit 6b481d3
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 8 deletions.
35 changes: 35 additions & 0 deletions lib/src/core/wiredash_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ class WiredashState extends State<Wiredash> {
projectId: widget.projectId,
secret: widget.secret,
);

_verifySyncLocalizationsDelegate();

_services.updateWidget(widget);
_services.addListener(_markNeedsBuild);
_services.wiredashModel.addListener(_markNeedsBuild);
Expand Down Expand Up @@ -229,6 +232,10 @@ class WiredashState extends State<Wiredash> {
secret: widget.secret,
);
}
if (oldWidget.options?.localizationDelegate !=
widget.options?.localizationDelegate) {
_verifySyncLocalizationsDelegate();
}
_services.updateWidget(widget);
}

Expand Down Expand Up @@ -383,6 +390,34 @@ class WiredashState extends State<Wiredash> {
// Use what's set by the operating system
return _defaultLocale;
}

void _verifySyncLocalizationsDelegate() {
assert(() {
final delegate = widget.options?.localizationDelegate;
if (delegate == null) {
return true;
}
final locale = _currentLocale;
if (!delegate.isSupported(locale)) {
// load should not be called
return true;
}
final loadFuture = delegate.load(locale);
if (loadFuture is! SynchronousFuture) {
reportWiredashInfo(
'Warning: $delegate load() is async',
StackTrace.current,
'Warning: ${delegate.runtimeType}.load() returned a Future for Locale "$locale".\n'
'This will lead to your app losing all its state when you open Wiredash!\n'
'\tDO return a SynchronousFuture from your LocalizationsDelegate.load() method. \n'
'\tDO NOT use the async keyword in LocalizationsDelegate.load().\n'
'When load() returns SynchronousFuture the Localizations widget can build your app widget with the already loaded localizations at the first frame.\n'
'For more information visit https://github.com/wiredashio/wiredash-sdk/issues/341',
);
}
return true;
}());
}
}

Locale get _defaultLocale {
Expand Down
160 changes: 160 additions & 0 deletions test/issues/issue_341_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wiredash/wiredash.dart';

import '../util/flutter_error.dart';

void main() {
group('issue 341', () {
testWidgets('no LocalizationsDelegate', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
expect(TestWidget.createCount, 1);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(TestWidget.createCount, 1); // Nice!
});

testWidgets('LocalizationsDelegate with SynchronousFuture', (tester) async {
await tester.pumpWidget(const MyApp(asyncDelegate: false));
await tester.pumpAndSettle();
expect(TestWidget.createCount, 1);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(TestWidget.createCount, 1); // Nice!
});

testWidgets('async LocalizationsDelegate', (tester) async {
final errors = captureFlutterErrors();
await tester.pumpWidget(const MyApp(asyncDelegate: true));
await tester.pumpAndSettle();
expect(TestWidget.createCount, 1);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
errors.restoreDefaultErrorHandlers();
// This causes the TestWidget to lose its state
expect(TestWidget.createCount, 2);
expect(errors.presentErrorText, contains("SynchronousFuture"));
expect(
errors.presentErrorText,
contains("AsyncCustomWiredashTranslationsDelegate"),
);
expect(errors.presentError.length, 1);
});
});
}

class MyApp extends StatefulWidget {
const MyApp({
super.key,
this.asyncDelegate,
});

@override
State<MyApp> createState() => _MyAppState();

final bool? asyncDelegate;
}

class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return Wiredash(
projectId: "xxxxx",
secret: "xxxxx",
options: WiredashOptionsData(
localizationDelegate: widget.asyncDelegate == null
? null
: widget.asyncDelegate == true
? const AsyncCustomWiredashTranslationsDelegate()
: const SyncCustomWiredashTranslationsDelegate(),
),
child: const MaterialApp(
home: TestWidget(),
),
);
}
}

class TestWidget extends StatefulWidget {
const TestWidget({super.key});

@override
State<TestWidget> createState() => _TestWidgetState();

static int createCount = 0;
}

class _TestWidgetState extends State<TestWidget> {
@override
void initState() {
super.initState();
debugPrint("TestWidget initState");
addTearDown(() {
TestWidget.createCount = 0;
});
TestWidget.createCount++;
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: const Placeholder(),
floatingActionButton: FloatingActionButton(
onPressed: () => Wiredash.of(context).show(),
),
);
}
}

class AsyncCustomWiredashTranslationsDelegate
extends LocalizationsDelegate<WiredashLocalizations> {
const AsyncCustomWiredashTranslationsDelegate();

@override
bool isSupported(Locale locale) {
return ['en'].contains(locale.languageCode);
}

@override
Future<WiredashLocalizations> load(Locale locale) async {
switch (locale.languageCode) {
case 'en':
return _EnOverrides();
default:
throw "Unsupported locale $locale";
}
}

@override
bool shouldReload(AsyncCustomWiredashTranslationsDelegate old) => false;
}

class SyncCustomWiredashTranslationsDelegate
extends LocalizationsDelegate<WiredashLocalizations> {
const SyncCustomWiredashTranslationsDelegate();

@override
bool isSupported(Locale locale) {
return ['en'].contains(locale.languageCode);
}

@override
Future<WiredashLocalizations> load(Locale locale) {
switch (locale.languageCode) {
case 'en':
return SynchronousFuture(_EnOverrides());
default:
throw "Unsupported locale $locale";
}
}

@override
bool shouldReload(SyncCustomWiredashTranslationsDelegate old) => false;
}

class _EnOverrides extends WiredashLocalizationsEn {
@override
String get feedbackStep1MessageHint => 'Test';
}
31 changes: 23 additions & 8 deletions test/util/flutter_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,47 @@ import 'package:flutter_test/flutter_test.dart';
/// Consumes all [FlutterError.onError] and [FlutterError.presentError] calls
/// during this test and makes them accessible as list for assertions.
FlutterErrors captureFlutterErrors() {
final errors = FlutterErrors();
final oldPresentHandler = FlutterError.presentError;
final errors = FlutterErrors(FlutterError.onError, FlutterError.presentError);
FlutterError.presentError = (details) {
errors._presentError.add(details);
};
addTearDown(() {
FlutterError.presentError = oldPresentHandler;
});

final oldOnErrorHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
errors._onError.add(details);
};

addTearDown(() {
FlutterError.onError = oldOnErrorHandler;
errors.restoreDefaultErrorHandlers();
});

return errors;
}

/// A summary of [FlutterError.onError] and [FlutterError.presentError] calls
class FlutterErrors {
final FlutterExceptionHandler? _originalOnError;
final FlutterExceptionHandler _originalPresentError;

FlutterErrors(
this._originalOnError,
this._originalPresentError,
);

List<FlutterErrorDetails> get onError => List.unmodifiable(_onError);
final List<FlutterErrorDetails> _onError = [];

List<FlutterErrorDetails> get presentError =>
List.unmodifiable(_presentError);
final List<FlutterErrorDetails> _presentError = [];

void restoreDefaultErrorHandlers() {
FlutterError.onError = _originalOnError;
FlutterError.presentError = _originalPresentError;
}

String get presentErrorText {
final renderer = TextTreeRenderer(wrapWidth: 100000);
return presentError.map((e) {
return renderer.render(e.toDiagnosticsNode());
}).join('\n');
}
}

0 comments on commit 6b481d3

Please sign in to comment.