diff --git a/examples/react-16/framer-motion/src/demo.tsx b/examples/react-16/framer-motion/src/demo.tsx index c554fdc..c4b69a3 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 DemoOpenAsyncWithDefaultValue() { + const [result, setResult] = useState('(no result yet)'); + const overlayId = '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} +

+ + +
+
+

External close (uses overlay.close):

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + + + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const overlayId = '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} +

+ +
+

External close (Promise stays pending!):

+ +
+ + ); + }, + { 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..c4b69a3 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 DemoOpenAsyncWithDefaultValue() { + const [result, setResult] = useState('(no result yet)'); + const overlayId = '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} +

+ + +
+
+

External close (uses overlay.close):

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + + + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const overlayId = '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} +

+ +
+

External close (Promise stays pending!):

+ +
+ + ); + }, + { 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..c4b69a3 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 DemoOpenAsyncWithDefaultValue() { + const [result, setResult] = useState('(no result yet)'); + const overlayId = '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} +

+ + +
+
+

External close (uses overlay.close):

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + + + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const overlayId = '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} +

+ +
+

External close (Promise stays pending!):

+ +
+ + ); + }, + { 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..c4b69a3 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 DemoOpenAsyncWithDefaultValue() { + const [result, setResult] = useState('(no result yet)'); + const overlayId = '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} +

+ + +
+
+

External close (uses overlay.close):

+
+ + +
+
+ + ); + }, + { overlayId, defaultValue: false } + ); + setResult(String(value)); + }} + > + open dialog + + + ); +} + +function DemoExternalClose() { + const [result, setResult] = useState('(no result yet)'); + const [status, setStatus] = useState<'idle' | 'waiting'>('idle'); + const overlayId = '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} +

+ +
+

External close (Promise stays pending!):

+ +
+ + ); + }, + { overlayId } + ); + setResult(String(value)); + setStatus('idle'); + }} + > + open dialog + + + ); +} diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index ac4e84d..a1503a5 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -457,6 +457,308 @@ describe('overlay object', () => { ); }); + 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'; + const mockFn = vi.fn(); + + function Component() { + return ( + , + { defaultValue: false } + ); + 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 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'; + 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(false); + }); + }); + + it('resolves with defaultValue 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('cancelled'); + }); + }); + + 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(); + + 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('resolves with defaultValue 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(false); + }); + }); + + it('resolves with defaultValue 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('unmounted'); + }); + }); + + 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(); + + 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 () => { const overlayIdMap = { first: 'overlay-content-1', diff --git a/packages/src/event.ts b/packages/src/event.ts index 6955cda..ddadb5f 100644 --- a/packages/src/event.ts +++ b/packages/src/event.ts @@ -18,8 +18,14 @@ type OpenOverlayOptions = { overlayId?: string; }; +type OpenAsyncOverlayOptions = OpenOverlayOptions & { + defaultValue?: 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,119 @@ export function createOverlay(overlayId: string) { return overlayId; }; - const openAsync = async (controller: OverlayAsyncControllerComponent, options?: OpenOverlayOptions) => { + /** + * 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) => { - 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); + /** + * 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(); + unsubscribeUnmount(); + unsubscribeUnmountAll(); + }; + + /** + * Resolves the Promise with given value. + * Idempotent - only the first call takes effect. + */ + const resolve = (value: T) => { + if (resolved) return; + resolved = true; + cleanup(); + _resolve(value); + }; + + 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) { + resolve(options!.defaultValue as T); + } + }) + : () => {}; + + const unsubscribeCloseAll = hasDefaultValue + ? subscribeEvent('closeAll', () => { + resolve(options!.defaultValue as T); + }) + : () => {}; + + const unsubscribeUnmount = hasDefaultValue + ? subscribeEvent('unmount', (unmountedOverlayId: string) => { + if (unmountedOverlayId === currentOverlayId) { + resolve(options!.defaultValue as T); + } + }) + : () => {}; + + const unsubscribeUnmountAll = hasDefaultValue + ? subscribeEvent('unmountAll', () => { + resolve(options!.defaultValue as T); + }) + : () => {}; + + open( + (overlayProps, ...deprecatedLegacyContext) => { + const close = (param: T) => { + resolve(param); + overlayProps.close(); + }; + + const reject = (reason?: unknown) => { + if (resolved) return; + resolved = true; + cleanup(); + _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..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,5 +83,46 @@ export function createUseExternalEvents) => dispatchEvent(`${prefix}:${String(event)}`, payload[0]); } - return [useExternalEvents, createEvent] as const; + /** + * 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] + ): () => 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; }