Skip to content

Commit 644deaa

Browse files
Merge pull request #374 from Workiva/3.1.0/error-boundary-logging
[3.1.0] CPLAT-7837 Add error logging to ErrorBoundary
2 parents 2211382 + 4c20169 commit 644deaa

File tree

3 files changed

+285
-2
lines changed

3 files changed

+285
-2
lines changed

lib/src/component/error_boundary_mixins.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import 'dart:async';
22

3+
import 'package:logging/logging.dart';
34
import 'package:meta/meta.dart';
45
import 'package:over_react/over_react.dart';
56

67
part 'error_boundary_mixins.over_react.g.dart';
78

9+
@visibleForTesting
10+
const String defaultErrorBoundaryLoggerName = 'over_react.ErrorBoundary';
11+
812
/// A props mixin you can use to implement / extend from the behaviors of an [ErrorBoundary]
913
/// within a custom component.
1014
///
@@ -83,6 +87,22 @@ abstract class _$ErrorBoundaryPropsMixin implements UiProps {
8387
///
8488
/// > Default: `const Duration(seconds: 5)`
8589
Duration identicalErrorFrequencyTolerance;
90+
91+
/// The name to use when the component's logger logs an error via [ErrorBoundaryComponent.componentDidCatch].
92+
///
93+
/// Not used if a custom [logger] is specified.
94+
///
95+
/// > Default: 'over_react.ErrorBoundary'
96+
String loggerName;
97+
98+
/// Whether errors caught by this [ErrorBoundary] should be logged using a [Logger].
99+
///
100+
/// > Default: `true`
101+
bool shouldLogErrors;
102+
103+
/// An optional custom logger instance that will be used to log errors caught by
104+
/// this [ErrorBoundary] when [shouldLogErrors] is true.
105+
Logger logger;
86106
}
87107

88108
/// A state mixin you can use to implement / extend from the behaviors of an [ErrorBoundary]
@@ -143,6 +163,8 @@ mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBound
143163
@override
144164
Map get defaultProps => (newProps()
145165
..identicalErrorFrequencyTolerance = Duration(seconds: 5)
166+
..loggerName = defaultErrorBoundaryLoggerName
167+
..shouldLogErrors = true
146168
);
147169

148170
@override
@@ -233,6 +255,8 @@ mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBound
233255
// [2.2.2] Since we should __never__ throw an error from our... uh... error boundary,
234256
// wrap in a try catch just in case `findDomNode` throws as a result of the
235257
// wrapped react tree rendering a string instead of a composite or dom component.
258+
//
259+
// [3] Log the caught error using a logger if `props.shouldLogErrors` is true.
236260
// ---------------------------------------------- /\ ----------------------------------------------
237261

238262
String _domAtTimeOfError;
@@ -246,6 +270,7 @@ mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBound
246270
if (props.fallbackUIRenderer != null) {
247271
_lastError = error;
248272
_lastErrorInfo = info;
273+
_logErrorCaughtByErrorBoundary(error, info); // [3]
249274
return;
250275
}
251276
// ----- [2] ----- //
@@ -261,9 +286,12 @@ mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBound
261286
if (props.onComponentIsUnrecoverable != null) { // [2.2.1]
262287
props.onComponentIsUnrecoverable(error, info);
263288
}
289+
290+
_logErrorCaughtByErrorBoundary(error, info, isRecoverable: false); // [3]
264291
} else {
265292
_lastError = error;
266293
_lastErrorInfo = info;
294+
_logErrorCaughtByErrorBoundary(error, info); // [3]
267295
}
268296

269297
setState(newState()
@@ -311,6 +339,27 @@ mixin ErrorBoundaryMixin<T extends ErrorBoundaryPropsMixin, S extends ErrorBound
311339
_identicalErrorTimer?.cancel();
312340
_identicalErrorTimer = null;
313341
}
342+
343+
String get _loggerName {
344+
if (props.logger != null) return props.logger.name;
345+
346+
return props.loggerName ?? defaultErrorBoundaryLoggerName;
347+
}
348+
349+
// ----- [3] ----- //
350+
void _logErrorCaughtByErrorBoundary(
351+
/*Error|Exception*/ dynamic error,
352+
ReactErrorInfo info, {
353+
bool isRecoverable = true,
354+
}) {
355+
if (!props.shouldLogErrors) return;
356+
357+
String message = isRecoverable
358+
? 'An error was caught by an ErrorBoundary: \nInfo: ${info.componentStack}'
359+
: 'An unrecoverable error was caught by an ErrorBoundary (attempting to remount it was unsuccessful): \nInfo: ${info.componentStack}';
360+
361+
(props.logger ?? Logger(_loggerName)).severe(message, error, info.dartStackTrace);
362+
}
314363
}
315364

316365
/// A MapView with the typed getters/setters for [ErrorBoundaryPropsMixin].

lib/src/component/error_boundary_mixins.over_react.g.dart

Lines changed: 77 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/over_react/component/shared_error_boundary_tests.dart

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:html';
2+
import 'package:logging/logging.dart';
23
import 'package:over_react/over_react.dart';
34
import 'package:over_react_test/over_react_test.dart';
45
import 'package:test/test.dart';
@@ -704,6 +705,164 @@ void sharedErrorBoundaryTests(BuilderOnlyUiFactory builder) {
704705
});
705706
});
706707

708+
group('logs errors using a `logger`', () {
709+
List<Map<String, List>> calls;
710+
List<LogRecord> logRecords;
711+
const identicalErrorFrequencyToleranceInMs = 500;
712+
713+
// Cause an error to be thrown within a ReactJS lifecycle method
714+
void triggerAComponentError() {
715+
queryByTestId(jacket.getInstance(), 'flawedComponent_flawedButton').click();
716+
}
717+
718+
void sharedSetup({String loggerName, bool shouldLogErrors = true, Logger customLogger}) {
719+
calls = [];
720+
jacket = mount(
721+
(ErrorBoundary()
722+
..loggerName = loggerName
723+
..shouldLogErrors = shouldLogErrors
724+
..logger = customLogger
725+
..identicalErrorFrequencyTolerance = const Duration(milliseconds: identicalErrorFrequencyToleranceInMs)
726+
..onComponentDidCatch = (err, info) {
727+
calls.add({'onComponentDidCatch': [err, info]});
728+
}
729+
..onComponentIsUnrecoverable = (err, info) {
730+
calls.add({'onComponentIsUnrecoverable': [err, info]});
731+
}
732+
)(Flawed()()),
733+
attachedToDocument: true,
734+
);
735+
736+
logRecords = [];
737+
final subscription =
738+
Logger(customLogger?.name ?? loggerName ?? defaultErrorBoundaryLoggerName).onRecord.listen(logRecords.add);
739+
addTearDown(subscription.cancel);
740+
}
741+
742+
tearDown(() {
743+
logRecords = null;
744+
});
745+
746+
test('when `props.shouldLogErrors` is false', () {
747+
sharedSetup(shouldLogErrors: false);
748+
triggerAComponentError();
749+
expect(logRecords, isEmpty);
750+
});
751+
752+
group('provided via `props.logger`', () {
753+
test('', () {
754+
sharedSetup(customLogger: Logger('myCustomLoggerLoggerName'));
755+
triggerAComponentError();
756+
757+
expect(logRecords, hasLength(1));
758+
expect(logRecords.single.level, Level.SEVERE);
759+
expect(logRecords.single.loggerName, 'myCustomLoggerLoggerName');
760+
expect(logRecords.single.error, calls.single['onComponentDidCatch'][0]);
761+
762+
ReactErrorInfo infoSentToCallback = calls.single['onComponentDidCatch'][1];
763+
expect(logRecords.single.stackTrace, infoSentToCallback.dartStackTrace);
764+
expect(logRecords.single.message, 'An error was caught by an ErrorBoundary:'
765+
' \nInfo: ${infoSentToCallback.componentStack}');
766+
});
767+
768+
test('and `props.loggerName` is also set', () {
769+
sharedSetup(loggerName: 'somethingElse', customLogger: Logger('myCustomLoggerLoggerName'));
770+
triggerAComponentError();
771+
772+
expect(logRecords.single.loggerName, 'myCustomLoggerLoggerName');
773+
});
774+
});
775+
776+
group('when `props.loggerName` is not set', () {
777+
setUp(sharedSetup);
778+
779+
test('and a component error is caught', () {
780+
triggerAComponentError();
781+
782+
expect(logRecords, hasLength(1));
783+
expect(logRecords.single.level, Level.SEVERE);
784+
expect(logRecords.single.loggerName, defaultErrorBoundaryLoggerName);
785+
expect(logRecords.single.error, calls.single['onComponentDidCatch'][0]);
786+
787+
ReactErrorInfo infoSentToCallback = calls.single['onComponentDidCatch'][1];
788+
expect(logRecords.single.stackTrace, infoSentToCallback.dartStackTrace);
789+
expect(logRecords.single.message, 'An error was caught by an ErrorBoundary:'
790+
' \nInfo: ${infoSentToCallback.componentStack}');
791+
});
792+
793+
test('and an unrecoverable component error is caught', () async {
794+
triggerAComponentError();
795+
await Future.delayed(const Duration(milliseconds: identicalErrorFrequencyToleranceInMs ~/ 2));
796+
triggerAComponentError();
797+
798+
expect(logRecords, hasLength(2));
799+
expect(logRecords[1].level, Level.SEVERE);
800+
expect(logRecords[1].loggerName, defaultErrorBoundaryLoggerName);
801+
expect(logRecords[1].error, calls[2]['onComponentIsUnrecoverable'][0]);
802+
803+
ReactErrorInfo infoSentToCallback = calls[2]['onComponentIsUnrecoverable'][1];
804+
expect(logRecords[1].stackTrace, infoSentToCallback.dartStackTrace);
805+
expect(logRecords[1].message,
806+
'An unrecoverable error was caught by an ErrorBoundary (attempting to remount it was unsuccessful):'
807+
' \nInfo: ${infoSentToCallback.componentStack}');
808+
});
809+
810+
group('but then `props.loggerName` is set to a non-null value', () {
811+
setUp(() {
812+
Logger('myCustomErrorLoggerName').clearListeners();
813+
jacket.rerender(
814+
(ErrorBoundary()
815+
..loggerName = 'myCustomErrorLoggerName'
816+
..identicalErrorFrequencyTolerance = const Duration(milliseconds: identicalErrorFrequencyToleranceInMs)
817+
..onComponentDidCatch = (err, info) {
818+
calls.add({'onComponentDidCatch': [err, info]});
819+
}
820+
..onComponentIsUnrecoverable = (err, info) {
821+
calls.add({'onComponentIsUnrecoverable': [err, info]});
822+
}
823+
)(Flawed()())
824+
);
825+
final subscription = Logger('myCustomErrorLoggerName').onRecord.listen(logRecords.add);
826+
addTearDown(subscription.cancel);
827+
});
828+
829+
test('and a component error is caught', () {
830+
triggerAComponentError();
831+
832+
expect(logRecords.single.loggerName, 'myCustomErrorLoggerName');
833+
});
834+
835+
group('and then to a null value', () {
836+
setUp(() {
837+
Logger(defaultErrorBoundaryLoggerName).clearListeners();
838+
jacket.rerender(
839+
(ErrorBoundary()
840+
..loggerName = null
841+
..identicalErrorFrequencyTolerance = const Duration(milliseconds: identicalErrorFrequencyToleranceInMs)
842+
..onComponentDidCatch = (err, info) {
843+
calls.add({'onComponentDidCatch': [err, info]});
844+
}
845+
..onComponentIsUnrecoverable = (err, info) {
846+
calls.add({'onComponentIsUnrecoverable': [err, info]});
847+
}
848+
)(Flawed()())
849+
);
850+
final subscription = Logger(defaultErrorBoundaryLoggerName).onRecord.listen(logRecords.add);
851+
addTearDown(subscription.cancel);
852+
});
853+
854+
test('and a component error is caught', () {
855+
triggerAComponentError();
856+
857+
expect(logRecords.single.loggerName, defaultErrorBoundaryLoggerName,
858+
reason: 'The loggerName should fall back to `defaultErrorBoundaryLoggerName` '
859+
'if a consumer attempts to set it to null');
860+
});
861+
});
862+
});
863+
});
864+
});
865+
707866
// group('throws a PropError when', () {
708867
// test('more than one child is provided', () {
709868
// expect(() => mount(builder()(dummyChild, dummyChild)),

0 commit comments

Comments
 (0)