From e375d418886f62a225e689e330ef1dfb48fb9778 Mon Sep 17 00:00:00 2001 From: AIlhomov Date: Tue, 10 Nov 2026 22:07:29 +0100 Subject: [PATCH] Cache source maps across DevTools error reports --- .../shared/framework/app_error_handling.dart | 22 ++--- .../release_notes/NEXT_RELEASE_NOTES.md | 3 + .../framework/app_error_handling_test.dart | 37 +++++++ .../app_error_handling_web_test.dart | 98 +++++++++++++++++++ 4 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 packages/devtools_app/test/framework/app_error_handling_test.dart create mode 100644 packages/devtools_app/test/framework/app_error_handling_web_test.dart diff --git a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart index dbfbf6a5ba1..a21539e9300 100644 --- a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart +++ b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart @@ -33,7 +33,7 @@ void setupErrorHandling(Future Function() appStartCallback) { () async { WidgetsFlutterBinding.ensureInitialized(); - await _initializeSourceMapping(); + await _fetchSourceMapping(); final FlutterExceptionHandler? oldHandler = FlutterError.onError; @@ -109,18 +109,18 @@ Future _reportError( } } -// ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. -SingleMapping? _cachedJsSourceMapping; - -// ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. -SingleMapping? _cachedWasmSourceMapping; +Future? _cachedSourceMapping; Future _fetchSourceMapping() async { - final cachedSourceMapping = kIsWasm - ? _cachedWasmSourceMapping - : _cachedJsSourceMapping; - - return cachedSourceMapping ?? (await _initializeSourceMapping()); + final pendingSourceMapping = _cachedSourceMapping ??= + _initializeSourceMapping(); + final sourceMapping = await pendingSourceMapping; + // Retry failed loads without letting an older failure clear a newer request. + if (sourceMapping == null && + identical(_cachedSourceMapping, pendingSourceMapping)) { + _cachedSourceMapping = null; + } + return sourceMapping; } Future _initializeSourceMapping() async { diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 5cb6a47678e..3a2e1beb10c 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -15,6 +15,9 @@ To learn more about DevTools, check out the ## General updates +* Reused source maps across error reports instead of fetching and parsing them + repeatedly. [#9910](https://github.com/flutter/devtools/issues/9910) + * Added guidance to run "flutter attach" when a Flutter debug app cannot evaluate expressions and a debug-only screen is unavailable. [#4091](https://github.com/flutter/devtools/issues/4091) diff --git a/packages/devtools_app/test/framework/app_error_handling_test.dart b/packages/devtools_app/test/framework/app_error_handling_test.dart new file mode 100644 index 00000000000..1348a939914 --- /dev/null +++ b/packages/devtools_app/test/framework/app_error_handling_test.dart @@ -0,0 +1,37 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/src/shared/framework/app_error_handling.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; + +void main() { + test( + 'keeps the original stack without fetching source maps on the VM', + () async { + var requests = 0; + final client = MockClient((_) async { + requests++; + return http.Response('{}', 200); + }); + + await http.runWithClient(() async { + final logged = Logger('app_error_handling').onRecord.first; + reportError( + 'VM error', + stack: StackTrace.fromString( + '#0 original (package:devtools_app/original.dart:1:1)', + ), + ); + expect((await logged).message, contains('original.dart')); + expect(requests, 0); + }, () => client); + }, + ); +} diff --git a/packages/devtools_app/test/framework/app_error_handling_web_test.dart b/packages/devtools_app/test/framework/app_error_handling_web_test.dart new file mode 100644 index 00000000000..4ca7927f6ad --- /dev/null +++ b/packages/devtools_app/test/framework/app_error_handling_web_test.dart @@ -0,0 +1,98 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('browser') +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:devtools_app/src/shared/framework/app_error_handling.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; + +void main() { + test( + 'shares source maps across startup and errors, retrying failed loads', + () async { + final oldFlutterErrorHandler = FlutterError.onError; + final oldPlatformErrorHandler = PlatformDispatcher.instance.onError; + addTearDown(() { + FlutterError.onError = oldFlutterErrorHandler; + PlatformDispatcher.instance.onError = oldPlatformErrorHandler; + }); + + var requests = 0; + final successfulResponse = Completer(); + final sourceMap = jsonEncode({ + 'version': 3, + 'file': 'main.dart.${kIsWasm ? 'wasm' : 'js'}', + 'sources': ['original.dart'], + 'names': ['originalFunction'], + 'mappings': 'AAAAA', + }); + final client = MockClient((request) { + expect(request.url.path, 'main.dart.${kIsWasm ? 'wasm' : 'js'}.map'); + requests++; + switch (requests) { + case 1: + return Future.value(http.Response('Not found', 404)); + case 2: + return Future.value(http.Response('invalid source map', 200)); + case 3: + return Future.error(http.ClientException('Offline', request.url)); + default: + return successfulResponse.future; + } + }); + + Future startApp() async { + final started = Completer(); + setupErrorHandling(() async { + started.complete(); + }); + await started.future; + } + + Future report(String message) { + final logged = Logger('app_error_handling').onRecord.firstWhere( + (record) => record.message.startsWith('[source-map-test]: $message'), + ); + reportError( + message, + errorType: 'source-map-test', + stack: StackTrace.fromString( + ' at generated (main.dart.${kIsWasm ? 'wasm' : 'js'}:1:1)', + ), + ); + return logged; + } + + await http.runWithClient(() async { + await startApp(); + expect(requests, 1); + await report('invalid map'); + await report('offline map'); + expect(requests, 3); + + final first = report('first mapped error'); + final second = report('second mapped error'); + successfulResponse.complete(http.Response(sourceMap, 200)); + final records = await Future.wait([first, second]); + for (final record in records) { + expect(record.message, contains('original.dart')); + } + expect( + (await report('later mapped error')).message, + contains('original.dart'), + ); + await startApp(); + expect(requests, 4); + }, () => client); + }, + ); +}