feat(react): React Router Enhancements (#21693)

This commit is contained in:
Ely Lucas
2020-07-07 11:02:05 -06:00
committed by GitHub
parent a0735b97bf
commit c171ccbd37
245 changed files with 26872 additions and 1126 deletions

View File

@ -1,15 +1,35 @@
import { Action as HistoryAction, Location as HistoryLocation, createHashHistory as createHistory } from 'history';
import React from 'react';
import { HashRouter, HashRouterProps } from 'react-router-dom';
import { BrowserRouterProps, Router } from 'react-router-dom';
import { RouteManagerWithRouter } from './Router';
import { IonRouter } from './IonRouter';
export class IonReactHashRouter extends React.Component<BrowserRouterProps> {
history = createHistory(this.props);
historyListenHandler?: ((location: HistoryLocation, action: HistoryAction) => void);
constructor(props: BrowserRouterProps) {
super(props);
this.history.listen(this.handleHistoryChange.bind(this));
this.registerHistoryListener = this.registerHistoryListener.bind(this);
}
handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
if (this.historyListenHandler) {
this.historyListenHandler(location, action);
}
}
registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
this.historyListenHandler = cb;
}
export class IonReactHashRouter extends React.Component<HashRouterProps> {
render() {
const { children, ...props } = this.props;
return (
<HashRouter {...props}>
<RouteManagerWithRouter>{children}</RouteManagerWithRouter>
</HashRouter>
<Router history={this.history} {...props}>
<IonRouter registerHistoryListener={this.registerHistoryListener}>{children}</IonRouter>
</Router>
);
}
}

View File

@ -1,21 +1,40 @@
import { MemoryHistory } from 'history';
import { Action as HistoryAction, Location as HistoryLocation, MemoryHistory } from 'history';
import React from 'react';
import { MemoryRouter, MemoryRouterProps, matchPath } from 'react-router';
import { MemoryRouterProps, Router } from 'react-router';
import { LocationState, RouteManager } from './Router';
import { IonRouter, LocationState } from './IonRouter';
interface IonReactMemoryRouterProps extends MemoryRouterProps {
history: MemoryHistory<LocationState>;
}
export class IonReactMemoryRouter extends React.Component<IonReactMemoryRouterProps> {
history: MemoryHistory<LocationState>;
historyListenHandler?: ((location: HistoryLocation, action: HistoryAction) => void);
constructor(props: IonReactMemoryRouterProps) {
super(props);
this.history = props.history;
this.history.listen(this.handleHistoryChange.bind(this));
this.registerHistoryListener = this.registerHistoryListener.bind(this);
}
handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
if (this.historyListenHandler) {
this.historyListenHandler(location, action);
}
}
registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
this.historyListenHandler = cb;
}
render() {
const { children, history, ...props } = this.props;
const match = matchPath(history.location.pathname, this.props);
const { children, ...props } = this.props;
return (
<MemoryRouter {...props}>
<RouteManager history={history} location={history.location} match={match!}>{children}</RouteManager>
</MemoryRouter>
<Router {...props}>
<IonRouter registerHistoryListener={this.registerHistoryListener}>{children}</IonRouter>
</Router>
);
}
}

View File

@ -1,15 +1,35 @@
import { Action as HistoryAction, Location as HistoryLocation, createBrowserHistory as createHistory } from 'history';
import React from 'react';
import { BrowserRouter, BrowserRouterProps } from 'react-router-dom';
import { BrowserRouterProps, Router } from 'react-router-dom';
import { RouteManagerWithRouter } from './Router';
import { IonRouter } from './IonRouter';
export class IonReactRouter extends React.Component<BrowserRouterProps> {
history = createHistory(this.props);
historyListenHandler?: ((location: HistoryLocation, action: HistoryAction) => void);
constructor(props: BrowserRouterProps) {
super(props);
this.history.listen(this.handleHistoryChange.bind(this));
this.registerHistoryListener = this.registerHistoryListener.bind(this);
}
handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
if (this.historyListenHandler) {
this.historyListenHandler(location, action);
}
}
registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
this.historyListenHandler = cb;
}
render() {
const { children, ...props } = this.props;
return (
<BrowserRouter {...props}>
<RouteManagerWithRouter>{children}</RouteManagerWithRouter>
</BrowserRouter>
<Router history={this.history} {...props}>
<IonRouter registerHistoryListener={this.registerHistoryListener}>{children}</IonRouter>
</Router>
);
}
}

View File

@ -1 +0,0 @@
export type IonRouteAction = 'push' | 'replace' | 'pop';

View File

@ -1,6 +0,0 @@
import { RouteProps, match } from 'react-router-dom';
export interface IonRouteData {
match: match | null;
childProps: RouteProps;
}

View File

@ -0,0 +1,11 @@
import { IonRouteProps } from '@ionic/react';
import React from 'react';
import { Route } from 'react-router';
export class IonRouteInner extends React.PureComponent<IonRouteProps> {
render() {
return (
<Route path={this.props.path} exact={this.props.exact} render={this.props.render} computedMatch={(this.props as any).computedMatch} />
);
}
}

View File

@ -0,0 +1,264 @@
import { AnimationBuilder } from '@ionic/core';
import {
LocationHistory,
NavManager,
RouteAction,
RouteInfo,
RouteManagerContext,
RouteManagerContextState,
RouterDirection,
ViewItem,
generateId,
getConfig
} from '@ionic/react';
import { Action as HistoryAction, Location as HistoryLocation } from 'history';
import React from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { IonRouteInner } from './IonRouteInner';
import { ReactRouterViewStack } from './ReactRouterViewStack';
import StackManager from './StackManager';
export interface LocationState {
direction?: RouterDirection;
routerOptions?: { as?: string, unmount?: boolean; };
}
interface IonRouteProps extends RouteComponentProps<{}, {}, LocationState> {
registerHistoryListener: (cb: (location: HistoryLocation<any>, action: HistoryAction) => void) => void;
}
interface IonRouteState {
routeInfo: RouteInfo;
}
class IonRouterInner extends React.PureComponent<IonRouteProps, IonRouteState> {
currentTab?: string;
exitViewFromOtherOutletHandlers: ((pathname: string) => ViewItem | undefined)[] = [];
incomingRouteParams?: Partial<RouteInfo>;
locationHistory = new LocationHistory();
viewStack = new ReactRouterViewStack();
routeMangerContextState: RouteManagerContextState = {
clearOutlet: this.viewStack.clear,
getViewItemForTransition: this.viewStack.getViewItemForTransition,
getChildrenToRender: this.viewStack.getChildrenToRender,
createViewItem: this.viewStack.createViewItem,
findViewItemByRouteInfo: this.viewStack.findViewItemByRouteInfo,
findLeavingViewItemByRouteInfo: this.viewStack.findLeavingViewItemByRouteInfo,
addViewItem: this.viewStack.add,
unMountViewItem: this.viewStack.remove
};
constructor(props: IonRouteProps) {
super(props);
const routeInfo = {
id: generateId('routeInfo'),
pathname: this.props.location.pathname,
search: this.props.location.search
};
this.locationHistory.add(routeInfo);
this.handleChangeTab = this.handleChangeTab.bind(this);
this.handleResetTab = this.handleResetTab.bind(this);
this.handleNavigate = this.handleNavigate.bind(this);
this.handleNavigateBack = this.handleNavigateBack.bind(this);
this.props.registerHistoryListener(this.handleHistoryChange.bind(this));
this.handleSetCurrentTab = this.handleSetCurrentTab.bind(this);
this.state = {
routeInfo
};
}
handleChangeTab(tab: string, path: string, routeOptions?: any) {
const routeInfo = this.locationHistory.getCurrentRouteInfoForTab(tab);
const [pathname, search] = path.split('?');
if (routeInfo) {
this.incomingRouteParams = { ...routeInfo, routeAction: 'push', routeDirection: 'none' };
if (routeInfo.pathname === pathname) {
this.incomingRouteParams.routeOptions = routeOptions;
this.props.history.push(routeInfo.pathname + (routeInfo.search || ''));
} else {
this.incomingRouteParams.pathname = pathname;
this.incomingRouteParams.search = search ? '?' + search : undefined;
this.incomingRouteParams.routeOptions = routeOptions;
this.props.history.push(pathname + (search ? '?' + search : ''));
}
} else {
this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
}
}
handleHistoryChange(location: HistoryLocation<LocationState>, action: HistoryAction) {
let leavingLocationInfo: RouteInfo;
if (this.incomingRouteParams) {
if (this.incomingRouteParams.routeAction === 'replace') {
leavingLocationInfo = this.locationHistory.previous();
} else {
leavingLocationInfo = this.locationHistory.current();
}
} else if (action === 'REPLACE') {
leavingLocationInfo = this.locationHistory.previous();
} else {
leavingLocationInfo = this.locationHistory.current();
}
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
if (leavingUrl !== location.pathname) {
if (!this.incomingRouteParams) {
if (action === 'REPLACE') {
this.incomingRouteParams = {
routeAction: 'replace',
routeDirection: 'none',
tab: this.currentTab
};
}
if (action === 'POP') {
const ri = this.locationHistory.current();
if (ri && ri.pushedByRoute) {
const prevInfo = this.locationHistory.findLastLocation(ri);
this.incomingRouteParams = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
} else {
const direction = 'none';
this.incomingRouteParams = {
routeAction: 'pop',
routeDirection: direction,
tab: this.currentTab
};
}
}
if (!this.incomingRouteParams) {
this.incomingRouteParams = {
routeAction: 'push',
routeDirection: location.state?.direction || 'forward',
routeOptions: location.state?.routerOptions,
tab: this.currentTab
};
}
}
let routeInfo: RouteInfo;
if (this.incomingRouteParams?.id) {
routeInfo = {
...this.incomingRouteParams as RouteInfo,
lastPathname: leavingLocationInfo.pathname
};
this.locationHistory.add(routeInfo);
} else {
const isPushed = (this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward');
routeInfo = {
id: generateId('routeInfo'),
...this.incomingRouteParams,
lastPathname: leavingLocationInfo.pathname,
pathname: location.pathname,
search: location.search,
params: this.props.match.params
};
if (isPushed) {
routeInfo.tab = leavingLocationInfo.tab;
routeInfo.pushedByRoute = leavingLocationInfo.pathname;
} else if (routeInfo.routeAction === 'pop') {
const r = this.locationHistory.findLastLocation(routeInfo);
routeInfo.pushedByRoute = r?.pushedByRoute;
} else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) {
// If we are switching tabs grab the last route info for the tab and use its pushedByRoute
const lastRoute = this.locationHistory.getCurrentRouteInfoForTab(routeInfo.tab);
routeInfo.pushedByRoute = lastRoute?.pushedByRoute;
}
this.locationHistory.add(routeInfo);
}
this.setState({
routeInfo
});
}
this.incomingRouteParams = undefined;
}
handleNavigate(path: string, routeAction: RouteAction, routeDirection?: RouterDirection, routeAnimation?: AnimationBuilder, routeOptions?: any, tab?: string) {
this.incomingRouteParams = {
routeAction,
routeDirection,
routeOptions,
routeAnimation,
tab
};
if (routeAction === 'push') {
this.props.history.push(path);
} else {
this.props.history.replace(path);
}
}
handleNavigateBack(defaultHref: string | RouteInfo = '/', routeAnimation?: AnimationBuilder) {
const config = getConfig();
defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref' as any);
const routeInfo = this.locationHistory.current();
if (routeInfo && routeInfo.pushedByRoute) {
const prevInfo = this.locationHistory.findLastLocation(routeInfo);
if (prevInfo) {
this.incomingRouteParams = { ...prevInfo, routeAction: 'pop', routeDirection: 'back', routeAnimation: routeAnimation || routeInfo.routeAnimation };
if (routeInfo.lastPathname === routeInfo.pushedByRoute) {
this.props.history.goBack();
} else {
this.props.history.replace(prevInfo.pathname + (prevInfo.search || ''));
}
} else {
this.handleNavigate(defaultHref as string, 'pop', 'back');
}
} else {
this.handleNavigate(defaultHref as string, 'pop', 'back');
}
}
handleResetTab(tab: string, originalHref: string, originalRouteOptions: any) {
const routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab);
if (routeInfo) {
const newRouteInfo = { ...routeInfo };
newRouteInfo.pathname = originalHref;
newRouteInfo.routeOptions = originalRouteOptions;
this.incomingRouteParams = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' };
this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || ''));
}
}
handleSetCurrentTab(tab: string) {
this.currentTab = tab;
const ri = { ...this.locationHistory.current() };
if (ri.tab !== tab) {
ri.tab = tab;
this.locationHistory.update(ri);
}
}
render() {
return (
<RouteManagerContext.Provider
value={this.routeMangerContextState}
>
<NavManager
ionRoute={IonRouteInner}
ionRedirect={{}}
stackManager={StackManager}
routeInfo={this.state.routeInfo!}
onNavigateBack={this.handleNavigateBack}
onNavigate={this.handleNavigate}
onSetCurrentTab={this.handleSetCurrentTab}
onChangeTab={this.handleChangeTab}
onResetTab={this.handleResetTab}
locationHistory={this.locationHistory}
>
{this.props.children}
</NavManager>
</RouteManagerContext.Provider>
);
}
}
export const IonRouter = withRouter(IonRouterInner);
IonRouter.displayName = 'IonRouter';

View File

@ -1,77 +0,0 @@
import { RouterDirection } from '@ionic/core';
import { NavContext, NavContextState } from '@ionic/react';
import { Location as HistoryLocation, UnregisterCallback } from 'history';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { IonRouteAction } from './IonRouteAction';
import { StackManager } from './StackManager';
interface NavManagerProps extends RouteComponentProps {
onNavigateBack: (defaultHref?: string) => void;
onNavigate: (ionRouteAction: IonRouteAction, path: string, state?: any) => void;
}
export class NavManager extends React.Component<NavManagerProps, NavContextState> {
listenUnregisterCallback: UnregisterCallback | undefined;
constructor(props: NavManagerProps) {
super(props);
this.state = {
goBack: this.goBack.bind(this),
hasIonicRouter: () => true,
navigate: this.navigate.bind(this),
getStackManager: this.getStackManager.bind(this),
getPageManager: this.getPageManager.bind(this),
currentPath: this.props.location.pathname,
registerIonPage: () => { return; } // overridden in View for each IonPage
};
this.listenUnregisterCallback = this.props.history.listen((location: HistoryLocation) => {
this.setState({
currentPath: location.pathname
});
});
if (document) {
document.addEventListener('ionBackButton', (e: any) => {
e.detail.register(0, (processNextHandler: () => void) => {
this.props.history.goBack();
processNextHandler();
});
});
}
}
componentWillUnmount() {
if (this.listenUnregisterCallback) {
this.listenUnregisterCallback();
}
}
goBack(defaultHref?: string) {
this.props.onNavigateBack(defaultHref);
}
navigate(path: string, direction?: RouterDirection | 'none', ionRouteAction: IonRouteAction = 'push') {
this.props.onNavigate(ionRouteAction, path, direction);
}
getPageManager() {
return (children: any) => children;
}
getStackManager() {
return StackManager;
}
render() {
return (
<NavContext.Provider value={this.state}>
{this.props.children}
</NavContext.Provider>
);
}
}

View File

@ -0,0 +1,178 @@
import { IonRoute, RouteInfo, ViewItem, ViewLifeCycleManager, ViewStacks, generateId } from '@ionic/react';
import React from 'react';
import { matchPath } from 'react-router';
export class ReactRouterViewStack extends ViewStacks {
constructor() {
super();
this.createViewItem = this.createViewItem.bind(this);
this.findViewItemByRouteInfo = this.findViewItemByRouteInfo.bind(this);
this.findLeavingViewItemByRouteInfo = this.findLeavingViewItemByRouteInfo.bind(this);
this.getChildrenToRender = this.getChildrenToRender.bind(this);
this.getViewItemForTransition = this.getViewItemForTransition.bind(this);
}
createViewItem(outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement) {
const viewItem: ViewItem = {
id: generateId('viewItem'),
outletId,
ionPageElement: page,
reactElement,
mount: true,
ionRoute: false
};
const matchProps = {
exact: reactElement.props.exact,
path: reactElement.props.path || reactElement.props.from,
component: reactElement.props.component
};
const match = matchPath(routeInfo.pathname, matchProps);
if (reactElement.type === IonRoute) {
viewItem.ionRoute = true;
viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
}
viewItem.routeData = {
match,
childProps: reactElement.props
};
return viewItem;
}
getChildrenToRender(outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) {
const viewItems = this.getViewItemsForOutlet(outletId);
// Sync latest routes with viewItems
React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => {
const viewItem = viewItems.find(v => {
return matchComponent(child, v.routeData.childProps.path || v.routeData.childProps.from);
});
if (viewItem) {
viewItem.reactElement = child;
}
});
const children = viewItems.map(viewItem => {
let clonedChild;
if (viewItem.ionRoute && !viewItem.disableIonPageManagement) {
clonedChild = (
<ViewLifeCycleManager key={`view-${viewItem.id}`} mount={viewItem.mount} removeView={() => this.remove(viewItem)}>
{React.cloneElement(viewItem.reactElement, {
computedMatch: viewItem.routeData.match
})}
</ViewLifeCycleManager>
);
} else {
const match = matchComponent(viewItem.reactElement, routeInfo.pathname);
clonedChild = (
<ViewLifeCycleManager key={`view-${viewItem.id}`} mount={viewItem.mount} removeView={() => this.remove(viewItem)}>
{React.cloneElement(viewItem.reactElement, {
computedMatch: viewItem.routeData.match
})}
</ViewLifeCycleManager>
);
if (!match && viewItem.routeData.match) {
viewItem.routeData.match = undefined;
viewItem.mount = false;
}
}
return clonedChild;
});
return children;
}
findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string) {
const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
if (viewItem && match) {
viewItem.routeData.match = match;
}
return viewItem;
}
findLeavingViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string) {
const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname!, outletId, false, true);
return viewItem;
}
getViewItemForTransition(pathname: string) {
const { viewItem } = this.findViewItemByPath(pathname, undefined, true, true);
return viewItem;
}
private findViewItemByPath(pathname: string, outletId?: string, forceExact?: boolean, mustBeIonRoute?: boolean) {
let viewItem: ViewItem | undefined;
let match: ReturnType<typeof matchPath> | undefined;
let viewStack: ViewItem[];
if (outletId) {
viewStack = this.getViewItemsForOutlet(outletId);
viewStack.some(matchView);
if (!viewItem) {
viewStack.some(matchDefaultRoute);
}
} else {
const viewItems = this.getAllViewItems();
viewItems.some(matchView);
if (!viewItem) {
viewItems.some(matchDefaultRoute);
}
}
return { viewItem, match };
function matchView(v: ViewItem) {
if (mustBeIonRoute && !v.ionRoute) {
return false;
}
const matchProps = {
exact: forceExact ? true : v.routeData.childProps.exact,
path: v.routeData.childProps.path || v.routeData.childProps.from,
component: v.routeData.childProps.component
};
const myMatch = matchPath(pathname, matchProps);
if (myMatch) {
viewItem = v;
match = myMatch;
return true;
}
return false;
}
function matchDefaultRoute(v: ViewItem) {
// try to find a route that doesn't have a path or from prop, that will be our default route
if (!v.routeData.childProps.path && !v.routeData.childProps.from) {
match = {
path: pathname,
url: pathname,
isExact: true,
params: {}
};
viewItem = v;
return true;
}
return false;
}
}
}
function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) {
const matchProps = {
exact: forceExact ? true : node.props.exact,
path: node.props.path || node.props.from,
component: node.props.component
};
const match = matchPath(pathname, matchProps);
return match;
}

View File

@ -1,27 +0,0 @@
import React, { ReactNode } from 'react';
import { ViewStacks } from './ViewStacks';
export interface RouteManagerContextState {
syncView: (page: HTMLElement, viewId: string) => void;
syncRoute: (route: any) => void;
hideView: (viewId: string) => void;
viewStacks: ViewStacks;
setupIonRouter: (id: string, children: ReactNode, routerOutlet: HTMLIonRouterOutletElement) => void;
removeViewStack: (stack: string) => void;
getRoute: (id: string) => any;
}
export const RouteManagerContext = /*@__PURE__*/React.createContext<RouteManagerContextState>({
viewStacks: new ViewStacks(),
syncView: () => { navContextNotFoundError(); },
syncRoute: () => { navContextNotFoundError(); },
hideView: () => { navContextNotFoundError(); },
setupIonRouter: () => Promise.reject(navContextNotFoundError()),
removeViewStack: () => { navContextNotFoundError(); },
getRoute: () => { navContextNotFoundError(); }
});
function navContextNotFoundError() {
console.error('IonReactRouter not found, did you add it to the app?');
}

View File

@ -1,529 +0,0 @@
import { NavDirection } from '@ionic/core';
import { RouterDirection, getConfig } from '@ionic/react';
import { Action as HistoryAction, Location as HistoryLocation, UnregisterCallback } from 'history';
import React from 'react';
import { RouteComponentProps, matchPath, withRouter } from 'react-router-dom';
import { generateId, isDevMode } from '../utils';
import { LocationHistory } from '../utils/LocationHistory';
import { IonRouteAction } from './IonRouteAction';
import { IonRouteData } from './IonRouteData';
import { NavManager } from './NavManager';
import { RouteManagerContext, RouteManagerContextState } from './RouteManagerContext';
import { ViewItem } from './ViewItem';
import { ViewStack, ViewStacks } from './ViewStacks';
export interface LocationState {
direction?: RouterDirection;
action?: IonRouteAction;
}
interface RouteManagerProps extends RouteComponentProps<{}, {}, LocationState> {
location: HistoryLocation<LocationState>;
}
interface RouteManagerState extends RouteManagerContextState {
location?: HistoryLocation<LocationState>;
action?: IonRouteAction;
}
export class RouteManager extends React.Component<RouteManagerProps, RouteManagerState> {
listenUnregisterCallback: UnregisterCallback | undefined;
activeIonPageId?: string;
currentIonRouteAction?: IonRouteAction;
currentRouteDirection?: RouterDirection;
locationHistory = new LocationHistory();
routes: { [key: string]: React.ReactElement<any>; } = {};
ionPageElements: { [key: string]: HTMLElement; } = {};
routerOutlets: { [key: string]: HTMLIonRouterOutletElement; } = {};
firstRender = true;
constructor(props: RouteManagerProps) {
super(props);
this.listenUnregisterCallback = this.props.history.listen(this.historyChange.bind(this));
this.handleNavigate = this.handleNavigate.bind(this);
this.navigateBack = this.navigateBack.bind(this);
this.state = {
viewStacks: new ViewStacks(),
hideView: this.hideView.bind(this),
setupIonRouter: this.setupIonRouter.bind(this),
removeViewStack: this.removeViewStack.bind(this),
syncView: this.syncView.bind(this),
syncRoute: this.syncRoute.bind(this),
getRoute: this.getRoute.bind(this)
};
this.locationHistory.add({
hash: window.location.hash,
key: generateId(),
pathname: window.location.pathname,
search: window.location.search,
state: {}
});
}
componentDidUpdate(_prevProps: RouteComponentProps, prevState: RouteManagerState) {
// Trigger a page change if the location or action is different
if (this.state.location && prevState.location !== this.state.location || prevState.action !== this.state.action) {
const viewStacks = Object.assign(new ViewStacks(), this.state.viewStacks);
this.setActiveView(this.state.location!, this.state.action!, viewStacks);
}
}
componentWillUnmount() {
if (this.listenUnregisterCallback) {
this.listenUnregisterCallback();
}
}
getRoute(id: string) {
return this.routes[id];
}
hideView(viewId: string) {
const viewStacks = Object.assign(new ViewStacks(), this.state.viewStacks);
const { view } = viewStacks.findViewInfoById(viewId);
if (view) {
view.show = false;
view.isIonRoute = false;
view.prevId = undefined;
view.key = generateId();
delete this.ionPageElements[view.id];
this.setState({
viewStacks
});
}
}
historyChange(location: HistoryLocation<LocationState>, action: HistoryAction) {
const ionRouteAction = this.currentIonRouteAction === 'pop' ? 'pop' : action.toLowerCase() as IonRouteAction;
let direction = this.currentRouteDirection;
if (ionRouteAction === 'push') {
this.locationHistory.add(location);
} else if (ionRouteAction === 'pop') {
this.locationHistory.pop();
direction = direction || 'back';
} else if (ionRouteAction === 'replace') {
this.locationHistory.replace(location);
direction = 'none';
}
if (direction === 'root') {
this.locationHistory.clear();
this.locationHistory.add(location);
}
location.state = location.state || { direction };
this.setState({
location,
action: ionRouteAction as IonRouteAction
});
this.currentRouteDirection = undefined;
this.currentIonRouteAction = undefined;
}
setActiveView(location: HistoryLocation<LocationState>, action: IonRouteAction, viewStacks: ViewStacks) {
let direction: RouterDirection | undefined = (location.state && location.state.direction) || 'forward';
let leavingView: ViewItem | undefined;
const viewStackKeys = viewStacks.getKeys();
let shouldTransitionPage = false;
let leavingViewHtml: string | undefined;
viewStackKeys.forEach(key => {
const { view: enteringView, viewStack: enteringViewStack, match } = viewStacks.findViewInfoByLocation(location, key);
if (!enteringView || !enteringViewStack) {
return;
}
leavingView = viewStacks.findViewInfoById(this.activeIonPageId).view;
if (enteringView.isIonRoute) {
enteringView.show = true;
enteringView.mount = true;
enteringView.routeData.match = match!;
shouldTransitionPage = true;
this.activeIonPageId = enteringView.id;
if (leavingView) {
if (action === 'push' && direction === 'forward') {
/**
* If the page is being pushed into the stack by another view,
* record the view that originally directed to the new view for back button purposes.
*/
enteringView.prevId = leavingView.id;
} else if (direction !== 'none') {
leavingView.mount = false;
this.removeOrphanedViews(enteringView, enteringViewStack);
}
leavingViewHtml = enteringView.id === leavingView.id ? this.ionPageElements[leavingView.id].outerHTML : undefined;
} else {
// If there is not a leavingView, then we shouldn't provide a direction
direction = undefined;
}
} else {
enteringView.show = true;
enteringView.mount = true;
enteringView.routeData.match = match!;
}
});
if (leavingView) {
if (!leavingView.isIonRoute) {
leavingView.mount = false;
leavingView.show = false;
}
}
this.setState({
viewStacks
}, () => {
if (shouldTransitionPage) {
const { view: enteringView, viewStack } = this.state.viewStacks.findViewInfoById(this.activeIonPageId);
if (enteringView && viewStack) {
const enteringEl = this.ionPageElements[enteringView.id];
const leavingEl = leavingView && this.ionPageElements[leavingView.id];
if (enteringEl) {
let navDirection: NavDirection | undefined;
if (leavingEl && leavingEl.innerHTML === '') {
// Don't animate from an empty view
navDirection = undefined;
} else if (direction === 'none' || direction === 'root') {
navDirection = undefined;
} else {
navDirection = direction;
}
const shouldGoBack = !!enteringView.prevId;
const routerOutlet = this.routerOutlets[viewStack.id];
this.commitView(
enteringEl!,
leavingEl!,
routerOutlet,
navDirection,
shouldGoBack,
leavingViewHtml);
} else if (leavingEl) {
leavingEl.classList.add('ion-page-hidden');
leavingEl.setAttribute('aria-hidden', 'true');
}
}
// Warn if an IonPage was not eventually rendered in Dev Mode
if (isDevMode()) {
if (enteringView && enteringView.routeData.match!.url !== location.pathname) {
setTimeout(() => {
const { view } = this.state.viewStacks.findViewInfoById(this.activeIonPageId);
if (view!.routeData.match!.url !== location.pathname) {
console.warn('No IonPage was found to render. Make sure you wrap your page with an IonPage component.');
}
}, 100);
}
}
}
});
}
removeOrphanedViews(view: ViewItem, viewStack: ViewStack) {
// Note: This technique is a bit wonky for views that reference each other and get into a circular loop.
// It can still remove a view that probably shouldn't be.
const viewsToRemove = viewStack.views.filter(v => v.prevId === view.id);
viewsToRemove.forEach(v => {
// Don't remove if view is currently active
if (v.id !== this.activeIonPageId) {
this.removeOrphanedViews(v, viewStack);
// If view is not currently visible, go ahead and remove it from DOM
const page = this.ionPageElements[v.id];
if (page.classList.contains('ion-page-hidden')) {
v.show = false;
v.isIonRoute = false;
v.prevId = undefined;
v.key = generateId();
delete this.ionPageElements[v.id];
}
v.mount = false;
}
});
}
setupIonRouter(id: string, children: any, routerOutlet: HTMLIonRouterOutletElement) {
const views: ViewItem[] = [];
let activeId: string | undefined;
const ionRouterOutlet = React.Children.only(children) as React.ReactElement;
let foundMatch = false;
React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => {
const routeId = generateId();
this.routes[routeId] = child;
views.push(createViewItem(child, routeId, this.props.history.location));
});
if (!foundMatch) {
const notFoundRoute = views.find(r => {
// try to find a route that doesn't have a path or from prop, that will be our not found route
return !r.routeData.childProps.path && !r.routeData.childProps.from;
});
if (notFoundRoute) {
notFoundRoute.show = true;
}
}
this.registerViewStack(id, activeId, views, routerOutlet, this.props.location);
function createViewItem(child: React.ReactElement<any>, routeId: string, location: HistoryLocation) {
const viewId = generateId();
const key = generateId();
// const route = child;
const matchProps = {
exact: child.props.exact,
path: child.props.path || child.props.from,
component: child.props.component
};
const match: IonRouteData['match'] = matchPath(location.pathname, matchProps);
const view: ViewItem<IonRouteData> = {
id: viewId,
key,
routeData: {
match,
childProps: child.props
},
routeId,
mount: true,
show: !!match,
isIonRoute: false
};
if (match && view.isIonRoute) {
activeId = viewId;
}
if (!foundMatch && match) {
foundMatch = true;
}
return view;
}
}
registerViewStack(stack: string, activeId: string | undefined, stackItems: ViewItem[], routerOutlet: HTMLIonRouterOutletElement, _location: HistoryLocation) {
this.setState(prevState => {
const prevViewStacks = Object.assign(new ViewStacks(), prevState.viewStacks);
const newStack: ViewStack = {
id: stack,
views: stackItems
};
this.routerOutlets[stack] = routerOutlet;
if (activeId) {
this.activeIonPageId = activeId;
}
prevViewStacks.set(stack, newStack);
return {
viewStacks: prevViewStacks
};
}, () => {
this.setupRouterOutlet(routerOutlet);
});
}
async setupRouterOutlet(routerOutlet: HTMLIonRouterOutletElement) {
const canStart = () => {
const config = getConfig();
const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios');
if (swipeEnabled) {
const { view } = this.state.viewStacks.findViewInfoById(this.activeIonPageId);
return !!(view && view.prevId);
} else {
return false;
}
};
const onStart = () => {
this.navigateBack();
};
routerOutlet.swipeHandler = {
canStart,
onStart,
onEnd: _shouldContinue => true
};
}
removeViewStack(stack: string) {
const viewStacks = Object.assign(new ViewStacks(), this.state.viewStacks);
viewStacks.delete(stack);
this.setState({
viewStacks
});
}
syncView(page: HTMLElement, viewId: string) {
const viewStacks = Object.assign(new ViewStacks(), this.state.viewStacks);
const { view } = viewStacks.findViewInfoById(viewId);
if (view) {
view.isIonRoute = true;
this.ionPageElements[view.id] = page;
this.setActiveView(this.state.location || this.props.location, this.state.action!, viewStacks);
}
}
syncRoute(routerOutlet: any) {
const ionRouterOutlet = React.Children.only(routerOutlet) as React.ReactElement;
React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => {
for (const routeKey in this.routes) {
const route = this.routes[routeKey];
if (
((route.props.path || route.props.from) === (child.props.path || child.props.from)) &&
(route.props.exact === child.props.exact) &&
(route.props.to === child.props.to)
) {
this.routes[routeKey] = child;
}
}
});
}
private async commitView(enteringEl: HTMLElement, leavingEl: HTMLElement, ionRouterOutlet: HTMLIonRouterOutletElement, direction?: NavDirection, showGoBack?: boolean, leavingViewHtml?: string) {
if (!this.firstRender) {
if (!('componentOnReady' in ionRouterOutlet)) {
await waitUntilRouterOutletReady(ionRouterOutlet);
}
if ((enteringEl === leavingEl) && direction && leavingViewHtml) {
// If a page is transitioning to another version of itself
// we clone it so we can have an animation to show
const newLeavingElement = clonePageElement(leavingViewHtml);
ionRouterOutlet.appendChild(newLeavingElement);
await ionRouterOutlet.commit(enteringEl, newLeavingElement, {
deepWait: true,
duration: direction === undefined ? 0 : undefined,
direction,
showGoBack,
progressAnimation: false
});
ionRouterOutlet.removeChild(newLeavingElement);
} else {
await ionRouterOutlet.commit(enteringEl, leavingEl, {
deepWait: true,
duration: direction === undefined ? 0 : undefined,
direction,
showGoBack,
progressAnimation: false
});
}
if (leavingEl && (enteringEl !== leavingEl)) {
/** add hidden attributes */
leavingEl.classList.add('ion-page-hidden');
leavingEl.setAttribute('aria-hidden', 'true');
}
} else {
enteringEl.classList.remove('ion-page-invisible');
enteringEl.style.zIndex = '101';
enteringEl.dispatchEvent(new Event('ionViewWillEnter'));
enteringEl.dispatchEvent(new Event('ionViewDidEnter'));
this.firstRender = false;
}
}
handleNavigate(ionRouteAction: IonRouteAction, path: string, direction?: RouterDirection) {
this.currentIonRouteAction = ionRouteAction;
switch (ionRouteAction) {
case 'push':
this.currentRouteDirection = direction;
this.props.history.push(path);
break;
case 'pop':
this.currentRouteDirection = direction || 'back';
this.props.history.replace(path);
break;
case 'replace':
this.currentRouteDirection = 'none';
this.props.history.replace(path);
break;
}
}
navigateBack(defaultHref?: string) {
const config = getConfig();
defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref');
const { view: leavingView } = this.state.viewStacks.findViewInfoById(this.activeIonPageId);
if (leavingView) {
if (leavingView.id === leavingView.prevId) {
const previousLocation = this.locationHistory.previous();
if (previousLocation) {
this.handleNavigate('pop', previousLocation.pathname + previousLocation.search);
} else {
defaultHref && this.handleNavigate('pop', defaultHref);
}
} else {
const { view: enteringView } = this.state.viewStacks.findViewInfoById(leavingView.prevId);
if (enteringView) {
const lastLocation = this.locationHistory.findLastLocationByUrl(enteringView.routeData.match!.url);
if (lastLocation) {
this.handleNavigate('pop', lastLocation.pathname + lastLocation.search);
} else {
this.handleNavigate('pop', enteringView.routeData.match!.url);
}
} else {
const currentLocation = this.locationHistory.previous();
if (currentLocation) {
this.handleNavigate('pop', currentLocation.pathname + currentLocation.search);
} else {
if (defaultHref) {
this.handleNavigate('pop', defaultHref);
}
}
}
}
} else {
if (defaultHref) {
this.handleNavigate('replace', defaultHref, 'back');
}
}
}
render() {
return (
<RouteManagerContext.Provider value={this.state}>
<NavManager
{...this.props}
onNavigateBack={this.navigateBack}
onNavigate={this.handleNavigate}
>
{this.props.children}
</NavManager>
</RouteManagerContext.Provider>
);
}
}
function clonePageElement(leavingViewHtml: string) {
const newEl = document.createElement('div');
newEl.innerHTML = leavingViewHtml;
newEl.classList.add('ion-page-hidden');
newEl.style.zIndex = '';
// Remove an existing back button so the new element doesn't get two of them
const ionBackButton = newEl.getElementsByTagName('ion-back-button');
if (ionBackButton[0]) {
ionBackButton[0].innerHTML = '';
}
return newEl.firstChild as HTMLElement;
}
async function waitUntilRouterOutletReady(ionRouterOutlet: HTMLIonRouterElement) {
if ('componentOnReady' in ionRouterOutlet) {
return;
} else {
setTimeout(() => {
waitUntilRouterOutletReady(ionRouterOutlet);
}, 0);
}
}
export const RouteManagerWithRouter = withRouter(RouteManager);
RouteManagerWithRouter.displayName = 'RouteManager';

View File

@ -1,109 +1,239 @@
import {
RouteInfo,
RouteManagerContext,
StackContext,
StackContextState,
ViewItem,
generateId
} from '@ionic/react';
import React from 'react';
import { matchPath } from 'react-router-dom';
import { generateId, isDevMode } from '../utils';
import { RouteManagerContext, RouteManagerContextState } from './RouteManagerContext';
import { View } from './View';
import { ViewItem } from './ViewItem';
import { ViewTransitionManager } from './ViewTransitionManager';
import { clonePageElement } from './clonePageElement';
interface StackManagerProps {
id?: string;
routeManager: RouteManagerContextState;
children?: React.ReactNode;
routeInfo: RouteInfo;
}
interface StackManagerState { }
class StackManagerInner extends React.Component<StackManagerProps, StackManagerState> {
routerOutletEl: React.RefObject<HTMLIonRouterOutletElement> = React.createRef();
export class StackManager extends React.PureComponent<StackManagerProps, StackManagerState> {
id: string;
context!: React.ContextType<typeof RouteManagerContext>;
ionRouterOutlet?: React.ReactElement;
routerOutletElement: HTMLIonRouterOutletElement | undefined;
stackContextValue: StackContextState = {
registerIonPage: this.registerIonPage.bind(this),
isInOutlet: () => true
};
constructor(props: StackManagerProps) {
super(props);
this.id = this.props.id || generateId();
this.handleViewSync = this.handleViewSync.bind(this);
this.handleHideView = this.handleHideView.bind(this);
this.state = {};
this.registerIonPage = this.registerIonPage.bind(this);
this.transitionPage = this.transitionPage.bind(this);
this.handlePageTransition = this.handlePageTransition.bind(this);
this.id = generateId('routerOutlet');
}
componentDidMount() {
this.props.routeManager.setupIonRouter(this.id, this.props.children, this.routerOutletEl.current!);
if (this.routerOutletElement) {
// console.log(`SM Mount - ${this.routerOutletElement.id} (${this.id})`);
this.handlePageTransition(this.props.routeInfo);
}
}
static getDerivedStateFromProps(props: StackManagerProps, state: StackManagerState) {
props.routeManager.syncRoute(props.children);
return state;
componentDidUpdate(prevProps: StackManagerProps) {
if (this.props.routeInfo.pathname !== prevProps.routeInfo.pathname) {
this.handlePageTransition(this.props.routeInfo);
}
}
componentWillUnmount() {
this.props.routeManager.removeViewStack(this.id);
// console.log(`SM UNMount - ${(this.routerOutletElement?.id as any).id} (${this.id})`);
this.context.clearOutlet(this.id);
}
handleViewSync(page: HTMLElement, viewId: string) {
this.props.routeManager.syncView(page, viewId);
async handlePageTransition(routeInfo: RouteInfo) {
// let shouldReRender = false;
// If routerOutlet isn't quite ready, give it another try in a moment
if (!this.routerOutletElement || !this.routerOutletElement.commit) {
setTimeout(() => this.handlePageTransition(routeInfo), 10);
} else {
let enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
const leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
if (!(routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward')) {
const shouldLeavingViewBeRemoved = routeInfo.routeDirection !== 'none' && leavingViewItem && (enteringViewItem !== leavingViewItem);
if (shouldLeavingViewBeRemoved) {
leavingViewItem!.mount = false;
}
}
if (leavingViewItem && routeInfo.routeOptions?.unmount) {
leavingViewItem.mount = false;
}
const enteringRoute = matchRoute(this.ionRouterOutlet?.props.children, routeInfo) as React.ReactElement;
if (enteringViewItem) {
enteringViewItem.reactElement = enteringRoute;
}
if (!enteringViewItem) {
if (enteringRoute) {
enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
this.context.addViewItem(enteringViewItem);
}
}
if (enteringViewItem && enteringViewItem.ionPageElement) {
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
} else if (leavingViewItem && !enteringRoute && !enteringViewItem) {
// If we have a leavingView but no entering view/route, we are probably leaving to
// another outlet, so hide this leavingView. We do it in a timeout to give time for a
// transition to finish.
setTimeout(() => {
if (leavingViewItem.ionPageElement) {
leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
}
}, 250);
}
this.forceUpdate();
}
}
handleHideView(viewId: string) {
this.props.routeManager.hideView(viewId);
registerIonPage(page: HTMLElement, routeInfo: RouteInfo) {
const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
if (foundView) {
foundView.ionPageElement = page;
foundView.ionRoute = true;
}
this.handlePageTransition(routeInfo);
}
renderChild(item: ViewItem, route: any) {
const component = React.cloneElement(route, {
computedMatch: item.routeData.match
});
return component;
async transitionPage(routeInfo: RouteInfo, enteringViewItem: ViewItem, leavingViewItem?: ViewItem) {
const routerOutlet = this.routerOutletElement!;
const direction = (routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root')
? undefined
: routeInfo.routeDirection;
if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) {
if (leavingViewItem && leavingViewItem.ionPageElement && (enteringViewItem === leavingViewItem)) {
// If a page is transitioning to another version of itself
// we clone it so we can have an animation to show
const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, true);
if (match) {
const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML);
if (newLeavingElement) {
this.routerOutletElement.appendChild(newLeavingElement);
await runCommit(enteringViewItem.ionPageElement, newLeavingElement);
this.routerOutletElement.removeChild(newLeavingElement);
}
} else {
await runCommit(enteringViewItem.ionPageElement, undefined);
}
} else {
await runCommit(enteringViewItem.ionPageElement, leavingViewItem?.ionPageElement);
if (leavingViewItem && leavingViewItem.ionPageElement) {
leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
}
}
}
async function runCommit(enteringEl: HTMLElement, leavingEl?: HTMLElement) {
enteringEl.classList.add('ion-page');
enteringEl.classList.add('ion-page-invisible');
await routerOutlet.commit(enteringEl, leavingEl, {
deepWait: true,
duration: direction === undefined ? 0 : undefined,
direction: direction as any,
showGoBack: direction === 'forward',
progressAnimation: false,
animationBuilder: routeInfo.routeAnimation
});
}
}
render() {
const routeManager = this.props.routeManager;
const viewStack = routeManager.viewStacks.get(this.id);
const views = (viewStack || { views: [] }).views.filter(x => x.show);
const ionRouterOutlet = React.Children.only(this.props.children) as React.ReactElement;
const childElements = views.map(view => {
const route = routeManager.getRoute(view.routeId);
return (
<ViewTransitionManager
id={view.id}
key={view.key}
mount={view.mount}
>
<View
onViewSync={this.handleViewSync}
onHideView={this.handleHideView}
view={view}
route={route}
>
{this.renderChild(view, route)}
</View>
</ViewTransitionManager>
);
});
const { children } = this.props;
const ionRouterOutlet = React.Children.only(children) as React.ReactElement;
this.ionRouterOutlet = ionRouterOutlet;
const elementProps: any = {
ref: this.routerOutletEl
};
const components = this.context.getChildrenToRender(
this.id,
this.ionRouterOutlet,
this.props.routeInfo,
() => {
this.forceUpdate();
});
return (
<StackContext.Provider value={this.stackContextValue}>
{React.cloneElement(ionRouterOutlet as any, {
ref: (node: HTMLIonRouterOutletElement) => {
if (ionRouterOutlet.props.setRef) {
ionRouterOutlet.props.setRef(node);
}
this.routerOutletElement = node;
const { ref } = ionRouterOutlet as any;
if (typeof ref === 'function') {
ref(node);
}
}
},
components
)}
</StackContext.Provider>
);
}
if (ionRouterOutlet.props.forwardedRef) {
ionRouterOutlet.props.forwardedRef.current = this.routerOutletEl;
}
if (isDevMode()) {
elementProps['data-stack-id'] = this.id;
}
const routerOutletChild = React.cloneElement(ionRouterOutlet, elementProps, childElements);
return routerOutletChild;
static get contextType() {
return RouteManagerContext;
}
}
const withContext = (Component: any) => {
return (props: any) => (
<RouteManagerContext.Consumer>
{context => <Component {...props} routeManager={context} />}
</RouteManagerContext.Consumer>
);
};
export default StackManager;
export const StackManager = withContext(StackManagerInner);
function matchRoute(node: React.ReactNode, routeInfo: RouteInfo) {
let matchedNode: React.ReactNode;
React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => {
const matchProps = {
exact: child.props.exact,
path: child.props.path || child.props.from,
component: child.props.component
};
const match = matchPath(routeInfo.pathname, matchProps);
if (match) {
matchedNode = child;
}
});
if (matchedNode) {
return matchedNode;
}
// If we haven't found a node
// try to find one that doesn't have a path or from prop, that will be our not found route
React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => {
if (!(child.props.path || child.props.from)) {
matchedNode = child;
}
});
return matchedNode;
}
function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) {
const matchProps = {
exact: forceExact ? true : node.props.exact,
path: node.props.path || node.props.from,
component: node.props.component
};
const match = matchPath(pathname, matchProps);
return match;
}

View File

@ -1,99 +0,0 @@
import { IonLifeCycleContext, NavContext } from '@ionic/react';
import React from 'react';
import { Redirect } from 'react-router';
import { isDevMode } from '../utils';
import { ViewItem } from './ViewItem';
interface ViewProps extends React.HTMLAttributes<HTMLElement> {
onViewSync: (page: HTMLElement, viewId: string) => void;
onHideView: (viewId: string) => void;
view: ViewItem;
route: any;
}
/**
* The View component helps manage the IonPage's lifecycle and registration
*/
export class View extends React.Component<ViewProps, {}> {
context!: React.ContextType<typeof IonLifeCycleContext>;
ionPage?: HTMLElement;
componentDidMount() {
/**
* If we can tell if view is a redirect, hide it so it will work again in future
*/
const { view, route } = this.props;
if (route.type === Redirect) {
this.props.onHideView(view.id);
} else if (route.props.render && !view.isIonRoute) {
// Test the render to see if it returns a redirect
if (route.props.render().type === Redirect) {
this.props.onHideView(view.id);
}
}
}
componentWillUnmount() {
if (this.ionPage) {
this.ionPage.removeEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionPage.removeEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionPage.removeEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionPage.removeEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
}
}
ionViewWillEnterHandler() {
this.context.ionViewWillEnter();
}
ionViewDidEnterHandler() {
this.context.ionViewDidEnter();
}
ionViewWillLeaveHandler() {
this.context.ionViewWillLeave();
}
ionViewDidLeaveHandler() {
this.context.ionViewDidLeave();
}
registerIonPage(page: HTMLElement) {
this.ionPage = page;
this.ionPage.addEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionPage.addEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionPage.addEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionPage.addEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
this.ionPage.classList.add('ion-page-invisible');
if (isDevMode()) {
this.ionPage.setAttribute('data-view-id', this.props.view.id);
}
this.props.onViewSync(page, this.props.view.id);
}
render() {
return (
<NavContext.Consumer>
{value => {
const newProvider = {
...value,
registerIonPage: this.registerIonPage.bind(this)
};
return (
<NavContext.Provider value={newProvider}>
{this.props.children}
</NavContext.Provider>
);
}}
</NavContext.Consumer>
);
}
static get contextType() {
return IonLifeCycleContext;
}
}

View File

@ -1,30 +0,0 @@
export interface ViewItem<RouteData = any> {
/** The generated id of the view */
id: string;
/** The key used by React. A new key is generated each time the view comes into the DOM so React thinks its a completely new element. */
key: string;
routeId: string;
/** The routeData for the view. */
routeData: RouteData;
/** Used to track which page pushed the page into view. Used for back button purposes. */
prevId?: string;
/**
* Mount is used for page transitions. If mount is false, it keeps the view in the DOM long enough to finish the transition.
*/
mount: boolean;
/**
* Show determines if the view will be in the DOM or not
*/
show: boolean;
/**
* An IonRoute is a Route that contains an IonPage. Only IonPages participate in transition and lifecycle events.
*/
isIonRoute: boolean;
/**
* location of the view
*/
location?: string;
}

View File

@ -1,97 +0,0 @@
import { Location as HistoryLocation } from 'history';
import { matchPath } from 'react-router-dom';
import { IonRouteData } from './IonRouteData';
import { ViewItem } from './ViewItem';
export interface ViewStack {
id: string;
views: ViewItem[];
}
/**
* The holistic view of all the Routes configured for an application inside of an IonRouterOutlet.
*/
export class ViewStacks {
private viewStacks: { [key: string]: ViewStack | undefined; } = {};
get(key: string) {
return this.viewStacks[key];
}
set(key: string, viewStack: ViewStack) {
this.viewStacks[key] = viewStack;
}
getKeys() {
return Object.keys(this.viewStacks);
}
delete(key: string) {
delete this.viewStacks[key];
}
findViewInfoByLocation(location: HistoryLocation, viewKey: string) {
let view: ViewItem<IonRouteData> | undefined;
let match: IonRouteData['match'] | null | undefined;
let viewStack: ViewStack | undefined;
viewStack = this.viewStacks[viewKey];
if (viewStack) {
viewStack.views.some(matchView);
if (!view) {
viewStack.views.some(r => {
// try to find a route that doesn't have a path or from prop, that will be our not found route
if (!r.routeData.childProps.path && !r.routeData.childProps.from) {
match = {
path: location.pathname,
url: location.pathname,
isExact: true,
params: {}
};
view = r;
return true;
}
return false;
});
}
}
return { view, viewStack, match };
function matchView(v: ViewItem) {
const matchProps = {
exact: v.routeData.childProps.exact,
path: v.routeData.childProps.path || v.routeData.childProps.from,
component: v.routeData.childProps.component
};
const myMatch: IonRouteData['match'] | null | undefined = matchPath(location.pathname, matchProps);
if (myMatch) {
view = v;
match = myMatch;
return true;
}
return false;
}
}
findViewInfoById(id = '') {
let view: ViewItem<IonRouteData> | undefined;
let viewStack: ViewStack | undefined;
const keys = this.getKeys();
keys.some(key => {
const vs = this.viewStacks[key];
view = vs!.views.find(x => x.id === id);
if (view) {
viewStack = vs;
return true;
} else {
return false;
}
});
return { view, viewStack };
}
}

View File

@ -1,62 +0,0 @@
import { DefaultIonLifeCycleContext, IonLifeCycleContext } from '@ionic/react';
import React from 'react';
import { RouteManagerContext } from './RouteManagerContext';
interface ViewTransitionManagerProps {
id: string;
mount: boolean;
}
interface ViewTransitionManagerState {
show: boolean;
}
/**
* Manages the View's DOM lifetime by keeping it around long enough to complete page transitions before removing it.
*/
export class ViewTransitionManager extends React.Component<ViewTransitionManagerProps, ViewTransitionManagerState> {
ionLifeCycleContext = new DefaultIonLifeCycleContext();
_isMounted = false;
context!: React.ContextType<typeof RouteManagerContext>;
constructor(props: ViewTransitionManagerProps) {
super(props);
this.state = {
show: true
};
this.ionLifeCycleContext.onComponentCanBeDestroyed(() => {
if (!this.props.mount) {
if (this._isMounted) {
this.setState({
show: false
}, () => {
this.context.hideView(this.props.id);
});
}
}
});
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
render() {
const { show } = this.state;
return (
<IonLifeCycleContext.Provider value={this.ionLifeCycleContext}>
{show && this.props.children}
</IonLifeCycleContext.Provider>
);
}
static get contextType() {
return RouteManagerContext;
}
}

View File

@ -1,75 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { IonApp, IonRouterOutlet, IonPage } from '@ionic/react';
import { IonReactRouter } from '../IonReactRouter';
import { render } from '@testing-library/react';
import { Route } from 'react-router';
// import {Router} from '../Router';
describe('Router', () => {
describe('on first page render', () => {
let IonTestApp: React.ComponentType<any>;
beforeEach(() => {
IonTestApp = ({ Page }) => {
return (
<IonApp>
<IonReactRouter>
<IonRouterOutlet>
<Route path="/" component={Page}></Route>
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
);
};
});
it('should be visible', () => {
const MyPage = () => {
return (
<IonPage className="ion-page-invisible">
<div>hello</div>
</IonPage>
);
};
const { container } = render(<IonTestApp Page={MyPage} />);
const page = container.getElementsByClassName('ion-page')[0];
expect(page).not.toHaveClass('ion-page-invisible');
expect(page).toHaveStyle('z-index: 101')
});
it('should fire initial lifecycle events', () => {
const ionViewWillEnterListener = jest.fn();
const ionViewDidEnterListener = jest.fn();
const MyPage = () => {
const ref = useRef<HTMLDivElement>();
useEffect(() => {
ref.current.addEventListener('ionViewWillEnter', ionViewWillEnterListener);
ref.current.addEventListener('ionViewDidEnter', ionViewDidEnterListener);
}, []);
return (
<IonPage ref={ref}>
<div>hello</div>
</IonPage>
);
};
render(<IonTestApp Page={MyPage} />);
expect(ionViewWillEnterListener).toHaveBeenCalledTimes(1);
expect(ionViewDidEnterListener).toHaveBeenCalledTimes(1);
});
});
});;

View File

@ -0,0 +1,20 @@
export function clonePageElement(leavingViewHtml: string | HTMLElement) {
let html: string;
if (typeof leavingViewHtml === 'string') {
html = leavingViewHtml;
} else {
html = leavingViewHtml.outerHTML;
}
if (document) {
const newEl = document.createElement('div');
newEl.innerHTML = html;
newEl.style.zIndex = '';
// Remove an existing back button so the new element doesn't get two of them
const ionBackButton = newEl.getElementsByTagName('ion-back-button');
if (ionBackButton[0]) {
ionBackButton[0].remove();
}
return newEl.firstChild as HTMLElement;
}
return undefined;
}

View File

@ -1,3 +0,0 @@
export { IonReactRouter } from './IonReactRouter';
export { IonReactHashRouter } from './IonReactHashRouter';
export { IonReactMemoryRouter } from './IonReactMemoryRouter';