I just shipped 0.3.1 of constellation_particles, a pub.dev package that draws a pointer-reactive particle field behind your content. It fixes a RangeError that could crash the widget on the very next frame after the particle count changed at runtime. The interesting part is how a user could trigger this without ever touching the particleCount parameter directly: the package advertises that it halves the particle count under the platform's high-contrast accessibility setting, and that halving was the crash trigger. Someone with this screen already open, who then flips high contrast on (a control-center toggle, or backgrounding the app, changing it in Settings, and coming back), could watch the app crash a frame later, for a reason that has nothing to do with anything they did inside the app itself.
What the widget does and why it has a grid at all
ConstellationParticles draws a field of particles that drift, wrap at the screen edges, and connect to nearby particles with a fading line. Normal usage looks like this:
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
const Positioned.fill(
child: ConstellationParticles(),
),
Center(
child: Text(
'Hello',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
);
}
}
Comparing every particle to every other particle to decide who counts as "nearby" is O(n^2) work per frame. That is fine at 50 particles and a dropped-frame machine at a few hundred. So the package buckets particles into a spatial hash grid, keyed by cell, sized to the connection distance:
class _SpatialGrid {
_SpatialGrid(this.cellSize);
final double cellSize;
final Map<int, List<int>> _cells = {};
void clear() => _cells.clear();
int _key(double x, double y) {
final cx = (x / cellSize).floor();
final cy = (y / cellSize).floor();
return cx * 100000 + cy;
}
void insert(int index, double x, double y) {
_cells.putIfAbsent(_key(x, y), () => []).add(index);
}
List<int> getNearby(double x, double y) {
final cx = (x / cellSize).floor();
final cy = (y / cellSize).floor();
final result = <int>[];
for (var dx = -1; dx <= 1; dx++) {
for (var dy = -1; dy <= 1; dy++) {
final cell = _cells[(cx + dx) * 100000 + (cy + dy)];
if (cell != null) result.addAll(cell);
}
}
return result;
}
}
The grid stores integer indices into _particles, not particle objects. That distinction is the whole bug: the grid is a cache of positions built from a list, and the cache can drift out of sync with the list the moment one changes without the other.
The accessibility feature that turned out to be the trigger
The README lists this among the things the widget does automatically:
Halves the particle count when the platform requests high contrast.
The implementation is small. _effectiveCount reads the platform setting:
int get _effectiveCount {
final highContrast = MediaQuery.maybeHighContrastOf(context) ?? false;
return highContrast
? (widget.particleCount * 0.5).round()
: widget.particleCount;
}
and didChangeDependencies calls it whenever an inherited value the widget depends on changes, MediaQuery among them:
@override
void didChangeDependencies() {
super.didChangeDependencies();
final target = _effectiveCount;
if (target != _particles.length && !_lastSize.isEmpty) {
_initParticles(_lastSize, count: target);
}
// ...reduce-motion handling omitted here...
}
Nothing in this path requires the app author to pass a different particleCount. The OS-level setting flips, MediaQuery.maybeHighContrastOf reports the change, didChangeDependencies fires, and _initParticles replaces _particles with a shorter list generated fresh from the seed:
void _initParticles(Size size, {int? count}) {
if (size.isEmpty) return;
final rng = math.Random(widget.seed);
final n = count ?? _effectiveCount;
_particles = List.generate(
n,
(_) => _Particle(
x: rng.nextDouble() * size.width,
y: rng.nextDouble() * size.height,
vx: (rng.nextDouble() - 0.5) * 0.4,
vy: (rng.nextDouble() - 0.5) * 0.4,
radius: rng.nextDouble() * 1.5 + 0.5,
opacity: rng.nextDouble() * 0.4 + 0.1,
),
);
_lastSize = size;
_grid.clear();
}
That last line, _grid.clear();, is the fix. Before 0.3.1 it was not there. _particles got a shorter list. _grid kept whatever indices it already had from the previous, larger population, because nothing told it to forget them.
Where the stale indices actually get read
The grid gets rebuilt in exactly one other place, _tick(), which runs once per animation frame off the AnimationController:
void _tick() {
if (_lastSize.isEmpty || _particles.isEmpty) return;
// ...movement and repulsion updates on _particles omitted here...
_grid.clear();
for (var i = 0; i < _particles.length; i++) {
_grid.insert(i, _particles[i].x, _particles[i].y);
}
_generation++;
}
The painter reads it here, looping every particle against its neighbors from the grid:
for (var i = 0; i < particles.length; i++) {
final pi = particles[i];
for (final j in grid.getNearby(pi.x, pi.y)) {
if (j <= i || j >= particles.length) continue;
final pj = particles[j];
// ...distance check and line drawing omitted here...
}
}
The j >= particles.length clause is also new in 0.3.1, a defensive backstop. Before the fix, the loop only checked j <= i. If the grid handed back an index like 159, left over from a population of 300 that had just been halved to 150 by high contrast, the painter went straight to particles[159] on a list of length 150, and Dart threw a RangeError.
Why the ordering lines up this way every time
This ordering is deterministic, not a timing fluke, which is what makes it worth explaining instead of just patching around it.
_tick() runs on the animation controller's clock: every frame, driven by the ticker, independent of anything else about the widget. didChangeDependencies runs when the framework rebuilds the element because something it depends on through an InheritedWidget, MediaQuery here, changed. Both can run within the handling of a single new frame, but they answer to different triggers: one to the vsync-driven animation, one to the dependency-change notification that comes with a rebuild. In the sequence that produces the crash, the tick that populates the grid with the full, larger population runs, then the dependency-change callback that shrinks the particle list runs after it, still ahead of paint in the same frame. Paint runs last, against whatever the grid and the list happen to hold at that point: a grid full of indices up to 299, a list now 150 long.
The grid and the list are two different pieces of state, meant to be kept in sync by one function that updates both. For one missing line, that function updated only one of them, and the gap does not show up until the next paint after a shrink.
Reproducing it and confirming the fix
The regression test that shipped with 0.3.1 covers both the direct path, dropping particleCount, and the accessibility path, flipping highContrast, with the same shape of test. Here is the accessibility one:
testWidgets(
'surviving high contrast halving the population after the grid has been populated',
(tester) async {
await tester.pumpWidget(_host(particleCount: 200));
for (var i = 0; i < 5; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(tester.takeException(), isNull);
await tester.pumpWidget(_host(particleCount: 200, highContrast: true));
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull);
});
with _host building the widget inside a MediaQuery you can flip:
Widget _host({required int particleCount, bool highContrast = false}) =>
MediaQuery(
data: MediaQueryData(highContrast: highContrast),
child: Directionality(
textDirection: TextDirection.ltr,
child: SizedBox(
width: 400,
height: 400,
child: ConstellationParticles(particleCount: particleCount, seed: 3),
),
),
);
Run this against the commit before the fix and it throws, either on the second pumpWidget call or the pump right after it, with a RangeError naming an index past the end of the shrunk list. Run the same test against 0.3.1 and it passes clean.
The crash needs a specific setup: the grid has to already be populated with the larger count before the shrink happens. A cold widget that starts with highContrast already true on its very first frame never hits it, because _effectiveCount is already halved by the time the first population is generated, so there is no larger population for the grid to remember in the first place. It is the transition, high contrast turning on (or particleCount dropping) while the widget is already running, that exposes the bug. That is also the realistic case for an app: a settings toggle changing while a screen with this widget is already on screen, not a cold start with the setting already on.
The lesson for anything that pairs a list with a derived index
A spatial grid is one shape of a pattern that shows up constantly: some structure computed from a source list and kept around because recomputing it from scratch every time would be wasteful. A lookup map keyed by id, a sorted index for binary search, a bounding-box tree, all the same shape. The rule that keeps them correct is that every path that mutates the source list has to invalidate or rebuild the derived structure, not just the one path you happened to write a test for first.
In this widget, three separate call sites can trigger _initParticles, the one function that actually replaces _particles: the initial LayoutBuilder in build(), didUpdateWidget when particleCount changes at runtime, and didChangeDependencies when high contrast flips. All three route through the same function, so the fix lives in one place: the function that builds the new list is also the function responsible for clearing the grid that described the old one. The particleCount path and the high-contrast path share the same bug for the same reason, and the high-contrast path is the one that went unnoticed, because it only shows up if an OS accessibility setting changes while you happen to be testing with the widget already running.
The general version of this, outside Flutter and outside particle fields, looks like this:
class PositionIndex {
PositionIndex(this.cellSize);
final double cellSize;
final Map<int, List<int>> _cells = {};
void clear() => _cells.clear();
void insert(int index, double x, double y) {
_cells.putIfAbsent(_keyFor(x, y), () => []).add(index);
}
int _keyFor(double x, double y) =>
(x / cellSize).floor() * 100000 + (y / cellSize).floor();
}
class Roster {
Roster(this.cellSize);
final double cellSize;
List<double> _xs = [];
late final PositionIndex _index = PositionIndex(cellSize);
// Every method that replaces _xs has to rebuild _index in the same
// breath. Add a second way to change _xs later, and if it does not
// also call this, the index will describe a list that no longer exists.
void setPositions(List<double> xs) {
_xs = xs;
_index.clear();
for (var i = 0; i < _xs.length; i++) {
_index.insert(i, _xs[i], 0);
}
}
}
If Roster grows a second method that swaps _xs directly, a filter, a sort-in-place, a splice, and that method does not also clear and rebuild _index, the same bug exists in a different shape: an index describing a list that no longer exists, waiting for whatever reads it next.
Testing the branch nobody has on by default
The reason this shipped at all is ordinary. Testing a widget usually means testing it at default settings. High contrast, reduced motion, larger text: these are branches in the code that only run when someone turns them on, and that someone is rarely the person writing the code. constellation_particles already had logic for reduced motion and high contrast before this fix, both advertised in the README, both exercised by nobody in an ordinary manual test pass, because turning on high contrast to check a decorative background particle field is not the first thing anyone thinks to do.
The fix itself is two small changes: one line, _grid.clear();, added to the end of _initParticles, and one line in the painter's neighbor loop extended with || j >= particles.length as a backstop in case some future code path replaces the list without going through _initParticles at all. Finding them took running the accessibility branch with the same seriousness as the default one: mount the widget, let it run a few ticks so its caches actually fill with something, then flip the setting and pump again. That is the whole repro. It just has to occur to you to write it, and the users most likely to hit it in the wild are the ones who have this setting on for real reasons, not the ones testing it.

Top comments (0)