Skip to content

1.3.0: serialize GanttController fetches and add GanttActivityAction style - #31

Merged
RichiB20 merged 6 commits into
insideapp-srl:mainfrom
a-nisi-insideapp:fix/serialize-fetch-race
Oct 6, 2026
Merged

RichiB20 merged 6 commits into
insideapp-srl:mainfrom
a-nisi-insideapp:fix/serialize-fetch-race

Conversation

@a-nisi-insideapp

@a-nisi-insideapp a-nisi-insideapp commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

What does it do?

Serialized fetches

  • GanttController.fetch() now tracks whether a fetch is already in flight. A call that arrives while one is running sets a pending flag and returns; the controller then reruns exactly once, right after, picking up whatever changed in the meantime. At most one fetch is ever in flight.
  • Fetch listeners changed from VoidCallback to FutureOr<void> Function() so the controller can await them. This is backward compatible: a plain synchronous callback still satisfies the new type.

Hardened fetch

  • A listener that throws (e.g. a failing activitiesAsync/holidaysAsync) no longer leaves the controller permanently unable to fetch, and no longer skips the remaining listeners. The error is reported through FlutterError.reportError instead of being swallowed.
  • Adding or removing a listener while a fetch is in flight (e.g. a Gantt widget disposed while its data is still loading) no longer throws a ConcurrentModificationError.

Action button style

  • GanttActivityAction has a new optional style (ButtonStyle?), applied to the action's IconButton. It is optional, so existing code is unaffected.

Why is it needed?

  • Overlapping fetches (e.g. the automatic fetch on mount racing a caller's refetch triggered by async filter restoration) resolved in arbitrary order, so a stale response could overwrite a fresher one, and every consumer had to guard against it on its own.
  • Action icons could not be styled, so apps could not customize them, e.g. turning a delete action red on hover to match the rest of their UI.

How to test it?

  • flutter test: covers serialization, coalescing of concurrent calls, listener errors, and listeners added or removed during a fetch.
  • In the example app, trigger several fetches in quick succession (e.g. change the visible range repeatedly): the chart always shows the data of the latest request.
  • Pass style: ButtonStyle(foregroundColor: WidgetStateProperty.resolveWith((s) => s.contains(WidgetState.hovered) ? Colors.red : null)) to a GanttActivityAction: the icon turns red on hover.

Related issues/PRs

Bumps the version to 1.3.0; CHANGELOG.md is updated.

…ently

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<void>
Function() so the controller can await them — backward compatible,
a plain synchronous callback still satisfies the new type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@rickypid

Copy link
Copy Markdown
Contributor

@RichiB20 Were you able to try it out or check it?

a-nisi-insideapp and others added 2 commits September 22, 2026 12:44
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) <noreply@anthropic.com>
Document the listener error handling and the ConcurrentModificationError
fix, and set the release date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@a-nisi-insideapp

Copy link
Copy Markdown
Contributor Author

What

Makes GanttController.fetch() safe to call at any time, from anywhere, without callers having to reason about concurrency:

  • Overlapping fetch() calls are serialized and coalesced — at most one fetch is ever in flight.
  • A fetch listener that throws no longer leaves the controller permanently unable to fetch, and no longer prevents the remaining listeners from running.
  • A listener added or removed while a fetch is in flight no longer throws a ConcurrentModificationError.

Adds test/gantt_controller_fetch_test.dart (12 tests) and bumps the package to 1.3.0.

Why

fetch() used to fire all listeners at once, with no coordination between calls. Three problems followed from that:

  1. Stale data won. Two overlapping fetches both ran, and whichever RPC resolved last overwrote the chart — regardless of which fetch was triggered last. The common trigger is the automatic fetch on mount racing a caller's own refetch from asynchronous filter restoration. Every consumer had to guard against this on its own.
  2. A failed fetch could kill the chart. With the listeners now awaited, an exception escaping a listener (a failing activitiesAsync, a dropped connection) left the in-flight flag stuck, turning every later fetch() into a silent no-op: panning and next()/prev() stopped loading anything, with no way to recover short of rebuilding the controller.
  3. Disposing mid-load crashed the run. Awaiting the listeners means the dispatch loop spans an await, so _GanttState.dispose() removing its own listener while data was still loading raised a ConcurrentModificationError — i.e. simply navigating away from a loading chart.

How

GanttController tracks two flags, _fetchInFlight and _fetchPending. fetch() starts a run only when none is active; a call arriving during a run just sets the pending flag and returns. When the run finishes, it reruns exactly once if anything was requested in the meantime, so N calls during a fetch collapse into a single follow-up rather than N parallel fetches.

The dispatch loop itself is hardened in two ways:

  • It iterates a snapshot of the listener list, so listeners may be added or removed while the loop is suspended on an await.
  • The run is wrapped in try/finally so the in-flight flag is always cleared, and each listener is invoked inside its own try/catch. A throwing listener is reported through FlutterError.reportError (library flutter_gantt) instead of being swallowed as an unhandled async error, and the remaining listeners still run.

Fetch listeners changed type from VoidCallback to FutureOr Function() so the controller can await them. This is not a breaking change: FutureOr normalizes to void in Dart's type system, so the two function types are equivalent and existing synchronous callbacks keep compiling unchanged. The observable difference is that asynchronous listeners are now awaited — which is the point of the fix.

Note on scope: listeners within a single run are now awaited one after another rather than dispatched concurrently. For the single-listener case (the Gantt widget's own) this changes nothing.

Testing

  • Tests added/updated — test/gantt_controller_fetch_test.dart, 12 tests covering coalescing, awaiting of async listeners, listener add/remove during an in-flight fetch, listener failures, and coalescing when the in-flight run fails. 7 of the 12 fail against the previous implementation, so they pin the actual regressions rather than the new code.
  • Tested manually on: local
  • CI pipeline green

Local verification:

dart format --set-exit-if-changed . → 16 files, 0 changed
flutter analyze --no-pub → No issues found
flutter test --no-pub → All tests passed! (12/12)

Heads-up for the reviewer: .github/workflows/flutter-analyze.yaml runs format, analyze and pana, but not flutter test — the new tests will not run in CI as things stand.

LAST_CHANGES.md

  • Updated — this package follows the pub.dev convention, so the equivalent entry lives in CHANGELOG.md under ## [1.3.0] - 2026-09-22, committed last.

a-nisi-insideapp and others added 3 commits September 22, 2026 15:18
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@a-nisi-insideapp a-nisi-insideapp changed the title Fix: serialize GanttController fetches instead of racing them concurr… 1.3.0: serialize GanttController fetches and add GanttActivityAction style Oct 6, 2026
@RichiB20
RichiB20 merged commit 70eda99 into insideapp-srl:main Oct 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants