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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ This is a lockstep workspace: all packages share the same version and release no

## [Unreleased]

### Fixed

- Fixed handler type matching to support inheritance hierarchies (sealed classes, abstract base classes).
- Handlers registered for a base class now correctly receive events of subclass types.
- Previously, publishing `SubclassEvent` would not trigger handlers registered for `BaseClass`.
- This is critical for freezed/sealed class patterns where events have multiple subtypes.

## [3.0.1] - 2026-01-22

### Changed
Expand Down
3 changes: 1 addition & 2 deletions packages/raiser/lib/src/bus/error_strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,5 @@ class AggregateException implements Exception {
AggregateException(this.errors, this.stackTraces);

@override
String toString() =>
'AggregateException: ${errors.length} error${errors.length == 1 ? '' : 's'} occurred';
String toString() => 'AggregateException: ${errors.length} error${errors.length == 1 ? '' : 's'} occurred';
}
80 changes: 59 additions & 21 deletions packages/raiser/lib/src/bus/event_bus.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,32 @@ typedef ErrorCallback = void Function(Object error, StackTrace stackTrace);
typedef Middleware = Future<void> Function(dynamic event, Future<void> Function() next);

/// Internal class for storing handler registrations with metadata.
class _HandlerEntry<T> {
/// The handler function to invoke.
final Future<void> Function(T) handler;
///
/// Stores handlers in a type-erased manner to enable inheritance-aware
/// type matching. The [canHandle] predicate checks if an event is assignable
/// to the handler's expected type, and [invoke] wraps the typed handler
/// to allow invocation with a dynamic event.
class _HandlerEntry {
/// Type-erased handler invocation.
///
/// Wraps the original typed handler and performs the cast internally.
/// This is safe because [canHandle] guarantees type compatibility.
final Future<void> Function(dynamic) invoke;

/// Priority for ordering (higher = earlier execution).
final int priority;

/// Registration order for stable sorting within same priority.
final int registrationOrder;

_HandlerEntry(this.handler, this.priority, this.registrationOrder);
/// Type predicate to check if an event is assignable to this handler's type.
///
/// Uses runtime type checking to support inheritance and sealed class
/// hierarchies. This allows a handler registered for `BaseEvent` to receive
/// events of type `SubclassEvent`.
final bool Function(dynamic) canHandle;

_HandlerEntry(this.invoke, this.priority, this.registrationOrder, this.canHandle);
}

/// Internal class for storing middleware registrations with metadata.
Expand Down Expand Up @@ -66,9 +81,12 @@ class EventBus {
/// Counter for assigning registration order to middleware.
int _middlewareCounter = 0;

/// Type-based handler storage.
/// Maps runtime types to lists of handler entries.
final Map<Type, List<_HandlerEntry<dynamic>>> _handlers = {};
/// Flat list of all registered handler entries.
///
/// We use a flat list instead of a type-keyed map to support
/// inheritance-aware type matching. Each entry contains a type predicate
/// that checks if an event is assignable to the handler's expected type.
final List<_HandlerEntry> _handlers = [];

/// Middleware pipeline.
final List<_MiddlewareEntry> _middleware = [];
Expand Down Expand Up @@ -138,14 +156,26 @@ class EventBus {
}

/// Internal method to add a handler to the registry.
///
/// Creates a handler entry with a type predicate that uses runtime type
/// checking to support inheritance hierarchies (e.g., sealed classes with
/// multiple subclasses). The handler is wrapped in a type-erased invocation
/// function that performs the cast internally.
Subscription _addHandler<T>(Future<void> Function(T) handler, int priority) {
final entry = _HandlerEntry<T>(handler, priority, _registrationCounter++);
// Create a type predicate that checks if an event is assignable to T.
// This enables inheritance-aware matching where a handler for BaseEvent
// will receive events of SubclassEvent.
bool canHandle(dynamic event) => event is T;

_handlers.putIfAbsent(T, () => []);
_handlers[T]!.add(entry);
// Wrap the typed handler in a type-erased function that performs the cast.
// This is safe because canHandle guarantees the event is of type T.
Future<void> invoke(dynamic event) => handler(event as T);

final entry = _HandlerEntry(invoke, priority, _registrationCounter++, canHandle);
_handlers.add(entry);

return Subscription(() {
_handlers[T]?.remove(entry);
_handlers.remove(entry);
});
}

Expand Down Expand Up @@ -196,26 +226,34 @@ class EventBus {
}

/// Executes handlers for the given event.
///
/// Uses inheritance-aware type matching to find all handlers that can
/// process the event. This supports sealed class hierarchies where a
/// handler registered for a base class receives subclass events.
Future<void> _executeHandlers<T>(T event) async {
final entries = _handlers[T];
if (entries == null || entries.isEmpty) {
// Find all handlers whose type predicate matches the event.
// This enables inheritance: a handler for BaseEvent will match SubclassEvent.
final matchingEntries = _handlers.where((entry) => entry.canHandle(event)).toList();

if (matchingEntries.isEmpty) {
return;
}

// Sort by priority (descending) then by registration order (ascending)
final sortedEntries = List<_HandlerEntry<dynamic>>.from(entries)
..sort((a, b) {
final priorityCompare = b.priority.compareTo(a.priority);
if (priorityCompare != 0) return priorityCompare;
return a.registrationOrder.compareTo(b.registrationOrder);
});
matchingEntries.sort((a, b) {
final priorityCompare = b.priority.compareTo(a.priority);
if (priorityCompare != 0) return priorityCompare;
return a.registrationOrder.compareTo(b.registrationOrder);
});

final errors = <Object>[];
final stackTraces = <StackTrace>[];

for (final entry in sortedEntries) {
for (final entry in matchingEntries) {
try {
await (entry as _HandlerEntry<T>).handler(event);
// The canHandle predicate guarantees the event is assignable to the
// handler's type, so the internal cast in invoke is safe.
await entry.invoke(event);
} catch (error, stackTrace) {
// Always invoke the error callback if configured
onError?.call(error, stackTrace);
Expand Down
160 changes: 160 additions & 0 deletions packages/raiser/test/edge_cases_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,107 @@ void main() {

expect(invoked, isFalse);
});

test('handler for base class receives subclass events', () async {
// Verifies inheritance-aware type matching for sealed class hierarchies.
// A handler registered for the sealed base class should receive events
// from any of its subclasses.
final bus = EventBus();
final received = <BaseEvent>[];

bus.on<BaseEvent>((event) async {
received.add(event);
});

// Publish subclass events - they should be received by the base handler
await bus.publish(SubEventA('first'));
await bus.publish(SubEventB(42));

expect(received.length, equals(2));
expect(received[0], isA<SubEventA>());
expect(received[1], isA<SubEventB>());
});

test('handler for specific subclass ignores other subtypes', () async {
// A handler for a specific subclass should not receive events from
// sibling subclasses.
final bus = EventBus();
final receivedA = <SubEventA>[];
final receivedB = <SubEventB>[];

bus.on<SubEventA>((event) async {
receivedA.add(event);
});
bus.on<SubEventB>((event) async {
receivedB.add(event);
});

await bus.publish(SubEventA('test'));

// Only SubEventA handler should have received the event
expect(receivedA.length, equals(1));
expect(receivedB.length, equals(0));
});

test('both base and subclass handlers receive subclass events', () async {
// When handlers are registered for both base and subclass, publishing a
// subclass event should trigger both handlers.
final bus = EventBus();
final baseReceived = <BaseEvent>[];
final subReceived = <SubEventA>[];

bus.on<BaseEvent>((event) async {
baseReceived.add(event);
});
bus.on<SubEventA>((event) async {
subReceived.add(event);
});

await bus.publish(SubEventA('test'));

// Both handlers should receive the event
expect(baseReceived.length, equals(1));
expect(subReceived.length, equals(1));
});

test('handler priority works correctly with inheritance', () async {
// Priority ordering should work correctly when multiple handlers at
// different inheritance levels receive the same event.
final bus = EventBus();
final order = <String>[];

// Register handlers with explicit priorities
bus.on<SubEventA>((event) async {
order.add('sub:low');
}, priority: 0);
bus.on<BaseEvent>((event) async {
order.add('base:high');
}, priority: 10);
bus.on<SubEventA>((event) async {
order.add('sub:high');
}, priority: 5);

await bus.publish(SubEventA('test'));

// Should execute in priority order: base:high (10), sub:high (5), sub:low (0)
expect(order, equals(['base:high', 'sub:high', 'sub:low']));
});

test('class-based handler receives subclass events', () async {
// Verifies that EventHandler<BaseType> receives subclass events,
// matching the pattern described in the user's issue.
final bus = EventBus();
final handler = BaseEventHandler();

bus.register<BaseEvent>(handler);

await bus.publish(SubEventA('test'));
await bus.publish(SubEventB(123));

expect(handler.received.length, equals(2));
expect(handler.received[0], isA<SubEventA>());
expect(handler.received[1], isA<SubEventB>());
});
});

group('Memory and Cleanup Edge Cases', () {
Expand Down Expand Up @@ -386,3 +487,62 @@ final class OtherEvent implements RaiserEvent {

Map<String, dynamic> toMetadataMap() => {'value': value};
}

/// Base event class for testing inheritance-aware type matching.
///
/// Simulates a sealed class hierarchy where handlers can be registered for
/// the base type and receive subclass events.
sealed class BaseEvent implements RaiserEvent {
const BaseEvent({required this.id, required this.occurredOn, required this.metadata});

@override
final EventId id;

@override
final DateTime occurredOn;

@override
final Map<String, Object?> metadata;
}

/// First subclass of [BaseEvent] for testing inheritance.
final class SubEventA extends BaseEvent {
SubEventA(
this.value, {
EventId? eventId,
DateTime? occurredOn,
Map<String, Object?> metadata = const {},
}) : super(
id: eventId ?? EventId.fromUlid(),
occurredOn: occurredOn ?? DateTime.now(),
metadata: Map<String, Object?>.unmodifiable(metadata),
);

final String value;
}

/// Second subclass of [BaseEvent] for testing inheritance.
final class SubEventB extends BaseEvent {
SubEventB(
this.number, {
EventId? eventId,
DateTime? occurredOn,
Map<String, Object?> metadata = const {},
}) : super(
id: eventId ?? EventId.fromUlid(),
occurredOn: occurredOn ?? DateTime.now(),
metadata: Map<String, Object?>.unmodifiable(metadata),
);

final int number;
}

/// Class-based handler for [BaseEvent] to test inheritance with register().
class BaseEventHandler implements EventHandler<BaseEvent> {
final List<BaseEvent> received = [];

@override
Future<void> handle(BaseEvent event) async {
received.add(event);
}
}
6 changes: 2 additions & 4 deletions packages/raiser/test/event_bus_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,7 @@ void main() {
expect(
executionOrder,
equals(expectedOrder),
reason:
'Priorities $priorities should execute in descending priority order',
reason: 'Priorities $priorities should execute in descending priority order',
);
}
});
Expand Down Expand Up @@ -343,8 +342,7 @@ void main() {
expect(
executionOrder,
equals(expectedOrder),
reason:
'Equal priority handlers should execute in registration order',
reason: 'Equal priority handlers should execute in registration order',
);
}
},
Expand Down
Loading