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
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,53 @@ describe('useIdempotentAction', () => {
await expect(secondExecution).resolves.toBe('success');
expect(mockAction).toHaveBeenCalledTimes(1);
});

// ── memory-leak regression (#1221) ─────────────────────────────────────────

it('regression: clears inFlightActions on unmount so promise closures are not retained', async () => {
// Bug: before fix, unmounting only set isMountedRef = false. The
// inFlightActions Map kept Promise references alive, preventing GC of the
// closures that captured the component's state setter and other hook
// internals.
let resolveAction!: (v: string) => void;
const mockAction = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveAction = resolve;
}),
);

const { result, unmount } = renderHook(() =>
useIdempotentAction({ cooldownMs: 0, logSuppressed: false }),
);

// Start an action so inFlightActions is non-empty.
act(() => {
result.current.execute(mockAction, 'leak_test');
});

await waitFor(() => expect(result.current.isProcessing).toBe(true));

// Unmount while the promise is still in flight.
unmount();

// Resolve after unmount — must not throw and must not attempt state updates.
await act(async () => {
resolveAction('done');
});

// The in-flight action map must have been cleared by the cleanup.
// We verify indirectly: re-mounting a fresh hook should behave normally
// (no lingering state from the previous instance).
const { result: result2 } = renderHook(() =>
useIdempotentAction({ cooldownMs: 0, logSuppressed: false }),
);
const freshAction = vi.fn().mockResolvedValue('fresh');
let freshResult: string | null = null;
await act(async () => {
freshResult = await result2.current.execute(freshAction, 'fresh_test');
});
expect(freshResult).toBe('fresh');
expect(freshAction).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export function useIdempotentAction(options: IdempotentActionOptions = {}) {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Clear in-flight promises on unmount so their closures (which hold
// references to state setters and other hook internals) can be GC'd.
inFlightActions.current.clear();
};
}, []);

Expand Down
Loading