UNPKG

@eolme/vma-router

Version:
493 lines (395 loc) 12.3 kB
import { createBus } from '@eolme/vma-engine'; import type { Emitter } from '@eolme/vma-engine'; import Route, { PAGE_MAIN } from './Route'; import Scheduler from './Scheduler'; import { log, error } from '../utils/report'; import type { RouteList, RouteStack, HistoryEvent, RouteLike } from '../types'; class History { private static _name = '[History]'; private _idle: boolean = false; private _offset!: number; private _stack!: RouteStack; private _index!: number; private _bus!: Emitter; private _scheduler!: Scheduler; routes!: RouteList; constructor(routes) { this.routes = routes; this._initEmitter(); this._initScheduler(); this._initHistory(); this._initListener(); } get index() { return this._index; } get location() { return window.location.hash.slice(1); } get route(): Readonly<Route> { return this._stack[this._index]; } get length() { return this._stack.length; } push(route: Route) { log(History._name, 'Queue push', route); this._scheduler.nextTick(() => { log(History._name, 'Enqueue push', route); const event: HistoryEvent = { prev: this.route, next: route }; route.index = ++this._index; this._stack.push(route); window.history.pushState(route, route.uri, '#' + route.uri); log(History._name, 'Update after push.'); this._bus.emit('update', event); }); } replace(route: Route) { log(History._name, 'Queue replace', route); this._scheduler.nextTick(() => { log(History._name, 'Enqueue replace', route); const event: HistoryEvent = { prev: this.route, next: route }; route.index = this._index; this._stack.pop(); this._stack.push(route); window.history.replaceState(route, route.uri, '#' + route.uri); log(History._name, 'Update after replace.'); this._bus.emit('update', event); }); } moveBy(by: number) { if (by === 0) { log(History._name, 'Moving from current to current is the same as reloading window.'); this._bus.emit('reload'); return; } const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); log(History._name, 'Queue move by', by); tick.then(() => { log(History._name, 'Enqueue move by', by); window.history.go(by); }); } moveTo(to: number) { const delta = to - this._index; if (delta === 0) { log(History._name, 'Moving from current to current is the same as reloading window.'); this._bus.emit('reload'); return; } const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); log(History._name, 'Queue move to', to); tick.then(() => { log(History._name, 'Enqueue move to', to); window.history.go(delta); }); } back() { if (this._index === 0) { log(History._name, 'Going back without history is the same as reloading window.'); this._bus.emit('reload'); return; } const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); log(History._name, 'Queue back'); tick.then(() => { log(History._name, 'Enqueue back'); window.history.back(); }); } reset() { if (this._index === 0) { log(History._name, 'Resetting without history is the same as reloading window.'); this._bus.emit('reload'); return; } const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); log(History._name, 'Queue reset.'); tick.then(() => { log(History._name, 'Enqueue reset.'); window.history.go(-1 * this._index); }); } pushAfterMove(prevRoute: Route, nextRoute: Route) { let prevIndex = prevRoute.index; if (prevRoute.index === -1) { prevIndex = this.indexOf(prevRoute); } if (this.canMoveTo(prevIndex)) { const delta = prevIndex - this._index; if (delta === 0) { this.replace(nextRoute); } else { this._idle = true; const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); this._scheduler.nextTick(() => { this.push(nextRoute); }).then(() => { this._idle = false; }); tick.then(() => { window.history.go(delta); }); } return; } let nextIndex = nextRoute.index; if (nextRoute.index === -1) { nextIndex = this.lastIndexOf(nextRoute); } if (this.canMoveTo(nextIndex)) { const delta = nextIndex - this._index; if (delta === 0) { this.push(nextRoute); } else { this._idle = true; const tick = this._scheduler.nextTick(); this._scheduler.setTick(this._createTickWithPopstate()); this._scheduler.nextTick(() => { this.push(nextRoute); }).then(() => { this._idle = false; }); tick.then(() => { window.history.go(delta); }); } return; } if (prevRoute.isSameWith(nextRoute)) { this.replace(nextRoute); } else { error('Cant find pair in history for', prevRoute, nextRoute); } } canMoveBy(by: number) { const next = this._index + by; return next >= 0 && next < this._stack.length; } canMoveTo(to: number) { return to >= 0 && to < this._stack.length; } indexOf(route: RouteLike) { for (let i = 0, find: Route; i < this._stack.length; ++i) { find = this._stack[i]; if (find.isSameWith(route)) { return i; } } return -1; } lastIndexOf(route: RouteLike) { for (let i = this._stack.length - 1, find: Route; i >= 0; --i) { find = this._stack[i]; if (find.isSameWith(route)) { return i; } } return -1; } check() { const historyLength = window.history.length - this._offset; const stackLength = this._stack.length; const historyRoute = window.history.state as Route; const stackRoute = this.route; const isNormal = ( historyLength === stackLength && historyRoute && stackRoute && historyRoute.index === stackRoute.index && historyRoute.uri === stackRoute.uri ); if (isNormal) { log(History._name, 'History in the correct state.'); } else { log(History._name, 'History in an incorrect state. Need to fix.'); this._fixHistory(); } } /** * History is broken after: * - VKPay * - Post from notification * - Outside manipulations */ private _fixHistory() { const historyIndex = ( window.history.state && typeof window.history.state.index === 'number' ? window.history.state.index : -1 ); let isHistoryClean = ( window.history.length === 1 || window.history.length === (this._offset + 1) || historyIndex === 0 ); const isCanPush = ( !isHistoryClean && historyIndex !== -1 && historyIndex < this._index ); if (isCanPush) { log(History._name, 'Fixing by push missing.'); log(History._name, 'Queue push missing.'); this._scheduler.nextTick(() => { log(History._name, 'Enqueue push missing.'); const append = this._stack.slice(historyIndex); append.forEach((route) => { window.history.pushState(route, route.uri, route.uri); }); const event: HistoryEvent = { prev: this.route, next: this.route }; log(History._name, 'Update after push missing.'); this._bus.emit('update', event); }); return; } const isCleanable = ( !isHistoryClean && window.history.length === (this._stack.length + this._offset) ); if (isCleanable) { log(History._name, 'Fixing by clean history.'); log(History._name, 'Queue history clearing.'); this._scheduler.nextTick(() => { log(History._name, 'Enqueue history clearing.'); this._idle = true; this._scheduler.setTick(this._createTickWithPopstate()); this._scheduler.nextTick(() => { this._idle = false; }); const by = this._offset - window.history.length + 1; window.history.go(by); }); isHistoryClean = true; } if (isHistoryClean) { log(History._name, 'Fixing by re-push.'); log(History._name, 'Queue re-push.'); this._scheduler.nextTick(() => { log(History._name, 'Enqueue re-push.'); const first = this._stack[0]; window.history.replaceState(first, first.uri, first.uri); const other = this._stack.slice(1); other.forEach((route) => { window.history.pushState(route, route.uri, route.uri); }); const event: HistoryEvent = { prev: this.route, next: this.route }; log(History._name, 'Update after re-push.'); this._bus.emit('update', event); }); return; } error('History in unknown state. Impossible to fix.'); } private _initEmitter() { this._bus = createBus(); this.on = this._bus.on.bind(this); this.once = this._bus.once.bind(this); this.off = this._bus.off.bind(this); } private _initScheduler() { this._scheduler = new Scheduler(); } private _initHistory() { const initRoute = new Route(); initRoute.index = 0; initRoute.page = PAGE_MAIN; initRoute.uri = PAGE_MAIN; window.history.replaceState(initRoute, initRoute.uri, '#' + initRoute.uri); this._stack = [initRoute]; this._offset = window.history.length - 1; this._index = 0; } private _initListener() { window.addEventListener('popstate', (e = window.event as PopStateEvent) => { log(History._name, 'Queue popstate.'); this._scheduler.nextTick(() => { log(History._name, 'Enqueue popstate.'); if (this._idle) { // Router is idle log(History._name, 'Popstate while Router is idle. This is normal behavior while waiting for an action.'); return; } let prevRoute: Route; let nextRoute: Route; const fromIndex = e.state?.index ?? -1; const toIndex = window.history.state?.index ?? -1; if (fromIndex !== -1) { if (fromIndex < this.length) { prevRoute = this._stack[fromIndex]; } else { const state = e.state as RouteLike; prevRoute = Route.buildFromState(this.routes, state); } } else { prevRoute = null; } if (toIndex !== -1) { if (toIndex < this._stack.length) { nextRoute = this._stack[toIndex]; } else { const state = window.history.state as RouteLike; nextRoute = Route.buildFromState(this.routes, state); } } else { nextRoute = Route.buildFromLocation(this.routes, this.location); } if (nextRoute.index !== -1) { this._index = nextRoute.index; } else { const index = this.lastIndexOf(nextRoute); if (index === -1) { this._index++; } else { this._index = index; } nextRoute.index = this._index; } const delta = () => nextRoute.index - (this._stack.length - 1); const offset = delta(); if (offset > 1) { error('Back to the Future.'); } while (delta() <= 0) { this._stack.pop(); } this._stack.push(nextRoute); const event: HistoryEvent = { prev: prevRoute, next: nextRoute }; log(History._name, 'Update after popstate.'); this._bus.emit('update', event); }); }); } _createTickWithPopstate(): Promise<void> { return new Promise((resolve) => { const flush = () => { window.removeEventListener('popstate', flush); window.setTimeout(resolve, 26); }; window.addEventListener('popstate', flush); }); } on: Emitter['on']; once: Emitter['once']; off: Emitter['off']; } export { History }; export default History;