From 897ae4a4546ac0dd811125d5513ef23d133a1589 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Wed, 2 Feb 2022 15:25:51 -0500 Subject: [PATCH] fix(angular, react, vue): overlays no longer throw errors when used inside tests (#24681) resolves #24549, resolves #24590 --- core/README.md | 44 ++++++++++++-- core/src/utils/overlays.ts | 57 ++++--------------- packages/react/src/hooks/useController.ts | 6 +- packages/react/src/hooks/useIonActionSheet.ts | 4 +- packages/react/src/hooks/useIonAlert.ts | 3 +- packages/react/src/hooks/useIonLoading.tsx | 4 +- packages/react/src/hooks/useIonModal.ts | 2 + packages/react/src/hooks/useIonPicker.tsx | 4 +- packages/react/src/hooks/useIonPopover.ts | 2 + packages/react/src/hooks/useIonToast.ts | 4 +- packages/react/src/hooks/useOverlay.ts | 3 + packages/vue/src/controllers.ts | 35 ++++++------ 12 files changed, 94 insertions(+), 74 deletions(-) diff --git a/core/README.md b/core/README.md index accf5f94cd7..a9b8231d933 100644 --- a/core/README.md +++ b/core/README.md @@ -44,19 +44,55 @@ The `@ionic/core` package can be used in simple HTML, or by vanilla JavaScript w In addition to the default, self lazy-loading components built by Stencil, this package also comes with each component exported as a stand-alone custom element within `@ionic/core/components`. Each component extends `HTMLElement`, and does not lazy-load itself. Instead, this package is useful for projects already using a bundler such as Webpack or Rollup. While all components are available to be imported, the custom elements build also ensures bundlers only import what's used, and tree-shakes any unused components. -Below is an example of importing `ion-toggle`, and initializing Ionic so it's able to correctly load the "mode", such as Material Design or iOS. Additionally, the `initialize({...})` function can receive the Ionic config. +Below is an example of importing `ion-badge`, and initializing Ionic so it is able to correctly load the "mode", such as Material Design or iOS. Additionally, the `initialize({...})` function can receive the Ionic config. ```typescript -import { IonBadge } from "@ionic/core/components/ion-badge"; +import { defineCustomElement } from "@ionic/core/components/ion-badge.js"; import { initialize } from "@ionic/core/components"; +// Initializes the Ionic config and `mode` behavior initialize(); -customElements.define("ion-badge", IonBadge); +// Defines the `ion-badge` web component +defineCustomElement(); ``` -Notice how `IonBadge` is imported from `@ionic/core/components/ion-badge` rather than just `@ionic/core/components`. Additionally, the `initialize` function is imported from `@ionic/core/components` rather than `@ionic/core`. All of this helps to ensure bundlers do not pull in more code than is needed. +Notice how we import from `@ionic/core/components` as opposed to `@ionic/core`. This helps bundlers pull in only the code that is needed. +The `defineCustomElement` function will automatically define the component as well as any child components that may be required. + +For example, if you wanted to use `ion-modal`, you would do the following: + +```typescript +import { defineCustomElement } from "@ionic/core/components/ion-modal.js"; +import { initialize } from "@ionic/core/components"; + +// Initializes the Ionic config and `mode` behavior +initialize(); + +// Defines the `ion-modal` and child `ion-backdrop` web components. +defineCustomElement(); +``` + +The `defineCustomElement` function will define `ion-modal`, but it will also define `ion-backdrop`, which is a component that `ion-modal` uses internally. + +### Using Overlay Controllers + +When using an overlay controller, developers will need to define the overlay component before it can be used. Below is an example of using `modalController`: + +```typescript +import { defineCustomElement } from '@ionic/core/components/ion-modal.js'; +import { initialize, modalController } from '@ionic/core/components'; + +initialize(); +defineCustomElement(); + +const showModal = async () => { + const modal = await modalController.create({ ... }); + + ... +} +``` ## How to contribute diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index da9b3b806be..588c5517e6b 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -1,14 +1,3 @@ -import { ActionSheet } from '../components/action-sheet/action-sheet'; -import { Alert } from '../components/alert/alert'; -import { Backdrop } from '../components/backdrop/backdrop'; -import { Loading } from '../components/loading/loading'; -import { Modal } from '../components/modal/modal'; -import { PickerColumnCmp } from '../components/picker-column/picker-column'; -import { Picker } from '../components/picker/picker'; -import { Popover } from '../components/popover/popover'; -import { RippleEffect } from '../components/ripple-effect/ripple-effect'; -import { Spinner } from '../components/spinner/spinner'; -import { Toast } from '../components/toast/toast'; import { config } from '../global/config'; import { getIonMode } from '../global/ionic-global'; import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface'; @@ -20,15 +9,10 @@ let lastId = 0; export const activeAnimations = new WeakMap(); -type ChildCustomElementDefinition = { - tagName: string; - customElement: any; -} - -const createController = (tagName: string, customElement?: any, childrenCustomElements?: ChildCustomElementDefinition[]) => { +const createController = (tagName: string) => { return { create(options: Opts): Promise { - return createOverlay(tagName, options, customElement, childrenCustomElements) as any; + return createOverlay(tagName, options) as any; }, dismiss(data?: any, role?: string, id?: string) { return dismissOverlay(document, data, role, tagName, id); @@ -39,13 +23,13 @@ const createController = (tagName: str }; }; -export const alertController = /*@__PURE__*/createController('ion-alert', Alert, [{ tagName: 'ion-backdrop', customElement: Backdrop }]); -export const actionSheetController = /*@__PURE__*/createController('ion-action-sheet', ActionSheet, [{ tagName: 'ion-backdrop', customElement: Backdrop }, { tagName: 'ion-ripple-effect', customElement: RippleEffect }]); -export const loadingController = /*@__PURE__*/createController('ion-loading', Loading, [{ tagName: 'ion-backdrop', customElement: Backdrop }, { tagName: 'ion-spinner', customElement: Spinner }]); -export const modalController = /*@__PURE__*/createController('ion-modal', Modal, [{ tagName: 'ion-backdrop', customElement: Backdrop }]); -export const pickerController = /*@__PURE__*/createController('ion-picker', Picker, [{ tagName: 'ion-picker-column', customElement: PickerColumnCmp }, { tagName: 'ion-backdrop', customElement: Backdrop }]); -export const popoverController = /*@__PURE__*/createController('ion-popover', Popover, [{ tagName: 'ion-backdrop', customElement: Backdrop }]); -export const toastController = /*@__PURE__*/createController('ion-toast', Toast, [{ tagName: 'ion-ripple-effect', customElement: RippleEffect }]); +export const alertController = /*@__PURE__*/createController('ion-alert'); +export const actionSheetController = /*@__PURE__*/createController('ion-action-sheet'); +export const loadingController = /*@__PURE__*/createController('ion-loading'); +export const modalController = /*@__PURE__*/createController('ion-modal'); +export const pickerController = /*@__PURE__*/createController('ion-picker'); +export const popoverController = /*@__PURE__*/createController('ion-popover'); +export const toastController = /*@__PURE__*/createController('ion-toast'); export interface OverlayListenerOptions { trapKeyboardFocus: boolean; @@ -65,29 +49,10 @@ export const prepareOverlay = (el: T, options: } }; -const registerOverlayComponents = (tagName: string, customElement: any, childrenCustomElements?: ChildCustomElementDefinition[]): Promise => { - const { customElements } = window; - if (!customElements.get(tagName)) { - customElements.define(tagName, customElement); - } - /** - * If the parent element has nested usage of custom elements, - * we need to manually define those custom elements. - */ - if (childrenCustomElements) { - for (const customElementDefinition of childrenCustomElements) { - if (!customElements.get(customElementDefinition.tagName)) { - customElements.define(customElementDefinition.tagName, customElementDefinition.customElement); - } - } - } - return customElements.whenDefined(tagName); -} - -export const createOverlay = (tagName: string, opts: object | undefined, customElement?: any, childrenCustomElements?: ChildCustomElementDefinition[]): Promise => { +export const createOverlay = (tagName: string, opts: object | undefined): Promise => { /* tslint:disable-next-line */ if (typeof window !== 'undefined' && typeof window.customElements !== 'undefined') { - return registerOverlayComponents(tagName, customElement, childrenCustomElements).then(() => { + return window.customElements.whenDefined(tagName).then(() => { const element = document.createElement(tagName) as HTMLIonOverlayElement; element.classList.add('overlay-hidden'); diff --git a/packages/react/src/hooks/useController.ts b/packages/react/src/hooks/useController.ts index 9b465e796a3..5fffbcf41c8 100644 --- a/packages/react/src/hooks/useController.ts +++ b/packages/react/src/hooks/useController.ts @@ -12,7 +12,8 @@ interface OverlayBase extends HTMLElement { export function useController( displayName: string, - controller: { create: (options: OptionsType) => Promise } + controller: { create: (options: OptionsType) => Promise }, + defineCustomElement: () => void ) { const overlayRef = useRef(); const didDismissEventName = useMemo(() => `on${displayName}DidDismiss`, [displayName]); @@ -20,11 +21,14 @@ export function useController( const willDismissEventName = useMemo(() => `on${displayName}WillDismiss`, [displayName]); const willPresentEventName = useMemo(() => `on${displayName}WillPresent`, [displayName]); + defineCustomElement(); + const present = useCallback( async (options: OptionsType & HookOverlayOptions) => { if (overlayRef.current) { return; } + const { onDidDismiss, onWillDismiss, onDidPresent, onWillPresent, ...rest } = options; const handleDismiss = (event: CustomEvent>) => { diff --git a/packages/react/src/hooks/useIonActionSheet.ts b/packages/react/src/hooks/useIonActionSheet.ts index 82bc869a4df..2b9edf871b2 100644 --- a/packages/react/src/hooks/useIonActionSheet.ts +++ b/packages/react/src/hooks/useIonActionSheet.ts @@ -1,4 +1,5 @@ import { ActionSheetButton, ActionSheetOptions, actionSheetController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-action-sheet.js'; import { useCallback } from 'react'; import { HookOverlayOptions } from './HookOverlayOptions'; @@ -11,7 +12,8 @@ import { useController } from './useController'; export function useIonActionSheet(): UseIonActionSheetResult { const controller = useController( 'IonActionSheet', - actionSheetController + actionSheetController, + defineCustomElement ); const present = useCallback( diff --git a/packages/react/src/hooks/useIonAlert.ts b/packages/react/src/hooks/useIonAlert.ts index 1fdf399084a..cd65c75292a 100644 --- a/packages/react/src/hooks/useIonAlert.ts +++ b/packages/react/src/hooks/useIonAlert.ts @@ -1,4 +1,5 @@ import { AlertButton, AlertOptions, alertController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-alert.js'; import { useCallback } from 'react'; import { HookOverlayOptions } from './HookOverlayOptions'; @@ -9,7 +10,7 @@ import { useController } from './useController'; * @returns Returns the present and dismiss methods in an array */ export function useIonAlert(): UseIonAlertResult { - const controller = useController('IonAlert', alertController); + const controller = useController('IonAlert', alertController, defineCustomElement); const present = useCallback( (messageOrOptions: string | (AlertOptions & HookOverlayOptions), buttons?: AlertButton[]) => { diff --git a/packages/react/src/hooks/useIonLoading.tsx b/packages/react/src/hooks/useIonLoading.tsx index bab4b627214..1c380c54995 100644 --- a/packages/react/src/hooks/useIonLoading.tsx +++ b/packages/react/src/hooks/useIonLoading.tsx @@ -1,4 +1,5 @@ import { LoadingOptions, SpinnerTypes, loadingController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-loading.js'; import { useCallback } from 'react'; import { HookOverlayOptions } from './HookOverlayOptions'; @@ -11,7 +12,8 @@ import { useController } from './useController'; export function useIonLoading(): UseIonLoadingResult { const controller = useController( 'IonLoading', - loadingController + loadingController, + defineCustomElement ); const present = useCallback( diff --git a/packages/react/src/hooks/useIonModal.ts b/packages/react/src/hooks/useIonModal.ts index d6ee90e9ca5..f9f94002fbf 100644 --- a/packages/react/src/hooks/useIonModal.ts +++ b/packages/react/src/hooks/useIonModal.ts @@ -1,4 +1,5 @@ import { ModalOptions, modalController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-modal.js'; import { useCallback } from 'react'; import { ReactComponentOrElement } from '../models/ReactComponentOrElement'; @@ -19,6 +20,7 @@ export function useIonModal( const controller = useOverlay( 'IonModal', modalController, + defineCustomElement, component, componentProps ); diff --git a/packages/react/src/hooks/useIonPicker.tsx b/packages/react/src/hooks/useIonPicker.tsx index 3eace0dcf25..cb58ed0f288 100644 --- a/packages/react/src/hooks/useIonPicker.tsx +++ b/packages/react/src/hooks/useIonPicker.tsx @@ -4,6 +4,7 @@ import { PickerOptions, pickerController, } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-picker.js'; import { useCallback } from 'react'; import { HookOverlayOptions } from './HookOverlayOptions'; @@ -16,7 +17,8 @@ import { useController } from './useController'; export function useIonPicker(): UseIonPickerResult { const controller = useController( 'IonPicker', - pickerController + pickerController, + defineCustomElement ); const present = useCallback(( diff --git a/packages/react/src/hooks/useIonPopover.ts b/packages/react/src/hooks/useIonPopover.ts index 13e69bf745c..e5800f54725 100644 --- a/packages/react/src/hooks/useIonPopover.ts +++ b/packages/react/src/hooks/useIonPopover.ts @@ -1,4 +1,5 @@ import { PopoverOptions, popoverController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-popover.js'; import { useCallback } from 'react'; import { ReactComponentOrElement } from '../models/ReactComponentOrElement'; @@ -16,6 +17,7 @@ export function useIonPopover(component: ReactComponentOrElement, componentProps const controller = useOverlay( 'IonPopover', popoverController, + defineCustomElement, component, componentProps ); diff --git a/packages/react/src/hooks/useIonToast.ts b/packages/react/src/hooks/useIonToast.ts index 1347b4c70a6..2b358b4f425 100644 --- a/packages/react/src/hooks/useIonToast.ts +++ b/packages/react/src/hooks/useIonToast.ts @@ -1,4 +1,5 @@ import { ToastOptions, toastController } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-toast.js'; import { useCallback } from 'react'; import { HookOverlayOptions } from './HookOverlayOptions'; @@ -11,7 +12,8 @@ import { useController } from './useController'; export function useIonToast(): UseIonToastResult { const controller = useController( 'IonToast', - toastController + toastController, + defineCustomElement ); const present = useCallback((messageOrOptions: string | ToastOptions & HookOverlayOptions, duration?: number) => { diff --git a/packages/react/src/hooks/useOverlay.ts b/packages/react/src/hooks/useOverlay.ts index 96637b3f3e2..f1afcf38316 100644 --- a/packages/react/src/hooks/useOverlay.ts +++ b/packages/react/src/hooks/useOverlay.ts @@ -16,6 +16,7 @@ interface OverlayBase extends HTMLElement { export function useOverlay( displayName: string, controller: { create: (options: OptionsType) => Promise }, + defineCustomElement: () => void, component: ReactComponentOrElement, componentProps?: any ) { @@ -29,6 +30,8 @@ export function useOverlay( const ionContext = useContext(IonContext); const [overlayId] = useState(generateId('overlay')); + defineCustomElement(); + useEffect(() => { if (isOpen && component && containerElRef.current) { if (React.isValidElement(component)) { diff --git a/packages/vue/src/controllers.ts b/packages/vue/src/controllers.ts index 99f7ff17590..2f164024d45 100644 --- a/packages/vue/src/controllers.ts +++ b/packages/vue/src/controllers.ts @@ -7,17 +7,16 @@ import { pickerController as pickerCtrl, toastController as toastCtrl, } from '@ionic/core/components'; -import { defineCustomElement } from './utils'; import { VueDelegate } from './framework-delegate'; -import { IonModal } from '@ionic/core/components/ion-modal.js'; -import { IonPopover } from '@ionic/core/components/ion-popover.js' -import { IonAlert } from '@ionic/core/components/ion-alert.js' -import { IonActionSheet } from '@ionic/core/components/ion-action-sheet.js' -import { IonLoading } from '@ionic/core/components/ion-loading.js' -import { IonPicker } from '@ionic/core/components/ion-picker.js' -import { IonToast } from '@ionic/core/components/ion-toast.js' +import { defineCustomElement as defineIonActionSheetCustomElement } from '@ionic/core/components/ion-action-sheet.js' +import { defineCustomElement as defineIonAlertCustomElement } from '@ionic/core/components/ion-alert.js' +import { defineCustomElement as defineIonLoadingCustomElement } from '@ionic/core/components/ion-loading.js' +import { defineCustomElement as defineIonPickerCustomElement } from '@ionic/core/components/ion-picker.js' +import { defineCustomElement as defineIonToastCustomElement } from '@ionic/core/components/ion-toast.js' +import { defineCustomElement as defineIonModalCustomElement } from '@ionic/core/components/ion-modal.js' +import { defineCustomElement as defineIonPopoverCustomElement } from '@ionic/core/components/ion-popover.js' /** * Wrap the controllers export from @ionic/core @@ -25,12 +24,12 @@ import { IonToast } from '@ionic/core/components/ion-toast.js' * (optionally) provide a framework delegate. */ const createController: { - (tagName: string, customElement: any, oldController: T, useDelegate?: boolean): T -} = (tagName: string, customElement: any, oldController: any, useDelegate = false) => { + (defineCustomElement: () => void, oldController: T, useDelegate?: boolean): T +} = (defineCustomElement: () => void, oldController: any, useDelegate = false) => { const delegate = useDelegate ? VueDelegate() : undefined; const oldCreate = oldController.create.bind(oldController); oldController.create = (options: any) => { - defineCustomElement(tagName, customElement); + defineCustomElement(); return oldCreate({ ...options, @@ -41,13 +40,13 @@ const createController: { return oldController; } -const modalController = /*@__PURE__*/ createController('ion-modal', IonModal, modalCtrl, true); -const popoverController = /*@__PURE__*/ createController('ion-popover', IonPopover, popoverCtrl, true); -const alertController = /*@__PURE__*/ createController('ion-alert', IonAlert, alertCtrl); -const actionSheetController = /*@__PURE__*/ createController('ion-action-sheet', IonActionSheet, actionSheetCtrl); -const loadingController = /*@__PURE__*/ createController('ion-loading', IonLoading, loadingCtrl); -const pickerController = /*@__PURE__*/ createController('ion-picker', IonPicker, pickerCtrl); -const toastController = /*@__PURE__*/ createController('ion-toast', IonToast, toastCtrl); +const modalController = /*@__PURE__*/ createController(defineIonModalCustomElement, modalCtrl, true); +const popoverController = /*@__PURE__*/ createController(defineIonPopoverCustomElement, popoverCtrl, true); +const alertController = /*@__PURE__*/ createController(defineIonAlertCustomElement, alertCtrl); +const actionSheetController = /*@__PURE__*/ createController(defineIonActionSheetCustomElement, actionSheetCtrl); +const loadingController = /*@__PURE__*/ createController(defineIonLoadingCustomElement, loadingCtrl); +const pickerController = /*@__PURE__*/ createController(defineIonPickerCustomElement, pickerCtrl); +const toastController = /*@__PURE__*/ createController(defineIonToastCustomElement, toastCtrl); export { modalController,