-
Notifications
You must be signed in to change notification settings - Fork 69
fix(openAsync): resolve promise when closed externally #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(openAsync): resolve promise when closed externally #215
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
- 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' });
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
|
|
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.
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 fixes a critical issue where overlay.openAsync() promises never resolve when overlays are closed externally (via overlay.close(id), overlay.closeAll(), overlay.unmount(), or overlay.unmountAll()), which could lead to memory leaks and deadlocked code. The solution introduces an optional defaultValue parameter that enables the Promise to resolve with a specified value when external close events occur, while maintaining full backward compatibility.
Changes:
- Added
subscribeEventfunction to enable event subscription outside React's lifecycle - Extended
openAsyncto accept adefaultValueoption that resolves the Promise on external close/unmount - Added comprehensive test coverage for all close/unmount scenarios
- Updated demo files across all React versions (16-19) to showcase the new functionality
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/src/utils/create-use-external-events.ts | Adds subscribeEvent function for manual event subscription outside React components with proper cleanup support |
| packages/src/event.ts | Implements defaultValue option for openAsync with event subscriptions for external close/unmount events and memory leak prevention |
| packages/src/event.test.tsx | Comprehensive test suite covering internal/external close, double resolution prevention, and backward compatibility |
| examples/react-{16,17,18,19}/framer-motion/src/demo.tsx | Identical demo implementations showcasing basic openAsync, defaultValue usage, and external close behavior |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
soft remind @jungpaeng |
Summary
Fixes
overlay.openAsyncPromise not resolving when the overlay is closed externally (viaoverlay.close(id)rather than the internalclose(value)callback).Adds a
defaultValueoption to specify what value to resolve in such cases.Closes #169
Screen.Recording.2026-01-23.at.6.01.42.PM.mov
🔴 PROBLEM
Core Issue
overlay.openAsync()Promise never resolves when closed externallyRequirements & Constraints
openAsynccalls without options must continue to work unchangedProblem Scenario
close(value)calloverlay.close(id)calloverlay.closeAll()calloverlay.unmount(id)callReal-World Scenarios Where This Occurs
overlay.close()setTimeout(() => overlay.close(id), 5000)overlay.close()Technical Root Cause
Call Flow Comparison:
Downstream Problems
awaitblocks indefinitely, subsequent code never runs🟢 ACTION
Design Rationale: Why
defaultValueOption?The solution uses an optional
defaultValueparameter to satisfy the no breaking changes requirement from issue #169:undefinedon external closeopenAsyncSafe()defaultValueparameterWhy this works:
defaultValue: Behavior unchanged (Promise stays pending on external close)defaultValue: Promise resolves with specified value on external closeDesign Decisions
onDismissdefaultValueclose,closeAllunmount,unmountAllcleanup()function addedopenAsyncSafe)Background: Hook vs External Subscription
Previously, overlay-kit only subscribed to events inside React hooks via
useExternalEvents. This change introducessubscribeEventto subscribe outside React's lifecycle (inside a Promise executor).useExternalEvents)subscribeEvent)useEffectreturnPotential Risks Identified
1. Memory Leak Risk
Problem: Without React's automatic cleanup, event listeners could remain in memory indefinitely.
Mitigation:
2. Double Resolution Risk
Problem: Both internal
close(value)and externaloverlay.close(id)could try to resolve the same Promise.Mitigation:
3. Handler Reference Management
Problem:
emitter.off()requires the exact same function reference asemitter.on().Mitigation:
4. Subscription Timing (Race Condition)
Problem: Event might fire before subscription is established.
Mitigation:
open()is called5. Global Singleton Emitter
Problem: All overlay instances share one emitter. Could cause cross-contamination.
Mitigation:
${prefix}:${event}overlayIdbefore acting6. Conditional Subscription (Unnecessary Listeners)
Problem: Subscribing when not needed wastes resources.
Mitigation:
Summary: Risk Mitigation Matrix
cleanup()on all exit pathsresolvedflag guardCore Implementation
1. New Option Type
2. External Event Subscription (using subscribeEvent)
3. Memory Leak Prevention
4. Conditional Subscription (Backward Compatibility)
Additional Issues Resolved
defaultValueoption resolves on external closecleanup()function unsubscribes all listenersunmount,unmountAllevent subscriptionsresolvedflag prevents it🔵 RESULT
Usage Example (Before vs After)
Before: The Problem
After: The Solution
Test Coverage
close(value)resolves with that valueoverlay.close()resolves withdefaultValueoverlay.closeAll()resolves withdefaultValueoverlay.unmount()resolves withdefaultValueoverlay.unmountAll()resolves withdefaultValuedefaultValue, maintains existing behavior (pending)Key Benefits
defaultValueis not providedclose,closeAll,unmount,unmountAll<T>applies todefaultValueas well📐 Architecture Change (AS-IS / TO-BE)
Event Subscription Layer
AS-IS: Only React Hook subscription
flowchart TD subgraph LAYER["create-use-external-events.ts"] UEE["useExternalEvents()<br/>React Hook"] CE["createEvent()<br/>Event Dispatcher"] end subgraph EMITTER["Emitter"] ON["emitter.on()"] EMIT["emitter.emit()"] end UEE -->|"useLayoutEffect"| ON CE -->|"dispatch"| EMITTO-BE: Added external subscription function
flowchart TD subgraph LAYER["create-use-external-events.ts"] UEE["useExternalEvents()<br/>React Hook"] CE["createEvent()<br/>Event Dispatcher"] SE["subscribeEvent() 🆕<br/>External Subscription"] end subgraph EMITTER["Emitter"] ON["emitter.on()"] OFF["emitter.off()"] EMIT["emitter.emit()"] end UEE -->|"useLayoutEffect"| ON UEE -->|"cleanup"| OFF CE -->|"dispatch"| EMIT SE -->|"subscribe"| ON SE -->|"returns unsubscribe"| OFFPromise Resolution Flow
AS-IS: External close → Promise pending forever
flowchart TD subgraph INTERNAL["Internal Close ✅"] I1["User clicks button"] --> I2["close(value)"] I2 --> I3["_resolve(value)"] I3 --> I4["Promise Resolved"] end subgraph EXTERNAL["External Close ❌"] E1["overlay.close(id)"] --> E2["emit 'close' event"] E2 --> E3["useOverlayEvent receives"] E3 --> E4["UI closes (isOpen=false)"] E4 --> E5["❌ _resolve never called"] E5 --> E6["Promise Pending Forever"] end style E5 fill:#f66,stroke:#333 style E6 fill:#f66,stroke:#333TO-BE: External close → Promise resolves with defaultValue
flowchart TD subgraph INTERNAL["Internal Close ✅"] I1["User clicks button"] --> I2["close(value)"] I2 --> I3["cleanup()"] I3 --> I4["_resolve(value)"] I4 --> I5["Promise Resolved"] end subgraph EXTERNAL["External Close ✅ 🆕"] E1["overlay.close(id)"] --> E2["emit 'close' event"] E2 --> E3["subscribeEvent handler triggered"] E3 --> E4{"resolved?"} E4 -->|"No"| E5["cleanup()"] E5 --> E6["_resolve(defaultValue)"] E6 --> E7["Promise Resolved with defaultValue"] E4 -->|"Yes"| E8["Skip (already resolved)"] end style E7 fill:#6f6,stroke:#333Multiple Subscribers Pattern
AS-IS: Single subscriber (React Hook only)
flowchart LR E["emit('close', overlayId)"] E --> S1["OverlayProvider<br/>(useOverlayEvent)"] S1 --> R1["UI Update"] X["openAsync Promise"] -.->|"❌ No connection"| ETO-BE: Multiple subscribers (Hook + External)
flowchart LR E["emit('close', overlayId)"] E --> S1["OverlayProvider<br/>(useOverlayEvent)"] E --> S2["openAsync 🆕<br/>(subscribeEvent)"] S1 --> R1["UI Update"] S2 --> R2["Promise Resolve"] style S2 fill:#9f9,stroke:#333 style R2 fill:#9f9,stroke:#333Data Flow Overview
AS-IS
flowchart TD USER["User Code<br/>overlay.openAsync()"] subgraph EVENT["Event Layer"] CREATE["createEvent()"] EMIT["emit()"] end subgraph REACT["React Layer"] HOOK["useOverlayEvent"] REDUCER["useReducer"] end UI["Overlay UI"] USER --> CREATE --> EMIT EMIT --> HOOK --> REDUCER --> UI USER -.->|"❌ No feedback<br/>on external close"| USERTO-BE
flowchart TD USER["User Code<br/>overlay.openAsync()"] subgraph EVENT["Event Layer"] CREATE["createEvent()"] EMIT["emit()"] SUB["subscribeEvent() 🆕"] end subgraph REACT["React Layer"] HOOK["useOverlayEvent"] REDUCER["useReducer"] end UI["Overlay UI"] USER --> CREATE --> EMIT EMIT --> HOOK --> REDUCER --> UI EMIT --> SUB SUB -->|"✅ Resolve with<br/>defaultValue"| USER style SUB fill:#9f9,stroke:#333Changes
packages/src/event.ts- AdddefaultValueoption toopenAsyncpackages/src/utils/create-use-external-events.ts- AddsubscribeEventfunctionpackages/src/event.test.tsx- Add comprehensive test casesexamples/*/demo.tsx- Sync demo across React versionsBreaking Changes
None. This is a backward-compatible change.