native-spa-route
Version:
provide SPA route's experience with native web component
808 lines (710 loc) • 22.1 kB
text/typescript
import debug from 'debug';
import { css, html, LitElement, nothing, PropertyValueMap } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { cache } from 'lit/directives/cache.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { hook_route_change, unhook_route_change } from '../lib/hooks';
import { afterCssReady } from '../lib/cssReady';
const dlog = debug('native-spa-route');
const dcachelog = debug('native-spa-route:cache');
export type RouteConfig = {
path: string;
title: string;
children?: RouteConfig[];
}[];
const DYNAMIC_ROUTE_PATH_REGEXP = /^\:/;
function getFullPath(url: string, node: HTMLElement): string {
let isR = /^\//.test(url);
if (node.parentElement && node.parentElement.tagName !== 'BODY') {
if (node.parentElement.tagName === 'NATIVE-ROUTE') {
const parentPath = node.parentElement.getAttribute('path') ?? '';
let _url = `${isR ? '' : '/'}${url}`;
if (parentPath === '' || parentPath === '/') {
return _url;
} else {
return getFullPath(`${parentPath}${_url}`, node.parentElement);
}
} else {
return getFullPath(url, node.parentElement);
}
}
let ret = url;
if (!isR) {
ret = `/${url}`;
}
if (ret !== '/') {
ret = ret.replace(/\/$/, '');
}
return ret;
}
export class Route extends LitElement {
static styles = css`
.loading-container {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
height: 100%;
}
`;
path: string = '';
exact: boolean = false;
element: string = '';
url?: string;
lazy: boolean = false;
appendDirection: 'before' | 'after' = 'after';
customRender: boolean | string = false;
renderAfterReady: boolean = false;
disableShadow: boolean = false;
shadowCSSUrl: string = '';
loadingElement?: string;
lockLoadingTime?: number;
/**
* alias as shadowCSSUrl, more readable
*/
cssUrl: string | string[] = '';
cssParseMode: 'inline' | 'base64' = 'inline';
cssDelayRender: number = 32;
errorRender?: (error: Error) => string;
drop: boolean = false;
cssOnly: boolean = false;
customCSSBlockRenderTime?: number;
cacheVaildTime: number = 10 * 60 * 1000;
/**
* if enabled virtualNode, native-route-container will ignore this node.
*/
virtualNode: boolean = false;
groupMatchMode: boolean = false;
private _style_tag_ref = createRef<HTMLStyleElement>();
private log(...args: any[]) {
return dlog(`[${this.path}]`, ...args);
}
private cachelog(...args: any[]) {
return dcachelog(`[${this.path}]`, ...args);
}
private active: boolean = false;
private fullpath: string = '';
protected _url_module?: Promise<any>;
protected moduleReady: 'nothing' | 'pending' | 'fulfilled' | 'rejected' =
'nothing';
protected cssReady: 'nothing' | 'pending' | 'fulfilled' | 'rejected' =
'nothing';
protected cssContent: string = '';
protected _css_base64?: string;
isExactMatch: boolean = false;
isModuleRenderInstanceDestroied: boolean = false;
cacheDestroyTimer?: ReturnType<typeof setTimeout>;
public checkIsExactMatch() {
return this.isExactMatch;
}
public getFullPath() {
return this.fullpath;
}
private isCssExsit(): boolean {
const cssUrl = this.shadowCSSUrl || this.cssUrl;
if (Array.isArray(cssUrl)) {
return cssUrl.length > 0;
} else {
return cssUrl !== '';
}
}
public isActive() {
return this.active;
}
private isRenderError() {
if (this.errorRender) {
if (
this.moduleReady === 'rejected' ||
((this.shadowCSSUrl || this.cssUrl) && this.cssReady === 'rejected')
) {
return true;
}
}
return false;
}
private isRenderLoading() {
if (this.renderAfterReady) {
if (this.moduleReady !== 'fulfilled') {
if (this.loadingElement) {
return true;
}
return false;
}
if (this.isCssExsit()) {
if (this.cssReady !== 'fulfilled') {
if (this.loadingElement) {
return true;
}
return false;
}
}
}
return false;
}
private isRenderElement() {
if (!this.renderAfterReady) return true;
if (this.isCssExsit()) {
if (this.cssReady !== 'fulfilled') {
return false;
}
}
if (this.moduleReady !== 'fulfilled') {
return false;
}
return true;
}
private renderErrorContent() {
if (!this.errorRender) return nothing;
if (this.moduleReady === 'rejected') {
return html`${unsafeHTML(
this.errorRender(new Error('module load rejected'))
)}`;
}
if (this.isCssExsit()) {
if (this.cssReady === 'rejected') {
return html`${unsafeHTML(
this.errorRender(new Error('css load rejected'))
)}`;
}
}
return nothing;
}
private renderLoading() {
return html`
<div part="loading-container" class="loading-container">
${unsafeHTML(this.loadingElement)}
</div>
`;
}
private renderElement() {
return html`${unsafeHTML(this.element)}`;
}
protected render() {
// console.groupCollapsed('render');
const content = !this.active
? nothing
: this.isRenderError()
? this.renderErrorContent()
: html`
${this.appendDirection === 'before' ? html`<slot></slot>` : nothing}
<style ${ref(this._style_tag_ref)} src=${ifDefined(this._css_base64)}>
${this.cssParseMode === 'base64' ? '' : this.cssContent}
</style>
${this.isRenderLoading()
? this.renderLoading()
: !this.customRender
? this.isRenderElement()
? this.renderElement()
: nothing
: html`<div
part="custom-render-container"
class="custom-render-container"
></div>`}
${this.appendDirection !== 'before' ? html`<slot></slot>` : nothing}
`;
// console.log('this.cssContent: ', this.cssContent);
// console.groupEnd();
return this.drop ? content : html`${cache(content)}`;
}
private setCSSContent(css: string, append: boolean = false) {
if (append) {
this.cssContent += css;
} else {
this.cssContent = css;
}
}
private async _load_CSS() {
if (!this.isCssExsit() || this.cssReady === 'fulfilled') {
return;
}
if (this.cssContent) {
this.cssReady = 'pending';
this.setCSSContent(this.cssContent, false);
} else {
const css_url = this.shadowCSSUrl || this.cssUrl;
if (css_url) {
this.cssReady = 'pending';
try {
// fetch css content
if (Array.isArray(css_url)) {
const css_content = await Promise.all(
css_url.map((l) => fetch(l).then((t) => t.text()))
);
this.setCSSContent(css_content.join(''), false);
} else {
const css_content = await fetch(css_url).then((t) => t.text());
this.setCSSContent(css_content, false);
}
} catch (error) {
console.error(error);
this.cssReady = 'rejected';
return;
}
}
}
await this.updateComplete;
// block custom render until style parse end
if (this.renderAfterReady) {
let styleTag = this._style_tag_ref.value;
if (!styleTag) {
// try find style dom
let t;
if (this.disableShadow) {
t = this.querySelector('style');
} else {
t = this.renderRoot.querySelector('style');
}
if (t?.tagName === 'STYLE') {
styleTag = t;
}
}
if (!styleTag) {
console.error('cannot found style tag, make css load rejected');
this.cssReady = 'rejected';
return;
}
let isReady = await afterCssReady(this, styleTag);
if (isReady) {
this.cssReady = 'fulfilled';
} else {
this.cssReady = 'rejected';
}
} else {
this.cssReady = 'fulfilled';
}
}
private async _load_URL_module() {
const url = this.url;
if (typeof url === 'string' && this.moduleReady !== 'fulfilled') {
this.moduleReady = 'pending';
const loadStartTime = Date.now();
this._url_module = import(
/* @vite-ignore */ /* webpackIgnore: true */ url
)
.then((t) => {
this.log('module load fulfilled');
if (!this.drop && !t.destroy) {
console.error(
`cannot found module [${this._url_module}] \`destroy\` method`
);
}
// show loading in a fixed time for better experience
if (this.lockLoadingTime) {
const _now = Date.now();
const _now_d = _now - loadStartTime;
if (_now_d < this.lockLoadingTime) {
setTimeout(() => {
this.moduleReady = 'fulfilled';
}, this.lockLoadingTime - _now_d);
} else {
this.moduleReady = 'fulfilled';
}
} else {
this.moduleReady = 'fulfilled';
}
return t;
})
.catch((e) => {
console.error(e);
this.log('module load rejected');
this.moduleReady = 'rejected';
});
}
}
private async loadAssets() {
this.log('start load assets');
this._load_CSS();
this._load_URL_module();
}
_route_match_check_path: string = '';
_route_match_check_grouped_path: (string | RegExp)[] = [];
private route_change_callback = () => {
let _path = this.fullpath;
let _grouped_path = this._route_match_check_grouped_path;
this.isExactMatch = window.location.pathname === _path;
if (this.isExactMatch) {
this.active = true;
return;
}
if (this.exact) {
this.active = window.location.pathname === _path;
return;
}
const pathname = window.location.pathname ?? '/';
const local_pg = pathname.split('/');
const route_pg = _grouped_path;
if (route_pg.length > local_pg.length) {
this.active = false;
return;
}
let isMatch = true;
for (let i = 0; i < route_pg.length; i++) {
if (i >= local_pg.length) break;
const l_p = local_pg[i];
const r_p = route_pg[i];
if (r_p instanceof RegExp) {
if (!r_p.test(l_p)) {
isMatch = false;
break;
}
} else if (l_p !== r_p) {
isMatch = false;
break;
}
}
this.active = isMatch;
return;
};
// @query('div.custom-render-container', true)
cacheCustomRenderDom?: HTMLDivElement;
private _could_set_cache_invalid_timer() {
return this.customRender && !this.drop && this.moduleReady === 'fulfilled';
}
private _call_module_destroy() {
this.log('call module destroy');
if (this.moduleReady === 'fulfilled') {
if (this.cacheDestroyTimer) {
this.log('clear destroy timer first');
clearTimeout(this.cacheDestroyTimer);
this.cacheDestroyTimer = undefined;
}
this._url_module?.then(({ destroy }) => {
if (typeof destroy === 'function') {
try {
this.cachelog('call module destory in module: ', this);
destroy();
if (!this.drop) {
this.isModuleRenderInstanceDestroied = true;
}
} catch (error) {
console.warn('module destory failed');
console.warn(error);
}
} else {
this.cachelog('not found destroy function');
}
});
} else {
console.warn(
'module not fullfilled, cannot call route component destroy'
);
}
}
private _set_cache_invalid_timer() {
this.log('call cache invaild timer');
if (!this._could_set_cache_invalid_timer()) {
return this.cachelog('cannot set cache clear timer');
}
if (this.cacheDestroyTimer) {
this.cachelog('clear cache timer');
clearTimeout(this.cacheDestroyTimer);
this.cacheDestroyTimer = undefined;
}
if (!this.active) {
this.cachelog('start set cache clear timer');
this.cacheDestroyTimer = setTimeout(() => {
this._call_module_destroy();
}, this.cacheVaildTime);
}
}
private async _render_url_module() {
if (this.shadowCSSUrl && this.cssUrl) {
if (this.cssReady !== 'fulfilled' && this.cssReady !== 'rejected') {
return;
}
}
if (this.moduleReady !== 'fulfilled' && this.moduleReady !== 'rejected') {
return;
}
this.log('all source load end, continue logic');
let render;
if (this.moduleReady === 'rejected') {
if (this.errorRender) {
render = this.errorRender;
} else {
throw new Error(
'No component render found, and error render not found'
);
}
}
if (this.moduleReady === 'fulfilled') {
let module = await this._url_module;
this.log('module: ', module);
this.log('this.customRender: ', this.customRender);
if (typeof this.customRender === 'string') {
render = module[this.customRender];
} else if (this.customRender) {
render = module['render'];
} else {
// do nothing
}
}
this.log('render function: ', render);
if (render) {
await this.updateComplete; // wait dom render end
let customRenderDom = this.disableShadow
? (this.querySelector('div.custom-render-container') as HTMLDivElement)
: this.cacheCustomRenderDom;
if (!customRenderDom) {
console.warn('not found custom render dom');
return;
}
if (!this.drop) {
if (this.isModuleRenderInstanceDestroied) {
this.log('cache invalid, call `render` function');
this.isModuleRenderInstanceDestroied = false;
render(customRenderDom);
} else if (!customRenderDom?.children?.length) {
this.log('custom render target has no children, call `render`');
render(customRenderDom);
} else {
this.log(
'custom render target has children, will not duplicately call render'
);
}
} else {
render(customRenderDom);
}
return;
}
}
protected willUpdate(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
if (this.cssParseMode === 'base64') {
if (_changedProperties.has('cssContent')) {
this._css_base64 = Buffer.from(this.cssContent, 'base64url').toString();
}
}
}
async updated(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('active')) {
this.dispatchEvent(
new CustomEvent('route:active_status_change', {
detail: {
current: this.active,
previous: changedProperties.get('active'),
},
bubbles: true,
composed: true,
})
);
this.dispatchEvent(
new CustomEvent(`route:${this.active ? 'active' : 'un_active'}`, {
detail: {},
bubbles: true,
composed: true,
})
);
if (this.url) {
if (this.active) {
if (this.cacheDestroyTimer) {
this.cachelog('current module active, clear timer', this);
clearTimeout(this.cacheDestroyTimer);
this.cacheDestroyTimer = undefined;
}
this.log('attach route active');
if (
this.lazy &&
(!this._url_module ||
this.moduleReady !== 'fulfilled' ||
(this.isCssExsit() && this.cssReady !== 'fulfilled'))
) {
this.log('in lazy mode, some source not loaded, call loadAssets');
this.loadAssets();
} else {
this._render_url_module();
}
} else {
if (this.drop) {
this._call_module_destroy();
} else {
this._set_cache_invalid_timer();
}
}
}
}
if (changedProperties.has('moduleReady')) {
if (changedProperties.get('moduleReady') === 'pending') {
this._render_url_module();
}
}
if (changedProperties.has('cssReady')) {
if (changedProperties.get('cssReady') === 'pending') {
this._render_url_module();
}
}
if (changedProperties.has('path')) {
this.fullpath = getFullPath(this.path, this);
}
if (changedProperties.has('isExactMatch')) {
this.dispatchEvent(
new CustomEvent('route:exact_match_change', {
detail: this.isExactMatch,
bubbles: true,
composed: true,
})
);
}
}
private _parse_route_match_check_group() {
let _fullpath = this.fullpath;
let _grouped_path: string[];
const baseSplit = (path: string) => {
if (path === '/') {
// '/'.split('/') === ['', '']
// expact as ['']
return [''];
}
let pathGroup = path.split('/');
return pathGroup;
};
if (this.groupMatchMode) {
_grouped_path = _fullpath.split(this.path);
if (_fullpath.endsWith(this.path)) {
_grouped_path = _grouped_path.slice(0, -1); // remove lastest empty str
}
_grouped_path = _grouped_path.map((t) => t.replace(/\/$/, '')); // remove lastest `/`
_grouped_path = [
...baseSplit(_grouped_path[0]),
this.path,
..._grouped_path.slice(1),
];
} else {
_grouped_path = baseSplit(_fullpath);
}
return _grouped_path.map((t) =>
DYNAMIC_ROUTE_PATH_REGEXP.test(t)
? new RegExp(t.replace(DYNAMIC_ROUTE_PATH_REGEXP, ''))
: t
);
}
connectedCallback() {
super.connectedCallback();
this.fullpath = getFullPath(this.path, this);
// parse route group
this._route_match_check_path = this.fullpath;
this._route_match_check_grouped_path =
this._parse_route_match_check_group();
if (!this.lazy && this.url) {
this.loadAssets();
}
// while css only mode, direct set module load fulfilled;
if (this.cssOnly) {
this.moduleReady = 'fulfilled';
this._url_module = Promise.resolve({
render() {},
destroy() {},
});
}
this.route_change_callback();
hook_route_change(this.route_change_callback);
}
disconnectedCallback() {
super.disconnectedCallback();
unhook_route_change(this.route_change_callback);
}
protected createRenderRoot() {
if (!this.disableShadow) {
return super.createRenderRoot();
}
return this;
}
}
declare global {
interface HTMLElementTagNameMap {
'native-route': Route;
}
interface DocumentEventMap {
/**
* dispatch event while active status changed
*/
'route:active_status_change': CustomEvent<{
current: boolean;
previous: boolean;
}>;
'route:active': CustomEvent<{}>;
'route:un_active': CustomEvent<{}>;
/**
* dispatch event while isExactMatch attribute changed
*/
'route:exact_match_change': CustomEvent<boolean>;
}
}