Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
* 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
Expand Down Expand Up @@ -582,14 +582,18 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
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
// <Routes>), 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) {
Expand Down
19 changes: 15 additions & 4 deletions packages/react-router/test/base/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RootSplatSiblingApp />;
}
if (isUnder(ROOT_SINGLE_VIEW_BASENAME)) {
return <RootSingleViewApp />;
}
return <App />;
};

const container = document.getElementById('root');
const root = createRoot(container!);
root.render(<React.StrictMode>{isRootSplatSibling ? <RootSplatSiblingApp /> : <App />}</React.StrictMode>);
root.render(<React.StrictMode>{renderApp()}</React.StrictMode>);
2 changes: 1 addition & 1 deletion packages/react-router/test/base/src/ionic-setup.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -167,6 +168,9 @@ const Main: React.FC = () => {
<IonItem href={`${ROOT_SPLAT_SIBLING_BASENAME}/feed`}>
<IonLabel>Root Splat Sibling</IonLabel>
</IonItem>
<IonItem href={`${ROOT_SINGLE_VIEW_BASENAME}/a`}>
<IonLabel>Root Single View</IonLabel>
</IonItem>
</IonList>

<IonList>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Routes>, 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 }) => (
<IonPage data-pageid={`root-single-view-${name}`} data-splat={splatPath}>
<IonHeader>
<IonToolbar>
<IonTitle>Page {name.toUpperCase()}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
{pages.map((target) => (
<React.Fragment key={target}>
<IonButton id={`root-to-${target}`} routerLink={`/${target}`} routerDirection="root">
Root to {target.toUpperCase()}
</IonButton>
<IonButton id={`push-to-${target}`} routerLink={`/${target}`}>
Push to {target.toUpperCase()}
</IonButton>
</React.Fragment>
))}
<TestDescription>
Every page here lives in the same Ionic view. Each button should leave its target page on screen, never a blank
page.
</TestDescription>
</IonContent>
</IonPage>
);

const RootSingleViewApp: React.FC = () => (
<IonApp>
<IonReactRouter basename={ROOT_SINGLE_VIEW_BASENAME}>
<IonRouterOutlet id="root-single-view-outlet">
<Route
path={splatPath}
element={
<Routes>
{/* Keyed so each route mounts its own IonPage, as distinct page components would. */}
{pages.map((name) => (
<Route key={name} path={name} element={<SingleViewPage key={name} name={name} />} />
))}
</Routes>
}
/>
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
);

export default RootSingleViewApp;
Original file line number Diff line number Diff line change
@@ -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`;
Original file line number Diff line number Diff line change
@@ -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 <Routes>. 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');
});
});
Loading