Skip to content
Open
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
30 changes: 30 additions & 0 deletions packages/core/src/events/EventEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,34 @@ describe('EventEmitter', () => {
expect(handlerB).toHaveBeenCalledTimes(1); // Not called again
expect(handlerC).toHaveBeenCalledTimes(2);
});

it('hasSkippedHandlers returns true when regular handlers are skipped due to re-entrant emit', () => {
const emitter = new EventEmitter<TestEvents>();
const outerHandler = vi.fn();
const innerHandler = vi.fn();

emitter.on('message', outerHandler);
emitter.on('message', () => {
innerHandler();
// Re-entrant emit on same event
emitter.emit('message', 'reentrant');
});

// Normal emit: both handlers fire (re-entrant emit fires once handlers but skips regulars)
emitter.emit('message', 'first');
expect(outerHandler).toHaveBeenCalledTimes(1);
expect(innerHandler).toHaveBeenCalledTimes(1);
// hasSkippedHandlers = true because a re-entrant emit occurred during this cycle
expect(emitter.hasSkippedHandlers('message')).toBe(true);
});

it('hasSkippedHandlers returns false after a normal emit with no re-entrancy', () => {
const emitter = new EventEmitter<TestEvents>();
const handler = vi.fn();
emitter.on('message', handler);

// Normal emit with no re-entrant calls
emitter.emit('message', 'test');
expect(emitter.hasSkippedHandlers('message')).toBe(false);
});
});
36 changes: 31 additions & 5 deletions packages/core/src/events/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
export class EventEmitter<TEventMap extends Record<string, any>> {
private _handlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map();
private _onceHandlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map();
private _emitting: Set<keyof TEventMap> = new Set();
// Tracks the current emit depth for each event (0 = not emitting, 1 = outermost, 2+ = re-entrant)
private _emitting: Map<keyof TEventMap, number> = new Map();
private _skipped: Set<keyof TEventMap> = new Set();

/** Optional error handler for event handler errors. Called when a handler throws. */
onError?: (event: keyof TEventMap, error: unknown) => void;
Expand Down Expand Up @@ -79,9 +81,11 @@ export class EventEmitter<TEventMap extends Record<string, any>> {
this._onceHandlers.delete(event);
}

// Regular handlers — iterate over a snapshot to prevent concurrent modification issues
if (!this._emitting.has(event)) {
this._emitting.add(event);
// Capture depth before modifying anything — used to detect re-entrancy
const depth = this._emitting.get(event) ?? 0;
if (depth === 0) {
// Outermost emit: start fresh; clear any prior _skipped state for this event
this._emitting.set(event, 1);
const handlers = this._handlers.get(event);
if (handlers) {
for (const handler of [...handlers]) {
Expand All @@ -90,10 +94,23 @@ export class EventEmitter<TEventMap extends Record<string, any>> {
}
}
}
// Detect if any re-entrant emit occurred during this cycle
const wasReentrant = (this._emitting.get(event) ?? 1) > 1;
// Set _skipped only if re-entrant emit occurred during this cycle
if (wasReentrant) {
this._skipped.add(event);
} else {
this._skipped.delete(event);
}
// Always clear _emitting so the next emit starts fresh
this._emitting.delete(event);
} else {
// Re-entrant emit: regular handlers are skipped; track this
this._skipped.add(event);
this._emitting.set(event, depth + 1);
}

// Once handlers — fire removed handlers
// Once handlers — fire removed handlers (fires even on re-entrant emit)
for (const handler of onceSnapshot) {
try { handler(data); } catch (err) {
this.onError?.(event, err);
Expand Down Expand Up @@ -123,4 +140,13 @@ export class EventEmitter<TEventMap extends Record<string, any>> {
(this._onceHandlers.get(event)?.size ?? 0) > 0
);
}

/**
* Check if handlers were skipped due to a re-entrant emit call.
* Returns true if emit() was called re-entrantly (from within a handler),
* causing regular handlers for this event to be skipped.
*/
hasSkippedHandlers(event: keyof TEventMap): boolean {
return this._skipped.has(event);
}
}
23 changes: 23 additions & 0 deletions packages/store/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ describe('createStore', () => {
expect(spy).toHaveBeenCalledTimes(1)
})

it('subscribeOnce retries when the listener throws', () => {
const useStore = createStore((set) => ({
count: 0,
}))

let shouldThrow = true
const spy = vi.fn(() => {
if (shouldThrow) throw new Error('oops')
})

useStore.subscribeOnce(spy)
// First setState: listener throws, error propagates out of setState.
// The wrapper re-registers so future changes can still notify.
expect(() => useStore.setState({ count: 1 })).toThrow('oops')
// First call threw — wrapper should still be subscribed
expect(spy).toHaveBeenCalledTimes(1)

shouldThrow = false
useStore.setState({ count: 2 })
// Second state change should retry and succeed
expect(spy).toHaveBeenCalledTimes(2)
})

it('multiple subscribers all get notified', () => {
const useStore = createStore((set) => ({
x: 0,
Expand Down
10 changes: 9 additions & 1 deletion packages/store/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,15 @@ export function createStore<T extends object>(
currentUnsub();
unsub = null;
}
listener(state, prevState);
try {
listener(state, prevState);
} catch (err) {
// If the listener throws, re-register the wrapper so future
// state changes can still notify it. Errors are re-thrown so
// the caller's error handler can observe them.
unsub = subscribe(wrapper);
throw err;
}
};
unsub = subscribe(wrapper);
return () => {
Expand Down
Loading