UNPKG

reactant-share

Version:

A framework for building shared web applications with Reactant

730 lines (686 loc) 21.1 kB
/* 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; } @injectable({ name: routerModuleName, }) 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, @inject(SharedAppOptions) protected sharedAppOptions: ISharedAppOptions, @inject(RouterOptions) 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; @state protected _routers: Record<string, RouterState | undefined> = { [this.portDetector.name]: this.router, }; @action 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 };