Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## 5.19.0 - 2025.08.22
### Added
- Added methods to unregister from state machine events [#625]
- `OnTransitionedUnregister` and `OnTransitionedAsyncUnregister` for transition events
- `OnTransitionCompletedUnregister` and `OnTransitionCompletedAsyncUnregister` for transition completed events
- `UnregisterAllCallbacks` to unregister all callbacks at once
### Fixed
- Fixed transition precedence issue where substate transitions were not given priority over parent state transitions [#626]
### Changed
- Improved performance by replacing string concatenations with `StringBuilder` in graph generation [#622]
- Moved repeated string literal into `internal const string` for better maintainability [#622]
- Refactored reflection classes for better code organization and consistency [#623]
- Enhanced parameter conversion with additional test coverage [#623]

## 5.18.0 - 2025.08.02
### Added
- Added support for `PermitIfAsync` and `PermitReentryIfAsync` methods to allow async guard conditions [#618], [#189]
Expand Down Expand Up @@ -241,6 +255,10 @@ Version 5.10.0 is now listed as the newest, since it has the highest version num
### Removed
### Fixed

[#626]: https://github.com/dotnet-state-machine/stateless/pull/626
[#625]: https://github.com/dotnet-state-machine/stateless/pull/625
[#623]: https://github.com/dotnet-state-machine/stateless/pull/623
[#622]: https://github.com/dotnet-state-machine/stateless/pull/622
[#618]: https://github.com/dotnet-state-machine/stateless/pull/618
[#610]: https://github.com/dotnet-state-machine/stateless/pull/610
[#604]: https://github.com/dotnet-state-machine/stateless/issues/604
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,16 +212,70 @@ Stateless supports 2 types of state machine events:

#### State transition
```csharp
// Synchronously
stateMachine.OnTransitioned((transition) => { });

// Asynchronously
stateMachine.OnTransitionedAsync((transition) => { return Task.FromResult(0); });
```
This event will be invoked every time the state machine changes state.

#### State machine transition completed
```csharp
// Synchronously
stateMachine.OnTransitionCompleted((transition) => { });

// Asynchronously
stateMachine.OnTransitionCompletedAsync((transition) => { return Task.FromResult(0); });
```
This event will be invoked at the very end of the trigger handling, after the last entry action has been executed.

---

In addition to this, Stateless also provides you with the ability to unregister from state machine events in 3 ways.
* State transition unregister (sync/async)
* State machine transition completed (sync/async)
* State machine unregister from all (sync and async)


#### State machine transition unregister (synchronous)
```csharp
// Keep a reference to the synchronous callback action we want to unregister later.
Action transitionCallbackAction = (transition) => { };
stateMachine.OnTransitionedUnregister(transitionCallbackAction);
```
This method will unregister the specified action callback from the transition event.

#### State machine transition unregister (asynchronous)
```csharp
// Keep a reference to the asynchronous callback function we want to unregister later.
Func<Transition, Task> transitionAsyncCallback => (transition) => { return Task.FromResult(0); };
stateMachine.OnTransitionedAsyncUnregister(transitionAsyncCallback);
````
This method will unregister the specified async function callback from the transition event.

#### State machine transition completed unregister (synchronous)
```csharp
// Keep a reference to the synchronous callback action we want to unregister later.
Action transitionCompletedCallbackAction = (transition) => { });
stateMachine.OnTransitionCompletedUnregister(transitionCompletedCallbackAction);
```
This method will unregister the specified action callback from the transition completed event.

#### State machine transition completed unregister (asynchronous)
```csharp
// Keep a reference to to the asynchronous callback function we want to unregister later.
Func<Transition, Task> transitionCompletedAsyncCallback => (transition) => { return Task.FromResult(0); });
stateMachine.OnTransitionCompletedAsyncUnregister(transitionCompletedAsyncCallback);
```
This method will unregister the specified async function callback from the transition completed event.

#### Unregister all registered callbacks (sync and async)
```csharp
stateMachine.UnregisterAllCallbacks();
```
This method will unregister all synchronous and asynchronously registered callbacks from the state machine.

### Export to DOT graph

It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date.
Expand Down
32 changes: 30 additions & 2 deletions example/OnOffExample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using Stateless;
using Stateless.Graph;

namespace OnOffExample
{
Expand All @@ -9,6 +10,11 @@ namespace OnOffExample
/// </summary>
class Program
{
static void TransitionAnnounce(StateMachine<string, char>.Transition transition)
{
Console.WriteLine($"State Machine Transitioning from '{transition.Source}' to '{transition.Destination}'");
}

static void Main(string[] args)
{
const string on = "On";
Expand All @@ -28,9 +34,31 @@ static void Main(string[] args)
{
Console.WriteLine("Switch is in state: " + onOffSwitch.State);
var pressed = Console.ReadKey(true).KeyChar;

// Check if user wants to exit
if (pressed != space) break;
if (pressed != space)
{
// Before exiting this is how you can safely register and unregister from transition events.
// Keys 'r' or 'R' = Register for transition events with double subscription prevention built-in.
// Keys 'u' or 'U' = Unregister from transition events to prevent memory leaks in long running applications.
switch (pressed)
{
case 'r':
case 'R':
onOffSwitch.OnTransitioned(TransitionAnnounce);
Console.WriteLine("Now subscribed to transition events..");
continue;

case 'u':
case 'U':
onOffSwitch.OnTransitionedUnregister(TransitionAnnounce);
Console.WriteLine("Successfully unsubscribed from transition events..");
continue;
}

Console.WriteLine("Exiting program");
break;
}

// Use the Fire method with the trigger as payload to supply the state machine with an event.
// The state machine will react according to its configuration.
Expand Down
32 changes: 16 additions & 16 deletions src/Stateless/Graph/GraphStyleBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ public abstract class GraphStyleBase
/// <returns>Description of all transitions, in the desired format.</returns>
public virtual List<string> FormatAllTransitions(List<Transition> transitions)
{
List<string> lines = new List<string>();
if (transitions == null) return lines;
if (transitions == null)
return new List<string>();

// Eagerly set the initial capacity to minimize re-allocation of internal array.
List<string> lines = new List<string>(transitions.Count);

foreach (var transit in transitions)
{
Expand All @@ -84,26 +87,23 @@ public virtual List<string> FormatAllTransitions(List<Transition> transitions)
stay.SourceState.NodeName, stay.Guards.Select(x => x.Description));
}
}
else
else if (transit is FixedTransition fix)
{
if (transit is FixedTransition fix)
{
line = FormatOneTransition(fix.SourceState.NodeName, fix.Trigger.UnderlyingTrigger.ToString(),
line = FormatOneTransition(fix.SourceState.NodeName, fix.Trigger.UnderlyingTrigger.ToString(),
fix.DestinationEntryActions.Select(x => x.Method.Description),
fix.DestinationState.NodeName, fix.Guards.Select(x => x.Description));
}
else
{
if (transit is DynamicTransition dyn)
{
line = FormatOneTransition(dyn.SourceState.NodeName, dyn.Trigger.UnderlyingTrigger.ToString(),
}
else if (transit is DynamicTransition dyn)
{
line = FormatOneTransition(dyn.SourceState.NodeName, dyn.Trigger.UnderlyingTrigger.ToString(),
dyn.DestinationEntryActions.Select(x => x.Method.Description),
dyn.DestinationState.NodeName, new List<string> { dyn.Criterion });
}
else
throw new ArgumentException("Unexpected transition type");
}
}
else
{
throw new ArgumentException("Unexpected transition type");
}

if (line != null)
lines.Add(line);
}
Expand Down
27 changes: 17 additions & 10 deletions src/Stateless/Graph/MermaidGraphStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,21 @@ public override string FormatOneState(State state)
public override string GetPrefix()
{
BuildSanitizedNamedStateMap();
string prefix = "stateDiagram-v2";

StringBuilder sb = new StringBuilder("stateDiagram-v2");
if (_direction.HasValue)
{
prefix += $"{Environment.NewLine}\tdirection {GetDirectionCode(_direction.Value)}";
sb.AppendLine();
sb.Append($"\tdirection {GetDirectionCode(_direction.Value)}");
}

foreach (var state in _stateMap.Where(x => !x.Key.Equals(x.Value.StateName, StringComparison.Ordinal)))
{
prefix += $"{Environment.NewLine}\t{state.Key} : {state.Value.StateName}";
sb.AppendLine();
sb.Append($"\t{state.Key} : {state.Value.StateName}");
}

return prefix;
return sb.ToString();
}

/// <inheritdoc/>
Expand All @@ -91,25 +94,29 @@ public override string GetInitialTransition(StateInfo initialState)
/// <inheritdoc/>
public override string FormatOneTransition(string sourceNodeName, string trigger, IEnumerable<string> actions, string destinationNodeName, IEnumerable<string> guards)
{
string label = trigger ?? "";
StringBuilder sb = new StringBuilder(trigger ?? string.Empty);

if (actions?.Count() > 0)
label += " / " + string.Join(", ", actions);
{
sb.Append(" / ");
sb.Append(string.Join(", ", actions));
}

if (guards.Any())
{
foreach (var info in guards)
{
if (label.Length > 0)
label += " ";
label += "[" + info + "]";
if (sb.Length > 0)
sb.Append(" ");

sb.Append("[" + info + "]");
}
}

var sanitizedSourceNodeName = GetSanitizedStateName(sourceNodeName);
var sanitizedDestinationNodeName = GetSanitizedStateName(destinationNodeName);

return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, label);
return FormatOneLine(sanitizedSourceNodeName, sanitizedDestinationNodeName, sb.ToString());
}

internal string FormatOneLine(string fromNodeName, string toNodeName, string label)
Expand Down
35 changes: 22 additions & 13 deletions src/Stateless/Graph/StateGraph.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Stateless.Reflection;

namespace Stateless.Graph
Expand Down Expand Up @@ -58,37 +61,41 @@ public StateGraph(StateMachineInfo machineInfo)
/// <returns></returns>
public string ToGraph(GraphStyleBase style)
{
string dirgraphText = style.GetPrefix();
StringBuilder sb = new StringBuilder(style.GetPrefix());

// Start with the clusters
foreach (var state in States.Values.Where(x => x is SuperState))
{
dirgraphText += style.FormatOneCluster((SuperState)state);
sb.Append(style.FormatOneCluster((SuperState)state));
}

// Next process all non-cluster states
foreach (var state in States.Values)
{
if (state is SuperState || state is Decision || state.SuperState != null)
continue;
dirgraphText += style.FormatOneState(state);

sb.Append(style.FormatOneState(state));
}

// Finally, add decision nodes
foreach (var dec in Decisions)
{
dirgraphText += style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description);
sb.Append(style.FormatOneDecisionNode(dec.NodeName, dec.Method.Description));
}

// now build behaviours
List<string> transits = style.FormatAllTransitions(Transitions);
foreach (var transit in transits)
dirgraphText += System.Environment.NewLine + transit;
{
sb.Append(Environment.NewLine);
sb.Append(transit);
}

// Add initial transition if present
dirgraphText += style.GetInitialTransition(initialState);
sb.Append(style.GetInitialTransition(initialState));

return dirgraphText;
return sb.ToString();
}

/// <summary>
Expand Down Expand Up @@ -202,8 +209,9 @@ void AddSingleStates(StateMachineInfo machineInfo)
{
foreach (var stateInfo in machineInfo.States)
{
if (!States.ContainsKey(stateInfo.UnderlyingState.ToString()))
States[stateInfo.UnderlyingState.ToString()] = new State(stateInfo);
string underlyingState = stateInfo.UnderlyingState.ToString();
if (!States.ContainsKey(underlyingState))
States[underlyingState] = new State(stateInfo);
}
}

Expand All @@ -225,22 +233,23 @@ void AddSubstates(SuperState superState, IEnumerable<StateInfo> substates)
{
foreach (var subState in substates)
{
if (States.ContainsKey(subState.UnderlyingState.ToString()))
string underlyingState = subState.UnderlyingState.ToString();
if (States.ContainsKey(underlyingState))
{
// This shouldn't happen
}
else if (subState.Substates.Any())
{
SuperState sub = new SuperState(subState);
States[subState.UnderlyingState.ToString()] = sub;
States[underlyingState] = sub;
superState.SubStates.Add(sub);
sub.SuperState = superState;
AddSubstates(sub, subState.Substates);
}
else
{
State sub = new State(subState);
States[subState.UnderlyingState.ToString()] = sub;
States[underlyingState] = sub;
superState.SubStates.Add(sub);
sub.SuperState = superState;
}
Expand Down
Loading
Loading