From 12f0d5ee049d47f2f4f0f1c84d3b6a177dbca448 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 13 Jan 2026 11:06:57 -0800 Subject: [PATCH] fix(react-router): prevent white flash during tab switches with selectTab --- .../src/ReactRouter/ReactRouterViewStack.tsx | 16 ++-- .../src/ReactRouter/StackManager.tsx | 89 ++++++++++++++----- packages/react/src/routing/PageManager.tsx | 18 +++- 3 files changed, 91 insertions(+), 32 deletions(-) diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index c1fdb8446e..a62d520bcd 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -736,11 +736,17 @@ export class ReactRouterViewStack extends ViewStacks { return true; } - // For wildcard routes (without params), only reuse if the pathname exactly matches - if (isWildcardRoute && isSamePath) { - match = result; - viewItem = v; - return true; + // For pure wildcard routes (without : params), compare pathnameBase to allow + // child path changes while preserving the parent view. This handles container + // routes like /tabs/* where switching between /tabs/tab1 and /tabs/tab2 + // should reuse the same ViewItem. + if (isWildcardRoute && !isParameterRoute) { + const isSameBase = result.pathnameBase === previousMatch?.pathnameBase; + if (isSameBase) { + match = result; + viewItem = v; + return true; + } } } diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 1ddfa6bcc8..d1e286036d 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -308,12 +308,14 @@ export class StackManager extends React.PureComponent { leavingViewItem: ViewItem | undefined, shouldUnmountLeavingViewItem: boolean ): void { - // Handle parameterized route changes (same view, different params) - if (enteringViewItem === leavingViewItem) { - const routePath = enteringViewItem.reactElement?.props?.path as string | undefined; - const isParameterizedRoute = routePath ? routePath.includes(':') : false; + const routePath = enteringViewItem.reactElement?.props?.path as string | undefined; + const isParameterizedRoute = routePath ? routePath.includes(':') : false; + const isWildcardContainerRoute = routePath ? routePath.endsWith('/*') : false; - if (isParameterizedRoute) { + // Handle same-view transitions (parameterized routes like /user/:id or container routes like /tabs/*) + // When entering === leaving, the view is already visible - skip transition to prevent flash + if (enteringViewItem === leavingViewItem) { + if (isParameterizedRoute || isWildcardContainerRoute) { const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true); if (updatedMatch) { enteringViewItem.routeData.match = updatedMatch; @@ -330,6 +332,28 @@ export class StackManager extends React.PureComponent { } } + // For wildcard container routes, check if we're navigating within the same container. + // If both the current pathname and the previous pathname match the same container route, + // skip the transition - the nested outlet will handle the actual page change. + // This handles cases where leavingViewItem lookup fails (e.g., no IonPage wrapper). + if (isWildcardContainerRoute && routeInfo.lastPathname) { + // routePath is guaranteed to exist since isWildcardContainerRoute checks routePath?.endsWith('/*') + const containerBase = routePath!.replace(/\/\*$/, ''); + const currentInContainer = + routeInfo.pathname.startsWith(containerBase + '/') || routeInfo.pathname === containerBase; + const previousInContainer = + routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase; + + if (currentInContainer && previousInContainer) { + const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true); + if (updatedMatch) { + enteringViewItem.routeData.match = updatedMatch; + } + this.forceUpdate(); + return; + } + } + if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) { leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id); } @@ -557,8 +581,10 @@ export class StackManager extends React.PureComponent { return; } - // Hide leaving view while we wait for the entering view's IonPage to mount - hideIonPageElement(leavingViewItem?.ionPageElement); + // Do not hide the leaving view here - wait until the entering view is ready. + // Hiding the leaving view while the entering view is still mounting causes a flash + // where both views are hidden/invisible simultaneously. + // The leaving view will be hidden in transitionPage() after the entering view is visible. this.waitingForIonPage = true; @@ -586,6 +612,16 @@ export class StackManager extends React.PureComponent { this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView); } + this.forceUpdate(); + } else { + /** + * Timeout fired and entering view still has no ionPageElement. + * This happens for container routes that render nested outlets without a direct IonPage. + * Hide the leaving view since there's no entering IonPage to wait for. + */ + if (latestLeavingView?.ionPageElement) { + hideIonPageElement(latestLeavingView.ionPageElement); + } this.forceUpdate(); } }, ION_PAGE_WAIT_TIMEOUT_MS); @@ -782,7 +818,7 @@ export class StackManager extends React.PureComponent { /** * DO NOT remove ion-page-invisible here. * - * PageManager.componentDidMount adds ion-page-invisible before calling registerIonPage. + * PageManager.render() adds ion-page-invisible to prevent flash before componentDidMount. * 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 @@ -997,7 +1033,15 @@ export class StackManager extends React.PureComponent { this.skipTransition = false; } else { enteringEl.classList.add('ion-page'); - enteringEl.classList.add('ion-page-invisible'); + /** + * Only add ion-page-invisible if the element is not already visible. + * During tab switches, the container page (e.g., TabContext wrapper) is + * already visible and should remain so. Adding ion-page-invisible would + * cause a flash where the visible page briefly becomes invisible. + */ + if (!isViewVisible(enteringEl)) { + enteringEl.classList.add('ion-page-invisible'); + } } await routerOutlet.commit(enteringEl, leavingEl, { @@ -1039,29 +1083,28 @@ export class StackManager extends React.PureComponent { if (isNonAnimatedTransition && leavingEl) { /** * Flicker prevention for non-animated transitions: - * 1. Keep entering invisible during commit and component mounting - * 2. Wait for components (including menu button) to be ready - * 3. Swap visibility atomically + * Skip commit() entirely for simple visibility swaps (like tab switches). + * commit() runs animation logic that can cause intermediate paints even with + * duration: 0. Instead, we directly swap visibility classes and wait for + * components to be ready before showing the entering element. */ const enteringEl = enteringViewItem.ionPageElement; + // Ensure entering element has proper base classes enteringEl.classList.add('ion-page'); - if (!enteringEl.classList.contains('ion-page-invisible')) { + // Only add ion-page-invisible if not already visible (e.g., tab switches) + if (!isViewVisible(enteringEl)) { enteringEl.classList.add('ion-page-invisible'); } enteringEl.classList.remove('ion-page-hidden'); enteringEl.removeAttribute('aria-hidden'); - await routerOutlet.commit(enteringEl, undefined, { - duration: 0, - direction: undefined, - showGoBack: !!routeInfo.pushedByRoute, - progressAnimation: false, - animationBuilder: routeInfo.routeAnimation, - }); - - // Re-add invisible after commit removes it (commit's afterTransition handling) - enteringEl.classList.add('ion-page-invisible'); + // Handle can-go-back class since we're skipping commit() which normally sets this + if (routeInfo.pushedByRoute) { + enteringEl.classList.add('can-go-back'); + } else { + enteringEl.classList.remove('can-go-back'); + } /** * Wait for components to be ready. Menu buttons start hidden (menu-button-hidden) diff --git a/packages/react/src/routing/PageManager.tsx b/packages/react/src/routing/PageManager.tsx index ce3517b589..bc093a7b9d 100644 --- a/packages/react/src/routing/PageManager.tsx +++ b/packages/react/src/routing/PageManager.tsx @@ -73,9 +73,10 @@ export class PageManager extends React.PureComponent { }); } - if (this.context.isInOutlet()) { - this.ionPageElementRef.current.classList.add('ion-page-invisible'); - } + // 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. + this.context.registerIonPage(this.ionPageElementRef.current, this.props.routeInfo!); this.ionPageElementRef.current.addEventListener('ionViewWillEnter', this.ionViewWillEnterHandler); this.ionPageElementRef.current.addEventListener('ionViewDidEnter', this.ionViewDidEnterHandler); @@ -125,12 +126,21 @@ 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'; + return ( {(context) => { this.ionLifeCycleContext = context; return ( -
+
{children}
);