feat(openAsync): add onDismiss option for external close handling#214
feat(openAsync): add onDismiss option for external close handling#214
Conversation
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<boolean | undefined>(
({ isOpen, close }) => <Dialog ... />,
{ 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 toss#169
|
|
Someone is attempting to deploy a commit to the Toss Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR adds an onDismiss option to overlay.openAsync that allows the Promise to resolve with a specified value when the overlay is closed externally (via overlay.close(id) or overlay.closeAll()), rather than only when the internal close(value) callback is invoked. This addresses issue #169 where Promises from openAsync would never resolve when overlays were closed externally through browser back buttons, ESC key handlers, or route changes.
Changes:
- Added
subscribeEventfunction to enable non-hook event subscription for detecting external close events - Introduced
OpenAsyncOverlayOptions<T>type with optionalonDismissproperty for specifying the resolution value - Refactored
openAsyncto subscribe to close/closeAll events and resolve with the onDismiss value when applicable, with double-resolution protection
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/src/utils/create-use-external-events.ts | Added subscribeEvent function for programmatic event subscription with proper cleanup |
| packages/src/event.ts | Added OpenAsyncOverlayOptions type and refactored openAsync to handle external close events via subscriptions |
| packages/src/event.test.tsx | Added comprehensive test suite covering internal close, external close via close/closeAll, backward compatibility, and double-resolution prevention |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/src/event.ts
Outdated
| if (closedOverlayId === currentOverlayId && 'onDismiss' in (options ?? {})) { | ||
| resolve(options!.onDismiss as T); | ||
| } | ||
| }); | ||
|
|
||
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | ||
| if ('onDismiss' in (options ?? {})) { | ||
| resolve(options!.onDismiss as T); |
There was a problem hiding this comment.
Unsafe type casting: The expression options!.onDismiss as T could lead to type safety issues if onDismiss is explicitly set to a value that doesn't match the generic type T.
For example, if someone writes:
overlay.openAsync<boolean>(component, { onDismiss: "string" as any })
The type system wouldn't catch this at compile time if they bypass the type checker, and at runtime the Promise would resolve with a string even though the caller expects a boolean.
While TypeScript's type system should prevent this in most cases, the non-null assertion (!) combined with type casting (as T) bypasses some safety checks. Consider using a safer approach that validates the type or at minimum removing the non-null assertion since we've already checked that 'onDismiss' is in the options object.
| if (closedOverlayId === currentOverlayId && 'onDismiss' in (options ?? {})) { | |
| resolve(options!.onDismiss as T); | |
| } | |
| }); | |
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | |
| if ('onDismiss' in (options ?? {})) { | |
| resolve(options!.onDismiss as T); | |
| if (closedOverlayId === currentOverlayId && options && 'onDismiss' in options) { | |
| resolve(options.onDismiss as T); | |
| } | |
| }); | |
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | |
| if (options && 'onDismiss' in options) { | |
| resolve(options.onDismiss as T); |
packages/src/event.ts
Outdated
| resolve(options!.onDismiss as T); | ||
| } | ||
| }); | ||
|
|
||
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | ||
| if ('onDismiss' in (options ?? {})) { | ||
| resolve(options!.onDismiss as T); |
There was a problem hiding this comment.
Unsafe type casting: Same issue as line 55 - using options!.onDismiss as T could lead to type safety issues. The non-null assertion combined with type casting bypasses TypeScript's safety checks. Consider a safer approach or at minimum remove the non-null assertion since the 'onDismiss' key existence is already verified.
| resolve(options!.onDismiss as T); | |
| } | |
| }); | |
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | |
| if ('onDismiss' in (options ?? {})) { | |
| resolve(options!.onDismiss as T); | |
| resolve(options?.onDismiss as T); | |
| } | |
| }); | |
| const unsubscribeCloseAll = subscribeEvent('closeAll', () => { | |
| if ('onDismiss' in (options ?? {})) { | |
| resolve(options?.onDismiss as T); |
| type OpenAsyncOverlayOptions<T> = OpenOverlayOptions & { | ||
| onDismiss?: T; | ||
| }; |
There was a problem hiding this comment.
Missing documentation: The new onDismiss option for openAsync is not documented in the README or in code comments. Users won't know about this feature unless they read the changelog or source code.
Consider adding:
- A section in the README showing how to use onDismiss to handle external closes
- JSDoc comments on the OpenAsyncOverlayOptions type explaining when and why to use onDismiss
- An example showing a common use case like handling browser back button or ESC key dismissals
This is especially important since the behavior of openAsync changes subtly based on whether onDismiss is present (even if set to undefined).
packages/src/event.ts
Outdated
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Memory leak: Event subscriptions are never cleaned up when the overlay is closed externally without onDismiss option.
When onDismiss is not provided and the overlay is closed externally (via overlay.close or overlay.closeAll), the Promise never resolves, but the event listeners created by subscribeEvent remain active indefinitely. This creates a memory leak that accumulates with each openAsync call without onDismiss.
The unsubscribeClose and unsubscribeCloseAll functions are only called when the Promise resolves or rejects, but if neither happens (the backward-compatible case), these subscriptions persist forever.
Consider adding cleanup logic that unsubscribes when the overlay is closed, regardless of whether onDismiss is provided. One approach would be to also listen for the close/closeAll events and clean up the subscriptions even when not resolving the Promise.
- 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
- 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)
- 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<boolean>(..., { defaultValue: false });
// T = string, defaultValue = string
overlay.openAsync<string>(..., { defaultValue: 'cancelled' });
993abdb to
fc168e3
Compare
External close buttons were hidden behind the modal overlay. Moving them inside the modal makes them clickable for demo purposes.
- 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
Summary
Adds an
onDismissoption tooverlay.openAsyncthat specifies a value to resolve when the overlay is closed externally (viaoverlay.close(id)oroverlay.closeAll()rather than the internalclose(value)callback).Problem
When using
openAsync, the Promise only resolves when the wrappedclose(value)function is called from inside the overlay component.However, if the overlay is closed externally via
overlay.close(overlayId)oroverlay.closeAll(), the Promise never resolves because the wrappedclosefunction is never invoked.Common external close scenarios:
Related issue: #169
Solution
Add optional
onDismissparameter that resolves when the overlay is closed externally:Implementation Approach
Instead of trying to wrap or intercept the close behavior (which doesn't work because external closes bypass the wrapper), this implementation:
subscribeEventto listen forcloseandcloseAlleventsonDismissvalueChanges
subscribeEventfunction tocreateUseExternalEventsfor non-hook event subscriptionOpenAsyncOverlayOptions<T>type with optionalonDismisspropertyopenAsyncto subscribe toclose/closeAllevents and resolve withonDismisswhen applicableTest Plan
New test cases added:
close(value)when called internally (backward compatible)onDismisswhen closed externally viaoverlay.close()onDismisswhen closed viaoverlay.closeAll()onDismissoption (backward compatible)Verification:
yarn test- all 62 tests passyarn build- builds successfullyyarn lint- no lint errorsyarn tsc --noEmit- no type errorsBreaking Changes
None. This is a backward-compatible change:
onDismissoption: existing behavior (Promise stays pending on external close)onDismissoption: Promise resolves with specified value on external closeCloses #169