@@ -28,18 +28,20 @@ import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './ut
2828const VIEW_UNMOUNT_DELAY_MS = 250 ;
2929
3030/**
31- * Delay in milliseconds to wait for an IonPage element to be mounted before
32- * proceeding with a page transition.
31+ * Delay (ms) to wait for an IonPage to mount before proceeding with a
32+ * page transition. Only container routes (nested outlets with no direct
33+ * IonPage) actually hit this timeout; normal routes clear it early via
34+ * registerIonPage, so a larger value here doesn't affect the happy path.
3335 */
34- const ION_PAGE_WAIT_TIMEOUT_MS = 50 ;
36+ const ION_PAGE_WAIT_TIMEOUT_MS = 300 ;
3537
3638interface StackManagerProps {
3739 routeInfo : RouteInfo ;
3840 id ?: string ;
3941}
4042
4143const isViewVisible = ( el : HTMLElement ) =>
42- ! el . classList . contains ( 'ion-page-invisible' ) && ! el . classList . contains ( 'ion-page-hidden' ) && el . style . display !== 'none ' ;
44+ ! el . classList . contains ( 'ion-page-invisible' ) && ! el . classList . contains ( 'ion-page-hidden' ) && el . style . visibility !== 'hidden ' ;
4345
4446const hideIonPageElement = ( element : HTMLElement | undefined ) : void => {
4547 if ( element ) {
@@ -50,7 +52,7 @@ const hideIonPageElement = (element: HTMLElement | undefined): void => {
5052
5153const showIonPageElement = ( element : HTMLElement | undefined ) : void => {
5254 if ( element ) {
53- element . style . removeProperty ( 'display ' ) ;
55+ element . style . removeProperty ( 'visibility ' ) ;
5456 element . classList . remove ( 'ion-page-hidden' ) ;
5557 element . removeAttribute ( 'aria-hidden' ) ;
5658 }
@@ -586,10 +588,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
586588 * nested scrollbars (each page has its own IonContent). Top-level outlets
587589 * are unaffected and animate normally.
588590 *
589- * Uses inline display:none rather than ion-page-hidden class because core's
590- * beforeTransition() removes ion-page-hidden via setPageHidden().
591- * Inline display:none survives that removal, keeping the page hidden
592- * until React unmounts it after ionViewDidLeave fires.
591+ * Uses inline visibility:hidden rather than ion-page-hidden class because
592+ * core's beforeTransition() removes ion-page-hidden via setPageHidden().
593+ * Inline visibility:hidden survives that removal, keeping the page hidden
594+ * until React unmounts it after ionViewDidLeave fires. Unlike display:none,
595+ * visibility:hidden preserves element geometry so commit() animations
596+ * can resolve normally.
593597 */
594598 private applySkipAnimationIfNeeded (
595599 enteringViewItem : ViewItem ,
@@ -599,7 +603,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
599603 const shouldSkip = isNestedOutlet && ! ! leavingViewItem && enteringViewItem !== leavingViewItem ;
600604
601605 if ( shouldSkip && leavingViewItem ?. ionPageElement ) {
602- leavingViewItem . ionPageElement . style . setProperty ( 'display ' , 'none ' ) ;
606+ leavingViewItem . ionPageElement . style . setProperty ( 'visibility ' , 'hidden ' ) ;
603607 leavingViewItem . ionPageElement . setAttribute ( 'aria-hidden' , 'true' ) ;
604608 }
605609
@@ -693,6 +697,20 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
693697 }
694698 } ) ;
695699 this . forceUpdate ( ) ;
700+
701+ // Safety net: after forceUpdate triggers a React render cycle, check if
702+ // any pages in this outlet are stuck with ion-page-invisible. This can
703+ // happen when view lookup fails (e.g., wildcard-to-index transitions
704+ // where the view item gets corrupted). The forceUpdate above causes
705+ // React to render the correct component, but ion-page-invisible may
706+ // persist if no transition runs for that page.
707+ setTimeout ( ( ) => {
708+ if ( ! this . _isMounted || ! this . routerOutletElement ) return ;
709+ const stuckPages = this . routerOutletElement . querySelectorAll ( ':scope > .ion-page-invisible' ) ;
710+ stuckPages . forEach ( ( page ) => {
711+ page . classList . remove ( 'ion-page-invisible' ) ;
712+ } ) ;
713+ } , ION_PAGE_WAIT_TIMEOUT_MS ) ;
696714 }
697715 } , ION_PAGE_WAIT_TIMEOUT_MS ) ;
698716
@@ -1145,13 +1163,29 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11451163 }
11461164 }
11471165
1148- await routerOutlet . commit ( enteringEl , leavingEl , {
1149- duration : skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined ,
1166+ const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined ;
1167+
1168+ // Race commit against a timeout to recover from hangs
1169+ const commitPromise = routerOutlet . commit ( enteringEl , leavingEl , {
1170+ duration : commitDuration ,
11501171 direction : directionToUse ,
11511172 showGoBack : ! ! routeInfo . pushedByRoute ,
11521173 progressAnimation,
11531174 animationBuilder : routeInfo . routeAnimation ,
11541175 } ) ;
1176+
1177+ const timeoutMs = 5000 ;
1178+ const timeoutPromise = new Promise < 'timeout' > ( ( resolve ) => setTimeout ( ( ) => resolve ( 'timeout' ) , timeoutMs ) ) ;
1179+ const result = await Promise . race ( [ commitPromise . then ( ( ) => 'done' as const ) , timeoutPromise ] ) ;
1180+
1181+ if ( result === 'timeout' ) {
1182+ // Force entering page visible even though commit hung
1183+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1184+ }
1185+
1186+ if ( ! progressAnimation ) {
1187+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1188+ }
11551189 } ;
11561190
11571191 const routerOutlet = this . routerOutletElement ! ;
@@ -1183,22 +1217,23 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11831217
11841218 if ( isNonAnimatedTransition && leavingEl ) {
11851219 /**
1186- * Flicker prevention for non-animated transitions:
1187- * Skip commit() entirely for simple visibility swaps (like tab switches).
1188- * commit() runs animation logic that can cause intermediate paints even with
1189- * duration: 0. Instead, we directly swap visibility classes and wait for
1190- * components to be ready before showing the entering element.
1220+ * Skip commit() for non-animated transitions (like tab switches).
1221+ * commit() runs animation logic that can cause intermediate paints
1222+ * even with duration: 0. Instead, swap visibility synchronously.
1223+ *
1224+ * Synchronous DOM class changes are batched into a single browser
1225+ * paint, so there's no gap frame where neither page is visible and
1226+ * no overlap frame where both pages are visible.
11911227 */
11921228 const enteringEl = enteringViewItem . ionPageElement ;
11931229
11941230 // Ensure entering element has proper base classes
11951231 enteringEl . classList . add ( 'ion-page' ) ;
1196- // Only add ion-page-invisible if not already visible (e.g., tab switches)
1197- if ( ! isViewVisible ( enteringEl ) ) {
1198- enteringEl . classList . add ( 'ion-page-invisible' ) ;
1199- }
1200- enteringEl . classList . remove ( 'ion-page-hidden' ) ;
1201- enteringEl . removeAttribute ( 'aria-hidden' ) ;
1232+
1233+ // Clear ALL hidden state from entering element. showIonPageElement
1234+ // removes visibility:hidden (from applySkipAnimationIfNeeded),
1235+ // ion-page-hidden, and aria-hidden in one call.
1236+ showIonPageElement ( enteringEl ) ;
12021237
12031238 // Handle can-go-back class since we're skipping commit() which normally sets this
12041239 if ( routeInfo . pushedByRoute ) {
@@ -1274,31 +1309,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
12741309 // Bail out if the component unmounted during waitForComponentsReady
12751310 if ( ! this . _isMounted ) return ;
12761311
1277- // Swap visibility in sync with browser's render cycle
1278- await new Promise < void > ( ( resolve ) => {
1279- const outerRafId = requestAnimationFrame ( ( ) => {
1280- this . transitionRafIds = this . transitionRafIds . filter ( ( id ) => id !== outerRafId ) ;
1281- if ( ! this . _isMounted ) {
1282- resolve ( ) ;
1283- return ;
1284- }
1285- enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1286- // Second rAF ensures entering is painted before hiding leaving
1287- const innerRafId = requestAnimationFrame ( ( ) => {
1288- this . transitionRafIds = this . transitionRafIds . filter ( ( id ) => id !== innerRafId ) ;
1289- if ( this . _isMounted ) {
1290- leavingEl . classList . add ( 'ion-page-hidden' ) ;
1291- leavingEl . setAttribute ( 'aria-hidden' , 'true' ) ;
1292- }
1293- resolve ( ) ;
1294- } ) ;
1295- this . transitionRafIds . push ( innerRafId ) ;
1296- } ) ;
1297- this . transitionRafIds . push ( outerRafId ) ;
1298- } ) ;
1312+ // Swap visibility synchronously - show entering, hide leaving
1313+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1314+ leavingEl . classList . add ( 'ion-page-hidden' ) ;
1315+ leavingEl . setAttribute ( 'aria-hidden' , 'true' ) ;
12991316 } else {
13001317 await runCommit ( enteringViewItem . ionPageElement , leavingEl ) ;
1301- // For animated transitions, hide leaving element after commit completes
13021318 if ( leavingEl && ! progressAnimation ) {
13031319 leavingEl . classList . add ( 'ion-page-hidden' ) ;
13041320 leavingEl . setAttribute ( 'aria-hidden' , 'true' ) ;
0 commit comments