UNPKG

native-spa-route

Version:

provide SPA route's experience with native web component

758 lines (757 loc) 26 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import debug from 'debug'; import { css, html, LitElement, nothing } 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'); const DYNAMIC_ROUTE_PATH_REGEXP = /^\:/; function getFullPath(url, node) { 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; } let Route = class Route extends LitElement { constructor() { super(...arguments); this.path = ''; this.exact = false; this.element = ''; this.lazy = false; this.appendDirection = 'after'; this.customRender = false; this.renderAfterReady = false; this.disableShadow = false; this.shadowCSSUrl = ''; /** * alias as shadowCSSUrl, more readable */ this.cssUrl = ''; this.cssParseMode = 'inline'; this.cssDelayRender = 32; this.drop = false; this.cssOnly = false; this.cacheVaildTime = 10 * 60 * 1000; /** * if enabled virtualNode, native-route-container will ignore this node. */ this.virtualNode = false; this.groupMatchMode = false; this._style_tag_ref = createRef(); this.active = false; this.fullpath = ''; this.moduleReady = 'nothing'; this.cssReady = 'nothing'; this.cssContent = ''; this.isExactMatch = false; this.isModuleRenderInstanceDestroied = false; this._route_match_check_path = ''; this._route_match_check_grouped_path = []; this.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; }; } static { this.styles = css ` .loading-container { display: flex; align-items: center; justify-content: space-around; width: 100%; height: 100%; } `; } log(...args) { return dlog(`[${this.path}]`, ...args); } cachelog(...args) { return dcachelog(`[${this.path}]`, ...args); } checkIsExactMatch() { return this.isExactMatch; } getFullPath() { return this.fullpath; } isCssExsit() { const cssUrl = this.shadowCSSUrl || this.cssUrl; if (Array.isArray(cssUrl)) { return cssUrl.length > 0; } else { return cssUrl !== ''; } } isActive() { return this.active; } isRenderError() { if (this.errorRender) { if (this.moduleReady === 'rejected' || ((this.shadowCSSUrl || this.cssUrl) && this.cssReady === 'rejected')) { return true; } } return false; } 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; } isRenderElement() { if (!this.renderAfterReady) return true; if (this.isCssExsit()) { if (this.cssReady !== 'fulfilled') { return false; } } if (this.moduleReady !== 'fulfilled') { return false; } return true; } 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; } renderLoading() { return html ` <div part="loading-container" class="loading-container"> ${unsafeHTML(this.loadingElement)} </div> `; } renderElement() { return html `${unsafeHTML(this.element)}`; } 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)}`; } setCSSContent(css, append = false) { if (append) { this.cssContent += css; } else { this.cssContent = css; } } 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'; } } 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'; }); } } async loadAssets() { this.log('start load assets'); this._load_CSS(); this._load_URL_module(); } _could_set_cache_invalid_timer() { return this.customRender && !this.drop && this.moduleReady === 'fulfilled'; } _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'); } } _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); } } 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') : 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; } } willUpdate(_changedProperties) { if (this.cssParseMode === 'base64') { if (_changedProperties.has('cssContent')) { this._css_base64 = Buffer.from(this.cssContent, 'base64url').toString(); } } } async updated(changedProperties) { 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, })); } } _parse_route_match_check_group() { let _fullpath = this.fullpath; let _grouped_path; const baseSplit = (path) => { 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); } createRenderRoot() { if (!this.disableShadow) { return super.createRenderRoot(); } return this; } }; __decorate([ property({ type: String }) ], Route.prototype, "path", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "exact", void 0); __decorate([ property({ type: String }) ], Route.prototype, "element", void 0); __decorate([ property({ type: String }) ], Route.prototype, "url", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "lazy", void 0); __decorate([ property({ type: String, attribute: 'append-direction' }) ], Route.prototype, "appendDirection", void 0); __decorate([ property({ attribute: 'custom-render', converter: { fromAttribute: (value) => { if (value === '') { return true; } else { return value; } }, toAttribute: (value) => { if (typeof value !== 'string') { if (!value) { return 'false'; } else { return ''; } } else { return value; } }, }, }) ], Route.prototype, "customRender", void 0); __decorate([ property({ type: Boolean, attribute: 'render-after-ready' }) ], Route.prototype, "renderAfterReady", void 0); __decorate([ property({ type: Boolean, attribute: 'disable-shadow' }) ], Route.prototype, "disableShadow", void 0); __decorate([ property({ type: String }) ], Route.prototype, "shadowCSSUrl", void 0); __decorate([ property({ type: String }) ], Route.prototype, "loadingElement", void 0); __decorate([ property({ type: Number }) ], Route.prototype, "lockLoadingTime", void 0); __decorate([ property({ attribute: 'css-url', converter: { fromAttribute(value) { if (!value) return ''; if (/\[.*\]/.test(value)) { return JSON.parse(value); } else { return value; } }, toAttribute(value) { if (Array.isArray(value)) { return JSON.stringify(value); } else { return value; } }, }, }) ], Route.prototype, "cssUrl", void 0); __decorate([ property({ type: String }) ], Route.prototype, "cssParseMode", void 0); __decorate([ property({ type: Number }) ], Route.prototype, "cssDelayRender", void 0); __decorate([ property({ type: Function }) ], Route.prototype, "errorRender", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "drop", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "cssOnly", void 0); __decorate([ property({ type: Number }) ], Route.prototype, "customCSSBlockRenderTime", void 0); __decorate([ property({ type: Number }) ], Route.prototype, "cacheVaildTime", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "virtualNode", void 0); __decorate([ property({ type: Boolean }) ], Route.prototype, "groupMatchMode", void 0); __decorate([ state() ], Route.prototype, "active", void 0); __decorate([ state({ hasChanged: () => false }) ], Route.prototype, "fullpath", void 0); __decorate([ state({ hasChanged: () => false }) ], Route.prototype, "_url_module", void 0); __decorate([ state() ], Route.prototype, "moduleReady", void 0); __decorate([ state() ], Route.prototype, "cssReady", void 0); __decorate([ state() ], Route.prototype, "cssContent", void 0); __decorate([ state({ hasChanged: () => false }) ], Route.prototype, "_css_base64", void 0); __decorate([ state() ], Route.prototype, "isExactMatch", void 0); __decorate([ state({ hasChanged: () => false }) ], Route.prototype, "isModuleRenderInstanceDestroied", void 0); __decorate([ state({ hasChanged: () => false }) ], Route.prototype, "cacheDestroyTimer", void 0); __decorate([ query('div.custom-render-container', false) ], Route.prototype, "cacheCustomRenderDom", void 0); Route = __decorate([ customElement('native-route') ], Route); export { Route };