11import type { HTMLIonOverlayElement , OverlayEventDetail } from '@ionic/core/components' ;
2+ import { componentOnReady } from '@ionic/core/components' ;
23import React , { createElement } from 'react' ;
4+ import { createPortal } from 'react-dom' ;
35
46import {
57 attachProps ,
@@ -17,6 +19,15 @@ type InlineOverlayState = {
1719 isOpen : boolean ;
1820} ;
1921
22+ /**
23+ * Set to `true` when rendering inside another inline overlay. Nested
24+ * overlays render at their JSX position (no portal) so that core's
25+ * `el.closest('ion-popover')`-style nesting detection keeps working,
26+ * and the outer overlay's portal already gives the subtree the correct
27+ * React event-delegation root.
28+ */
29+ const NestedOverlayContext = React . createContext ( false ) ;
30+
2031interface IonicReactInternalProps < ElementType > extends React . HTMLAttributes < ElementType > {
2132 forwardedRef ?: React . ForwardedRef < ElementType > ;
2233 ref ?: React . Ref < any > ;
@@ -36,12 +47,18 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
3647 defineCustomElement ( ) ;
3748 }
3849 const displayName = dashToPascalCase ( tagName ) ;
39- const ReactComponent = class extends React . Component < IonicReactInternalProps < PropType > , InlineOverlayState > {
50+
51+ type InternalProps = IonicReactInternalProps < PropType > & { isNested ?: boolean } ;
52+
53+ const ReactComponent = class extends React . Component < InternalProps , InlineOverlayState > {
4054 ref : React . RefObject < HTMLIonOverlayElement > ;
4155 wrapperRef : React . RefObject < HTMLElement > ;
56+ markerRef : React . RefObject < HTMLTemplateElement > ;
4257 stableMergedRefs : React . RefCallback < HTMLElement > ;
58+ portalTarget : HTMLElement | null ;
59+ isUnmounted = false ;
4360
44- constructor ( props : IonicReactInternalProps < PropType > ) {
61+ constructor ( props : InternalProps ) {
4562 super ( props ) ;
4663 // Create a local ref to to attach props to the wrapped element.
4764 this . ref = React . createRef ( ) ;
@@ -51,29 +68,64 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
5168 this . state = { isOpen : false } ;
5269 // Create a local ref to the inner child element.
5370 this . wrapperRef = React . createRef ( ) ;
71+ // Marker stays at the JSX location so we can recover the immediate
72+ // JSX parent after the overlay has been portaled to ion-app.
73+ this . markerRef = React . createRef ( ) ;
74+ /**
75+ * Resolve the portal target to the same container CoreDelegate
76+ * teleports overlays into. Portaling here keeps the overlay inside
77+ * React's tree so React's synthetic events still dispatch to its
78+ * children, even after CoreDelegate moves the DOM node out of the
79+ * declared JSX parent.
80+ */
81+ this . portalTarget = typeof document !== 'undefined' ? document . querySelector ( 'ion-app' ) || document . body : null ;
5482 }
5583
5684 componentDidMount ( ) {
85+ // Reset for React 18 StrictMode: the dev-mode unmount/remount cycle
86+ // re-uses this instance and leaves the flag set from the prior
87+ // componentWillUnmount.
88+ this . isUnmounted = false ;
89+
5790 this . componentDidUpdate ( this . props ) ;
5891
5992 this . ref . current ?. addEventListener ( 'ionMount' , this . handleIonMount ) ;
6093 this . ref . current ?. addEventListener ( 'willPresent' , this . handleWillPresent ) ;
6194 this . ref . current ?. addEventListener ( 'didDismiss' , this . handleDidDismiss ) ;
95+
96+ /**
97+ * The overlay is portaled to `portalTarget`, so Stencil caches that
98+ * container as `cachedOriginalParent`. Modal features (sheet
99+ * child-route passthrough, parent-removal auto-dismiss) walk up
100+ * from `cachedOriginalParent` to find the enclosing `.ion-page`,
101+ * so we redirect it at the marker's JSX parent.
102+ */
103+ const overlay = this . ref . current ;
104+ if ( overlay ) {
105+ componentOnReady ( overlay as HTMLElement , ( ) => {
106+ if ( this . isUnmounted ) return ;
107+ const markerParent = this . markerRef . current ?. parentElement ?? null ;
108+ if ( markerParent && markerParent !== this . portalTarget ) {
109+ ( overlay as any ) . cachedOriginalParent = markerParent ;
110+ }
111+ } ) ;
112+ }
62113 }
63114
64- componentDidUpdate ( prevProps : IonicReactInternalProps < PropType > ) {
115+ componentDidUpdate ( prevProps : InternalProps ) {
65116 const node = this . ref . current ! as HTMLElement ;
66117 /**
67118 * onDidDismiss and onWillPresent have manual implementations that
68119 * will invoke the original handler. We need to filter those out
69120 * so they don't get attached twice and called twice.
70121 */
71122 // eslint-disable-next-line @typescript-eslint/no-unused-vars
72- const { onDidDismiss, onWillPresent, ...cProps } = this . props ;
123+ const { onDidDismiss, onWillPresent, isNested , ...cProps } = this . props ;
73124 attachProps ( node , cProps , prevProps ) ;
74125 }
75126
76127 componentWillUnmount ( ) {
128+ this . isUnmounted = true ;
77129 const node = this . ref . current ;
78130 /**
79131 * If the overlay is being unmounted, but is still
@@ -97,14 +149,28 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
97149 * avoid memory leaks.
98150 */
99151 node . removeEventListener ( 'didDismiss' , this . handleDidDismiss ) ;
100- node . remove ( ) ;
152+ if ( this . props . isNested ) {
153+ /**
154+ * Nested overlays render inline (no portal). CoreDelegate may
155+ * have moved the node out of its React parent, so React's
156+ * unmount won't reach it. Remove it directly.
157+ */
158+ node . remove ( ) ;
159+ } else if ( node . isConnected && this . portalTarget && node . parentNode !== this . portalTarget ) {
160+ /**
161+ * Portaled path: move the overlay back into `portalTarget` so
162+ * React's portal removeChild can find it. CoreDelegate (or user
163+ * code in onWillPresent) may have moved it elsewhere while open.
164+ */
165+ this . portalTarget . appendChild ( node ) ;
166+ }
101167 detachProps ( node , this . props ) ;
102168 }
103169 }
104170
105171 render ( ) {
106172 // eslint-disable-next-line @typescript-eslint/no-unused-vars
107- const { children, forwardedRef, style, className, ref, ...cProps } = this . props ;
173+ const { children, forwardedRef, style, className, ref, isNested , ...cProps } = this . props ;
108174
109175 const propsToPass = Object . keys ( cProps ) . reduce ( ( acc , name ) => {
110176 if ( name . indexOf ( 'on' ) === 0 && name [ 2 ] === name [ 2 ] . toUpperCase ( ) ) {
@@ -136,17 +202,16 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
136202 return DELEGATE_HOST ;
137203 } ;
138204
139- return createElement (
140- 'template' ,
141- { } ,
205+ const overlayElement = createElement (
206+ tagName ,
207+ newProps ,
208+ // Children, not the overlay host, observe `isNested = true`.
142209 createElement (
143- tagName ,
144- newProps ,
210+ NestedOverlayContext . Provider ,
211+ { value : true } ,
145212 /**
146- * We only want the inner component
147- * to be mounted if the overlay is open,
148- * so conditionally render the component
149- * based on the isOpen state.
213+ * We only want the inner component to be mounted if the overlay
214+ * is open, so conditionally render based on `isOpen` state.
150215 */
151216 this . state . isOpen || this . props . keepContentsMounted
152217 ? createElement (
@@ -160,6 +225,21 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
160225 : null
161226 )
162227 ) ;
228+
229+ // Top-level overlays portal into `portalTarget` with a marker
230+ // `<template>` at the JSX location to recover the immediate JSX
231+ // parent after CoreDelegate teleports. Nested overlays and SSR
232+ // fall back to a `<template>` wrapper.
233+ if ( ! isNested && this . portalTarget ) {
234+ return createElement (
235+ React . Fragment ,
236+ null ,
237+ createElement ( 'template' , { ref : this . markerRef } ) ,
238+ createPortal ( overlayElement , this . portalTarget )
239+ ) ;
240+ }
241+
242+ return createElement ( 'template' , { } , overlayElement ) ;
163243 }
164244
165245 static get displayName ( ) {
@@ -206,7 +286,15 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
206286 this . props . onDidDismiss && this . props . onDidDismiss ( evt ) ;
207287 } ;
208288 } ;
209- return createForwardRef < PropType , ElementType > ( ReactComponent , displayName ) ;
289+
290+ // Forward the nesting context as a prop to avoid contextType on the class.
291+ const ReactComponentWithNesting : React . FC < IonicReactInternalProps < PropType > > = ( props ) =>
292+ createElement ( NestedOverlayContext . Consumer , null , ( isNested : boolean ) =>
293+ createElement ( ReactComponent , { ...( props as InternalProps ) , isNested } )
294+ ) ;
295+ ReactComponentWithNesting . displayName = displayName ;
296+
297+ return createForwardRef < PropType , ElementType > ( ReactComponentWithNesting , displayName ) ;
210298} ;
211299
212300const DELEGATE_HOST = 'ion-delegate-host' ;
0 commit comments