reactant-share
Version:
A framework for building shared web applications with Reactant
730 lines (686 loc) • 21.1 kB
text/typescript
/* eslint-disable prefer-destructuring */
/* eslint-disable no-shadow */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable consistent-return */
import {
injectable,
inject,
watch,
state,
action,
stateKey,
modulesKey,
} from 'reactant';
import {
Router as BaseReactantRouter,
LOCATION_CHANGE,
RouterOptions,
} from 'reactant-router';
import type {
IRouterOptions as IBaseRouterOptions,
LocationChangeAction,
RouterState,
} from 'reactant-router';
import type { LocationState } from 'history';
import {
routerModuleName,
SharedAppOptions,
storageModuleName,
syncRouterName,
syncWorkerRouterName,
} from '../constants';
import type { ISharedAppOptions } from '../interfaces';
import { PortDetector } from './portDetector';
import { delegate } from '../delegate';
import { fork } from '../fork';
import { isSharedWorker } from '../utils';
export {
createBrowserHistory,
createHashHistory,
createMemoryHistory,
} from 'reactant-router';
export interface IRouterOptions extends IBaseRouterOptions {
/**
* default initial route
*/
defaultRoute?: string;
/**
* The maximum number of historical records stored in the browser, the default is 10.
*/
maxHistoryLength?: number;
}
class ReactantRouter extends BaseReactantRouter {
private passiveRoute = false;
private cachedHistory: RouterState[] = [];
private forwardHistory: RouterState[] = [];
private firstRenderingSync: Promise<void>;
private firstRenderingSyncResolve!: () => void;
private firstActiveSync: Promise<void>;
private firstActiveSyncResolve!: () => void;
/**
* The promise of the first client sync.
*/
public firstClientSync: Promise<[void, void]>;
constructor(
protected portDetector: PortDetector,
protected sharedAppOptions: ISharedAppOptions,
protected options: IRouterOptions
) {
super({
...options,
autoCreateHistory: !(
(sharedAppOptions.type === 'SharedWorker' &&
sharedAppOptions.port === 'server') ||
!globalThis.document
),
});
this.firstRenderingSync = new Promise<void>((resolve) => {
this.firstRenderingSyncResolve = resolve;
});
this.firstActiveSync = new Promise<void>((resolve) => {
this.firstActiveSyncResolve = resolve;
});
this.firstClientSync = Promise.all([
this.firstRenderingSync,
this.firstActiveSync,
]);
this.portDetector.onClient(() => {
const stopWatching = watch(
this,
() => this.portDetector.lastAction.action,
() => {
const action = this.portDetector.lastAction
.action as any as LocationChangeAction;
if (
action.type === LOCATION_CHANGE &&
action.payload.isFirstRendering
) {
stopWatching();
const router = this._routers[this.portDetector.name];
if (router && this.compareRouter(router, this.router!)) {
// router reducer @@router/LOCATION_CHANGE event and syncFullState event The events may be out of order, so we re-check route consistency after synchronizing the state.
this.history.replace(router.location);
}
this.firstRenderingSyncResolve();
}
}
);
});
if (globalThis.document) {
window.addEventListener('popstate', () => {
if (!this.passiveRoute) {
this.lastRoutedTimestamp = Date.now();
}
});
}
if (!this.portDetector.shared) {
const stopWatching = this.watchRehydratedRouting();
watch(
this,
() => this.router,
() => {
if (this.router) {
// just update the current router to routers mapping by name
this._setRouters(this.portDetector.name, this.router);
}
if (!this.enableCacheRouting) {
stopWatching();
}
}
);
}
this.portDetector.onClient(() => {
if (!this.portDetector.sharedAppOptions.forcedSyncClient) {
const visibilitychange = async () => {
if (document.visibilityState === 'visible') {
await this.portDetector.syncFullState({ forceSync: false });
if (this.toBeRouted) {
const fn = this.toBeRouted;
this.toBeRouted = null;
fn();
}
}
};
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
};
}
});
// #region sync init router from clients in Worker mode
this.portDetector.onServer((transport) => {
watch(
this,
() => this.router,
(router) => {
if (
router &&
(!this.cachedHistory[0] ||
this.compareRouter(router, this.cachedHistory[0]))
) {
if (router.action === 'REPLACE') {
this.cachedHistory[0] = router;
} else {
this.cachedHistory.unshift(router!);
}
this.cachedHistory.length = this.maxHistoryLength; // Limit the length of the historical stack
}
}
);
if (this.portDetector.isWorkerMode && !this.enableCacheRouting) {
transport
.emit(syncWorkerRouterName, this.portDetector.name)
.then((router) => {
if (router) {
this._changeRoutingOnSever(
this.portDetector.name,
router,
Date.now()
);
}
});
} else if (this.enableCacheRouting) {
return this.watchRehydratedRouting();
}
});
this.portDetector.onClient((transport) => {
if (this.portDetector.isWorkerMode) {
return transport.listen(syncWorkerRouterName, async (name) => {
if (name === this.portDetector.name) {
return this.router;
}
});
}
});
// #endregion
// #region watch router and sync up router to all clients and server port
this.portDetector.onClient(() => {
return watch(
this,
() => this.router,
() => {
delegate(
this as any,
'_changeRoutingOnSever',
[
this.portDetector.name,
this.router,
this.lastRoutedTimestamp,
this.portDetector.clientId,
],
{
respond: false,
}
);
}
);
});
this.portDetector.onServer(() => {
return watch(
this,
() => this.router,
() => {
if (!this.portDetector.isWorkerMode) {
if (globalThis.document) {
// just update the current router to routers mapping by name at every time in shared tab mode
this._setRouters(this.portDetector.name, this.router!);
}
fork(
this as any,
'_changeRoutingOnClient',
[this.portDetector.name, this.router, this.lastRoutedTimestamp],
{
silent: true,
}
);
}
}
);
});
// #endregion
// #region sync init router from server port in all modes
this.portDetector.onServer((transport) => {
const rehydratedPromise = this.enableCacheRouting
? new Promise<void>((resolve) => {
const stopWatching = watch(
this,
() => (this as any)[stateKey]._persist?.rehydrated,
(rehydrated) => {
if (rehydrated) {
stopWatching();
resolve();
}
}
);
})
: Promise.resolve();
return transport!.listen(
syncRouterName,
async (name, timestamp, router) => {
await rehydratedPromise;
const currentRouter = this._routers[name]!;
if (!currentRouter && router) {
this._changeRoutingOnSever(name, router, timestamp);
}
return currentRouter;
}
);
});
this.portDetector.onClient((transport) => {
transport!
.emit(
syncRouterName,
this.portDetector.name,
this.lastRoutedTimestamp,
this.router
)
.then((router) => {
if (!router) {
this.firstActiveSyncResolve();
return;
}
this.passiveRoute = true;
this.history.replace(router.location);
this.passiveRoute = false;
this.firstActiveSyncResolve();
});
});
// #endregion
}
get maxHistoryLength() {
return this.options.maxHistoryLength ?? 50;
}
watchRehydratedRouting() {
// The first rendering and the hydration of the persistent router may emit actions in a different order due to the module order.
let firstTrigger = false;
const stopWatchingRehydrated = watch(
this,
() => (this as any)[stateKey]._persist?.rehydrated,
(rehydrated) => {
if (!this.enableCacheRouting) {
stopWatchingRehydrated();
}
if (rehydrated) {
stopWatchingRehydrated();
if (!firstTrigger) {
firstTrigger = true;
return;
}
const router = this._routers[this.portDetector.name];
this._changeRoutingOnSever(
this.portDetector.name,
router ?? this.defaultHistory,
Date.now()
);
}
}
);
const stopWatchingIsFirstRendering = watch(
this,
() => this.portDetector.lastAction.action,
() => {
if (!this.enableCacheRouting) {
stopWatchingIsFirstRendering();
}
const action = this.portDetector.lastAction
.action as any as LocationChangeAction;
if (
action.type === LOCATION_CHANGE &&
action.payload.isFirstRendering
) {
stopWatchingIsFirstRendering();
if (!firstTrigger) {
firstTrigger = true;
return;
}
const router = this._routers[this.portDetector.name];
this._changeRoutingOnSever(
this.portDetector.name,
router ?? this.defaultHistory,
Date.now()
);
}
}
);
return () => {
stopWatchingRehydrated();
stopWatchingIsFirstRendering();
};
}
compareRouter(router1: RouterState, router2: RouterState) {
return (
router1.location.pathname !== router2.location.pathname ||
router1.location.hash !== router2.location.hash ||
router1.location.search !== router2.location.search ||
JSON.stringify(router1.location.state) !==
JSON.stringify(router2.location.state) ||
JSON.stringify((router1.location as any).query) !==
JSON.stringify((router2.location as any).query)
);
}
/**
* The timestamp of the last routing.
*/
lastRoutedTimestamp = isSharedWorker ? 0 : Date.now();
protected _changeRoutingOnSever(
name: string,
router: RouterState,
timestamp: number,
clientId?: string
) {
if (!this.portDetector.isServerWorker && globalThis.document) {
// Only update the latest routes
if (this.lastRoutedTimestamp >= timestamp) return;
this.lastRoutedTimestamp = timestamp;
}
this._setRouters(name, router);
if (name === this.portDetector.name) {
if (this.portDetector.isWorkerMode) {
this.dispatchChanged(router);
} else if (this.compareRouter(router, this.router!)) {
this.passiveRoute = true;
this.history.push(router.location);
this.passiveRoute = false;
}
if (this.portDetector.shared) {
fork(
this as any,
'_changeRoutingOnClient',
[this.portDetector.name, this.router, timestamp],
{
silent: true,
clientIds: clientId
? // Skip routing the origin of the client
this.portDetector.clientIds.filter((id) => id !== clientId)
: undefined,
}
);
}
} else if (this.portDetector.shared) {
fork(this as any, '_changeRoutingOnClient', [name, router, timestamp], {
silent: true,
clientIds: clientId
? // Skip routing the origin of the client
this.portDetector.clientIds.filter((id) => id !== clientId)
: undefined,
});
}
}
protected _changeRoutingOnClient(
name: string,
router: RouterState,
timestamp?: number
) {
// if the client is the non-origin of the routing, skip it
// or if the timestamp of the routing is earlier than the last routing, skip it
if (
name !== this.portDetector.name ||
(timestamp && this.lastRoutedTimestamp >= timestamp)
)
return;
const route = () => {
if (this.history && this.compareRouter(router, this.router!)) {
this.passiveRoute = true;
this.history.push(router.location);
this.passiveRoute = false;
}
};
if (this.portDetector.disableSyncClient) {
this.toBeRouted = route;
} else {
route();
}
}
protected _makeRoutingOnClient({
args,
action,
name,
}: {
args: any[];
action: 'push' | 'replace' | 'go' | 'goBack' | 'goForward';
name: string;
}) {
return new Promise((resolve) => {
const route = () => {
if (name === this.portDetector.name) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
super[action](...args);
resolve(this.router);
}
};
if (this.portDetector.disableSyncClient) {
this.toBeRouted = route;
} else {
route();
}
});
}
toBeRouted: (() => void) | null = null;
protected _routers: Record<string, RouterState | undefined> = {
[this.portDetector.name]: this.router,
};
protected _setRouters(name: string, router: RouterState) {
if (
!this.enableCacheRouting ||
(this.enableCacheRouting && (this as any)[stateKey]._persist?.rehydrated)
) {
this._routers[name] = router;
}
}
// The server port routing state is received asynchronously, so there should be a default route.
protected get defaultRoute() {
return this.options.defaultRoute ?? '/';
}
protected get enableCacheRouting() {
const Storage = (this as any)[modulesKey][storageModuleName];
const routerPersistConfig = Storage?.persistConfig[routerModuleName];
return (
routerPersistConfig &&
(routerPersistConfig!.whitelist?.includes('_routers') ||
routerPersistConfig!.blacklist?.includes('_routers') === false)
);
}
protected defaultHistory = {
action: 'POP',
location: {
pathname: this.defaultRoute,
search: '',
hash: '',
state: undefined,
},
} as RouterState;
protected dispatchChanged(router?: RouterState) {
if (!router) return;
this.store?.dispatch(
this.onLocationChanged(router.location, router.action)!
);
}
get currentPath() {
return this.router?.location.pathname ?? this.defaultRoute;
}
async push(path: string, locationState?: LocationState) {
if (this.portDetector.isServerWorker) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [path, locationState],
action: 'push',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
} else {
this.lastRoutedTimestamp = Date.now();
super.push(path, locationState);
}
}
async replace(path: string, locationState?: LocationState) {
if (this.portDetector.isServerWorker) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [path, locationState],
action: 'replace',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
} else {
this.lastRoutedTimestamp = Date.now();
super.replace(path, locationState);
}
}
async go(n: number): Promise<void> {
if (!this.portDetector.shared) {
this.lastRoutedTimestamp = Date.now();
super.go(n);
return;
}
if (this.portDetector.isClient) {
return delegate(this as ReactantRouter, 'go', [n]);
}
if (n < 0) {
// Navigate backward (like goBack)
const stepsBack = Math.abs(n);
if (this.cachedHistory.length > stepsBack) {
// Pop the current route to the forward history stack
const currentRouter = this.cachedHistory.shift();
this.forwardHistory.unshift(currentRouter!); // Add to forward history
// Get the target router (stepsBack-th item)
const targetRouter = this.cachedHistory[stepsBack - 1];
if (targetRouter) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [
targetRouter.location.pathname,
targetRouter.location.state,
],
action: 'replace',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
} else {
console.warn('No more history to go back.');
}
} else {
console.warn('No more history to go back.');
}
} else if (n > 0) {
// Navigate forward (like goForward)
const stepsForward = n;
if (this.forwardHistory.length >= stepsForward) {
const targetRouter = this.forwardHistory[stepsForward - 1];
if (targetRouter) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [
targetRouter.location.pathname,
targetRouter.location.state,
],
action: 'push',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
} else {
console.warn('No more history to go forward.');
}
// Remove the used entry from the forward stack
this.forwardHistory.splice(0, stepsForward);
} else {
console.warn('No more history to go forward.');
}
} else {
// Go to the current route (refresh the page)
console.warn('Going to the current route (n = 0) does nothing.');
}
}
async goBack(): Promise<void> {
if (!this.portDetector.shared) {
this.lastRoutedTimestamp = Date.now();
super.goBack();
return;
}
if (this.portDetector.isClient) {
return delegate(this as ReactantRouter, 'goBack', []);
}
if (this.cachedHistory.length > 1) {
const currentRouter = this.cachedHistory.shift(); // Pop the current route
this.forwardHistory.unshift(currentRouter!); // Push to forward stack
this.forwardHistory.length = this.maxHistoryLength; // Limit the length of the forward stack
const previousRouter = this.cachedHistory[0]; // Get the previous route
if (previousRouter) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [
previousRouter.location.pathname,
previousRouter.location.state,
],
action: 'push',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
} else {
console.warn('No forward route available.');
}
} else {
console.warn('No previous route available.');
}
}
async goForward(): Promise<void> {
if (!this.portDetector.shared) {
this.lastRoutedTimestamp = Date.now();
super.goForward();
return;
}
if (this.portDetector.isClient) {
return delegate(this as ReactantRouter, 'goForward', []);
}
if (this.forwardHistory.length > 0) {
const nextRouter = this.forwardHistory.shift(); // Pop from forward stack
if (nextRouter) {
const router: RouterState = await fork(
this as any,
'_makeRoutingOnClient',
[
{
args: [nextRouter.location.pathname, nextRouter.location.state],
action: 'push',
name: this.portDetector.name,
},
]
);
this.dispatchChanged(router);
}
} else {
console.warn('No forward route available.');
}
}
}
export { ReactantRouter as Router, RouterOptions };