Skip to content
Closed
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
15 changes: 8 additions & 7 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1417,22 +1417,22 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const { routeInfo } = this.props;
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);

// View might have mount=false but ionPageElement still in DOM
const ionPageInDocument = Boolean(
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
);

// For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
// never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
// alone isn't sufficient. Also, verify the entering view's resolved pathname
// differs from the current pathname — if they match, the entering and leaving
// views are the same and the swipe gesture shouldn't start.
// A splat consumes whatever is left of the pathname, so its match resolves to the whole
// current pathname and comparing pathnames can't tell a container page underneath a
// pushed sibling from the page being left. Compare the view items instead, like onEnd
// does. Without a leaving view onStart skips the transition, so reject that here too.
const canStartSwipe =
!!enteringViewItem &&
!!leavingViewItem &&
(enteringViewItem.mount || ionPageInDocument) &&
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
enteringViewItem !== leavingViewItem;

debug('SwipeBackCanStart', () => ({
outletId: this.id,
Expand All @@ -1442,6 +1442,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
ionPageInDocument,
leavingViewId: leavingViewItem?.id,
canStartSwipe,
}));

Expand Down
30 changes: 7 additions & 23 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
import { IonApp, setupIonicReact, LogLevel, IonRouterOutlet } from '@ionic/react';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import React from 'react';
import { Route, Navigate } from 'react-router-dom';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/display.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/padding.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';

/* Theme variables */
import './theme/variables.css';
/* Ionic CSS and setupIonicReact */
import './ionic-setup';
import Main from './pages/Main';

import { IonReactRouter } from '@ionic/react-router';
Expand Down Expand Up @@ -66,17 +50,17 @@ import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams
import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack';
import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick';
import {
RouterLinkModifierClick,
RouterLinkModifierClickTarget,
} from './pages/router-link-modifier-click/RouterLinkModifierClick';
import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot';
import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet';
import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate';
import DisabledButton from './pages/disabled-button/DisabledButton';
import SplatSibling from './pages/splat-sibling/SplatSibling';
import { EmptyPathSibling, IndexSibling } from './pages/index-sibling/IndexSibling';

// Debug logs on so failing specs include the navigation diagnostics.
setupIonicReact({ logLevel: LogLevel.DEBUG });

const App: React.FC = () => {
return (
<IonApp>
Expand Down
16 changes: 11 additions & 5 deletions packages/react-router/test/base/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import React from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';
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.
*/
const { pathname } = window.location;
const isRootSplatSibling =
pathname === ROOT_SPLAT_SIBLING_BASENAME || pathname.startsWith(`${ROOT_SPLAT_SIBLING_BASENAME}/`);

const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
root.render(<React.StrictMode>{isRootSplatSibling ? <RootSplatSiblingApp /> : <App />}</React.StrictMode>);
28 changes: 28 additions & 0 deletions packages/react-router/test/base/src/ionic-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Ionic CSS and runtime config. Both App and RootSplatSiblingApp 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.
*/
import { setupIonicReact, LogLevel } from '@ionic/react';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/display.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/padding.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';

/* Theme variables */
import './theme/variables.css';

setupIonicReact({ logLevel: LogLevel.DEBUG });
5 changes: 5 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_SPLAT_SIBLING_BASENAME } from '../root-splat-sibling/basename';

const Main: React.FC = () => {
return (
Expand Down Expand Up @@ -162,6 +163,10 @@ const Main: React.FC = () => {
<IonItem routerLink="/empty-path-sibling">
<IonLabel>Empty Path Sibling</IonLabel>
</IonItem>
{/* A separate React root, so a plain href rather than a routerLink. */}
<IonItem href={`${ROOT_SPLAT_SIBLING_BASENAME}/feed`}>
<IonLabel>Root Splat Sibling</IonLabel>
</IonItem>
</IonList>

<IonList>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
IonApp,
IonBackButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonList,
IonPage,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { ellipse, triangle } from 'ionicons/icons';
import React, { useState } from 'react';
import { Navigate, Route, useParams } from 'react-router-dom';

/* Ionic CSS and setupIonicReact */
import '../ionic-setup';
import { ROOT_SPLAT_SIBLING_BASENAME } from './basename';
import TestDescription from '../components/TestDescription';

/**
* A splat container page holding tabs, next to a more specific sibling that gets pushed over
* the whole tab bar, in the root outlet.
*
* A root catch-all can't live in the shared route tree in App.tsx without swallowing every
* other spec's pathname, so this app gets its own basename and index.tsx mounts it instead
* of App for that prefix.
*/

/**
* The two spellings of a root splat fail differently, so "?splat=bare" runs the same flows
* against a bare "*" instead of "/*". Stamped onto the tabs page as data-splat so a test can
* confirm which spelling is live.
*/
const splatPath = new URLSearchParams(window.location.search).get('splat') === 'bare' ? '*' : '/*';

/** A fresh mount gets a fresh instance id, which tells a remount apart from a reveal. */
let instanceCounter = 0;
const nextInstanceId = () => `tabs-${++instanceCounter}`;

const Feed: React.FC = () => {
const [count, setCount] = useState(0);

return (
<IonContent>
<IonList>
<IonItem button data-testid="increment" onClick={() => setCount((c) => c + 1)}>
<IonLabel>Increment</IonLabel>
</IonItem>
<IonItem detail routerLink="/feed/12" data-testid="open-detail">
<IonLabel>Open item 12</IonLabel>
</IonItem>
</IonList>
<div data-testid="count">{count}</div>
<TestDescription>
Increment the counter, then open item 12. The detail page should push over the whole tab bar, leaving these tabs
mounted behind it. Going back should reveal the same tabs with the counter unchanged.
</TestDescription>
</IonContent>
);
};

const FeedTab: React.FC = () => (
<IonPage data-pageid="root-splat-sibling-feed">
<IonHeader>
<IonToolbar>
<IonTitle>Feed</IonTitle>
</IonToolbar>
</IonHeader>
<Feed />
</IonPage>
);

const ProfileTab: React.FC = () => (
<IonPage data-pageid="root-splat-sibling-profile">
<IonHeader>
<IonToolbar>
<IonTitle>Profile</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="profile-content">Profile</div>
</IonContent>
</IonPage>
);

/** The container page the splat route renders. */
const Tabs: React.FC = () => {
const [instanceId] = useState(nextInstanceId);

return (
// These land on both the .ion-page div and the ion-tabs element, so select with div.ion-page.
<IonTabs data-pageid="root-splat-sibling-tabs" data-instance={instanceId} data-splat={splatPath}>
<IonRouterOutlet id="root-splat-sibling-tabs-outlet">
<Route path="/feed" element={<FeedTab />} />
<Route path="/profile" element={<ProfileTab />} />
<Route path="/" element={<Navigate to="/feed" replace />} />
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="feed" href="/feed" data-testid="tab-feed">
<IonIcon icon={triangle} />
<IonLabel>Feed</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile" data-testid="tab-profile">
<IonIcon icon={ellipse} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
};

const Detail: React.FC = () => {
const { id } = useParams<{ id: string }>();

return (
<IonPage data-pageid="root-splat-sibling-detail">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/feed" />
</IonButtons>
<IonTitle>Detail</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="detail-id">{id}</div>
</IonContent>
</IonPage>
);
};

const RootSplatSiblingApp: React.FC = () => (
<IonApp>
<IonReactRouter basename={ROOT_SPLAT_SIBLING_BASENAME}>
<IonRouterOutlet id="root-splat-sibling-outlet">
<Route path="/feed/:id" element={<Detail />} />
<Route path={splatPath} element={<Tabs />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
);

export default RootSplatSiblingApp;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* The prefix the root splat sibling 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_SPLAT_SIBLING_BASENAME = `${import.meta.env?.BASE_URL?.replace(/\/$/, '') || ''}/root-splat-sibling`;
Loading
Loading