Skip to content

Instantly share code, notes, and snippets.

@radiovisual
Created July 22, 2025 14:41
Show Gist options
  • Save radiovisual/f72d78fdc0c2741ceee0b49e252d1074 to your computer and use it in GitHub Desktop.
Save radiovisual/f72d78fdc0c2741ceee0b49e252d1074 to your computer and use it in GitHub Desktop.
// utils/RobustPortal.tsx
import { createPortal } from 'react-dom';
import React, { useContext, createContext, useState, useEffect, useCallback, useRef } from 'react';
// Enhanced portal context with timing guarantees
interface ProcessWrapperPortalContextType {
registerPortal: (portalId: string, element: HTMLElement | null) => void;
isPortalReady: (portalId: string) => boolean;
getPortalElement: (portalId: string) => HTMLElement | null;
portalReadyStates: Map<string, boolean>;
}
export const ProcessWrapperPortalContext = createContext<ProcessWrapperPortalContextType>({
registerPortal: () => {},
isPortalReady: () => false,
getPortalElement: () => null,
portalReadyStates: new Map()
});
// Robust Portal Provider with timing coordination
export const ProcessWrapperPortalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [portals, setPortals] = useState<Map<string, HTMLElement | null>>(new Map());
const [readyStates, setReadyStates] = useState<Map<string, boolean>>(new Map());
const observerRef = useRef<MutationObserver | null>(null);
const registerPortal = useCallback((portalId: string, element: HTMLElement | null) => {
setPortals(prev => {
const newMap = new Map(prev);
newMap.set(portalId, element);
return newMap;
});
setReadyStates(prev => {
const newMap = new Map(prev);
newMap.set(portalId, !!element);
return newMap;
});
}, []);
const isPortalReady = useCallback((portalId: string) => {
return readyStates.get(portalId) || false;
}, [readyStates]);
const getPortalElement = useCallback((portalId: string) => {
return portals.get(portalId) || null;
}, [portals]);
// Set up MutationObserver to handle dynamic DOM changes
useEffect(() => {
observerRef.current = new MutationObserver((mutations) => {
let needsUpdate = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// Check if any registered portal elements were added/removed
portals.forEach((element, portalId) => {
if (element) {
const stillExists = document.contains(element);
const currentState = readyStates.get(portalId);
if (currentState !== stillExists) {
needsUpdate = true;
setReadyStates(prev => {
const newMap = new Map(prev);
newMap.set(portalId, stillExists);
return newMap;
});
}
}
});
}
});
});
observerRef.current.observe(document.body, {
childList: true,
subtree: true,
attributes: false
});
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [portals, readyStates]);
const contextValue = {
registerPortal,
isPortalReady,
getPortalElement,
portalReadyStates: readyStates
};
return (
<ProcessWrapperPortalContext.Provider value={contextValue}>
{children}
</ProcessWrapperPortalContext.Provider>
);
};
// Enhanced FooterPortal with timing guarantees and error handling
export const FooterPortal: React.FC<{
children: React.ReactNode;
portalId?: string;
fallback?: React.ReactNode;
}> = ({ children, portalId = 'process-wrapper-footer', fallback = null }) => {
const { isPortalReady, getPortalElement } = useContext(ProcessWrapperPortalContext);
const [renderAttempts, setRenderAttempts] = useState(0);
const maxRetries = 10;
// Retry mechanism for portal availability
useEffect(() => {
if (!isPortalReady(portalId) && renderAttempts < maxRetries) {
const timer = setTimeout(() => {
setRenderAttempts(prev => prev + 1);
}, 50 + (renderAttempts * 50)); // Exponential backoff
return () => clearTimeout(timer);
}
}, [portalId, isPortalReady, renderAttempts, maxRetries]);
// Reset retry counter when portal becomes ready
useEffect(() => {
if (isPortalReady(portalId)) {
setRenderAttempts(0);
}
}, [portalId, isPortalReady]);
const portalElement = getPortalElement(portalId);
if (!isPortalReady(portalId) || !portalElement) {
// Show fallback or nothing while waiting for portal target
return renderAttempts >= maxRetries ? (fallback as React.ReactElement) : null;
}
try {
return createPortal(children, portalElement);
} catch (error) {
console.error('FooterPortal render failed:', error);
return fallback as React.ReactElement;
}
};
// Hook for managing portal elements with timing coordination
export const usePortalElement = (portalId: string) => {
const { registerPortal, isPortalReady } = useContext(ProcessWrapperPortalContext);
const elementRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const element = elementRef.current;
if (element) {
// Register the portal immediately
registerPortal(portalId, element);
setIsReady(true);
}
return () => {
// Clean up on unmount
registerPortal(portalId, null);
setIsReady(false);
};
}, [portalId, registerPortal]);
return { elementRef, isReady, isPortalReady: isPortalReady(portalId) };
};
// ContactDataContainer.tsx - Updated with robust portal
import React, { useState, useEffect } from 'react';
import { ContactData } from './ContactData';
import { StepProps } from './types/StepComponent';
import { FooterPortal } from '../utils/RobustPortal';
import { EMAIL_PATTERN } from '../../../utilities/constants';
export function ContactDataContainer(props: ContactDataContainerProps & StepProps) {
const {
title, onClose, onNext, locale, isNative, formValues,
setEmailAddress, emailAddresses, translate
} = props;
const [emailErrorMessage, setEmailErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [shouldShowPortal, setShouldShowPortal] = useState(false);
const emailValidationErrorMessages = {
required: translate('payments.inbox.validation.required'),
pattern: translate('payments.inbox.validation.pattern'),
};
const validateEmail = (email: string) => {
let validationError = '';
if (!(email || '').trim()) {
validationError = emailValidationErrorMessages['required'];
} else if (!EMAIL_PATTERN.test(email)) {
validationError = emailValidationErrorMessages['pattern'];
}
return validationError;
};
const handleSubmit = async () => {
const emailValidationError = validateEmail(formValues?.email);
setEmailErrorMessage(emailValidationError);
if (!emailValidationError) {
setIsSubmitting(true);
try {
// Your existing async validation logic
await new Promise(resolve => setTimeout(resolve, 1000));
onNext();
} catch (error) {
setEmailErrorMessage('Email validation failed');
} finally {
setIsSubmitting(false);
}
}
};
const isValid = !!formValues?.email && !validateEmail(formValues.email);
// Show portal after a brief delay to ensure parent is ready
useEffect(() => {
const timer = setTimeout(() => {
setShouldShowPortal(true);
}, 100);
return () => clearTimeout(timer);
}, []);
if (!props.isActive) return null;
return (
<>
<ContactData
locale={locale}
firstName={formValues?.firstName}
lastName={formValues?.lastName}
emailAddress={formValues?.email}
setEmailAddress={setEmailAddress}
emailAddresses={emailAddresses}
emailErrorMessage={emailErrorMessage}
/>
{shouldShowPortal && (
<FooterPortal
portalId="process-wrapper-footer"
fallback={
<div style={{ padding: '16px', textAlign: 'center' }}>
<button onClick={handleSubmit} disabled={!isValid}>
{translate('payments.inbox.buttons.labels.next')}
</button>
</div>
}
>
<div className="footer-buttons-container">
<button
id="contact-data-next-btn"
type="button"
disabled={!isValid || isSubmitting}
onClick={handleSubmit}
className={`btn ${isValid && !isSubmitting ? 'btn-primary' : 'btn-disabled'}`}
>
{isSubmitting ? (
<>
<span className="loading-spinner" />
{translate('common.validating')}
</>
) : (
translate('payments.inbox.buttons.labels.next')
)}
</button>
</div>
</FooterPortal>
)}
</>
);
}
// EBillRegistration.tsx - Updated main component with robust portal management
import React, { useState, useMemo, useEffect } from 'react';
import { ProcessWrapper } from '@ubs-hba/responsive';
import { ProcessWrapperPortalProvider, usePortalElement } from './utils/RobustPortal';
import { ContactDataContainer } from './Steps/ContactData/ContactDataContainer';
import { EmailConfirmationContainer } from './Steps/EmailConfirmation/EmailConfirmationContainer';
export function EBillRegistration({
locale,
onClose,
isNative = true,
onNavigateToEbillSettings,
onNavigateToOverview,
}: EBillRegistrationProps) {
const translate = useMemo(() => createTranslate(locale), [locale]);
const [currentStep, setCurrentStep] = useState(StepTypeId.CONTACT_DATA);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [formValues, setFormValues] = useState<any>({});
const [emailAddresses, setEmailAddresses] = useState<string[]>([]);
// Use the portal element hook for footer management
const { elementRef: footerElementRef, isReady: footerReady } = usePortalElement('process-wrapper-footer');
// Navigation handlers
const goToNextStep = () => {
setCompletedSteps(prev => [...prev.filter(s => s !== currentStep), currentStep]);
if (currentStep === StepTypeId.CONTACT_DATA) {
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION);
} else if (currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) {
setCurrentStep(StepTypeId.INVOICE_ADDRESS);
} else if (currentStep === StepTypeId.CONFIRMATION_CODE) {
setCurrentStep(StepTypeId.INVOICE_ADDRESS);
} else if (currentStep === StepTypeId.INVOICE_ADDRESS) {
setCurrentStep(StepTypeId.SUCCESS_CONFIRMATION);
}
};
const goToPreviousStep = () => {
if (currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) {
setCurrentStep(StepTypeId.CONTACT_DATA);
} else if (currentStep === StepTypeId.INVOICE_ADDRESS) {
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION);
} else if (currentStep === StepTypeId.CONFIRMATION_CODE) {
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION);
} else if (currentStep === StepTypeId.SUCCESS_CONFIRMATION) {
setCurrentStep(StepTypeId.INVOICE_ADDRESS);
}
};
const goToError = () => setCurrentStep(StepTypeId.ERROR_PAGE);
// Step titles
const step1Title = translate('payments.inbox.payment.ebillRegistration.step1.process.title');
const step2Title = translate('payments.inbox.payment.ebillRegistration.step2.process.title');
const step3Title = translate('payments.inbox.payment.ebillRegistration.step3.process.title');
const step4Title = translate('payments.inbox.payment.ebillRegistration.step4.process.title');
const navigationProps = {
steps: [step1Title, step2Title, step3Title, step4Title],
active: currentStep,
completed: completedSteps,
showIcon: true,
};
const wrapperTitle = translate('payments.inbox.payment.ebillRegistration.digitalBanking');
const wrapperSubtitle = translate('payments.inbox.payment.ebillRegistration');
const commonStepProps = {
locale,
translate,
formValues,
onClose,
onNext: goToNextStep,
onPrevious: goToPreviousStep,
onDataChange: setFormValues
};
if (currentStep === StepTypeId.ERROR_PAGE) {
return (
<ErrorPageContainer
title={wrapperTitle}
translate={translate}
isNative={isNative}
locale={locale}
onClose={onClose}
/>
);
}
return (
<ProcessWrapperPortalProvider>
<ProcessWrapper
isNativeApp={isNative}
backLabel="Back"
closeLabel="Close"
title={[wrapperTitle, wrapperSubtitle]}
navigationProps={navigationProps}
onCancel={() => console.log('cancel')}
>
<ProcessWrapper.Header
title={[wrapperTitle, wrapperSubtitle]}
navigationProps={navigationProps}
/>
<ProcessWrapper.Content>
{/* EmailConfirmation modal overlays ContactData when active */}
{(currentStep === StepTypeId.CONTACT_DATA || currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) && (
<EmailConfirmationContainer
{...commonStepProps}
show={currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION}
title={wrapperTitle}
isNative={isNative}
account={accountDataResponse?.data}
skipToConfirmationCode={() => setCurrentStep(StepTypeId.CONFIRMATION_CODE)}
isActive={currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION}
/>
)}
{currentStep === StepTypeId.CONTACT_DATA && (
<ContactDataContainer
{...commonStepProps}
title={wrapperTitle}
isNative={isNative}
emailAddresses={emailAddresses}
setEmailAddress={(value: string) =>
setFormValues(prev => ({ ...prev, email: value }))
}
isActive={true}
/>
)}
</ProcessWrapper.Content>
{/* Footer with portal target */}
<ProcessWrapper.Footer>
<div
ref={footerElementRef}
className="process-wrapper-footer-content"
data-portal-ready={footerReady}
style={{ minHeight: '60px' }} // Prevent layout shift
>
{/* Debug info - remove in production */}
{process.env.NODE_ENV === 'development' && !footerReady && (
<div style={{ color: '#666', fontSize: '12px' }}>
Portal target loading... (isNative: {isNative.toString()})
</div>
)}
</div>
</ProcessWrapper.Footer>
</ProcessWrapper>
</ProcessWrapperPortalProvider>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment