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

@ -0,0 +1,166 @@
import { RouteInfo } from '../models/RouteInfo';
// const RESTRICT_SIZE = 100;
export class LocationHistory {
private locationHistory: RouteInfo[] = [];
private tabHistory: {
[key: string]: RouteInfo[];
} = {};
add(routeInfo: RouteInfo) {
if (routeInfo.routeAction === 'push' || routeInfo.routeAction == null) {
this._add(routeInfo);
} else if (routeInfo.routeAction === 'pop') {
this._pop(routeInfo);
} else if (routeInfo.routeAction === 'replace') {
this._replace(routeInfo);
}
if (routeInfo.routeDirection === 'root') {
this._clear();
this._add(routeInfo);
}
}
clearTabStack(tab: string) {
const routeInfos = this._getRouteInfosByKey(tab);
if (routeInfos) {
routeInfos.forEach(ri => {
this.locationHistory = this.locationHistory.filter(x => x.id !== ri.id);
});
this.tabHistory[tab] = [];
}
}
update(routeInfo: RouteInfo) {
const locationIndex = this.locationHistory.findIndex(x => x.id === routeInfo.id);
if (locationIndex > -1) {
this.locationHistory.splice(locationIndex, 1, routeInfo);
}
const tabArray = this.tabHistory[routeInfo.tab || ''];
if (tabArray) {
const tabIndex = tabArray.findIndex(x => x.id === routeInfo.id);
if (tabIndex > -1) {
tabArray.splice(tabIndex, 1, routeInfo);
} else {
tabArray.push(routeInfo);
}
} else if (routeInfo.tab) {
this.tabHistory[routeInfo.tab] = [routeInfo];
}
}
private _add(routeInfo: RouteInfo) {
const routeInfos = this._getRouteInfosByKey(routeInfo.tab);
if (routeInfos) {
// If the latest routeInfo is the same (going back and forth between tabs), replace it
if (routeInfos[routeInfos.length - 1]?.id === routeInfo.id) {
routeInfos.pop();
}
routeInfos.push(routeInfo);
}
this.locationHistory.push(routeInfo);
}
private _pop(routeInfo: RouteInfo) {
const routeInfos = this._getRouteInfosByKey(routeInfo.tab);
let ri: RouteInfo;
if (routeInfos) {
// Pop all routes until we are back
ri = routeInfos[routeInfos.length - 1];
while (ri && ri.id !== routeInfo.id) {
routeInfos.pop();
ri = routeInfos[routeInfos.length - 1];
}
// Replace with updated route
routeInfos.pop();
routeInfos.push(routeInfo);
}
ri = this.locationHistory[this.locationHistory.length - 1];
while (ri && ri.id !== routeInfo.id) {
this.locationHistory.pop();
ri = this.locationHistory[this.locationHistory.length - 1];
}
// Replace with updated route
this.locationHistory.pop();
this.locationHistory.push(routeInfo);
}
private _replace(routeInfo: RouteInfo) {
const routeInfos = this._getRouteInfosByKey(routeInfo.tab);
routeInfos && routeInfos.pop();
this.locationHistory.pop();
this._add(routeInfo);
}
private _clear() {
const keys = Object.keys(this.tabHistory);
keys.forEach(k => this.tabHistory[k] = []);
this.locationHistory = [];
}
private _getRouteInfosByKey(key?: string) {
let routeInfos: RouteInfo[] | undefined;
if (key) {
routeInfos = this.tabHistory[key];
if (!routeInfos) {
routeInfos = this.tabHistory[key] = [];
}
}
return routeInfos;
}
getFirstRouteInfoForTab(tab: string) {
const routeInfos = this._getRouteInfosByKey(tab);
if (routeInfos) {
return routeInfos[0];
}
return undefined;
}
getCurrentRouteInfoForTab(tab?: string) {
const routeInfos = this._getRouteInfosByKey(tab);
if (routeInfos) {
return routeInfos[routeInfos.length - 1];
}
return undefined;
}
findLastLocation(routeInfo: RouteInfo) {
const routeInfos = this._getRouteInfosByKey(routeInfo.tab);
if (routeInfos) {
for (let i = routeInfos.length - 2; i >= 0; i--) {
const ri = routeInfos[i];
if (ri) {
if (ri.pathname === routeInfo.pushedByRoute) {
return ri;
}
}
}
}
for (let i = this.locationHistory.length - 2; i >= 0; i--) {
const ri = this.locationHistory[i];
if (ri) {
if (ri.pathname === routeInfo.pushedByRoute) {
return ri;
}
}
}
return undefined;
}
previous() {
return this.locationHistory[this.locationHistory.length - 2] || this.locationHistory[this.locationHistory.length - 1];
}
current() {
return this.locationHistory[this.locationHistory.length - 1];
}
canGoBack() {
return this.locationHistory.length > 1;
}
}

View File

@ -0,0 +1,101 @@
import { AnimationBuilder } from '@ionic/core';
import React from 'react';
import { IonRouterContext, IonRouterContextState } from '../components/IonRouterContext';
import { NavContext, NavContextState } from '../contexts/NavContext';
import { RouteAction } from '../models/RouteAction';
import { RouteInfo } from '../models/RouteInfo';
import { RouterDirection } from '../models/RouterDirection';
import { RouterOptions } from '../models/RouterOptions';
import { LocationHistory } from './LocationHistory';
import PageManager from './PageManager';
interface NavManagerProps {
routeInfo: RouteInfo;
onNavigateBack: (route?: string | RouteInfo, animationBuilder?: AnimationBuilder) => void;
onNavigate: (path: string, action: RouteAction, direction?: RouterDirection, animationBuilder?: AnimationBuilder, options?: any, tab?: string) => void;
onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void;
onChangeTab: (tab: string, path: string, routeOptions?: any) => void;
onResetTab: (tab: string, path: string, routeOptions?: any) => void;
ionRedirect: any;
ionRoute: any;
stackManager: any;
locationHistory: LocationHistory;
}
export class NavManager extends React.Component<NavManagerProps, NavContextState> {
ionRouterContextValue: IonRouterContextState = {
push: (pathname: string, routerDirection?: RouterDirection, routeAction?: RouteAction, routerOptions?: RouterOptions, animationBuilder?: AnimationBuilder) => {
this.navigate(pathname, routerDirection, routeAction, animationBuilder, routerOptions);
},
back: (animationBuilder?: AnimationBuilder) => {
this.goBack(undefined, animationBuilder);
},
canGoBack: () => this.props.locationHistory.canGoBack(),
routeInfo: this.props.routeInfo
};
constructor(props: NavManagerProps) {
super(props);
this.state = {
goBack: this.goBack.bind(this),
hasIonicRouter: () => true,
navigate: this.navigate.bind(this),
getIonRedirect: this.getIonRedirect.bind(this),
getIonRoute: this.getIonRoute.bind(this),
getStackManager: this.getStackManager.bind(this),
getPageManager: this.getPageManager.bind(this),
routeInfo: this.props.routeInfo,
setCurrentTab: this.props.onSetCurrentTab,
changeTab: this.props.onChangeTab,
resetTab: this.props.onResetTab,
};
if (typeof document !== 'undefined') {
document.addEventListener('ionBackButton', (e: any) => {
e.detail.register(0, (processNextHandler: () => void) => {
this.goBack();
processNextHandler();
});
});
}
}
goBack(route?: string | RouteInfo, animationBuilder?: AnimationBuilder) {
this.props.onNavigateBack(route, animationBuilder);
}
navigate(path: string, direction: RouterDirection = 'forward', action: RouteAction = 'push', animationBuilder?: AnimationBuilder, options?: any, tab?: string) {
this.props.onNavigate(path, action, direction, animationBuilder, options, tab);
}
getPageManager() {
return PageManager;
}
getIonRedirect() {
return this.props.ionRedirect;
}
getIonRoute() {
return this.props.ionRoute;
}
getStackManager() {
return this.props.stackManager;
}
render() {
return (
<NavContext.Provider value={{ ...this.state, routeInfo: this.props.routeInfo }}>
<IonRouterContext.Provider value={{ ...this.ionRouterContextValue, routeInfo: this.props.routeInfo }}>
{this.props.children}
</IonRouterContext.Provider>
</NavContext.Provider>
);
}
}

View File

@ -0,0 +1,86 @@
import React from 'react';
import { IonRouterOutletInner } from '../components/inner-proxies';
import { IonLifeCycleContext } from '../contexts/IonLifeCycleContext';
import { RouteInfo } from '../models';
import { StackContext } from './StackContext';
interface OutletPageManagerProps {
className?: string;
forwardedRef?: React.RefObject<HTMLDivElement>;
routeInfo?: RouteInfo;
StackManager: any;
}
export class OutletPageManager extends React.Component<OutletPageManagerProps> {
ionLifeCycleContext!: React.ContextType<typeof IonLifeCycleContext>;
context!: React.ContextType<typeof StackContext>;
ionRouterOutlet: HTMLIonRouterOutletElement | undefined;
constructor(props: OutletPageManagerProps) {
super(props);
}
componentDidMount() {
if (this.ionRouterOutlet) {
setTimeout(() => {
this.context.registerIonPage(this.ionRouterOutlet!, this.props.routeInfo!);
}, 25);
this.ionRouterOutlet.addEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionRouterOutlet.addEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionRouterOutlet.addEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionRouterOutlet.addEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
}
}
componentWillUnmount() {
if (this.ionRouterOutlet) {
this.ionRouterOutlet.removeEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionRouterOutlet.removeEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionRouterOutlet.removeEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionRouterOutlet.removeEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
}
}
ionViewWillEnterHandler() {
this.ionLifeCycleContext.ionViewWillEnter();
}
ionViewDidEnterHandler() {
this.ionLifeCycleContext.ionViewDidEnter();
}
ionViewWillLeaveHandler() {
this.ionLifeCycleContext.ionViewWillLeave();
}
ionViewDidLeaveHandler() {
this.ionLifeCycleContext.ionViewDidLeave();
}
render() {
const { StackManager, children, routeInfo, ...props } = this.props;
return (
<IonLifeCycleContext.Consumer>
{context => {
this.ionLifeCycleContext = context;
return (
<StackManager routeInfo={routeInfo}>
<IonRouterOutletInner setRef={(val: HTMLIonRouterOutletElement) => this.ionRouterOutlet = val} {...props}>
{children}
</IonRouterOutletInner>
</StackManager>
);
}}
</IonLifeCycleContext.Consumer>
);
}
static get contextType() {
return StackContext;
}
}
export default OutletPageManager;

View File

@ -0,0 +1,86 @@
import React from 'react';
import { IonLifeCycleContext } from '../contexts/IonLifeCycleContext';
import { RouteInfo } from '../models';
import { StackContext } from './StackContext';
interface PageManagerProps {
className?: string;
forwardedRef?: React.RefObject<HTMLDivElement>;
routeInfo?: RouteInfo;
}
export class PageManager extends React.PureComponent<PageManagerProps> {
ionLifeCycleContext!: React.ContextType<typeof IonLifeCycleContext>;
context!: React.ContextType<typeof StackContext>;
ionPageElementRef: React.RefObject<HTMLDivElement>;
constructor(props: PageManagerProps) {
super(props);
this.ionPageElementRef = this.props.forwardedRef || React.createRef();
}
componentDidMount() {
if (this.ionPageElementRef.current) {
this.context.registerIonPage(this.ionPageElementRef.current, this.props.routeInfo!);
this.ionPageElementRef.current.addEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionPageElementRef.current.addEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionPageElementRef.current.addEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionPageElementRef.current.addEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
}
}
componentWillUnmount() {
if (this.ionPageElementRef.current) {
this.ionPageElementRef.current.removeEventListener('ionViewWillEnter', this.ionViewWillEnterHandler.bind(this));
this.ionPageElementRef.current.removeEventListener('ionViewDidEnter', this.ionViewDidEnterHandler.bind(this));
this.ionPageElementRef.current.removeEventListener('ionViewWillLeave', this.ionViewWillLeaveHandler.bind(this));
this.ionPageElementRef.current.removeEventListener('ionViewDidLeave', this.ionViewDidLeaveHandler.bind(this));
}
}
ionViewWillEnterHandler() {
this.ionLifeCycleContext.ionViewWillEnter();
}
ionViewDidEnterHandler() {
this.ionLifeCycleContext.ionViewDidEnter();
}
ionViewWillLeaveHandler() {
this.ionLifeCycleContext.ionViewWillLeave();
}
ionViewDidLeaveHandler() {
this.ionLifeCycleContext.ionViewDidLeave();
}
render() {
const { className, children, routeInfo, forwardedRef, ...props } = this.props;
return (
<IonLifeCycleContext.Consumer>
{context => {
this.ionLifeCycleContext = context;
const hidePageClass = this.context.isInOutlet() ? 'ion-page-invisible' : '';
return (
<div
className={className ? `${className} ion-page ${hidePageClass}` : `ion-page ${hidePageClass}`}
ref={this.ionPageElementRef}
{...props}
>
{children}
</div>
);
}}
</IonLifeCycleContext.Consumer>
);
}
static get contextType() {
return StackContext;
}
}
export default PageManager;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { RouteInfo } from '../models/RouteInfo';
import { ViewItem } from './ViewItem';
export interface RouteManagerContextState {
addViewItem: (viewItem: ViewItem) => void;
clearOutlet: (outletId: string) => void;
createViewItem: (outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement) => ViewItem;
findLeavingViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined;
// findViewItemByPathname: (pathname: string, outletId?: string) => ViewItem | undefined;
findViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined;
getChildrenToRender: (outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo, reRender: () => void) => React.ReactNode[];
getViewItemForTransition: (pathname: string) => ViewItem | undefined;
unMountViewItem: (viewItem: ViewItem) => void;
}
export const RouteManagerContext = /*@__PURE__*/React.createContext<RouteManagerContextState>({
addViewItem: () => undefined,
clearOutlet: () => undefined,
createViewItem: () => undefined as any,
findLeavingViewItemByRouteInfo: () => undefined,
// findViewItemByPathname: () => undefined,
findViewItemByRouteInfo: () => undefined,
getChildrenToRender: () => undefined as any,
getViewItemForTransition: () => undefined,
unMountViewItem: () => undefined,
});

View File

@ -0,0 +1,13 @@
import React from 'react';
import { RouteInfo } from '../models/RouteInfo';
export interface StackContextState {
registerIonPage: (page: HTMLElement, routeInfo: RouteInfo) => void;
isInOutlet: () => boolean;
}
export const StackContext = React.createContext<StackContextState>({
registerIonPage: () => undefined,
isInOutlet: () => false
});

View File

@ -0,0 +1,13 @@
import { ReactElement } from 'react';
export interface ViewItem<T = any> {
id: string;
reactElement: ReactElement;
ionPageElement?: HTMLElement | undefined;
ionRoute?: boolean;
mount: boolean;
routeData?: T;
transitionHtml?: string;
outletId: string;
disableIonPageManagement?: boolean;
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { DefaultIonLifeCycleContext, IonLifeCycleContext } from '../contexts/IonLifeCycleContext';
interface ViewTransitionManagerProps {
removeView: () => void;
mount: boolean;
}
interface ViewTransitionManagerState {
show: boolean;
}
export class ViewLifeCycleManager extends React.Component<ViewTransitionManagerProps, ViewTransitionManagerState> {
ionLifeCycleContext = new DefaultIonLifeCycleContext();
private _isMounted = false;
constructor(props: ViewTransitionManagerProps) {
super(props);
this.ionLifeCycleContext.onComponentCanBeDestroyed(() => {
if (!this.props.mount) {
if (this._isMounted) {
this.setState({
show: false
}, () => this.props.removeView());
}
}
});
this.state = {
show: true
};
}
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>
);
}
}

View File

@ -0,0 +1,67 @@
import { RouteInfo } from '../models/RouteInfo';
import { ViewItem } from './ViewItem';
export abstract class ViewStacks {
private viewStacks: { [key: string]: ViewItem[]; } = {};
constructor() {
this.add = this.add.bind(this);
this.clear = this.clear.bind(this);
this.getViewItemsForOutlet = this.getViewItemsForOutlet.bind(this);
this.remove = this.remove.bind(this);
}
add(viewItem: ViewItem) {
const { outletId } = viewItem;
if (!this.viewStacks[outletId]) {
this.viewStacks[outletId] = [viewItem];
} else {
this.viewStacks[outletId].push(viewItem);
}
}
clear(outletId: string) {
// Give some time for the leaving views to transition before removing
setTimeout(() => {
// console.log('Removing viewstack for outletID ' + outletId);
delete this.viewStacks[outletId];
}, 500);
}
getViewItemsForOutlet(outletId: string) {
return (this.viewStacks[outletId] || []);
}
remove(viewItem: ViewItem) {
const { outletId } = viewItem;
const viewStack = this.viewStacks[outletId];
if (viewStack) {
const viewItemToRemove = viewStack.find(x => x.id === viewItem.id);
if (viewItemToRemove) {
viewItemToRemove.mount = false;
this.viewStacks[outletId] = viewStack.filter(x => x.id !== viewItemToRemove.id);
}
}
}
protected getStackIds() {
return Object.keys(this.viewStacks);
}
protected getAllViewItems() {
const keys = this.getStackIds();
const viewItems: ViewItem[] = [];
keys.forEach(k => {
viewItems.push(...this.viewStacks[k]);
});
return viewItems;
}
abstract createViewItem(outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement): ViewItem;
// abstract findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined;
abstract findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string): ViewItem | undefined;
abstract findLeavingViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string): ViewItem | undefined;
abstract getChildrenToRender(outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo, reRender: () => void, setInTransition: () => void): React.ReactNode[];
abstract getViewItemForTransition(pathname: string): ViewItem | undefined;
}

View File

@ -0,0 +1,7 @@
export * from './RouteManagerContext';
export * from './ViewLifeCycleManager';
export * from './LocationHistory';
export * from './NavManager';
export * from './ViewItem';
export * from './StackContext';
export * from './ViewStacks';

View File

@ -0,0 +1,8 @@
{
"name": "@ionic/react/routing",
"version": "5.0.1",
"module": "index.mjs",
"main": "index.js",
"typings": "../dist/types/routing/index.d.ts",
"private": true
}