@eolme/vma-router
Version:
Router for VK Mini Apps
493 lines (395 loc) • 12.3 kB
text/typescript
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;