diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index d1e286036d..f9c5f3f628 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -818,7 +818,7 @@ export class StackManager extends React.PureComponent { /** * DO NOT remove ion-page-invisible here. * - * PageManager.render() adds ion-page-invisible to prevent flash before componentDidMount. + * PageManager's ref callback adds ion-page-invisible synchronously to prevent flash. * At this point, the div exists but its CHILDREN (header, toolbar, menu-button) * have NOT rendered yet. If we remove ion-page-invisible now, the page becomes visible * with empty/incomplete content, causing a flicker (especially for ion-menu-button which diff --git a/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx b/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx index 2d0018aa3c..09fef6c78b 100644 --- a/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx +++ b/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx @@ -1,52 +1,46 @@ -import { - IonButton, - IonContent, - IonHeader, - IonPage, - IonRouterOutlet, - IonTitle, - IonToolbar, -} from '@ionic/react'; +import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react'; import React, { useEffect, useRef, useState } from 'react'; -import { Route } from 'react-router'; +/** + * Test page for verifying that dynamically changing className on IonPage + * preserves framework-added classes (can-go-back, ion-page-invisible, etc.). + * + * Related issue: https://github.com/ionic-team/ionic-framework/issues/22631 + */ const DynamicIonpageClassnames: React.FC = () => { - return ( - - } /> - - ); + return ; }; export default DynamicIonpageClassnames; -const Page: React.FC = (props) => { +const Page: React.FC = () => { const [styleClass, setStyleClass] = useState('initial-class'); const [divClasses, setDivClasses] = useState(); - const ref = useRef(); + const ref = useRef(null); + useEffect(() => { - let observer: MutationObserver | undefined; - if(ref.current) { - observer = new MutationObserver(function (event) { - setDivClasses(ref.current?.className) - }) + if (!ref.current) return; - observer.observe(ref.current, { - attributes: true, - attributeFilter: ['class'], - childList: false, - characterData: false - }) - } - return () => observer?.disconnect() - }, []) + const observer = new MutationObserver(() => { + setDivClasses(ref.current?.className); + }); + observer.observe(ref.current, { + attributes: true, + attributeFilter: ['class'], + childList: false, + characterData: false, + }); + + return () => observer.disconnect(); + }, []); return ( Dynamic Ionpage Classnames + @@ -59,7 +53,6 @@ const Page: React.FC = (props) => { both "other-class" AND "ion-page". If only "other-class" appears, the framework classes were incorrectly removed.

- setStyleClass('other-class')}>Add Class

Current classes: {divClasses}

diff --git a/packages/react/src/routing/PageManager.tsx b/packages/react/src/routing/PageManager.tsx index bc093a7b9d..33dfc8210d 100644 --- a/packages/react/src/routing/PageManager.tsx +++ b/packages/react/src/routing/PageManager.tsx @@ -22,7 +22,18 @@ export class PageManager extends React.PureComponent { super(props); this.ionPageElementRef = React.createRef(); // React refs must be stable (not created inline). - this.stableMergedRefs = mergeRefs(this.ionPageElementRef, this.props.forwardedRef); + // Wrap merged refs to add ion-page-invisible synchronously when element is created + const baseMergedRefs = mergeRefs(this.ionPageElementRef, this.props.forwardedRef); + this.stableMergedRefs = (node: HTMLDivElement | null) => { + if (node && !node.classList.contains('ion-page-invisible') && !node.classList.contains('ion-page-hidden')) { + // Add ion-page-invisible synchronously before first paint (if in an outlet) + // This prevents the flash that occurs when componentDidMount runs after paint + if (this.context?.isInOutlet?.()) { + node.classList.add('ion-page-invisible'); + } + } + baseMergedRefs(node); + }; /** * This binds the scope of the following methods to the class scope. @@ -66,16 +77,11 @@ export class PageManager extends React.PureComponent { componentDidMount() { if (this.ionPageElementRef.current) { - // Add user classes via classList to preserve framework-added classes on re-renders - if (this.props.className) { - this.parseClasses(this.props.className).forEach((cls) => { - this.ionPageElementRef.current!.classList.add(cls); - }); - } - - // Note: ion-page-invisible is now added in render() to prevent flash. - // We no longer add it here to avoid race conditions where the browser - // paints the visible element before componentDidMount runs. + // Add user classes via DOM manipulation to preserve framework-added classes. + // We only set "ion-page" in JSX; user classes are added here. + // Note: ion-page-invisible is added in the ref callback (stableMergedRefs) to prevent flash. + // The ref callback runs synchronously when the element is created, before the browser paints. + this.updateUserClasses(undefined, this.props.className); this.context.registerIonPage(this.ionPageElementRef.current, this.props.routeInfo!); this.ionPageElementRef.current.addEventListener('ionViewWillEnter', this.ionViewWillEnterHandler); @@ -126,21 +132,14 @@ export class PageManager extends React.PureComponent { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { className, children, routeInfo, forwardedRef, ...props } = this.props; - /** - * Start with ion-page-invisible when inside an outlet to prevent flash. - * Previously, ion-page-invisible was added in componentDidMount, but the browser - * could paint the visible element before componentDidMount runs, causing a flash. - * The invisible class is removed by the StackManager when the page becomes active. - */ - const isInOutlet = this.context?.isInOutlet?.() ?? false; - const initialClassName = isInOutlet ? 'ion-page ion-page-invisible' : 'ion-page'; - + // Only set "ion-page" in JSX. User classes are managed via DOM in componentDidMount/componentDidUpdate + // to preserve framework-added classes (can-go-back, ion-page-invisible, etc.) when className prop changes. return ( {(context) => { this.ionLifeCycleContext = context; return ( -
+
{children}
);