From 6f498a9da0ad0786856a692f6b0ccf6b2bbd728e Mon Sep 17 00:00:00 2001
From: ShaneK
Date: Tue, 13 Jan 2026 13:41:53 -0800
Subject: [PATCH] chore(tests): fixing issue with dynamic classname test
---
.../src/ReactRouter/StackManager.tsx | 2 +-
.../DynamicIonpageClassnames.tsx | 57 ++++++++-----------
packages/react/src/routing/PageManager.tsx | 41 +++++++------
3 files changed, 46 insertions(+), 54 deletions(-)
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 (
-