diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 7e9c5050340..34c0610d550 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -17,6 +17,9 @@ To learn more about DevTools, check out the * Remove the legacy Provider screen, which was a placeholder to point users to the `package:provider` DevTools extension. [#10046](https://github.com/flutter/devtools/pull/10046) +* Fixed an issue where Dart and Flutter web apps compiled with WebAssembly + (Wasm) or modern web libraries (such as `package:web`) were not recognized as + web applications. [#10045](https://github.com/flutter/devtools/pull/10045) ## Inspector updates diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index 683bc2bdaf9..231da6a74db 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md @@ -4,6 +4,11 @@ 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. --> ## 0.5.2-wip +* Detect Dart web apps by the presence of `dart:js_interop` or + `dart:js_interop_unsafe` in addition to `dart:html`, so dart2wasm and + `package:web` apps are recognized without dropping support for older + `dart:html` apps. Adds the `dartJsInteropLibraryUri`, + `dartJsInteropUnsafeLibraryUri`, and `dartWebLibraryUris` constants. * Fix a `RangeError` thrown by `SplitPane` when the number of children changes between rebuilds. * Fix garbage collection and timeout issues with the result list in `asyncEval` on both native VM and web. diff --git a/packages/devtools_app_shared/lib/src/service/connected_app.dart b/packages/devtools_app_shared/lib/src/service/connected_app.dart index 5c926ef4b06..94d7e96f990 100644 --- a/packages/devtools_app_shared/lib/src/service/connected_app.dart +++ b/packages/devtools_app_shared/lib/src/service/connected_app.dart @@ -15,10 +15,30 @@ final _log = Logger('connected_app'); const flutterLibraryUri = 'package:flutter/src/widgets/binding.dart'; -// TODO(kenz): if we want to support debugging dart2wasm web apps, we will need -// to check for the presence of a different library. +/// Present in older Dart and Flutter web apps, including google3. +/// +/// Modern apps may use `package:web` / [dartJsInteropLibraryUri] instead. const dartHtmlLibraryUri = 'dart:html'; +/// Present in Dart web apps compiled to JavaScript or Wasm. +/// +/// `package:web` is built on this library, so it is the reliable signal for +/// modern and dart2wasm apps that no longer import [dartHtmlLibraryUri]. +const dartJsInteropLibraryUri = 'dart:js_interop'; + +/// Part of the modern Dart web JS interop library suite. +const dartJsInteropUnsafeLibraryUri = 'dart:js_interop_unsafe'; + +/// Libraries whose presence indicates the connected app is a Dart web app. +/// +/// [dartHtmlLibraryUri] is kept so older apps (including google3) are still +/// detected. +const dartWebLibraryUris = [ + dartHtmlLibraryUri, + dartJsInteropLibraryUri, + dartJsInteropUnsafeLibraryUri, +]; + // TODO(https://github.com/flutter/devtools/issues/6239): try to remove this. @sealed class ConnectedApp { @@ -85,8 +105,15 @@ class ConnectedApp { bool? _isProfileBuild; // TODO(kenz): investigate if we can use `libraryUriAvailableNow` instead. - Future get isDartWebApp async => _isDartWebApp ??= await serviceManager! - .libraryUriAvailable(dartHtmlLibraryUri); + Future get isDartWebApp async { + if (_isDartWebApp != null) return _isDartWebApp!; + for (final uri in dartWebLibraryUris) { + if (await serviceManager!.libraryUriAvailable(uri)) { + return _isDartWebApp = true; + } + } + return _isDartWebApp = false; + } bool? get isDartWebAppNow { assert(_isDartWebApp != null); @@ -230,6 +257,11 @@ final class OfflineConnectedApp extends ConnectedApp { @override final bool? isDartWebAppNow; + /// There is no [serviceManager] to query for an offline app, so this + /// returns the value that was recorded when the app was exported. + @override + Future get isDartWebApp async => isDartWebAppNow ?? false; + @override final bool? isRunningOnDartVM; diff --git a/packages/devtools_app_shared/test/service/connected_app_test.dart b/packages/devtools_app_shared/test/service/connected_app_test.dart new file mode 100644 index 00000000000..b9153d2a82c --- /dev/null +++ b/packages/devtools_app_shared/test/service/connected_app_test.dart @@ -0,0 +1,152 @@ +// 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. + +import 'package:devtools_app_shared/service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ConnectedApp', () { + test('isDartWebApp is true when dart:html is available', () async { + final serviceManager = _FakeServiceManager( + availableLibraries: ['dart:html'], + ); + final app = ConnectedApp(serviceManager); + expect(await app.isDartWebApp, isTrue); + expect(app.isDartWebAppNow, isTrue); + }); + + test('isDartWebApp is true when dart:js_interop is available', () async { + final serviceManager = _FakeServiceManager( + availableLibraries: ['dart:js_interop'], + ); + final app = ConnectedApp(serviceManager); + expect(await app.isDartWebApp, isTrue); + expect(app.isDartWebAppNow, isTrue); + }); + + test( + 'isDartWebApp is true when dart:js_interop_unsafe is available', + () async { + final serviceManager = _FakeServiceManager( + availableLibraries: ['dart:js_interop_unsafe'], + ); + final app = ConnectedApp(serviceManager); + expect(await app.isDartWebApp, isTrue); + expect(app.isDartWebAppNow, isTrue); + }, + ); + + test('isDartWebApp is false when no web libraries are available', () async { + final serviceManager = _FakeServiceManager( + availableLibraries: [ + 'dart:io', + 'package:flutter/src/widgets/binding.dart', + ], + ); + final app = ConnectedApp(serviceManager); + expect(await app.isDartWebApp, isFalse); + expect(app.isDartWebAppNow, isFalse); + }); + + test('isDartWebApp short-circuits on first matching library', () async { + final queriedLibraries = []; + final serviceManager = _FakeServiceManager( + availableLibraries: ['dart:html', 'dart:js_interop'], + onLibraryUriAvailable: queriedLibraries.add, + ); + final app = ConnectedApp(serviceManager); + + expect(await app.isDartWebApp, isTrue); + expect(queriedLibraries, equals(['dart:html'])); + }); + + test('isDartWebApp caches results', () async { + var callCount = 0; + final serviceManager = _FakeServiceManager( + availableLibraries: ['dart:js_interop'], + onLibraryUriAvailable: (_) => callCount++, + ); + final app = ConnectedApp(serviceManager); + + expect(await app.isDartWebApp, isTrue); + final callsAfterFirst = callCount; + expect(callsAfterFirst, greaterThan(0)); + + expect(await app.isDartWebApp, isTrue); + expect(callCount, equals(callsAfterFirst)); + }); + + test('isDartWebAppNow asserts when not initialized', () { + final serviceManager = _FakeServiceManager(); + final app = ConnectedApp(serviceManager); + expect(() => app.isDartWebAppNow, throwsAssertionError); + }); + + test( + 'isFlutterWebAppNow is true and isFlutterNativeAppNow is false for modern Flutter web apps', + () async { + final serviceManager = _FakeServiceManager( + availableLibraries: [flutterLibraryUri, 'dart:js_interop'], + ); + final app = ConnectedApp(serviceManager); + expect(await app.isFlutterApp, isTrue); + expect(await app.isDartWebApp, isTrue); + expect(app.isFlutterWebAppNow, isTrue); + expect(app.isFlutterNativeAppNow, isFalse); + }, + ); + }); + + group('OfflineConnectedApp', () { + test( + 'isDartWebApp returns the recorded value without a service manager', + () async { + expect( + await OfflineConnectedApp(isDartWebAppNow: true).isDartWebApp, + isTrue, + ); + expect( + await OfflineConnectedApp(isDartWebAppNow: false).isDartWebApp, + isFalse, + ); + expect(await OfflineConnectedApp().isDartWebApp, isFalse); + }, + ); + }); + + group('constants', () { + test('dartWebLibraryUris contains all web library URIs', () { + expect( + dartWebLibraryUris, + containsAll([ + dartHtmlLibraryUri, + dartJsInteropLibraryUri, + dartJsInteropUnsafeLibraryUri, + ]), + ); + }); + }); +} + +class _FakeServiceManager extends Fake implements ServiceManager { + _FakeServiceManager({ + this.availableLibraries = const [], + this.onLibraryUriAvailable, + }); + + final List availableLibraries; + final void Function(String uri)? onLibraryUriAvailable; + + @override + bool libraryUriAvailableNow(String? uri) { + if (uri == null) return false; + onLibraryUriAvailable?.call(uri); + return availableLibraries.any((u) => u.startsWith(uri)); + } + + @override + Future libraryUriAvailable(String uri) async { + return libraryUriAvailableNow(uri); + } +} diff --git a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart index bb2b821ce0d..cd1cce3b950 100644 --- a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart @@ -234,6 +234,11 @@ class FakeServiceManager extends Fake return availableLibraries.any((u) => u.startsWith(uri)); } + @override + Future libraryUriAvailable(String uri) async { + return libraryUriAvailableNow(uri); + } + @override Future manuallyDisconnect() async { setConnectedState(false, manual: true);