@@ -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 }
@@ -446,6 +448,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
446448
447449 const shouldSkipAnimation = this . applySkipAnimationIfNeeded ( enteringViewItem , leavingViewItem ) ;
448450
451+ console . log ( `[handleReadyEnteringView] outletId=${ this . id } pathname=${ routeInfo . pathname } lastPathname=${ routeInfo . lastPathname } action=${ routeInfo . routeAction } direction=${ routeInfo . routeDirection } skipAnimation=${ shouldSkipAnimation } entering=${ enteringViewItem . ionPageElement ?. getAttribute ( 'data-pageid' ) } enteringClasses=${ enteringViewItem . ionPageElement ?. className } leaving=${ leavingViewItem ?. ionPageElement ?. getAttribute ( 'data-pageid' ) } leavingClasses=${ leavingViewItem ?. ionPageElement ?. className } leavingVisibility=${ leavingViewItem ?. ionPageElement ?. style . visibility } shouldUnmount=${ shouldUnmountLeavingViewItem } ` ) ;
452+
449453 this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , undefined , false , shouldSkipAnimation ) ;
450454
451455 if ( shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem ) {
@@ -586,10 +590,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
586590 * nested scrollbars (each page has its own IonContent). Top-level outlets
587591 * are unaffected and animate normally.
588592 *
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.
593+ * Uses inline visibility:hidden rather than ion-page-hidden class because
594+ * core's beforeTransition() removes ion-page-hidden via setPageHidden().
595+ * Inline visibility:hidden survives that removal, keeping the page hidden
596+ * until React unmounts it after ionViewDidLeave fires. Unlike display:none,
597+ * visibility:hidden preserves element geometry so commit() animations
598+ * can resolve normally.
593599 */
594600 private applySkipAnimationIfNeeded (
595601 enteringViewItem : ViewItem ,
@@ -599,7 +605,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
599605 const shouldSkip = isNestedOutlet && ! ! leavingViewItem && enteringViewItem !== leavingViewItem ;
600606
601607 if ( shouldSkip && leavingViewItem ?. ionPageElement ) {
602- leavingViewItem . ionPageElement . style . setProperty ( 'display' , 'none' ) ;
608+ console . log ( `[applySkipAnimation] hiding leaving=${ leavingViewItem . ionPageElement . getAttribute ( 'data-pageid' ) } via visibility:hidden, entering=${ enteringViewItem . ionPageElement ?. getAttribute ( 'data-pageid' ) } ` ) ;
609+ leavingViewItem . ionPageElement . style . setProperty ( 'visibility' , 'hidden' ) ;
603610 leavingViewItem . ionPageElement . setAttribute ( 'aria-hidden' , 'true' ) ;
604611 }
605612
@@ -660,13 +667,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
660667 this . ionPageWaitTimeout = undefined ;
661668
662669 if ( ! this . waitingForIonPage ) {
670+ console . log ( `[handleWaitingForIonPage] timeout fired but no longer waiting, outletId=${ this . id } ` ) ;
663671 return ;
664672 }
665673 this . waitingForIonPage = false ;
666674
667675 const latestEnteringView = this . context . findViewItemByRouteInfo ( routeInfo , this . id ) ?? enteringViewItem ;
668676 const latestLeavingView = this . context . findLeavingViewItemByRouteInfo ( routeInfo , this . id ) ?? leavingViewItem ;
669677
678+ console . log ( `[handleWaitingForIonPage] timeout fired outletId=${ this . id } pathname=${ routeInfo . pathname } hasIonPageEl=${ ! ! latestEnteringView ?. ionPageElement } entering=${ latestEnteringView ?. ionPageElement ?. getAttribute ( 'data-pageid' ) } leaving=${ latestLeavingView ?. ionPageElement ?. getAttribute ( 'data-pageid' ) } ` ) ;
679+
670680 if ( latestEnteringView ?. ionPageElement ) {
671681 const shouldSkipAnimation = this . applySkipAnimationIfNeeded ( latestEnteringView , latestLeavingView ?? undefined ) ;
672682 this . transitionPage ( routeInfo , latestEnteringView , latestLeavingView ?? undefined , undefined , false , shouldSkipAnimation ) ;
@@ -693,6 +703,21 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
693703 }
694704 } ) ;
695705 this . forceUpdate ( ) ;
706+
707+ // Safety net: after forceUpdate triggers a React render cycle, check if
708+ // any pages in this outlet are stuck with ion-page-invisible. This can
709+ // happen when view lookup fails (e.g., wildcard-to-index transitions
710+ // where the view item gets corrupted). The forceUpdate above causes
711+ // React to render the correct component, but ion-page-invisible may
712+ // persist if no transition runs for that page.
713+ setTimeout ( ( ) => {
714+ if ( ! this . _isMounted || ! this . routerOutletElement ) return ;
715+ const stuckPages = this . routerOutletElement . querySelectorAll ( ':scope > .ion-page-invisible' ) ;
716+ stuckPages . forEach ( ( page ) => {
717+ console . log ( `[handleWaitingForIonPage] clearing stuck ion-page-invisible on ${ page . getAttribute ( 'data-pageid' ) } ` ) ;
718+ page . classList . remove ( 'ion-page-invisible' ) ;
719+ } ) ;
720+ } , ION_PAGE_WAIT_TIMEOUT_MS ) ;
696721 }
697722 } , ION_PAGE_WAIT_TIMEOUT_MS ) ;
698723
@@ -865,8 +890,10 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
865890 this . ionPageWaitTimeout = undefined ;
866891 }
867892
893+ console . log ( `[handlePageTransition] READY outletId=${ this . id } pathname=${ routeInfo . pathname } entering=${ enteringViewItem . ionPageElement ?. getAttribute ( 'data-pageid' ) } leaving=${ leavingViewItem ?. ionPageElement ?. getAttribute ( 'data-pageid' ) } ionPageInDoc=${ ionPageIsInDocument } ` ) ;
868894 this . handleReadyEnteringView ( routeInfo , enteringViewItem , leavingViewItem , shouldUnmountLeavingViewItem ) ;
869895 } else if ( enteringViewItem && ! ionPageIsInDocument ) {
896+ console . log ( `[handlePageTransition] WAITING outletId=${ this . id } pathname=${ routeInfo . pathname } enteringHasEl=${ ! ! enteringViewItem . ionPageElement } ionPageInDoc=${ ionPageIsInDocument } ` ) ;
870897 // Wait for ion-page to mount
871898 // This handles both: no ionPageElement, or stale ionPageElement (not in document)
872899 // Clear stale reference if the element is no longer in the document
@@ -961,6 +988,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
961988 return ;
962989 }
963990 }
991+ console . log ( `[registerIonPage] outletId=${ this . id } page=${ page . getAttribute ( 'data-pageid' ) } pageClasses="${ page . className } " pathname=${ routeInfo . pathname } ` ) ;
964992 this . handlePageTransition ( routeInfo ) ;
965993 }
966994
@@ -1145,13 +1173,35 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11451173 }
11461174 }
11471175
1148- await routerOutlet . commit ( enteringEl , leavingEl , {
1149- duration : skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined ,
1176+ const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined ;
1177+ console . log ( `[runCommit] BEFORE commit entering=${ enteringEl . getAttribute ( 'data-pageid' ) } enteringClasses="${ enteringEl . className } " leaving=${ leavingEl ?. getAttribute ( 'data-pageid' ) } leavingClasses="${ leavingEl ?. className } " leavingDisplay="${ leavingEl ?. style . display } " leavingVisibility="${ leavingEl ?. style . visibility } " duration=${ commitDuration } direction=${ directionToUse } skipTransition=${ skipTransition } skipAnimation=${ skipAnimation } ` ) ;
1178+ const commitStart = Date . now ( ) ;
1179+
1180+ // Race commit against a timeout to detect hangs
1181+ const commitPromise = routerOutlet . commit ( enteringEl , leavingEl , {
1182+ duration : commitDuration ,
11501183 direction : directionToUse ,
11511184 showGoBack : ! ! routeInfo . pushedByRoute ,
11521185 progressAnimation,
11531186 animationBuilder : routeInfo . routeAnimation ,
11541187 } ) ;
1188+
1189+ const timeoutMs = 5000 ;
1190+ const timeoutPromise = new Promise < 'timeout' > ( ( resolve ) => setTimeout ( ( ) => resolve ( 'timeout' ) , timeoutMs ) ) ;
1191+ const result = await Promise . race ( [ commitPromise . then ( ( ) => 'done' as const ) , timeoutPromise ] ) ;
1192+
1193+ if ( result === 'timeout' ) {
1194+ console . error ( `[runCommit] TIMEOUT commit hung for ${ timeoutMs } ms! entering=${ enteringEl . getAttribute ( 'data-pageid' ) } enteringClasses="${ enteringEl . className } " leaving=${ leavingEl ?. getAttribute ( 'data-pageid' ) } leavingClasses="${ leavingEl ?. className } " leavingDisplay="${ leavingEl ?. style . display } " leavingVisibility="${ leavingEl ?. style . visibility } "` ) ;
1195+ // Force entering page visible even though commit hung
1196+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1197+ console . log ( `[runCommit] forced ion-page-invisible removal on ${ enteringEl . getAttribute ( 'data-pageid' ) } , classes now: "${ enteringEl . className } "` ) ;
1198+ } else {
1199+ console . log ( `[runCommit] AFTER commit resolved in ${ Date . now ( ) - commitStart } ms entering=${ enteringEl . getAttribute ( 'data-pageid' ) } enteringClasses="${ enteringEl . className } "` ) ;
1200+ }
1201+
1202+ if ( ! progressAnimation ) {
1203+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1204+ }
11551205 } ;
11561206
11571207 const routerOutlet = this . routerOutletElement ! ;
@@ -1181,24 +1231,27 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11811231 // flicker caused by commit() briefly unhiding the leaving page
11821232 const isNonAnimatedTransition = directionToUse === undefined && ! progressAnimation ;
11831233
1234+ console . log ( `[transitionPage] outletId=${ this . id } directionToUse=${ directionToUse } isNonAnimatedTransition=${ isNonAnimatedTransition } skipAnimation=${ skipAnimation } hasLeavingEl=${ ! ! leavingEl } entering=${ enteringViewItem . ionPageElement ?. getAttribute ( 'data-pageid' ) } leaving=${ leavingEl ?. getAttribute ( 'data-pageid' ) } ` ) ;
1235+
11841236 if ( isNonAnimatedTransition && leavingEl ) {
11851237 /**
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.
1238+ * Skip commit() for non-animated transitions (like tab switches).
1239+ * commit() runs animation logic that can cause intermediate paints
1240+ * even with duration: 0. Instead, swap visibility synchronously.
1241+ *
1242+ * Synchronous DOM class changes are batched into a single browser
1243+ * paint, so there's no gap frame where neither page is visible and
1244+ * no overlap frame where both pages are visible.
11911245 */
11921246 const enteringEl = enteringViewItem . ionPageElement ;
11931247
11941248 // Ensure entering element has proper base classes
11951249 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' ) ;
1250+
1251+ // Clear ALL hidden state from entering element. showIonPageElement
1252+ // removes visibility:hidden (from applySkipAnimationIfNeeded),
1253+ // ion-page-hidden, and aria-hidden in one call.
1254+ showIonPageElement ( enteringEl ) ;
12021255
12031256 // Handle can-go-back class since we're skipping commit() which normally sets this
12041257 if ( routeInfo . pushedByRoute ) {
@@ -1274,31 +1327,13 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
12741327 // Bail out if the component unmounted during waitForComponentsReady
12751328 if ( ! this . _isMounted ) return ;
12761329
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- } ) ;
1330+ // Swap visibility synchronously - show entering, hide leaving
1331+ enteringEl . classList . remove ( 'ion-page-invisible' ) ;
1332+ leavingEl . classList . add ( 'ion-page-hidden' ) ;
1333+ leavingEl . setAttribute ( 'aria-hidden' , 'true' ) ;
12991334 } else {
1335+ console . log ( `[transitionPage] taking commit() path for entering=${ enteringViewItem . ionPageElement ?. getAttribute ( 'data-pageid' ) } leaving=${ leavingEl ?. getAttribute ( 'data-pageid' ) } ` ) ;
13001336 await runCommit ( enteringViewItem . ionPageElement , leavingEl ) ;
1301- // For animated transitions, hide leaving element after commit completes
13021337 if ( leavingEl && ! progressAnimation ) {
13031338 leavingEl . classList . add ( 'ion-page-hidden' ) ;
13041339 leavingEl . setAttribute ( 'aria-hidden' , 'true' ) ;
0 commit comments