You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two related defects in _CurrentLocationLayerState combine to crash the app after the layer is disposed.
1. _subscriptHeadingStream() has no re-entrancy guard, so a null position orphans a subscription.
A null position resets the status without cancelling the heading subscription:
if (position ==null) {
if (_status !=_Status.initialing) {
setState(() {
_status =_Status.initialing; // heading subscription NOT cancelled
...
When a position fix returns, _status != _Status.ready passes again and _subscriptHeadingStream() runs a second time. Unlike its siblings, it has no guard and overwrites the field:
void_subscriptAlignPositionStream() {
if (_alignPositionSubscription !=null) return; // guard
...
}
void_subscriptAlignDirectionStream() {
if (_alignDirectionSubscription !=null) return; // guard
...
}
void_subscriptHeadingStream() {
final headingStream = ...; // no guard
_headingSubscription = headingStream.listen(...); // overwrites, orphans the previous one
}
The first subscription is now unreachable, so dispose()'s _headingSubscription?.cancel() cancels only the newest. The orphan outlives the widget and keeps the rotation sensor streaming — EventChannel's onCancel never fires while a listener remains, so the compass keeps running after the map is gone. Every dropout leaks another.
2. The heading stream's onError calls setState without a mounted check.
It is the only callback in the class without one — onData at the top of the same listen() has it, as do both align-stream callbacks:
_headingSubscription = headingStream.listen(
(heading) {
if (!mounted) return; // guarded
...
},
onError: (error) {
error = widget.errorHandler(error);
if (error isUnsupportedException) { if (kDebugMode) print(error); }
if (_animatingHeading !=null) {
setState(() => _animatingHeading =null); // NOT guarded -> crash
}
},
);
A sensor error reaching an orphan's onError calls setState on a defunct State. In debug this throws setState() called after dispose(); in release the asserts are stripped and it reaches _element!.markNeedsBuild(), throwing Null check operator used on a null value.
Note errorHandler cannot prevent this — its return value only gates the debug print; the setState runs regardless. Passing headingStream: const Stream.empty() is currently the only way for a consumer to opt out, since the layer subscribes to the sensor regardless of LocationMarkerStyle.showHeadingSector.
To Reproduce
This widget test reproduces both defects deterministically (no device or real sensor needed):
import'dart:async';
import'package:flutter/material.dart';
import'package:flutter_map/flutter_map.dart';
import'package:flutter_map_location_marker/flutter_map_location_marker.dart';
import'package:flutter_test/flutter_test.dart';
import'package:latlong2/latlong.dart';
voidmain() {
testWidgets('null position orphans the heading subscription', (tester) async {
final positions =StreamController<LocationMarkerPosition?>.broadcast();
final headings =StreamController<LocationMarkerHeading?>.broadcast();
Widgetbuild({bool withLayer =true}) =>MaterialApp(
home:FlutterMap(
options:constMapOptions(
initialCenter:LatLng(52.52, 13.405),
initialZoom:13,
),
children: [
if (withLayer)
CurrentLocationLayer(
positionStream: positions.stream,
headingStream: headings.stream,
),
],
),
);
await tester.pumpWidget(build());
// 1. Position fix -> heading subscription #1 created, status becomes ready.
positions.add(
LocationMarkerPosition(latitude:52.52, longitude:13.405, accuracy:10),
);
await tester.pump(constDuration(seconds:1));
// 2. Heading arrives -> _animatingHeading becomes non-null.
headings.add(LocationMarkerHeading(heading:1, accuracy:0.3));
await tester.pump(constDuration(seconds:1));
// 3. GPS dropout -> _status resets to initialing, subscription #1 NOT cancelled.
positions.add(null);
await tester.pump(constDuration(seconds:1));
// 4. Fix returns -> _subscriptHeadingStream() runs again, orphaning #1.
positions.add(
LocationMarkerPosition(latitude:52.52, longitude:13.405, accuracy:10),
);
await tester.pump(constDuration(seconds:1));
// 5. Remove the layer -> dispose() cancels only subscription #2.await tester.pumpWidget(build(withLayer:false));
await tester.pump(constDuration(seconds:1));
// Defect 1: the orphan is still listening, so the sensor never stops.expect(headings.hasListener, isTrue);
// Defect 2: an error now reaches the orphan's unguarded onError.
headings.addError(Exception('sensor fault'));
await tester.pump(constDuration(seconds:1));
// => setState() called after dispose(): _CurrentLocationLayerState#...// (release builds: Null check operator used on a null value)
});
}
Stack trace from the failing pump:
setState() called after dispose(): _CurrentLocationLayerState#9563c(lifecycle state: defunct, not mounted, ...)
#0 State.setState.<anonymous closure> (package:flutter/src/widgets/framework.dart:1163:9)
#1 State.setState (package:flutter/src/widgets/framework.dart:1198:6)
#2 _CurrentLocationLayerState._subscriptHeadingStream.<anonymous closure> (package:flutter_map_location_marker/src/widgets/current_location_layer.dart:526:11)
On device, the equivalent steps are: open a map with CurrentLocationLayer, acquire a position fix and a heading, lose GPS (tunnel / underground car park / toggle location services), regain it, navigate away from the map, then have the compass emit an error.
Expected behavior
_subscriptHeadingStream() should not orphan a live subscription. Cancelling first is the smallest fix that also keeps didUpdateWidget's existing cancel-then-resubscribe working:
(An early-return guard matching the align-stream methods would need _headingSubscription = null after each cancel(), or didUpdateWidget would silently stop resubscribing.)
The heading onError should check mounted before setState, as every other callback in the class does:
onError: (error) {
if (!mounted) return;
...
}
After (1), dispose() cancels the only live subscription and no error can arrive late; (2) is defence in depth and makes the class internally consistent.
Screenshots
N/A — no visual symptom; the app terminates.
Desktop (please complete the following information):
N/A.
Smartphone (please complete the following information):
Device: iPad Air 11-inch (M2)
OS: iPadOS 26.4.2
Browser: N/A (Flutter app, not web)
Version: 10.3.0
Additional context
Affects 10.3.0 and 10.2.0 — the unguarded setState in the heading onError is present in both (10.2.0 line 498, 10.3.0 line 526). It is not a 10.3.0 regression. The reproduction above is against 10.3.0.
LocationMarkerStyle(showHeadingSector: false) does not avoid this: the layer subscribes to the rotation sensor regardless of style, so apps that never display a heading sector are still exposed.
The crash is only visible as Null check operator used on a null value in release/profile builds, where setState's asserts are stripped and execution reaches _element!.markNeedsBuild(). Debug builds show setState() called aft er dispose() instead. Crash reporters will therefore show the null-check-operator form with a framework.dartState.setState frame.
Related: Crash on iOS with Position Accuracy 0 #169 (non-finite accuracy reaching CirclePainter) surfaces the same "Null check operator used on a null value" message from a completely different cause. They are distinct bugs.
Describe the bug
Two related defects in
_CurrentLocationLayerStatecombine to crash the app after the layer is disposed.1.
_subscriptHeadingStream()has no re-entrancy guard, so a null position orphans a subscription.A null position resets the status without cancelling the heading subscription:
When a position fix returns,
_status != _Status.readypasses again and_subscriptHeadingStream()runs a second time. Unlike its siblings, it has no guard and overwrites the field:The first subscription is now unreachable, so
dispose()'s_headingSubscription?.cancel()cancels only the newest. The orphan outlives the widget and keeps the rotation sensor streaming —EventChannel'sonCancelnever fires while a listener remains, so the compass keeps running after the map is gone. Every dropout leaks another.2. The heading stream's
onErrorcallssetStatewithout amountedcheck.It is the only callback in the class without one —
onDataat the top of the samelisten()has it, as do both align-stream callbacks:A sensor error reaching an orphan's
onErrorcallssetStateon a defunctState. In debug this throwssetState() called after dispose(); in release the asserts are stripped and it reaches_element!.markNeedsBuild(), throwingNull check operator used on a null value.Note
errorHandlercannot prevent this — its return value only gates the debugprint; thesetStateruns regardless. PassingheadingStream: const Stream.empty()is currently the only way for a consumer to opt out, since the layer subscribes to the sensor regardless ofLocationMarkerStyle.showHeadingSector.To Reproduce
This widget test reproduces both defects deterministically (no device or real sensor needed):
Stack trace from the failing pump:
On device, the equivalent steps are: open a map with
CurrentLocationLayer, acquire a position fix and a heading, lose GPS (tunnel / underground car park / toggle location services), regain it, navigate away from the map, then have the compass emit an error.Expected behavior
_subscriptHeadingStream()should not orphan a live subscription. Cancelling first is the smallest fix that also keepsdidUpdateWidget's existing cancel-then-resubscribe working:(An early-return guard matching the align-stream methods would need
_headingSubscription = nullafter eachcancel(), ordidUpdateWidgetwould silently stop resubscribing.)onErrorshould checkmountedbeforesetState, as every other callback in the class does:After (1),
dispose()cancels the only live subscription and no error can arrive late; (2) is defence in depth and makes the class internally consistent.Screenshots
N/A — no visual symptom; the app terminates.
Desktop (please complete the following information):
N/A.
Smartphone (please complete the following information):
Additional context
setStatein the headingonErroris present in both (10.2.0 line 498, 10.3.0 line 526). It is not a 10.3.0 regression. The reproduction above is against 10.3.0.LocationMarkerStyle(showHeadingSector: false)does not avoid this: the layer subscribes to the rotation sensor regardless of style, so apps that never display a heading sector are still exposed.Null check operator used on a null valuein release/profile builds, wheresetState's asserts are stripped and execution reaches_element!.markNeedsBuild(). Debug builds showsetState() called aft er dispose()instead. Crash reporters will therefore show the null-check-operator form with aframework.dartState.setStateframe.CirclePainter) surfaces the same "Null check operator used on a null value" message from a completely different cause. They are distinct bugs.