From f6bc0e0b206bdb87c43005e550750ebaf5fa93d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 17:21:35 +0900 Subject: [PATCH 1/7] feat(openAsync): add onDismiss option for external close handling When using openAsync, the Promise only resolves when the wrapped close(value) function is called from inside the overlay component. However, if the overlay is closed externally via overlay.close(overlayId) or overlay.closeAll(), the Promise never resolves. This change adds an optional onDismiss parameter that specifies a value to resolve when the overlay is closed externally. Example: const result = await overlay.openAsync( ({ isOpen, close }) => , { onDismiss: undefined } ); // result is undefined when closed externally Changes: - Add onDismiss option to OpenAsyncOverlayOptions type - Add subscribeEvent function to createUseExternalEvents for non-hook subscription - Subscribe to close/closeAll events in openAsync to detect external close - Add guard to prevent double resolution - Add comprehensive test cases - Backward compatible: existing code works unchanged Closes #169 --- packages/src/event.test.tsx | 200 ++++++++++++++++++ packages/src/event.ts | 78 ++++--- .../src/utils/create-use-external-events.ts | 16 +- 3 files changed, 268 insertions(+), 26 deletions(-) diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index ac4e84d..d7bf19a 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -457,6 +457,206 @@ describe('overlay object', () => { ); }); + describe('openAsync with onDismiss option', () => { + it('resolves with close(value) when called internally (backward compatible)', async () => { + const overlayDialogContent = 'openasync-dialog-content'; + const overlayTriggerContent = 'openasync-trigger-content'; + const mockFn = vi.fn(); + + function Component() { + return ( + , + { onDismiss: undefined } + ); + mockFn(result); + }} + > + {overlayTriggerContent} + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + await user.click(await screen.findByRole('button', { name: overlayDialogContent })); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(true); + }); + }); + + it('resolves with onDismiss value when closed externally via overlay.close()', async () => { + const overlayDialogContent = 'openasync-external-close-dialog'; + const overlayTriggerContent = 'openasync-external-close-trigger'; + const testOverlayId = 'test-external-close-overlay'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + // Wait for overlay to be visible + await waitFor(() => { + expect(screen.getByTestId('overlay-content')).toBeInTheDocument(); + }); + + // Close externally + act(() => { + overlay.close(testOverlayId); + }); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(undefined); + }); + }); + + it('resolves with onDismiss value when closed via overlay.closeAll()', async () => { + const overlayTriggerContent = 'openasync-closeall-trigger'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + // Wait for overlay to be visible + await waitFor(() => { + expect(screen.getByTestId('overlay-closeall')).toBeInTheDocument(); + }); + + // Close all overlays + act(() => { + overlay.closeAll(); + }); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(null); + }); + }); + + it('stays pending without onDismiss option when closed externally (backward compatible)', async () => { + const overlayTriggerContent = 'openasync-no-ondismiss-trigger'; + const testOverlayId = 'test-no-ondismiss-overlay'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + // Wait for overlay to be visible + await waitFor(() => { + expect(screen.getByTestId('overlay-no-ondismiss')).toBeInTheDocument(); + }); + + // Close externally + act(() => { + overlay.close(testOverlayId); + }); + + // Wait a bit and verify mockFn was NOT called (Promise stays pending) + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockFn).not.toHaveBeenCalled(); + }); + + it('prevents double resolution when close is called after external close', async () => { + const overlayTriggerContent = 'openasync-double-resolve-trigger'; + const testOverlayId = 'test-double-resolve-overlay'; + const mockFn = vi.fn(); + let closeRef: ((value: string) => void) | null = null; + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + // Wait for overlay to be visible + await waitFor(() => { + expect(screen.getByTestId('overlay-double-resolve')).toBeInTheDocument(); + }); + + // Close externally first + act(() => { + overlay.close(testOverlayId); + }); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith('dismissed'); + }); + + // Try to close internally after (should not call mockFn again) + act(() => { + closeRef?.('internal-value'); + }); + + // mockFn should still only be called once + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); + it('unmount function requires the exact id to be provided', async () => { const overlayIdMap = { first: 'overlay-content-1', diff --git a/packages/src/event.ts b/packages/src/event.ts index 6955cda..16dcc87 100644 --- a/packages/src/event.ts +++ b/packages/src/event.ts @@ -18,8 +18,14 @@ type OpenOverlayOptions = { overlayId?: string; }; +type OpenAsyncOverlayOptions = OpenOverlayOptions & { + onDismiss?: T; +}; + export function createOverlay(overlayId: string) { - const [useOverlayEvent, createEvent] = createUseExternalEvents(`${overlayId}/overlay-kit`); + const [useOverlayEvent, createEvent, subscribeEvent] = createUseExternalEvents( + `${overlayId}/overlay-kit` + ); const open = (controller: OverlayControllerComponent, options?: OpenOverlayOptions) => { const overlayId = options?.overlayId ?? randomId(); @@ -30,31 +36,53 @@ export function createOverlay(overlayId: string) { return overlayId; }; - const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenOverlayOptions) => { + const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenAsyncOverlayOptions) => { return new Promise((_resolve, _reject) => { - open((overlayProps, ...deprecatedLegacyContext) => { - /** - * @description close the overlay with resolve - */ - const close = (param: T) => { - _resolve(param); - overlayProps.close(); - }; - - /** - * @description close the overlay with reject - */ - const reject = (reason?: unknown) => { - _reject(reason); - overlayProps.close(); - }; - - /** - * @description Passing overridden methods - */ - const props: OverlayAsyncControllerProps = { ...overlayProps, close, reject }; - return controller(props, ...deprecatedLegacyContext); - }, options); + let resolved = false; + + const resolve = (value: T) => { + if (resolved) return; + resolved = true; + unsubscribeClose(); + unsubscribeCloseAll(); + _resolve(value); + }; + + const currentOverlayId = options?.overlayId ?? randomId(); + + const unsubscribeClose = subscribeEvent('close', (closedOverlayId: string) => { + if (closedOverlayId === currentOverlayId && 'onDismiss' in (options ?? {})) { + resolve(options!.onDismiss as T); + } + }); + + const unsubscribeCloseAll = subscribeEvent('closeAll', () => { + if ('onDismiss' in (options ?? {})) { + resolve(options!.onDismiss as T); + } + }); + + open( + (overlayProps, ...deprecatedLegacyContext) => { + const close = (param: T) => { + resolve(param); + overlayProps.close(); + }; + + const reject = (reason?: unknown) => { + if (resolved) return; + resolved = true; + unsubscribeClose(); + unsubscribeCloseAll(); + _reject(reason); + overlayProps.close(); + }; + + const props: OverlayAsyncControllerProps = { ...overlayProps, close, reject }; + return controller(props, ...deprecatedLegacyContext); + }, + { overlayId: currentOverlayId } + ); }); }; diff --git a/packages/src/utils/create-use-external-events.ts b/packages/src/utils/create-use-external-events.ts index 146063c..5c59c89 100644 --- a/packages/src/utils/create-use-external-events.ts +++ b/packages/src/utils/create-use-external-events.ts @@ -54,5 +54,19 @@ export function createUseExternalEvents) => dispatchEvent(`${prefix}:${String(event)}`, payload[0]); } - return [useExternalEvents, createEvent] as const; + function subscribeEvent( + event: EventKey, + handler: EventHandlers[EventKey] + ): () => void { + const eventKey = `${prefix}:${String(event)}`; + const wrappedHandler = (payload: Parameters[0]) => { + handler(payload); + }; + emitter.on(eventKey, wrappedHandler); + return () => { + emitter.off(eventKey, wrappedHandler); + }; + } + + return [useExternalEvents, createEvent, subscribeEvent] as const; } From c65cd5068d12abb4f4ea1ba32b155d720827ea73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 17:40:56 +0900 Subject: [PATCH 2/7] fix(openAsync): prevent memory leak and handle unmount events - Only subscribe to events when onDismiss option is provided (prevents memory leak) - Add unmount and unmountAll event handling for complete coverage - Add cleanup function to centralize unsubscribe logic - Add test cases for unmount/unmountAll scenarios - Add test to verify no subscription when onDismiss is not provided --- packages/src/event.test.tsx | 102 ++++++++++++++++++++++++++++++++++++ packages/src/event.ts | 52 ++++++++++++------ 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index d7bf19a..7c6217d 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -655,6 +655,108 @@ describe('overlay object', () => { // mockFn should still only be called once expect(mockFn).toHaveBeenCalledTimes(1); }); + + it('resolves with onDismiss value when unmounted externally via overlay.unmount()', async () => { + const overlayTriggerContent = 'openasync-unmount-trigger'; + const testOverlayId = 'test-unmount-overlay'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + await waitFor(() => { + expect(screen.getByTestId('overlay-unmount')).toBeInTheDocument(); + }); + + act(() => { + overlay.unmount(testOverlayId); + }); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(undefined); + }); + }); + + it('resolves with onDismiss value when unmounted via overlay.unmountAll()', async () => { + const overlayTriggerContent = 'openasync-unmountall-trigger'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + + await waitFor(() => { + expect(screen.getByTestId('overlay-unmountall')).toBeInTheDocument(); + }); + + act(() => { + overlay.unmountAll(); + }); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(null); + }); + }); + + it('does not subscribe to events when onDismiss is not provided (no memory leak)', async () => { + const overlayTriggerContent = 'openasync-no-leak-trigger'; + const overlayDialogContent = 'openasync-no-leak-dialog'; + const mockFn = vi.fn(); + + function Component() { + return ( + + ); + mockFn(result); + }} + > + {overlayTriggerContent} + + ); + } + + const { user } = renderWithUser(); + await user.click(await screen.findByRole('button', { name: overlayTriggerContent })); + await user.click(await screen.findByRole('button', { name: overlayDialogContent })); + + await waitFor(() => { + expect(mockFn).toHaveBeenCalledWith(true); + }); + }); }); it('unmount function requires the exact id to be provided', async () => { diff --git a/packages/src/event.ts b/packages/src/event.ts index 16dcc87..a0c086b 100644 --- a/packages/src/event.ts +++ b/packages/src/event.ts @@ -39,28 +39,51 @@ export function createOverlay(overlayId: string) { const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenAsyncOverlayOptions) => { return new Promise((_resolve, _reject) => { let resolved = false; + const hasOnDismiss = options !== undefined && 'onDismiss' in options; + + const cleanup = () => { + unsubscribeClose(); + unsubscribeCloseAll(); + unsubscribeUnmount(); + unsubscribeUnmountAll(); + }; const resolve = (value: T) => { if (resolved) return; resolved = true; - unsubscribeClose(); - unsubscribeCloseAll(); + cleanup(); _resolve(value); }; const currentOverlayId = options?.overlayId ?? randomId(); - const unsubscribeClose = subscribeEvent('close', (closedOverlayId: string) => { - if (closedOverlayId === currentOverlayId && 'onDismiss' in (options ?? {})) { - resolve(options!.onDismiss as T); - } - }); - - const unsubscribeCloseAll = subscribeEvent('closeAll', () => { - if ('onDismiss' in (options ?? {})) { - resolve(options!.onDismiss as T); - } - }); + const unsubscribeClose = hasOnDismiss + ? subscribeEvent('close', (closedOverlayId: string) => { + if (closedOverlayId === currentOverlayId) { + resolve(options!.onDismiss as T); + } + }) + : () => {}; + + const unsubscribeCloseAll = hasOnDismiss + ? subscribeEvent('closeAll', () => { + resolve(options!.onDismiss as T); + }) + : () => {}; + + const unsubscribeUnmount = hasOnDismiss + ? subscribeEvent('unmount', (unmountedOverlayId: string) => { + if (unmountedOverlayId === currentOverlayId) { + resolve(options!.onDismiss as T); + } + }) + : () => {}; + + const unsubscribeUnmountAll = hasOnDismiss + ? subscribeEvent('unmountAll', () => { + resolve(options!.onDismiss as T); + }) + : () => {}; open( (overlayProps, ...deprecatedLegacyContext) => { @@ -72,8 +95,7 @@ export function createOverlay(overlayId: string) { const reject = (reason?: unknown) => { if (resolved) return; resolved = true; - unsubscribeClose(); - unsubscribeCloseAll(); + cleanup(); _reject(reason); overlayProps.close(); }; From a07ce24ba399ce5cd18e263d3510c50135e0210a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 17:45:06 +0900 Subject: [PATCH 3/7] refactor(openAsync): rename onDismiss to defaultValue - Rename option from 'onDismiss' to 'defaultValue' for clearer semantics - 'defaultValue' better conveys the intent: value to resolve when no explicit value is provided - Add comprehensive examples demonstrating the feature in all React version examples (16, 17, 18, 19) Examples show: 1. Basic openAsync usage 2. openAsync with defaultValue (resolves on external close) 3. Comparison with behavior without defaultValue (Promise stays pending) --- examples/react-16/framer-motion/src/demo.tsx | 181 ++++++++++++++++++- examples/react-17/framer-motion/src/demo.tsx | 181 ++++++++++++++++++- examples/react-18/framer-motion/src/demo.tsx | 181 ++++++++++++++++++- examples/react-19/framer-motion/src/demo.tsx | 181 ++++++++++++++++++- packages/src/event.test.tsx | 26 +-- packages/src/event.ts | 20 +- 6 files changed, 735 insertions(+), 35 deletions(-) diff --git a/examples/react-16/framer-motion/src/demo.tsx b/examples/react-16/framer-motion/src/demo.tsx index c554fdc..4b42b5d 100644 --- a/examples/react-16/framer-motion/src/demo.tsx +++ b/examples/react-16/framer-motion/src/demo.tsx @@ -4,9 +4,18 @@ import { Modal } from './components/modal.tsx'; export function Demo() { return ( -
+
+

overlay-kit Examples

+
+
+
+ +
+ +
+
); } @@ -16,7 +25,7 @@ function DemoWithState() { return (
-

Demo with useState

+

1. Demo with useState

@@ -31,7 +40,7 @@ function DemoWithState() { function DemoWithEsOverlay() { return (
-

Demo with overlay-kit

+

2. Demo with overlay.open

); } + +function DemoOpenAsyncBasic() { + const [result, setResult] = useState('(no result yet)'); + + return ( +
+

3. Demo with overlay.openAsync (basic)

+

+ Result: {result} +

+ + +
+
+
+ ); + }); + setResult(String(value)); + }} + > + open confirm dialog + +
+ ); +} + +function DemoOpenAsyncWithOnDismiss() { + const [result, setResult] = useState('(no result yet)'); + const [overlayId] = useState('ondismiss-demo-overlay'); + + return ( +
+

4. Demo with overlay.openAsync + defaultValue option

+

+ With defaultValue, the Promise resolves even when closed externally. +

+

+ Result: {result} +

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: 'dismissed' } + ); + setResult(String(value)); + }} + > + open dialog + + + +
+ + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const [overlayId] = useState('external-close-demo-overlay'); + + return ( +
+

5. Demo: External Close WITHOUT defaultValue (Promise stays pending)

+

+ Without defaultValue, the Promise never resolves when closed externally. +

+

+ Result: {result} +

+

+ Status: {status} +

+
+ +
+ + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + + +
+ + ); +} diff --git a/examples/react-17/framer-motion/src/demo.tsx b/examples/react-17/framer-motion/src/demo.tsx index c554fdc..4b42b5d 100644 --- a/examples/react-17/framer-motion/src/demo.tsx +++ b/examples/react-17/framer-motion/src/demo.tsx @@ -4,9 +4,18 @@ import { Modal } from './components/modal.tsx'; export function Demo() { return ( -
+
+

overlay-kit Examples

+
+
+
+ +
+ +
+
); } @@ -16,7 +25,7 @@ function DemoWithState() { return (
-

Demo with useState

+

1. Demo with useState

@@ -31,7 +40,7 @@ function DemoWithState() { function DemoWithEsOverlay() { return (
-

Demo with overlay-kit

+

2. Demo with overlay.open

); } + +function DemoOpenAsyncBasic() { + const [result, setResult] = useState('(no result yet)'); + + return ( +
+

3. Demo with overlay.openAsync (basic)

+

+ Result: {result} +

+ + +
+
+
+ ); + }); + setResult(String(value)); + }} + > + open confirm dialog + +
+ ); +} + +function DemoOpenAsyncWithOnDismiss() { + const [result, setResult] = useState('(no result yet)'); + const [overlayId] = useState('ondismiss-demo-overlay'); + + return ( +
+

4. Demo with overlay.openAsync + defaultValue option

+

+ With defaultValue, the Promise resolves even when closed externally. +

+

+ Result: {result} +

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: 'dismissed' } + ); + setResult(String(value)); + }} + > + open dialog + + + +
+ + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const [overlayId] = useState('external-close-demo-overlay'); + + return ( +
+

5. Demo: External Close WITHOUT defaultValue (Promise stays pending)

+

+ Without defaultValue, the Promise never resolves when closed externally. +

+

+ Result: {result} +

+

+ Status: {status} +

+
+ +
+ + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + + +
+ + ); +} diff --git a/examples/react-18/framer-motion/src/demo.tsx b/examples/react-18/framer-motion/src/demo.tsx index c554fdc..4b42b5d 100644 --- a/examples/react-18/framer-motion/src/demo.tsx +++ b/examples/react-18/framer-motion/src/demo.tsx @@ -4,9 +4,18 @@ import { Modal } from './components/modal.tsx'; export function Demo() { return ( -
+
+

overlay-kit Examples

+
+
+
+ +
+ +
+
); } @@ -16,7 +25,7 @@ function DemoWithState() { return (
-

Demo with useState

+

1. Demo with useState

@@ -31,7 +40,7 @@ function DemoWithState() { function DemoWithEsOverlay() { return (
-

Demo with overlay-kit

+

2. Demo with overlay.open

); } + +function DemoOpenAsyncBasic() { + const [result, setResult] = useState('(no result yet)'); + + return ( +
+

3. Demo with overlay.openAsync (basic)

+

+ Result: {result} +

+ + +
+
+
+ ); + }); + setResult(String(value)); + }} + > + open confirm dialog + +
+ ); +} + +function DemoOpenAsyncWithOnDismiss() { + const [result, setResult] = useState('(no result yet)'); + const [overlayId] = useState('ondismiss-demo-overlay'); + + return ( +
+

4. Demo with overlay.openAsync + defaultValue option

+

+ With defaultValue, the Promise resolves even when closed externally. +

+

+ Result: {result} +

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: 'dismissed' } + ); + setResult(String(value)); + }} + > + open dialog + + + +
+ + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const [overlayId] = useState('external-close-demo-overlay'); + + return ( +
+

5. Demo: External Close WITHOUT defaultValue (Promise stays pending)

+

+ Without defaultValue, the Promise never resolves when closed externally. +

+

+ Result: {result} +

+

+ Status: {status} +

+
+ +
+ + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + + +
+ + ); +} diff --git a/examples/react-19/framer-motion/src/demo.tsx b/examples/react-19/framer-motion/src/demo.tsx index c554fdc..4b42b5d 100644 --- a/examples/react-19/framer-motion/src/demo.tsx +++ b/examples/react-19/framer-motion/src/demo.tsx @@ -4,9 +4,18 @@ import { Modal } from './components/modal.tsx'; export function Demo() { return ( -
+
+

overlay-kit Examples

+
+
+
+ +
+ +
+
); } @@ -16,7 +25,7 @@ function DemoWithState() { return (
-

Demo with useState

+

1. Demo with useState

@@ -31,7 +40,7 @@ function DemoWithState() { function DemoWithEsOverlay() { return (
-

Demo with overlay-kit

+

2. Demo with overlay.open

); } + +function DemoOpenAsyncBasic() { + const [result, setResult] = useState('(no result yet)'); + + return ( +
+

3. Demo with overlay.openAsync (basic)

+

+ Result: {result} +

+ + +
+
+
+ ); + }); + setResult(String(value)); + }} + > + open confirm dialog + +
+ ); +} + +function DemoOpenAsyncWithOnDismiss() { + const [result, setResult] = useState('(no result yet)'); + const [overlayId] = useState('ondismiss-demo-overlay'); + + return ( +
+

4. Demo with overlay.openAsync + defaultValue option

+

+ With defaultValue, the Promise resolves even when closed externally. +

+

+ Result: {result} +

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: 'dismissed' } + ); + setResult(String(value)); + }} + > + open dialog + + + +
+ + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const [overlayId] = useState('external-close-demo-overlay'); + + return ( +
+

5. Demo: External Close WITHOUT defaultValue (Promise stays pending)

+

+ Without defaultValue, the Promise never resolves when closed externally. +

+

+ Result: {result} +

+

+ Status: {status} +

+
+ +
+ + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + + +
+ + ); +} diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index 7c6217d..fc989ee 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -457,7 +457,7 @@ describe('overlay object', () => { ); }); - describe('openAsync with onDismiss option', () => { + describe('openAsync with defaultValue option', () => { it('resolves with close(value) when called internally (backward compatible)', async () => { const overlayDialogContent = 'openasync-dialog-content'; const overlayTriggerContent = 'openasync-trigger-content'; @@ -469,7 +469,7 @@ describe('overlay object', () => { onClick={async () => { const result = await overlay.openAsync( ({ isOpen, close }) => isOpen && , - { onDismiss: undefined } + { defaultValue: undefined } ); mockFn(result); }} @@ -488,7 +488,7 @@ describe('overlay object', () => { }); }); - it('resolves with onDismiss value when closed externally via overlay.close()', async () => { + it('resolves with defaultValue value when closed externally via overlay.close()', async () => { const overlayDialogContent = 'openasync-external-close-dialog'; const overlayTriggerContent = 'openasync-external-close-trigger'; const testOverlayId = 'test-external-close-overlay'; @@ -500,7 +500,7 @@ describe('overlay object', () => { onClick={async () => { const result = await overlay.openAsync( ({ isOpen }) => isOpen &&
{overlayDialogContent}
, - { overlayId: testOverlayId, onDismiss: undefined } + { overlayId: testOverlayId, defaultValue: undefined } ); mockFn(result); }} @@ -528,7 +528,7 @@ describe('overlay object', () => { }); }); - it('resolves with onDismiss value when closed via overlay.closeAll()', async () => { + it('resolves with defaultValue value when closed via overlay.closeAll()', async () => { const overlayTriggerContent = 'openasync-closeall-trigger'; const mockFn = vi.fn(); @@ -538,7 +538,7 @@ describe('overlay object', () => { onClick={async () => { const result = await overlay.openAsync( ({ isOpen }) => isOpen &&
Dialog
, - { onDismiss: null } + { defaultValue: null } ); mockFn(result); }} @@ -566,7 +566,7 @@ describe('overlay object', () => { }); }); - it('stays pending without onDismiss option when closed externally (backward compatible)', async () => { + it('stays pending without defaultValue option when closed externally (backward compatible)', async () => { const overlayTriggerContent = 'openasync-no-ondismiss-trigger'; const testOverlayId = 'test-no-ondismiss-overlay'; const mockFn = vi.fn(); @@ -620,7 +620,7 @@ describe('overlay object', () => { closeRef = close; return isOpen &&
Dialog
; }, - { overlayId: testOverlayId, onDismiss: 'dismissed' } + { overlayId: testOverlayId, defaultValue: 'dismissed' } ); mockFn(result); }} @@ -656,7 +656,7 @@ describe('overlay object', () => { expect(mockFn).toHaveBeenCalledTimes(1); }); - it('resolves with onDismiss value when unmounted externally via overlay.unmount()', async () => { + it('resolves with defaultValue value when unmounted externally via overlay.unmount()', async () => { const overlayTriggerContent = 'openasync-unmount-trigger'; const testOverlayId = 'test-unmount-overlay'; const mockFn = vi.fn(); @@ -667,7 +667,7 @@ describe('overlay object', () => { onClick={async () => { const result = await overlay.openAsync( ({ isOpen }) => isOpen &&
Dialog
, - { overlayId: testOverlayId, onDismiss: undefined } + { overlayId: testOverlayId, defaultValue: undefined } ); mockFn(result); }} @@ -693,7 +693,7 @@ describe('overlay object', () => { }); }); - it('resolves with onDismiss value when unmounted via overlay.unmountAll()', async () => { + it('resolves with defaultValue value when unmounted via overlay.unmountAll()', async () => { const overlayTriggerContent = 'openasync-unmountall-trigger'; const mockFn = vi.fn(); @@ -703,7 +703,7 @@ describe('overlay object', () => { onClick={async () => { const result = await overlay.openAsync( ({ isOpen }) => isOpen &&
Dialog
, - { onDismiss: null } + { defaultValue: null } ); mockFn(result); }} @@ -729,7 +729,7 @@ describe('overlay object', () => { }); }); - it('does not subscribe to events when onDismiss is not provided (no memory leak)', async () => { + it('does not subscribe to events when defaultValue is not provided (no memory leak)', async () => { const overlayTriggerContent = 'openasync-no-leak-trigger'; const overlayDialogContent = 'openasync-no-leak-dialog'; const mockFn = vi.fn(); diff --git a/packages/src/event.ts b/packages/src/event.ts index a0c086b..d1e6e4a 100644 --- a/packages/src/event.ts +++ b/packages/src/event.ts @@ -19,7 +19,7 @@ type OpenOverlayOptions = { }; type OpenAsyncOverlayOptions = OpenOverlayOptions & { - onDismiss?: T; + defaultValue?: T; }; export function createOverlay(overlayId: string) { @@ -39,7 +39,7 @@ export function createOverlay(overlayId: string) { const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenAsyncOverlayOptions) => { return new Promise((_resolve, _reject) => { let resolved = false; - const hasOnDismiss = options !== undefined && 'onDismiss' in options; + const hasDefaultValue = options !== undefined && 'defaultValue' in options; const cleanup = () => { unsubscribeClose(); @@ -57,31 +57,31 @@ export function createOverlay(overlayId: string) { const currentOverlayId = options?.overlayId ?? randomId(); - const unsubscribeClose = hasOnDismiss + const unsubscribeClose = hasDefaultValue ? subscribeEvent('close', (closedOverlayId: string) => { if (closedOverlayId === currentOverlayId) { - resolve(options!.onDismiss as T); + resolve(options!.defaultValue as T); } }) : () => {}; - const unsubscribeCloseAll = hasOnDismiss + const unsubscribeCloseAll = hasDefaultValue ? subscribeEvent('closeAll', () => { - resolve(options!.onDismiss as T); + resolve(options!.defaultValue as T); }) : () => {}; - const unsubscribeUnmount = hasOnDismiss + const unsubscribeUnmount = hasDefaultValue ? subscribeEvent('unmount', (unmountedOverlayId: string) => { if (unmountedOverlayId === currentOverlayId) { - resolve(options!.onDismiss as T); + resolve(options!.defaultValue as T); } }) : () => {}; - const unsubscribeUnmountAll = hasOnDismiss + const unsubscribeUnmountAll = hasDefaultValue ? subscribeEvent('unmountAll', () => { - resolve(options!.onDismiss as T); + resolve(options!.defaultValue as T); }) : () => {}; From fc168e3416a97f240bf57c565793ae014bc72011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 17:51:41 +0900 Subject: [PATCH 4/7] fix: ensure defaultValue type matches generic type T - Update tests to use consistent types (e.g., boolean with defaultValue: false) - Remove union types like 'boolean | undefined' in favor of proper type inference - Update examples to demonstrate correct usage pattern Example: // T = boolean, defaultValue = boolean overlay.openAsync(..., { defaultValue: false }); // T = string, defaultValue = string overlay.openAsync(..., { defaultValue: 'cancelled' }); --- examples/react-16/framer-motion/src/demo.tsx | 16 +++++---- examples/react-17/framer-motion/src/demo.tsx | 16 +++++---- examples/react-18/framer-motion/src/demo.tsx | 16 +++++---- examples/react-19/framer-motion/src/demo.tsx | 16 +++++---- packages/src/event.test.tsx | 36 ++++++++++---------- 5 files changed, 54 insertions(+), 46 deletions(-) diff --git a/examples/react-16/framer-motion/src/demo.tsx b/examples/react-16/framer-motion/src/demo.tsx index 4b42b5d..567bbae 100644 --- a/examples/react-16/framer-motion/src/demo.tsx +++ b/examples/react-16/framer-motion/src/demo.tsx @@ -13,7 +13,7 @@ export function Demo() {

- +
@@ -105,15 +105,17 @@ function DemoOpenAsyncBasic() { ); } -function DemoOpenAsyncWithOnDismiss() { +function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('ondismiss-demo-overlay'); + const [overlayId] = useState('defaultvalue-demo-overlay'); return (

4. Demo with overlay.openAsync + defaultValue option

With defaultValue, the Promise resolves even when closed externally. +
+ defaultValue: false → external close resolves with false

Result: {result} @@ -122,7 +124,7 @@ function DemoOpenAsyncWithOnDismiss() {

diff --git a/examples/react-17/framer-motion/src/demo.tsx b/examples/react-17/framer-motion/src/demo.tsx index 4b42b5d..567bbae 100644 --- a/examples/react-17/framer-motion/src/demo.tsx +++ b/examples/react-17/framer-motion/src/demo.tsx @@ -13,7 +13,7 @@ export function Demo() {

- +
@@ -105,15 +105,17 @@ function DemoOpenAsyncBasic() { ); } -function DemoOpenAsyncWithOnDismiss() { +function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('ondismiss-demo-overlay'); + const [overlayId] = useState('defaultvalue-demo-overlay'); return (

4. Demo with overlay.openAsync + defaultValue option

With defaultValue, the Promise resolves even when closed externally. +
+ defaultValue: false → external close resolves with false

Result: {result} @@ -122,7 +124,7 @@ function DemoOpenAsyncWithOnDismiss() {

diff --git a/examples/react-18/framer-motion/src/demo.tsx b/examples/react-18/framer-motion/src/demo.tsx index 4b42b5d..567bbae 100644 --- a/examples/react-18/framer-motion/src/demo.tsx +++ b/examples/react-18/framer-motion/src/demo.tsx @@ -13,7 +13,7 @@ export function Demo() {

- +
@@ -105,15 +105,17 @@ function DemoOpenAsyncBasic() { ); } -function DemoOpenAsyncWithOnDismiss() { +function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('ondismiss-demo-overlay'); + const [overlayId] = useState('defaultvalue-demo-overlay'); return (

4. Demo with overlay.openAsync + defaultValue option

With defaultValue, the Promise resolves even when closed externally. +
+ defaultValue: false → external close resolves with false

Result: {result} @@ -122,7 +124,7 @@ function DemoOpenAsyncWithOnDismiss() {

diff --git a/examples/react-19/framer-motion/src/demo.tsx b/examples/react-19/framer-motion/src/demo.tsx index 4b42b5d..567bbae 100644 --- a/examples/react-19/framer-motion/src/demo.tsx +++ b/examples/react-19/framer-motion/src/demo.tsx @@ -13,7 +13,7 @@ export function Demo() {

- +
@@ -105,15 +105,17 @@ function DemoOpenAsyncBasic() { ); } -function DemoOpenAsyncWithOnDismiss() { +function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('ondismiss-demo-overlay'); + const [overlayId] = useState('defaultvalue-demo-overlay'); return (

4. Demo with overlay.openAsync + defaultValue option

With defaultValue, the Promise resolves even when closed externally. +
+ defaultValue: false → external close resolves with false

Result: {result} @@ -122,7 +124,7 @@ function DemoOpenAsyncWithOnDismiss() {

diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index fc989ee..a1503a5 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -467,9 +467,9 @@ describe('overlay object', () => { return ( , - { defaultValue: undefined } + { defaultValue: false } ); mockFn(result); }} @@ -488,7 +488,7 @@ describe('overlay object', () => { }); }); - it('resolves with defaultValue value when closed externally via overlay.close()', async () => { + it('resolves with defaultValue when closed externally via overlay.close()', async () => { const overlayDialogContent = 'openasync-external-close-dialog'; const overlayTriggerContent = 'openasync-external-close-trigger'; const testOverlayId = 'test-external-close-overlay'; @@ -498,9 +498,9 @@ describe('overlay object', () => { return ( - - + + - - ); - }, - { overlayId, defaultValue: false } - ); - setResult(String(value)); - }} - > - open dialog - - - - +
+

External close (uses overlay.close):

+
+ + +
+ + + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + ); } @@ -174,7 +187,7 @@ function DemoOpenAsyncWithDefaultValue() { function DemoExternalClose() { const [result, setResult] = useState('(no result yet)'); const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); - const [overlayId] = useState('external-close-demo-overlay'); + const overlayId = 'external-close-demo-overlay'; return (
@@ -188,45 +201,51 @@ function DemoExternalClose() {

Status: {status}

-
- +
+

External close (Promise stays pending!):

+ -
- - ); - }, - { overlayId } - ); - setResult(String(value)); - setStatus('idle'); - }} - > - open dialog - - -
+ overlay.close() - NO resolve + + + + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + ); } From 82eb4dd6916efab229cb160beba29d20e0020a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 17:56:13 +0900 Subject: [PATCH 6/7] chore(examples): sync demo.tsx across React versions --- examples/react-16/framer-motion/src/demo.tsx | 187 ++++++++++--------- examples/react-17/framer-motion/src/demo.tsx | 187 ++++++++++--------- examples/react-19/framer-motion/src/demo.tsx | 187 ++++++++++--------- 3 files changed, 309 insertions(+), 252 deletions(-) diff --git a/examples/react-16/framer-motion/src/demo.tsx b/examples/react-16/framer-motion/src/demo.tsx index 567bbae..c4b69a3 100644 --- a/examples/react-16/framer-motion/src/demo.tsx +++ b/examples/react-16/framer-motion/src/demo.tsx @@ -107,7 +107,7 @@ function DemoOpenAsyncBasic() { function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('defaultvalue-demo-overlay'); + const overlayId = 'defaultvalue-demo-overlay'; return (
@@ -120,53 +120,66 @@ function DemoOpenAsyncWithDefaultValue() {

Result: {result}

-
- - -
+ +
- - ); - }, - { overlayId, defaultValue: false } - ); - setResult(String(value)); - }} - > - open dialog - - - - +
+

External close (uses overlay.close):

+
+ + +
+ + + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + ); } @@ -174,7 +187,7 @@ function DemoOpenAsyncWithDefaultValue() { function DemoExternalClose() { const [result, setResult] = useState('(no result yet)'); const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); - const [overlayId] = useState('external-close-demo-overlay'); + const overlayId = 'external-close-demo-overlay'; return (
@@ -188,45 +201,51 @@ function DemoExternalClose() {

Status: {status}

-
- +
+

External close (Promise stays pending!):

+ -
- - ); - }, - { overlayId } - ); - setResult(String(value)); - setStatus('idle'); - }} - > - open dialog - - -
+ overlay.close() - NO resolve + + + + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + ); } diff --git a/examples/react-17/framer-motion/src/demo.tsx b/examples/react-17/framer-motion/src/demo.tsx index 567bbae..c4b69a3 100644 --- a/examples/react-17/framer-motion/src/demo.tsx +++ b/examples/react-17/framer-motion/src/demo.tsx @@ -107,7 +107,7 @@ function DemoOpenAsyncBasic() { function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('defaultvalue-demo-overlay'); + const overlayId = 'defaultvalue-demo-overlay'; return (
@@ -120,53 +120,66 @@ function DemoOpenAsyncWithDefaultValue() {

Result: {result}

-
- - -
+ +
- - ); - }, - { overlayId, defaultValue: false } - ); - setResult(String(value)); - }} - > - open dialog - - - - +
+

External close (uses overlay.close):

+
+ + +
+ + + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + ); } @@ -174,7 +187,7 @@ function DemoOpenAsyncWithDefaultValue() { function DemoExternalClose() { const [result, setResult] = useState('(no result yet)'); const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); - const [overlayId] = useState('external-close-demo-overlay'); + const overlayId = 'external-close-demo-overlay'; return (
@@ -188,45 +201,51 @@ function DemoExternalClose() {

Status: {status}

-
- +
+

External close (Promise stays pending!):

+ -
- - ); - }, - { overlayId } - ); - setResult(String(value)); - setStatus('idle'); - }} - > - open dialog - - -
+ overlay.close() - NO resolve + + + + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + ); } diff --git a/examples/react-19/framer-motion/src/demo.tsx b/examples/react-19/framer-motion/src/demo.tsx index 567bbae..c4b69a3 100644 --- a/examples/react-19/framer-motion/src/demo.tsx +++ b/examples/react-19/framer-motion/src/demo.tsx @@ -107,7 +107,7 @@ function DemoOpenAsyncBasic() { function DemoOpenAsyncWithDefaultValue() { const [result, setResult] = useState('(no result yet)'); - const [overlayId] = useState('defaultvalue-demo-overlay'); + const overlayId = 'defaultvalue-demo-overlay'; return (
@@ -120,53 +120,66 @@ function DemoOpenAsyncWithDefaultValue() {

Result: {result}

-
- - -
+ +
- - ); - }, - { overlayId, defaultValue: false } - ); - setResult(String(value)); - }} - > - open dialog - - - - +
+

External close (uses overlay.close):

+
+ + +
+ + + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + ); } @@ -174,7 +187,7 @@ function DemoOpenAsyncWithDefaultValue() { function DemoExternalClose() { const [result, setResult] = useState('(no result yet)'); const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); - const [overlayId] = useState('external-close-demo-overlay'); + const overlayId = 'external-close-demo-overlay'; return (
@@ -188,45 +201,51 @@ function DemoExternalClose() {

Status: {status}

-
- +
+

External close (Promise stays pending!):

+ -
- - ); - }, - { overlayId } - ); - setResult(String(value)); - setStatus('idle'); - }} - > - open dialog - - -
+ overlay.close() - NO resolve + + + + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + ); } From 3a80a8e606fcab28a245250a024466e70dea285e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=98=81=EC=B0=BD=28piknow=29?= Date: Fri, 23 Jan 2026 19:44:04 +0900 Subject: [PATCH 7/7] docs: add JSDoc comments for openAsync and subscribeEvent - Add detailed JSDoc comments to openAsync explaining external event subscription pattern - Document memory safety guarantees for Promise resolution - Add comprehensive JSDoc for createUseExternalEvents and subscribeEvent --- packages/src/event.ts | 44 ++++++++++++++ .../src/utils/create-use-external-events.ts | 58 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/src/event.ts b/packages/src/event.ts index d1e6e4a..ddadb5f 100644 --- a/packages/src/event.ts +++ b/packages/src/event.ts @@ -36,11 +36,43 @@ export function createOverlay(overlayId: string) { return overlayId; }; + /** + * Opens an overlay and returns a Promise that resolves when the overlay is closed. + * + * ## External Event Subscription Pattern + * + * When `defaultValue` is provided, this function subscribes to external close/unmount events + * using `subscribeEvent` (outside React's lifecycle). This enables the Promise to resolve + * even when closed externally via `overlay.close(id)` or `overlay.closeAll()`. + * + * ### Why subscribe outside React hooks? + * - Promise executor runs outside React component lifecycle + * - Cannot use `useEffect` for cleanup - must manage manually + * - `subscribeEvent` returns unsubscribe function for manual cleanup + * + * ### Memory safety guarantees: + * 1. `resolved` flag prevents double-resolution + * 2. `cleanup()` unsubscribes ALL event listeners on resolution + * 3. Conditional subscription - no listeners if `defaultValue` not provided + * + * @param controller - Render function receiving overlay props (isOpen, close, reject) + * @param options - Optional config: `overlayId` and `defaultValue` + * @param options.defaultValue - Value to resolve with when closed externally. + * If not provided, Promise stays pending on external close (backward compatible) + */ const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenAsyncOverlayOptions) => { return new Promise((_resolve, _reject) => { + /** + * Prevents double-resolution of the Promise. + * Once resolved/rejected, subsequent calls to resolve() are no-ops. + */ let resolved = false; const hasDefaultValue = options !== undefined && 'defaultValue' in options; + /** + * Unsubscribes all external event listeners. + * MUST be called when Promise settles to prevent memory leaks. + */ const cleanup = () => { unsubscribeClose(); unsubscribeCloseAll(); @@ -48,6 +80,10 @@ export function createOverlay(overlayId: string) { unsubscribeUnmountAll(); }; + /** + * Resolves the Promise with given value. + * Idempotent - only the first call takes effect. + */ const resolve = (value: T) => { if (resolved) return; resolved = true; @@ -57,6 +93,14 @@ export function createOverlay(overlayId: string) { const currentOverlayId = options?.overlayId ?? randomId(); + /* + * External Event Subscriptions (only when defaultValue is provided) + * + * These subscriptions allow the Promise to resolve when the overlay is closed + * from outside (e.g., overlay.close(id), overlay.closeAll()). + * + * Without defaultValue: subscriptions are no-op functions (backward compatible) + */ const unsubscribeClose = hasDefaultValue ? subscribeEvent('close', (closedOverlayId: string) => { if (closedOverlayId === currentOverlayId) { diff --git a/packages/src/utils/create-use-external-events.ts b/packages/src/utils/create-use-external-events.ts index 5c59c89..77da819 100644 --- a/packages/src/utils/create-use-external-events.ts +++ b/packages/src/utils/create-use-external-events.ts @@ -19,7 +19,36 @@ function dispatchEvent(type: string, detail?: Detail) { emitter.emit(type, detail); } -// When creating an event, params can be of any type, so specify the type as any. +/** + * Creates a set of utilities for managing external events in overlay-kit. + * + * This factory function returns three utilities: + * 1. `useExternalEvents` - React Hook for subscribing to events within components + * 2. `createEvent` - Factory for creating event dispatchers + * 3. `subscribeEvent` - Function for subscribing to events outside of React components + * + * @remarks + * EventHandlers uses `any` for params because event payloads can be of any type. + * + * @param prefix - Namespace prefix to avoid event name collisions (e.g., 'overlay-kit') + * + * @example + * ```typescript + * const [useOverlayEvent, createEvent, subscribeEvent] = createUseExternalEvents('my-app/overlay'); + * + * // In React component + * useOverlayEvent({ open: (payload) => console.log('opened', payload) }); + * + * // Dispatching events + * const dispatchOpen = createEvent('open'); + * dispatchOpen({ overlayId: '123' }); + * + * // Outside React (e.g., in Promise) + * const unsubscribe = subscribeEvent('close', (id) => console.log('closed', id)); + * // IMPORTANT: Always call unsubscribe when done + * unsubscribe(); + * ``` + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createUseExternalEvents void>>(prefix: string) { function useExternalEvents(events: EventHandlers) { @@ -54,6 +83,33 @@ export function createUseExternalEvents) => dispatchEvent(`${prefix}:${String(event)}`, payload[0]); } + /** + * Subscribes to an event outside of React's lifecycle (e.g., inside a Promise). + * + * Unlike `useExternalEvents` which automatically cleans up on unmount, + * this function requires MANUAL cleanup by calling the returned unsubscribe function. + * + * @warning Memory Leak Risk - Always call the returned unsubscribe function when done. + * + * @param event - Event type to subscribe to + * @param handler - Callback function to handle the event + * @returns Unsubscribe function that MUST be called to prevent memory leaks + * + * @example + * ```typescript + * // Inside openAsync Promise + * const unsubscribe = subscribeEvent('close', (overlayId) => { + * if (overlayId === currentId) { + * resolve(defaultValue); + * } + * }); + * + * // Cleanup when Promise resolves + * const cleanup = () => { + * unsubscribe(); + * }; + * ``` + */ function subscribeEvent( event: EventKey, handler: EventHandlers[EventKey]