@v4fire/client
Version:
V4Fire client core library
451 lines (352 loc) • 10.1 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* This package provides a router engined based on the HTML history API with support of dynamic loading of entry points
* @packageDescription
*/
import symbolGenerator from 'core/symbol';
import { deprecate } from 'core/functools/deprecation';
import { session } from 'core/kv-storage';
import { fromQueryString, toQueryString } from 'core/url';
import { EventEmitter2 as EventEmitter } from 'eventemitter2';
import * as browser from 'core/browser';
import type bRouter from 'base/b-router/b-router';
import type { Router, Route, HistoryClearFilter } from 'core/router/interface';
export const
$$ = symbolGenerator();
const
isIFrame = location !== parent.location;
/**
* This code is needed to fix a bug with the History API router engine when backing to the
* first history item doesn’t emit a popstate event in Safari if the script is running within an iframe
* @see https://github.com/V4Fire/Client/issues/717
*/
if (isIFrame && (browser.is.Safari !== false || browser.is.iOS !== false)) {
history.pushState({}, '', location.href);
}
/**
* This flag is needed to get rid of a redundant router transition when restoring the page from BFCache in safari
* @see https://github.com/V4Fire/Client/issues/552
*/
let isOpenedFromBFCache = false;
// The code below is a shim of "clear" logic of the route history:
// it's used the session storage API to clone native history and some hacks to clear th history.
// The way to clear the history is base on the mechanics when we rewind to the previous route of the route we want
// to clear and after the router emit a new transition to erase the all upcoming routes.
// After this, we need to restore some routes, that were unnecessarily dropped from the history,
// that why we need the history clone.
let
historyLogPointer = 0,
isHistoryInit = false;
type HistoryLog = Array<{
route: string;
params: Route;
}>;
const
historyLog = <HistoryLog>[],
historyStorage = session.namespace('[[BROWSER_HISTORY]]');
/**
* Truncates the history clone log to the real history size
*/
function truncateHistoryLog(): void {
if (historyLog.length <= history.length) {
return;
}
if (historyLogPointer >= history.length) {
historyLogPointer = history.length - 1;
saveHistoryPos();
}
historyLog.splice(history.length);
saveHistoryLog();
}
/**
* Saves the history log to the session storage
*/
function saveHistoryLog(): void {
try {
historyStorage.set('log', historyLog);
} catch {}
}
/**
* Saves the active position of a history to the session storage
*/
function saveHistoryPos(): void {
try {
historyStorage.set('pos', historyLogPointer);
} catch {}
}
// Try to load history log from the session storage
try {
historyLogPointer = historyStorage.get('pos') ?? 0;
for (let o = <HistoryLog>historyStorage.get('log'), i = 0; i < o.length; i++) {
const
el = o[i];
if (Object.isPlainObject(el)) {
historyLog.push(el);
}
}
truncateHistoryLog();
} catch {}
/**
* Creates an engine (browser history api) for `bRouter` component
* @param component
*/
export default function createRouter(component: bRouter): Router {
const
{async: $a} = component;
const
engineGroup = {group: 'routerEngine'},
popstateLabel = {...engineGroup, label: $$.popstate},
pageshowLabel = {...engineGroup, label: $$.pageshow},
modHistoryLabel = {...engineGroup, label: $$.modHistory};
$a
.clearAll(engineGroup);
function load(route: string, params?: Route, method: string = 'pushState'): Promise<void> {
if (!Object.isTruly(route)) {
throw new ReferenceError('A page to load is not specified');
}
// Remove some redundant characters
route = route.replace(/[
return new Promise((resolve) => {
let
syncMethod = method;
if (params == null) {
location.href = route;
return;
}
// The route identifier is needed to support the feature of the history clearing
if (params._id == null) {
params._id = Math.random().toString().slice(2);
}
if (method !== 'replaceState') {
isHistoryInit = true;
} else if (!isHistoryInit) {
isHistoryInit = true;
// Prevent pushing of one route more than one times:
// this situation take a place when we reload the browser page
if (historyLog.length > 0 && !Object.fastCompare(
Object.reject(historyLog[historyLog.length - 1]?.params, '_id'),
Object.reject(params, '_id')
)) {
syncMethod = 'pushState';
}
}
if (historyLog.length === 0 || syncMethod === 'pushState') {
historyLog.push({route, params});
historyLogPointer = historyLog.length - 1;
saveHistoryPos();
} else {
historyLog[historyLog.length - 1] = {route, params};
}
saveHistoryLog();
const
qsRgxp = /\?.*?(?=
/**
* Parses parameters from the query string
*
* @param qs
* @param test
*/
const parseQuery = (qs: string, test?: boolean) => {
if (test && !RegExp.test(qsRgxp, qs)) {
return {};
}
return fromQueryString(qs);
};
params.query = Object.assign(parseQuery(route, true), params.query);
let
qs = toQueryString(params.query);
if (qs !== '') {
qs = `?${qs}`;
if (RegExp.test(qsRgxp, route)) {
route = route.replace(qsRgxp, qs);
} else {
route += qs;
}
}
if (location.href !== route) {
params.url = route;
// "params" can contain proxy objects,
// to avoid DataCloneError we should clone it by using Object.mixin({deep: true})
const filteredParams = Object.mixin({deep: true, filter: (el) => !Object.isFunction(el)}, {}, params);
history[method](filteredParams, params.name, route);
}
const
// eslint-disable-next-line @typescript-eslint/unbound-method
{load} = params.meta;
if (load == null) {
resolve();
return;
}
load().then(() => resolve()).catch(stderr);
});
}
const emitter = new EventEmitter({
maxListeners: 1e3,
newListener: false
});
const router = Object.mixin({withAccessors: true}, Object.create(emitter), <Router>{
get route(): CanUndef<Route> {
const
url = this.id(location.href);
return {
name: url,
/** @deprecated */
page: url,
query: fromQueryString(location.search),
...history.state,
url
};
},
get page(): CanUndef<Route> {
deprecate({name: 'page', type: 'accessor', renamedTo: 'route'});
return this.route;
},
get history(): Route[] {
const
list = <Route[]>[];
for (let i = 0; i < historyLog.length; i++) {
list.push(historyLog[i].params);
}
return list;
},
id(route: string): string {
try {
return new URL(route).pathname;
} catch {
return route;
}
},
push(route: string, params?: Route): Promise<void> {
return load(route, params);
},
replace(route: string, params?: Route): Promise<void> {
return load(route, params, 'replaceState');
},
go(pos: number): void {
history.go(pos);
},
forward(): void {
history.forward();
},
back(): void {
history.back();
},
async clear(filter?: HistoryClearFilter): Promise<void> {
$a.muteEventListener(popstateLabel);
truncateHistoryLog();
const
cutIntervals = <number[][]>[[]];
let
lastEnd = 0;
for (let i = 0; i < historyLog.length; i++) {
const
interval = cutIntervals[cutIntervals.length - 1];
if (i > 0 && (!filter || Object.isTruly(filter(historyLog[i].params)))) {
if (interval.length === 0) {
interval.push(i > 0 ? i : 1);
}
} else {
if (lastEnd === 0) {
lastEnd = i;
}
if (interval.length > 0) {
interval.push(i);
cutIntervals.push([]);
}
}
}
const
last = cutIntervals[cutIntervals.length - 1];
switch (last.length) {
case 0:
cutIntervals.pop();
break;
case 1:
last.push(lastEnd);
break;
default:
// Loopback
}
if (cutIntervals.length === 0) {
return;
}
for (let i = cutIntervals.length; i-- > 0;) {
const
el = cutIntervals[i];
const
from = el[0],
to = historyLog[el[1]];
if (from <= historyLogPointer) {
history.go(from - historyLogPointer - 1);
}
await $a.promisifyOnce(globalThis, 'popstate', modHistoryLabel);
historyLog.splice(from);
historyLog.push(to);
history.pushState(
to.params,
to.params.name,
to.route
);
saveHistoryLog();
// eslint-disable-next-line require-atomic-updates
historyLogPointer = historyLog.length - 1;
saveHistoryPos();
await $a.nextTick();
}
$a.unmuteEventListener(popstateLabel);
truncateHistoryLog();
const
lastPos = historyLogPointer - cutIntervals[0][0];
if (lastPos > 0) {
history.go(lastPos);
await $a.promisifyOnce(globalThis, 'popstate', modHistoryLabel);
// eslint-disable-next-line require-atomic-updates
historyLogPointer = lastPos;
saveHistoryPos();
}
},
clearTmp(): Promise<void> {
return this.clear((el) => {
if (!Object.isPlainObject(el)) {
return false;
}
return Object.isTruly(el.params?.tmp) || Object.isTruly(el.query?.tmp) || Object.isTruly(el.meta?.tmp);
});
}
});
$a.on(globalThis, 'popstate', async () => {
if (browser.is.iOS !== false && isOpenedFromBFCache) {
isOpenedFromBFCache = false;
return;
}
truncateHistoryLog();
const
routeId = Object.get(history, 'state._id');
if (routeId != null) {
try {
for (let i = 0; i < historyLog.length; i++) {
if (Object.get(historyLog[i], 'params._id') === routeId) {
historyLogPointer = i;
saveHistoryPos();
break;
}
}
} catch (err) {
stderr(err);
}
}
await component.emitTransition(location.href, history.state, 'event');
}, popstateLabel);
$a.on(globalThis, 'pageshow', (event: PageTransitionEvent) => {
if (event.persisted) {
isOpenedFromBFCache = true;
}
}, pageshowLabel);
return router;
}