From 4cce4f8fdcdad675d40ef6a3d240631d61725118 Mon Sep 17 00:00:00 2001 From: Djoume Salvetti Date: Fri, 2 Oct 2026 18:40:14 -0400 Subject: [PATCH] fix(react-router): show pages swapped into one view An outlet whose single route renders a plain keeps one view item and swaps a new IonPage into it on every navigation. Each new page mounts with ion-page-invisible, but the duplicate-transition guard only compared view ids, so from the second root navigation (or the second push under a bare "*" route) the transition was skipped and the page stayed invisible. Compare the entering IonPage element as well, so a newly mounted page gets its transition, reveal and lifecycle events. Co-Authored-By: Claude Opus 5.5 --- .../src/ReactRouter/StackManager.tsx | 8 +- packages/react-router/test/base/src/index.tsx | 19 ++++- .../react-router/test/base/src/ionic-setup.ts | 2 +- .../react-router/test/base/src/pages/Main.tsx | 4 + .../root-single-view/RootSingleViewApp.tsx | 74 +++++++++++++++++++ .../base/src/root-single-view/basename.ts | 8 ++ .../e2e/playwright/root-single-view.spec.ts | 59 +++++++++++++++ 7 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 packages/react-router/test/base/src/root-single-view/RootSingleViewApp.tsx create mode 100644 packages/react-router/test/base/src/root-single-view/basename.ts create mode 100644 packages/react-router/test/base/tests/e2e/playwright/root-single-view.spec.ts diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6848c4941bb..1165fd6a317 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -156,7 +156,7 @@ export class StackManager extends React.PureComponent { * Track the last transition's entering and leaving view IDs to prevent * duplicate transitions during rapid navigation (e.g., Navigate redirects) */ - private lastTransition?: { enteringId: string; leavingId?: string }; + private lastTransition?: { enteringId: string; leavingId?: string; enteringEl?: HTMLElement }; /** * Views that have been explicitly kept alive by the pop-preserve logic * (shouldPreserveLeavingView) so a future forward-pop can restore their React @@ -582,14 +582,18 @@ export class StackManager extends React.PureComponent { const currentTransition = { enteringId: enteringViewItem.id, leavingId: leavingViewItem?.id, + enteringEl: enteringViewItem.ionPageElement, }; + // A view can swap in a new IonPage while keeping its id (e.g. a splat route rendering + // ), so compare the element too or the new page is skipped and stays invisible. const isDuplicateTransition = leavingViewItem && this.lastTransition && this.lastTransition.leavingId && this.lastTransition.enteringId === currentTransition.enteringId && - this.lastTransition.leavingId === currentTransition.leavingId; + this.lastTransition.leavingId === currentTransition.leavingId && + this.lastTransition.enteringEl === currentTransition.enteringEl; // Skip if transition already performed (e.g., via swipe gesture) if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) { diff --git a/packages/react-router/test/base/src/index.tsx b/packages/react-router/test/base/src/index.tsx index 3c49e97c7a9..cd256c625d3 100644 --- a/packages/react-router/test/base/src/index.tsx +++ b/packages/react-router/test/base/src/index.tsx @@ -2,17 +2,28 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; +import RootSingleViewApp from './root-single-view/RootSingleViewApp'; +import { ROOT_SINGLE_VIEW_BASENAME } from './root-single-view/basename'; import RootSplatSiblingApp from './root-splat-sibling/RootSplatSiblingApp'; import { ROOT_SPLAT_SIBLING_BASENAME } from './root-splat-sibling/basename'; /** * A root-level splat route swallows every pathname in its outlet, so it can't share App's - * route tree. It gets its own root, picked here by pathname before anything renders. + * route tree. Each such app gets its own root, picked here by pathname before anything renders. */ const { pathname } = window.location; -const isRootSplatSibling = - pathname === ROOT_SPLAT_SIBLING_BASENAME || pathname.startsWith(`${ROOT_SPLAT_SIBLING_BASENAME}/`); +const isUnder = (basename: string) => pathname === basename || pathname.startsWith(`${basename}/`); + +const renderApp = () => { + if (isUnder(ROOT_SPLAT_SIBLING_BASENAME)) { + return ; + } + if (isUnder(ROOT_SINGLE_VIEW_BASENAME)) { + return ; + } + return ; +}; const container = document.getElementById('root'); const root = createRoot(container!); -root.render({isRootSplatSibling ? : }); +root.render({renderApp()}); diff --git a/packages/react-router/test/base/src/ionic-setup.ts b/packages/react-router/test/base/src/ionic-setup.ts index bd9f4918b32..ec157a3c834 100644 --- a/packages/react-router/test/base/src/ionic-setup.ts +++ b/packages/react-router/test/base/src/ionic-setup.ts @@ -1,5 +1,5 @@ /** - * Ionic CSS and runtime config. Both App and RootSplatSiblingApp import this because + * Ionic CSS and runtime config. App and each separately rooted app import this because * App.test.tsx renders App with no index.tsx in the graph. * * Debug logging is on so a failing spec includes the navigation diagnostics. diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 3dc6709f531..f3b10c1dbd4 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -10,6 +10,7 @@ import { IonLabel, } from '@ionic/react'; import React from 'react'; +import { ROOT_SINGLE_VIEW_BASENAME } from '../root-single-view/basename'; import { ROOT_SPLAT_SIBLING_BASENAME } from '../root-splat-sibling/basename'; const Main: React.FC = () => { @@ -167,6 +168,9 @@ const Main: React.FC = () => { Root Splat Sibling + + Root Single View + diff --git a/packages/react-router/test/base/src/root-single-view/RootSingleViewApp.tsx b/packages/react-router/test/base/src/root-single-view/RootSingleViewApp.tsx new file mode 100644 index 00000000000..5981e9a2552 --- /dev/null +++ b/packages/react-router/test/base/src/root-single-view/RootSingleViewApp.tsx @@ -0,0 +1,74 @@ +import { IonApp, IonButton, IonContent, IonHeader, IonPage, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/react'; +import { IonReactRouter } from '@ionic/react-router'; +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; + +/* Ionic CSS and setupIonicReact */ +import '../ionic-setup'; +import { ROOT_SINGLE_VIEW_BASENAME } from './basename'; +import TestDescription from '../components/TestDescription'; + +/** + * The whole route table mounted as one Ionic view: the outlet holds a single splat route + * whose element is a plain , so every navigation swaps the IonPage inside the same + * view item. + * + * A root catch-all swallows every pathname in its outlet, so this app gets its own basename + * and index.tsx mounts it instead of App for that prefix. + */ + +/** + * "?splat=bare" runs the same flows against a bare "*" instead of "/*". Stamped onto each + * page as data-splat so a test can confirm which spelling is live. + */ +const splatPath = new URLSearchParams(window.location.search).get('splat') === 'bare' ? '*' : '/*'; + +const pages = ['a', 'b', 'c']; + +const SingleViewPage: React.FC<{ name: string }> = ({ name }) => ( + + + + Page {name.toUpperCase()} + + + + {pages.map((target) => ( + + + Root to {target.toUpperCase()} + + + Push to {target.toUpperCase()} + + + ))} + + Every page here lives in the same Ionic view. Each button should leave its target page on screen, never a blank + page. + + + +); + +const RootSingleViewApp: React.FC = () => ( + + + + + {/* Keyed so each route mounts its own IonPage, as distinct page components would. */} + {pages.map((name) => ( + } /> + ))} + + } + /> + + + +); + +export default RootSingleViewApp; diff --git a/packages/react-router/test/base/src/root-single-view/basename.ts b/packages/react-router/test/base/src/root-single-view/basename.ts new file mode 100644 index 00000000000..dff6544e688 --- /dev/null +++ b/packages/react-router/test/base/src/root-single-view/basename.ts @@ -0,0 +1,8 @@ +/** + * The prefix the root single view app is served under, appended to Vite's base so + * previews keep working. + * + * Kept apart from the app itself so Main.tsx can link to it without pulling a second + * IonReactRouter into App's module graph. + */ +export const ROOT_SINGLE_VIEW_BASENAME = `${import.meta.env?.BASE_URL?.replace(/\/$/, '') || ''}/root-single-view`; diff --git a/packages/react-router/test/base/tests/e2e/playwright/root-single-view.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/root-single-view.spec.ts new file mode 100644 index 00000000000..e7c8bcf00db --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/root-single-view.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { ionPageVisible, withTestingMode } from './utils/test-utils'; + +/** + * An app can mount its whole route table as one Ionic view: a single root splat route whose + * element is a plain . Every navigation then swaps the IonPage inside the same view + * item, and each new IonPage mounts invisible until the outlet reveals it. + * + * https://github.com/ionic-team/ionic-framework/issues/31525 + */ + +const pageButton = (page: Page, from: string, buttonId: string) => + page.locator(`div.ion-page[data-pageid="root-single-view-${from}"] #${buttonId}`); + +test.describe('root single view', () => { + test('reveals the page after successive root navigations', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31525', + }); + + await page.goto(withTestingMode('/root-single-view/a')); + await ionPageVisible(page, 'root-single-view-a'); + await expect(page.locator('div.ion-page[data-pageid="root-single-view-a"]')).toHaveAttribute('data-splat', '/*'); + + // The first root navigation used to work; every one after it left the page invisible. + await pageButton(page, 'a', 'root-to-b').click(); + await ionPageVisible(page, 'root-single-view-b'); + + await pageButton(page, 'b', 'root-to-c').click(); + await ionPageVisible(page, 'root-single-view-c'); + + await pageButton(page, 'c', 'root-to-a').click(); + await ionPageVisible(page, 'root-single-view-a'); + }); + + // A bare "*" is neither parameterized nor a "/*" container, so even plain pushes broke. + test('reveals the page after successive pushes with a bare "*"', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31525', + }); + + await page.goto(withTestingMode('/root-single-view/a?splat=bare')); + await ionPageVisible(page, 'root-single-view-a'); + // The query param is the only thing selecting the bare spelling, and navigation drops it. + await expect(page.locator('div.ion-page[data-pageid="root-single-view-a"]')).toHaveAttribute('data-splat', '*'); + + await pageButton(page, 'a', 'push-to-b').click(); + await ionPageVisible(page, 'root-single-view-b'); + + await pageButton(page, 'b', 'push-to-c').click(); + await ionPageVisible(page, 'root-single-view-c'); + + await pageButton(page, 'c', 'push-to-a').click(); + await ionPageVisible(page, 'root-single-view-a'); + }); +});