Skip to content

feat(openAsync): add onDismiss option for external close handling#214

Closed
p-iknow wants to merge 7 commits intotoss:mainfrom
p-iknow:feat/openasync-ondismiss-option
Closed

feat(openAsync): add onDismiss option for external close handling#214
p-iknow wants to merge 7 commits intotoss:mainfrom
p-iknow:feat/openasync-ondismiss-option

Conversation

@p-iknow
Copy link

@p-iknow p-iknow commented Jan 23, 2026

Summary

Adds an onDismiss option to overlay.openAsync that specifies a value to resolve when the overlay is closed externally (via overlay.close(id) or overlay.closeAll() rather than the internal close(value) callback).

Problem

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 because the wrapped close function is never invoked.

Common external close scenarios:

  • Browser back button navigation
  • ESC key handlers
  • Auto-close timers
  • Route change cleanup
  • Global state-driven close

Related issue: #169

Solution

Add optional onDismiss parameter that resolves when the overlay is closed externally:

const result = await overlay.openAsync<boolean | undefined>(
  ({ isOpen, close }) => (
    <Dialog
      open={isOpen}
      onConfirm={() => close(true)}
      onCancel={() => close(false)}
    />
  ),
  { onDismiss: undefined }
);

// result is:
// - true when user clicks confirm
// - false when user clicks cancel
// - undefined when closed externally (e.g., overlay.close(id))

Implementation Approach

Instead of trying to wrap or intercept the close behavior (which doesn't work because external closes bypass the wrapper), this implementation:

  1. Subscribes to events: Uses subscribeEvent to listen for close and closeAll events
  2. Detects external close: When the close event fires with matching overlayId, resolves with onDismiss value
  3. Prevents double resolution: Guards against resolving twice if both internal and external close happen
  4. Cleans up subscriptions: Unsubscribes when Promise is resolved or rejected

Changes

  • Add subscribeEvent function to createUseExternalEvents for non-hook event subscription
  • Add OpenAsyncOverlayOptions<T> type with optional onDismiss property
  • Modify openAsync to subscribe to close/closeAll events and resolve with onDismiss when applicable
  • Add comprehensive test cases covering all scenarios

Test Plan

New test cases added:

  1. ✅ Resolves with close(value) when called internally (backward compatible)
  2. ✅ Resolves with onDismiss when closed externally via overlay.close()
  3. ✅ Resolves with onDismiss when closed via overlay.closeAll()
  4. ✅ Stays pending without onDismiss option (backward compatible)
  5. ✅ Prevents double resolution

Verification:

  • yarn test - all 62 tests pass
  • yarn build - builds successfully
  • yarn lint - no lint errors
  • yarn tsc --noEmit - no type errors

Breaking Changes

None. This is a backward-compatible change:

  • Without onDismiss option: existing behavior (Promise stays pending on external close)
  • With onDismiss option: Promise resolves with specified value on external close

Closes #169

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
@p-iknow p-iknow requested a review from jungpaeng as a code owner January 23, 2026 08:22
Copilot AI review requested due to automatic review settings January 23, 2026 08:22
@changeset-bot
Copy link

changeset-bot bot commented Jan 23, 2026

⚠️ No Changeset found

Latest commit: 3a80a8e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Jan 23, 2026

Someone is attempting to deploy a commit to the Toss Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 subscribeEvent function to enable non-hook event subscription for detecting external close events
  • Introduced OpenAsyncOverlayOptions<T> type with optional onDismiss property for specifying the resolution value
  • Refactored openAsync to 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.

Comment on lines 54 to 61
if (closedOverlayId === currentOverlayId && 'onDismiss' in (options ?? {})) {
resolve(options!.onDismiss as T);
}
});

const unsubscribeCloseAll = subscribeEvent('closeAll', () => {
if ('onDismiss' in (options ?? {})) {
resolve(options!.onDismiss as T);
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 55 to 61
resolve(options!.onDismiss as T);
}
});

const unsubscribeCloseAll = subscribeEvent('closeAll', () => {
if ('onDismiss' in (options ?? {})) {
resolve(options!.onDismiss as T);
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 21 to 23
type OpenAsyncOverlayOptions<T> = OpenOverlayOptions & {
onDismiss?: T;
};
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A section in the README showing how to use onDismiss to handle external closes
  2. JSDoc comments on the OpenAsyncOverlayOptions type explaining when and why to use onDismiss
  3. 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).

Copilot uses AI. Check for mistakes.
Comment on lines 53 to 63
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);
}
});
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
이영창(piknow) added 3 commits January 23, 2026 17:40
- 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' });
@p-iknow p-iknow force-pushed the feat/openasync-ondismiss-option branch from 993abdb to fc168e3 Compare January 23, 2026 08:51
이영창(piknow) added 3 commits January 23, 2026 17:55
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
@p-iknow p-iknow closed this Jan 23, 2026
@p-iknow p-iknow deleted the feat/openasync-ondismiss-option branch January 23, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Is it expected behavior that the Promise returned by openAsync never resolves when unmount is called?

1 participant