Skip to content

Commit 5cae9ca

Browse files
authored
[google_maps_flutter] Raise MapUsedAfterWidgetDisposedError when map controller used after map disposed (#9242)
This change introduces a new `MapUsedAfterWidgetDisposedError` that facilitates debugging when the user calls a method on `GoogleMapsController` after the associated map has been disposed. This replaces the previous behavior, which would sometimes throw a Platform-side error (`MissingPluginException`, `Unable to establish connection on channel`, etc.). Although technically non-breaking (we're replacing one error with another), this could be disruptive because the new error is raised eagerly and synchronously, as soon as a call is made. Before, the call could have potentially succeeded (?), and the error was asynchronous (and so, potentially, unawaited and ignored?). Fixes flutter/flutter#43785. ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 6646deb commit 5cae9ca

File tree

4 files changed

+98
-1
lines changed

4 files changed

+98
-1
lines changed

packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 2.14.0
2+
3+
* Adds a check that raises a `StateError`
4+
when map controller is used after its widget has been disposed.
5+
16
## 2.13.1
27

38
* Fixes exception when dispose is called while asynchronous update from

packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ class GoogleMapController {
264264
/// in-memory cache of tiles. If you want to cache tiles for longer, you
265265
/// should implement an on-disk cache.
266266
Future<void> clearTileCache(TileOverlayId tileOverlayId) async {
267+
_checkWidgetMountedOrThrow();
267268
return GoogleMapsFlutterPlatform.instance.clearTileCache(
268269
tileOverlayId,
269270
mapId: mapId,
@@ -278,6 +279,7 @@ class GoogleMapController {
278279
/// The returned [Future] completes after the change has been started on the
279280
/// platform side.
280281
Future<void> animateCamera(CameraUpdate cameraUpdate, {Duration? duration}) {
282+
_checkWidgetMountedOrThrow();
281283
return GoogleMapsFlutterPlatform.instance.animateCameraWithConfiguration(
282284
cameraUpdate,
283285
CameraUpdateAnimationConfiguration(duration: duration),
@@ -290,6 +292,7 @@ class GoogleMapController {
290292
/// The returned [Future] completes after the change has been made on the
291293
/// platform side.
292294
Future<void> moveCamera(CameraUpdate cameraUpdate) {
295+
_checkWidgetMountedOrThrow();
293296
return GoogleMapsFlutterPlatform.instance.moveCamera(
294297
cameraUpdate,
295298
mapId: mapId,
@@ -311,6 +314,7 @@ class GoogleMapController {
311314
/// style reference for more information regarding the supported styles.
312315
@Deprecated('Use GoogleMap.style instead.')
313316
Future<void> setMapStyle(String? mapStyle) {
317+
_checkWidgetMountedOrThrow();
314318
return GoogleMapsFlutterPlatform.instance.setMapStyle(
315319
mapStyle,
316320
mapId: mapId,
@@ -319,11 +323,13 @@ class GoogleMapController {
319323

320324
/// Returns the last style error, if any.
321325
Future<String?> getStyleError() {
326+
_checkWidgetMountedOrThrow();
322327
return GoogleMapsFlutterPlatform.instance.getStyleError(mapId: mapId);
323328
}
324329

325330
/// Return [LatLngBounds] defining the region that is visible in a map.
326331
Future<LatLngBounds> getVisibleRegion() {
332+
_checkWidgetMountedOrThrow();
327333
return GoogleMapsFlutterPlatform.instance.getVisibleRegion(mapId: mapId);
328334
}
329335

@@ -333,6 +339,7 @@ class GoogleMapController {
333339
/// Screen location is in screen pixels (not display pixels) with respect to the top left corner
334340
/// of the map, not necessarily of the whole screen.
335341
Future<ScreenCoordinate> getScreenCoordinate(LatLng latLng) {
342+
_checkWidgetMountedOrThrow();
336343
return GoogleMapsFlutterPlatform.instance.getScreenCoordinate(
337344
latLng,
338345
mapId: mapId,
@@ -344,6 +351,7 @@ class GoogleMapController {
344351
/// Returned [LatLng] corresponds to a screen location. The screen location is specified in screen
345352
/// pixels (not display pixels) relative to the top left of the map, not top left of the whole screen.
346353
Future<LatLng> getLatLng(ScreenCoordinate screenCoordinate) {
354+
_checkWidgetMountedOrThrow();
347355
return GoogleMapsFlutterPlatform.instance.getLatLng(
348356
screenCoordinate,
349357
mapId: mapId,
@@ -359,6 +367,7 @@ class GoogleMapController {
359367
/// * [hideMarkerInfoWindow] to hide the Info Window.
360368
/// * [isMarkerInfoWindowShown] to check if the Info Window is showing.
361369
Future<void> showMarkerInfoWindow(MarkerId markerId) {
370+
_checkWidgetMountedOrThrow();
362371
return GoogleMapsFlutterPlatform.instance.showMarkerInfoWindow(
363372
markerId,
364373
mapId: mapId,
@@ -374,6 +383,7 @@ class GoogleMapController {
374383
/// * [showMarkerInfoWindow] to show the Info Window.
375384
/// * [isMarkerInfoWindowShown] to check if the Info Window is showing.
376385
Future<void> hideMarkerInfoWindow(MarkerId markerId) {
386+
_checkWidgetMountedOrThrow();
377387
return GoogleMapsFlutterPlatform.instance.hideMarkerInfoWindow(
378388
markerId,
379389
mapId: mapId,
@@ -389,6 +399,7 @@ class GoogleMapController {
389399
/// * [showMarkerInfoWindow] to show the Info Window.
390400
/// * [hideMarkerInfoWindow] to hide the Info Window.
391401
Future<bool> isMarkerInfoWindowShown(MarkerId markerId) {
402+
_checkWidgetMountedOrThrow();
392403
return GoogleMapsFlutterPlatform.instance.isMarkerInfoWindowShown(
393404
markerId,
394405
mapId: mapId,
@@ -397,11 +408,13 @@ class GoogleMapController {
397408

398409
/// Returns the current zoom level of the map
399410
Future<double> getZoomLevel() {
411+
_checkWidgetMountedOrThrow();
400412
return GoogleMapsFlutterPlatform.instance.getZoomLevel(mapId: mapId);
401413
}
402414

403415
/// Returns the image bytes of the map
404416
Future<Uint8List?> takeSnapshot() {
417+
_checkWidgetMountedOrThrow();
405418
return GoogleMapsFlutterPlatform.instance.takeSnapshot(mapId: mapId);
406419
}
407420

@@ -414,4 +427,21 @@ class GoogleMapController {
414427
_streamSubscriptions.clear();
415428
GoogleMapsFlutterPlatform.instance.dispose(mapId: mapId);
416429
}
430+
431+
/// It is relatively easy to mistakenly call a method on the controller
432+
/// after the [GoogleMap] widget has already been disposed.
433+
/// Historically, this led to Platform-side errors such as
434+
/// `MissingPluginException` or `Unable to establish connection on channel`
435+
/// errors.
436+
///
437+
/// To facilitate debugging, this guard function
438+
/// raises a use-after-disposed [StateError].
439+
void _checkWidgetMountedOrThrow() {
440+
if (!_googleMapState.mounted) {
441+
throw StateError(
442+
'GoogleMapController for map ID $mapId was used after '
443+
'the associated GoogleMap widget had already been disposed.',
444+
);
445+
}
446+
}
417447
}

packages/google_maps_flutter/google_maps_flutter/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: google_maps_flutter
22
description: A Flutter plugin for integrating Google Maps in iOS and Android applications.
33
repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22
5-
version: 2.13.1
5+
version: 2.14.0
66

77
environment:
88
sdk: ^3.7.0
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2013 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/widgets.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:google_maps_flutter/google_maps_flutter.dart';
8+
import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';
9+
10+
import 'fake_google_maps_flutter_platform.dart';
11+
12+
void main() {
13+
late FakeGoogleMapsFlutterPlatform platform;
14+
15+
setUp(() {
16+
platform = FakeGoogleMapsFlutterPlatform();
17+
GoogleMapsFlutterPlatform.instance = platform;
18+
});
19+
20+
testWidgets('onMapCreated is called with controller', (
21+
WidgetTester tester,
22+
) async {
23+
GoogleMapController? controller;
24+
25+
await tester.pumpWidget(
26+
Directionality(
27+
textDirection: TextDirection.ltr,
28+
child: GoogleMap(
29+
initialCameraPosition: const CameraPosition(target: LatLng(0.0, 0.0)),
30+
onMapCreated: (GoogleMapController value) => controller = value,
31+
),
32+
),
33+
);
34+
35+
expect(controller, isNotNull);
36+
await expectLater(controller?.getZoomLevel(), isNotNull);
37+
});
38+
39+
testWidgets('controller throws when used after dispose', (
40+
WidgetTester tester,
41+
) async {
42+
GoogleMapController? controller;
43+
44+
await tester.pumpWidget(
45+
Directionality(
46+
textDirection: TextDirection.ltr,
47+
child: GoogleMap(
48+
initialCameraPosition: const CameraPosition(target: LatLng(0.0, 0.0)),
49+
onMapCreated: (GoogleMapController value) => controller = value,
50+
),
51+
),
52+
);
53+
54+
// Now dispose of the map...
55+
await tester.pumpWidget(Container());
56+
57+
await expectLater(
58+
() => controller?.getZoomLevel(),
59+
throwsA(isA<StateError>()),
60+
);
61+
});
62+
}

0 commit comments

Comments
 (0)