From 98147714c7a157b2416ed6ddc2e440769c8aa23c Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Wed, 16 Sep 2026 12:38:55 +0200 Subject: [PATCH 1/6] Fix: serialize GanttController fetches instead of racing them concurrently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GanttController.fetch() could run multiple overlapping fetches at once (e.g. the automatic fetch on mount racing a caller's own refetch triggered by async filter restoration), and whichever RPC resolved last would win regardless of which fetch was triggered last — every consumer had to guard against a stale response overwriting a fresher one on its own. fetch() now tracks whether a fetch is already in flight; a call that arrives while one is running just marks a pending flag and returns, and the controller reruns exactly once, immediately after, picking up whatever changed in the meantime. At most one fetch is ever in flight, so there is nothing left to race against. Fetch listeners changed from VoidCallback to FutureOr Function() so the controller can await them — backward compatible, a plain synchronous callback still satisfies the new type. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++++++ lib/src/widgets/controller.dart | 36 +++++++++++++++++++++++++++++---- pubspec.yaml | 2 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d938adf..022d6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.3.0] - 2026-09-16 + +@a-nisi-insideapp + +- Fix a race between overlapping `fetch()` calls (e.g. the automatic fetch on mount racing a caller's own refetch triggered by async filter restoration): `GanttController` now serializes fetches instead of running them concurrently — at most one fetch is ever in flight, and any `fetch()` call that arrives while one is running is coalesced into a single follow-up run instead of starting a second one. This removes the need for callers to guard against stale responses overwriting fresher ones. + ## [1.2.2] - 2026-04-28 @rickypid diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 9633f63..3d410d2 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -164,13 +166,18 @@ class GanttController extends ChangeNotifier { notifyListeners(); } - final List _fetchListener = []; + bool _fetchInFlight = false; + bool _fetchPending = false; + + final List Function()> _fetchListener = + Function()>[]; /// Adds a listener to be called when data needs to be fetched. - void addFetchListener(VoidCallback fn) => _fetchListener.add(fn); + void addFetchListener(FutureOr Function() fn) => _fetchListener.add(fn); /// Removes a fetch listener. - void removeFetchListener(VoidCallback fn) => _fetchListener.remove(fn); + void removeFetchListener(FutureOr Function() fn) => + _fetchListener.remove(fn); /// Removes all fetch listeners. void removeAllFetchListener() { @@ -180,9 +187,30 @@ class GanttController extends ChangeNotifier { } /// Notifies all fetch listeners to load new data. + /// + /// If a fetch is already in flight, this does not start a second one in + /// parallel — it just marks that another fetch is needed and returns. + /// Once the running fetch completes, it immediately re-runs exactly once + /// more, picking up whatever changed in the meantime. This guarantees at + /// most one fetch in flight at any time, so a slower/older fetch can never + /// overwrite a faster/newer one — there is nothing left to race against. void fetch() { + if (_fetchInFlight) { + _fetchPending = true; + return; + } + _runFetch(); + } + + Future _runFetch() async { + _fetchInFlight = true; for (var fn in _fetchListener) { - fn(); + await Future.sync(fn); + } + _fetchInFlight = false; + if (_fetchPending) { + _fetchPending = false; + unawaited(_runFetch()); } } diff --git a/pubspec.yaml b/pubspec.yaml index c1001d6..978ae81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_gantt description: "Flutter package for rendering an interactive and customizable Gantt chart widget." -version: 1.2.2 +version: 1.3.0 homepage: https://github.com/insideapp-srl/flutter_gantt repository: https://github.com/insideapp-srl/flutter_gantt From b4a45f53e0c29a12a5010795c7f4caef9b4947ba Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Tue, 22 Sep 2026 12:44:59 +0200 Subject: [PATCH 2/6] Fix: harden GanttController fetch against listener errors and mutation Iterate over a snapshot of the listener list so a listener added or removed while a fetch is suspended on an await no longer throws a ConcurrentModificationError. A Gantt widget disposed while its data is still loading hits this on its own dispose(), which removes its fetch listener mid-run. Wrap the run in try/finally and catch per listener: a listener that throws no longer leaves _fetchInFlight stuck at true, which turned every later fetch() into a silent no-op and left the chart unable to reload, and no longer skips the remaining listeners. Failures are reported through FlutterError.reportError instead of being swallowed as an unhandled async error. Add test/gantt_controller_fetch_test.dart covering fetch coalescing, awaiting of async listeners, listener add/remove during an in-flight fetch, and listener failures. Seven of the twelve tests fail against the previous implementation. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/widgets/controller.dart | 34 +++- test/gantt_controller_fetch_test.dart | 282 ++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 test/gantt_controller_fetch_test.dart diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 3d410d2..18d2585 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -194,20 +194,46 @@ class GanttController extends ChangeNotifier { /// more, picking up whatever changed in the meantime. This guarantees at /// most one fetch in flight at any time, so a slower/older fetch can never /// overwrite a faster/newer one — there is nothing left to race against. + /// + /// A listener that throws is reported through [FlutterError.reportError]: it + /// neither skips the remaining listeners nor leaves the controller unable to + /// fetch again. void fetch() { if (_fetchInFlight) { _fetchPending = true; return; } - _runFetch(); + unawaited(_runFetch()); } Future _runFetch() async { _fetchInFlight = true; - for (var fn in _fetchListener) { - await Future.sync(fn); + try { + // Iterate over a snapshot: listeners may be added or removed while this + // loop is suspended on an await (a Gantt widget being disposed mid-fetch + // removes its own listener), which would otherwise throw a + // ConcurrentModificationError. + for (final fn in List Function()>.of(_fetchListener)) { + try { + await Future.sync(fn); + } catch (error, stackTrace) { + // A listener that throws must not abort the remaining listeners nor + // leave the controller wedged, but the failure still has to surface. + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'flutter_gantt', + context: ErrorDescription( + 'while running a GanttController fetch listener', + ), + ), + ); + } + } + } finally { + _fetchInFlight = false; } - _fetchInFlight = false; if (_fetchPending) { _fetchPending = false; unawaited(_runFetch()); diff --git a/test/gantt_controller_fetch_test.dart b/test/gantt_controller_fetch_test.dart new file mode 100644 index 0000000..ad6c83a --- /dev/null +++ b/test/gantt_controller_fetch_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_gantt/flutter_gantt.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Yields to the event loop so pending fetch runs can make progress. +Future settle() => Future.delayed(Duration.zero); + +void main() { + group('GanttController.fetch', () { + late GanttController controller; + late List reportedErrors; + FlutterExceptionHandler? previousOnError; + + setUp(() { + controller = GanttController(startDate: DateTime(2026, 1, 1)); + reportedErrors = []; + previousOnError = FlutterError.onError; + FlutterError.onError = reportedErrors.add; + }); + + tearDown(() { + FlutterError.onError = previousOnError; + }); + + test('runs every listener once when no fetch is in flight', () async { + final calls = []; + controller.addFetchListener(() => calls.add('sync')); + controller.addFetchListener(() async => calls.add('async')); + + controller.fetch(); + await settle(); + + expect(calls, ['sync', 'async']); + }); + + test('awaits async listeners before completing the run', () async { + final gate = Completer(); + var finished = false; + controller.addFetchListener(() async { + await gate.future; + finished = true; + }); + + controller.fetch(); + await settle(); + expect(finished, isFalse); + + // A fetch arriving now must be treated as overlapping, which only holds + // if the controller is actually awaiting the listener's future. + var followUps = 0; + controller.addFetchListener(() => followUps++); + controller.fetch(); + await settle(); + expect(followUps, 0, reason: 'the first run is still in flight'); + + gate.complete(); + await settle(); + expect(finished, isTrue); + expect(followUps, 1); + }); + + test('coalesces overlapping fetches into a single follow-up run', () async { + final gates = >[]; + var runs = 0; + controller.addFetchListener(() { + runs++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + controller.fetch(); + await settle(); + expect(runs, 1); + + // Three calls while the first run is in flight collapse into one rerun. + controller.fetch(); + controller.fetch(); + controller.fetch(); + await settle(); + expect(runs, 1, reason: 'no second run may start in parallel'); + + gates[0].complete(); + await settle(); + expect(runs, 2, reason: 'exactly one follow-up run, not three'); + + gates[1].complete(); + await settle(); + expect(runs, 2); + }); + + test('accepts a new fetch once the queued rerun has drained', () async { + final gates = >[]; + var runs = 0; + controller.addFetchListener(() { + runs++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + controller.fetch(); + await settle(); + controller.fetch(); + gates[0].complete(); + await settle(); + gates[1].complete(); + await settle(); + expect(runs, 2); + + controller.fetch(); + await settle(); + expect(runs, 3); + gates[2].complete(); + await settle(); + }); + + test( + 'does not throw when a listener is removed while a fetch is in flight', + () async { + // A Gantt widget disposed mid-fetch removes its own listener, which + // used to blow up the run with a ConcurrentModificationError. + final gate = Completer(); + FutureOr slowListener() => gate.future; + var tailCalls = 0; + + controller.addFetchListener(slowListener); + controller.addFetchListener(() => tailCalls++); + + controller.fetch(); + await settle(); + + controller.removeFetchListener(slowListener); + gate.complete(); + await settle(); + + expect(reportedErrors, isEmpty); + expect(tailCalls, 1, reason: 'the snapshot keeps the run intact'); + + // The controller is still usable afterwards. + controller.fetch(); + await settle(); + expect(tailCalls, 2); + }, + ); + + test( + 'does not throw when a listener is added while a fetch is in flight', + () async { + final gate = Completer(); + var lateCalls = 0; + controller.addFetchListener(() => gate.future); + + controller.fetch(); + await settle(); + + controller.addFetchListener(() => lateCalls++); + gate.complete(); + await settle(); + + expect(reportedErrors, isEmpty); + expect(lateCalls, 0, reason: 'not part of the run already started'); + + controller.fetch(); + await settle(); + expect(lateCalls, 1); + }, + ); + + test('stays usable after a listener throws', () async { + var calls = 0; + controller.addFetchListener(() async { + calls++; + throw StateError('network down'); + }); + + controller.fetch(); + await settle(); + expect(calls, 1); + + controller.fetch(); + await settle(); + expect( + calls, + 2, + reason: 'a failed fetch must not wedge the controller forever', + ); + + controller.fetch(); + await settle(); + expect(calls, 3); + }); + + test('reports a throwing listener through FlutterError', () async { + controller.addFetchListener(() => throw StateError('network down')); + + controller.fetch(); + await settle(); + + expect(reportedErrors, hasLength(1)); + expect(reportedErrors.single.exception, isStateError); + expect(reportedErrors.single.library, 'flutter_gantt'); + }); + + test('keeps running the remaining listeners after one throws', () async { + final calls = []; + controller.addFetchListener(() { + calls.add('first'); + throw StateError('network down'); + }); + controller.addFetchListener(() async { + calls.add('second'); + throw StateError('also down'); + }); + controller.addFetchListener(() => calls.add('third')); + + controller.fetch(); + await settle(); + + expect(calls, ['first', 'second', 'third']); + expect(reportedErrors, hasLength(2)); + }); + + test('still coalesces when the in-flight run fails', () async { + final gates = >[]; + var runs = 0; + controller.addFetchListener(() { + runs++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + controller.fetch(); + await settle(); + controller.fetch(); + controller.fetch(); + + gates[0].completeError(StateError('network down')); + await settle(); + + expect(runs, 2); + expect(reportedErrors, hasLength(1)); + gates[1].complete(); + await settle(); + }); + + test('date navigation triggers a fetch unless opted out', () async { + var runs = 0; + controller.addFetchListener(() => runs++); + + controller.next(); + await settle(); + expect(runs, 1); + + controller.prev(); + await settle(); + expect(runs, 2); + + controller.next(fetchData: false); + await settle(); + expect(runs, 2); + }); + + test('removed listeners are no longer invoked', () async { + var runs = 0; + FutureOr listener() => runs++; + controller.addFetchListener(listener); + + controller.fetch(); + await settle(); + expect(runs, 1); + + controller.removeFetchListener(listener); + controller.fetch(); + await settle(); + expect(runs, 1); + }); + }); +} From f144733fe9c01248a98b3f91fa078a21b56fbfa1 Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Tue, 22 Sep 2026 12:45:03 +0200 Subject: [PATCH 3/6] Docs: update CHANGELOG for 1.3.0 Document the listener error handling and the ConcurrentModificationError fix, and set the release date. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 022d6af..179432e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ -## [1.3.0] - 2026-09-16 +## [1.3.0] - 2026-09-22 @a-nisi-insideapp - Fix a race between overlapping `fetch()` calls (e.g. the automatic fetch on mount racing a caller's own refetch triggered by async filter restoration): `GanttController` now serializes fetches instead of running them concurrently — at most one fetch is ever in flight, and any `fetch()` call that arrives while one is running is coalesced into a single follow-up run instead of starting a second one. This removes the need for callers to guard against stale responses overwriting fresher ones. +- A fetch listener that throws (a failing `activitiesAsync`/`holidaysAsync`, for instance) no longer leaves `GanttController` permanently unable to fetch, and no longer skips the remaining listeners. The failure is reported through `FlutterError.reportError` instead of being swallowed. +- Fix a `ConcurrentModificationError` when a fetch listener was added or removed while a fetch was in flight — for example a `Gantt` widget disposed while its data was still loading. ## [1.2.2] - 2026-04-28 From 73b47b32b8b11f3eb121b81822d562baea8f23c4 Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Tue, 22 Sep 2026 15:18:36 +0200 Subject: [PATCH 4/6] Chore: update example lockfile for flutter_gantt 1.3.0 Keeps the example app's resolved version in sync with the package version bump. Also picks up the transitive meta 1.17.0 -> 1.18.0 bump and the Dart SDK constraint refresh from the local toolchain. Co-Authored-By: Claude Opus 5 (1M context) --- example/pubspec.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 5c30c61..f08b20c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -36,7 +36,7 @@ packages: path: ".." relative: true source: path - version: "1.2.2" + version: "1.3.0" flutter_lints: dependency: "direct dev" description: @@ -86,10 +86,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: @@ -128,5 +128,5 @@ packages: source: hosted version: "2.2.0" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=1.17.0" From 013965d7e43e103f841ca0a3f2732780025dc354 Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Tue, 6 Oct 2026 10:55:36 +0200 Subject: [PATCH 5/6] Feat: add optional style to GanttActivityAction Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/src/classes/activity.dart | 4 ++++ lib/src/widgets/activities_list.dart | 1 + 2 files changed, 5 insertions(+) diff --git a/lib/src/classes/activity.dart b/lib/src/classes/activity.dart index 160b22d..bdb3536 100644 --- a/lib/src/classes/activity.dart +++ b/lib/src/classes/activity.dart @@ -13,11 +13,15 @@ class GanttActivityAction { /// Optional tooltip text for the action. final String? tooltip; + /// Optional style for the action button. + final ButtonStyle? style; + /// Creates an activity action with an icon, tap handler, and optional tooltip. const GanttActivityAction({ required this.icon, required this.onTap, this.tooltip, + this.style, }); } diff --git a/lib/src/widgets/activities_list.dart b/lib/src/widgets/activities_list.dart index 4d91da6..a4d5a8b 100644 --- a/lib/src/widgets/activities_list.dart +++ b/lib/src/widgets/activities_list.dart @@ -84,6 +84,7 @@ class ActivitiesList extends StatelessWidget { padding: EdgeInsets.zero, onPressed: e.onTap, icon: Icon(e.icon, size: theme.cellHeight * 0.8), + style: e.style, ); return e.tooltip != null ? Tooltip(message: e.tooltip, child: child) From cd4d6c2d476bb1dc8d8e03e8fb632511eb8ee445 Mon Sep 17 00:00:00 2001 From: AlbertoNisi Date: Tue, 6 Oct 2026 10:55:48 +0200 Subject: [PATCH 6/6] Docs: add GanttActivityAction style to CHANGELOG for 1.3.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 179432e..6ff6f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Fix a race between overlapping `fetch()` calls (e.g. the automatic fetch on mount racing a caller's own refetch triggered by async filter restoration): `GanttController` now serializes fetches instead of running them concurrently — at most one fetch is ever in flight, and any `fetch()` call that arrives while one is running is coalesced into a single follow-up run instead of starting a second one. This removes the need for callers to guard against stale responses overwriting fresher ones. - A fetch listener that throws (a failing `activitiesAsync`/`holidaysAsync`, for instance) no longer leaves `GanttController` permanently unable to fetch, and no longer skips the remaining listeners. The failure is reported through `FlutterError.reportError` instead of being swallowed. - Fix a `ConcurrentModificationError` when a fetch listener was added or removed while a fetch was in flight — for example a `Gantt` widget disposed while its data was still loading. +- Add an optional `style` to `GanttActivityAction`, applied to the action's `IconButton`, so callers can customize the button (e.g. its color on hover). ## [1.2.2] - 2026-04-28