Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions packages/devtools_app_shared/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 36 additions & 4 deletions packages/devtools_app_shared/lib/src/service/connected_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +29 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove dartJsInteropUnsafeLibraryUri and its entry in dartWebLibraryUris. libraryUriAvailableNow matches with startsWith, so 'dart:js_interop' already matches dart:js_interop_unsafe. Also, dart:js_interop_unsafe imports dart:js_interop, so it is never loaded on its own. Update the CHANGELOG entry to match.

];

// TODO(https://github.com/flutter/devtools/issues/6239): try to remove this.
@sealed
class ConnectedApp {
Expand Down Expand Up @@ -85,8 +105,15 @@ class ConnectedApp {
bool? _isProfileBuild;

// TODO(kenz): investigate if we can use `libraryUriAvailableNow` instead.
Future<bool> get isDartWebApp async => _isDartWebApp ??= await serviceManager!
.libraryUriAvailable(dartHtmlLibraryUri);
Future<bool> get isDartWebApp async {
if (_isDartWebApp != null) return _isDartWebApp!;
for (final uri in dartWebLibraryUris) {
if (await serviceManager!.libraryUriAvailable(uri)) {
return _isDartWebApp = true;
}
}
return _isDartWebApp = false;
}
Comment thread
srawlins marked this conversation as resolved.

bool? get isDartWebAppNow {
assert(_isDartWebApp != null);
Expand Down Expand Up @@ -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<bool> get isDartWebApp async => isDartWebAppNow ?? false;

@override
final bool? isRunningOnDartVM;

Expand Down
152 changes: 152 additions & 0 deletions packages/devtools_app_shared/test/service/connected_app_test.dart
Original file line number Diff line number Diff line change
@@ -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);
},
);
Comment on lines +28 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this test. It passes only because the 'dart:js_interop' query prefix-matches 'dart:js_interop_unsafe', so it doesn't exercise the entry its name describes.


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 = <String>[];
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,
]),
);
});
});
Comment on lines +118 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this group. It just restates the const list and can't catch a regression.

}

class _FakeServiceManager extends Fake implements ServiceManager {
_FakeServiceManager({
this.availableLibraries = const [],
this.onLibraryUriAvailable,
});

final List<String> 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<bool> libraryUriAvailable(String uri) async {
return libraryUriAvailableNow(uri);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ class FakeServiceManager extends Fake
return availableLibraries.any((u) => u.startsWith(uri));
}

@override
Future<bool> libraryUriAvailable(String uri) async {
return libraryUriAvailableNow(uri);
}
Comment on lines +237 to +240

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this unless a test needs it. Nothing in this PR calls libraryUriAvailable on FakeServiceManager; the new tests use their own _FakeServiceManager.


@override
Future<void> manuallyDisconnect() async {
setConnectedState(false, manual: true);
Expand Down
Loading