@v4fire/client
Version:
V4Fire client core library
547 lines (436 loc) • 13.6 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:base/b-dynamic-page/README.md]]
* @packageDocumentation
*/
import symbolGenerator from 'core/symbol';
import addEmitter from 'core/cache/decorators/helpers/add-emitter';
import { Cache, RestrictedCache, AbstractCache } from 'core/cache';
import SyncPromise from 'core/promise/sync';
import type { EventEmitterLike } from 'core/async';
import iBlock from 'super/i-block/i-block';
import iDynamicPage, {
component,
prop,
system,
computed,
watch,
UnsafeGetter,
ComponentStatus,
InitLoadOptions
} from 'super/i-dynamic-page/i-dynamic-page';
import type {
Include,
Exclude,
iDynamicPageEl,
KeepAliveStrategy,
UnsafeBDynamicPage
} from 'base/b-dynamic-page/interface';
import { restorePageElementsScroll, saveScrollIntoAttribute } from 'base/b-dynamic-page/helpers';
export * from 'super/i-data/i-data';
export * from 'base/b-dynamic-page/interface';
export const
$$ = symbolGenerator();
/**
* Component to dynamically load page components.
* Basically, it uses with a router.
*/
export default class bDynamicPage extends iDynamicPage {
override readonly selfDispatching: boolean = true;
/**
* Initial component name to load
*/
readonly pageProp?: string;
/**
* Active component name to load
* @see [[bDynamicPage.pageProp]]
*/
page?: string;
/**
* Active page unique key.
* It is used to determine whether to reuse current page component or create a new one when switching between routes
* with the same page component.
*/
pageKey?: CanUndef<string>;
/**
* If true, when switching from one page to another, the old page is stored within a cache by its name.
* When occur switching back to this page, it will be restored.
* It helps to optimize switching between pages but grows memory using.
*
* Notice, when a page is switching, it will be deactivated by invoking `deactivate`.
* When the page is restoring, it will be activated by invoking `activate`.
*/
readonly keepAlive: boolean = false;
/**
* The maximum number of pages within the global `keepAlive` cache
*/
readonly keepAliveSize: number = 10;
/**
* A dictionary of `keepAlive` caches.
* The keys represent cache groups (by default uses `global`).
*/
<bDynamicPage>((o) => o.sync.link('keepAliveSize', (size: number) => ({
...o.keepAliveCache,
global: o.addClearListenersToCache(
size > 0 ?
new RestrictedCache<iDynamicPageEl>(size) :
new Cache<iDynamicPageEl>()
)
})))
keepAliveCache!: Dictionary<AbstractCache<iDynamicPageEl>>;
/**
* A predicate to include pages to the `keepAlive` caching: if not specified, will be cached all loaded pages.
* It can be defined as:
*
* 1. a component name (or a list of names);
* 2. a regular expression;
* 3. a function that takes a component name and returns `true` (include), `false` (does not include),
* a string key to cache (it uses instead of a component name),
* or a special object with information of the used cache strategy.
*/
readonly include?: Include;
/**
* A predicate to exclude some pages from the `keepAlive` caching.
* It can be defined as a component name (or a list of names), regular expression,
* or a function that takes a component name and returns `true` (exclude) or `false` (does not exclude).
*/
readonly exclude?: Exclude;
/**
* Link to an event emitter to listen to events of the page switching
*/
readonly emitter?: EventEmitterLike;
/**
* Event name of the page switching
*/
readonly event?: string = 'setRoute';
/**
* Function to extract a component name to load from the caught event object.
* Also, this function can return a tuple consisting of component name and unique key for the passed routed. The key
* will be used to determine whether to reuse current page component or create a new one
* when switching between routes with the same page component.
*/
readonly eventConverter!: CanArray<Function>;
/**
* Link to the loaded page component
*/
get component(): CanPromise<iDynamicPage> {
const
c = this.$refs.component;
const getComponent = () => {
const
c = this.$refs.component!;
if (Object.isArray(c)) {
return c[0];
}
return c;
};
return c != null && (!Object.isArray(c) || c.length > 0) ?
getComponent() :
this.waitRef('component').then(getComponent);
}
override get unsafe(): UnsafeGetter<UnsafeBDynamicPage<this>> {
return Object.cast(this);
}
protected override readonly componentStatusStore: ComponentStatus = 'ready';
protected override readonly $refs!: {
component?: iDynamicPage[];
};
/**
* True if the current page is taken from a cache
*/
protected pageTakenFromCache: boolean = false;
/**
* Handler: page has been changed
*/
protected onPageChange?: Function;
/**
* Iterator of the rendering loop (it uses with `asyncRender`)
*/
protected get renderIterator(): CanPromise<number> {
return SyncPromise.resolve(Infinity);
}
override initLoad(): Promise<void> {
return Promise.resolve();
}
/**
* Reloads the loaded page component
*/
override async reload(params?: InitLoadOptions): Promise<void> {
const component = await this.component;
return component.reload(params);
}
/**
* Filter of the rendering loop (it uses with `asyncRender`)
*/
protected renderFilter(): CanPromise<boolean> {
if (this.lfc.isBeforeCreate()) {
return true;
}
const
{unsafe, route, r} = this;
return new SyncPromise((r) => {
this.onPageChange = onPageChange(r, this.route);
});
function onPageChange(
resolve: Function,
currentRoute: typeof route
): AnyFunction {
return (newPage: CanUndef<string>, currentPage: CanUndef<string>) => {
unsafe.pageTakenFromCache = false;
const componentRef = unsafe.$refs.component;
componentRef?.pop();
const
currentPageEl = unsafe.block?.element<iDynamicPageEl>('component'),
currentPageComponent = currentPageEl?.component?.unsafe;
if (currentPageEl != null) {
r.emit('beforeSwitchPage', {saveScroll: saveScrollIntoAttribute});
if (currentPageComponent != null) {
const
currentPageStrategy = unsafe.getKeepAliveStrategy(currentPage, currentRoute);
if (currentPageStrategy.isLoopback) {
currentPageComponent.$destroy();
} else {
currentPageStrategy.add(currentPageEl);
currentPageComponent.deactivate();
}
}
currentPageEl.remove();
}
const
newPageStrategy = unsafe.getKeepAliveStrategy(newPage),
pageElFromCache = newPageStrategy.get();
if (pageElFromCache == null) {
const handler = () => {
if (!newPageStrategy.isLoopback) {
return SyncPromise.resolve(unsafe.component).then((c) => c.activate(true));
}
};
unsafe.localEmitter.once('asyncRenderChunkComplete', handler, {
label: $$.renderFilter
});
} else {
const
pageComponentFromCache = pageElFromCache.component;
if (pageComponentFromCache != null) {
pageComponentFromCache.activate();
unsafe.async.requestAnimationFrame(() => {
restorePageElementsScroll(pageElFromCache);
}, {label: $$.restorePageElementsScroll});
unsafe.$el?.append(pageElFromCache);
pageComponentFromCache.emit('mounted', pageElFromCache);
componentRef?.push(pageComponentFromCache);
unsafe.pageTakenFromCache = true;
} else {
newPageStrategy.remove();
}
}
resolve(true);
};
}
}
/**
* Returns a `keepAlive` cache strategy for the specified page
*
* @param page
* @param [route] - link to an application route object
*/
protected getKeepAliveStrategy(page: CanUndef<string>, route: this['route'] = this.route): KeepAliveStrategy {
const loopbackStrategy = {
isLoopback: true,
has: () => false,
get: () => undefined,
add: (page) => page,
remove: () => undefined
};
if (!this.keepAlive || page == null) {
return loopbackStrategy;
}
const
{exclude, include} = this;
if (exclude != null) {
if (Object.isFunction(exclude)) {
if (Object.isTruly(exclude(page, route, this))) {
return loopbackStrategy;
}
} else if (Object.isRegExp(exclude) ? exclude.test(page) : Array.concat([], exclude).includes(page)) {
return loopbackStrategy;
}
}
let
cacheKey = page;
const
globalCache = this.keepAliveCache.global!;
const globalStrategy = {
isLoopback: false,
has: () => globalCache.has(cacheKey),
get: () => globalCache.get(cacheKey),
add: (page) => globalCache.set(cacheKey, page),
remove: () => globalCache.remove(cacheKey)
};
if (include != null) {
if (Object.isFunction(include)) {
const
res = include(page, route, this);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (res == null || res === false) {
return loopbackStrategy;
}
if (Object.isString(res) || res === true) {
cacheKey = res === true ? page : res;
return globalStrategy;
}
const cache = this.keepAliveCache[res.cacheGroup] ?? this.addClearListenersToCache(res.createCache());
this.keepAliveCache[res.cacheGroup] = cache;
return {
isLoopback: false,
has: () => cache.has(res.cacheKey),
get: () => cache.get(res.cacheKey),
add: (page) => cache.set(res.cacheKey, page),
remove: () => cache.remove(res.cacheKey)
};
}
if (Object.isRegExp(include) ? !include.test(page) : !Array.concat([], include).includes(page)) {
return loopbackStrategy;
}
}
return globalStrategy;
}
protected override initBaseAPI(): void {
super.initBaseAPI();
this.addClearListenersToCache = this.instance.addClearListenersToCache.bind(this);
}
/**
* Wraps the specified cache object and returns a wrapper.
* The method adds listeners to destroy unused pages from the cache.
*
* @param cache
*/
protected addClearListenersToCache<T extends AbstractCache<iDynamicPageEl>>(cache: T): T {
const
wrappedCache = addEmitter<AbstractCache<iDynamicPageEl>>(cache);
let
instanceCache: WeakMap<iDynamicPageEl, number> = new WeakMap();
wrappedCache.subscribe('set', cache, changeCountInMap(0, 1));
wrappedCache.subscribe('remove', cache, changeCountInMap(1, -1));
wrappedCache.subscribe('remove', cache, ({result}) => {
if (result == null || (instanceCache.get(result) ?? 0) > 0) {
return;
}
result.component?.unsafe.$destroy();
});
wrappedCache.subscribe('clear', cache, ({result}) => {
result.forEach((el) => el.component?.unsafe.$destroy());
instanceCache = new WeakMap();
});
return cache;
function changeCountInMap(def: number, delta: number): AnyFunction {
return ({result}: {result: CanUndef<iDynamicPageEl>}) => {
if (result == null) {
return;
}
const count = instanceCache.get(result) ?? def;
instanceCache.set(result, count + delta);
};
}
}
/**
* Synchronization for the `emitter` prop
*/
protected syncEmitterWatcher(): void {
const
{async: $a} = this;
const
group = {group: 'emitter'};
$a
.clearAll(group);
if (this.event != null) {
$a.on(this.emitter ?? this.$root, this.event, (component, e) => {
if (component != null && !((<Dictionary>component).instance instanceof iBlock)) {
e = component;
}
let
newPageInfo = e;
if (Object.isTruly(this.eventConverter)) {
newPageInfo = Array
.concat([], this.eventConverter)
.reduce((res, fn) => fn.call(this, res, this.page), newPageInfo);
}
const
[newPageComponentName, newPageKey] = Object.isString(newPageInfo) ? [newPageInfo] : (newPageInfo ?? []);
const
pageChanged = newPageComponentName !== this.page,
oldPageKey = this.pageKey;
if (newPageComponentName == null || Object.isString(newPageComponentName)) {
this.page = newPageComponentName;
this.pageKey = newPageKey;
if (!pageChanged && newPageKey !== oldPageKey) {
this.syncPageWatcher(newPageComponentName, this.page);
}
}
}, group);
}
}
/**
* Synchronization for the `page` field
*/
protected syncPageWatcher(page: CanUndef<string>, oldPage: CanUndef<string>): void {
if (this.onPageChange == null) {
const
label = {label: $$.syncPageWatcher};
this.watch('onPageChange', {...label, immediate: true}, () => {
if (this.onPageChange == null) {
return;
}
this.onPageChange(page, oldPage);
this.async.terminateWorker(label);
});
} else {
this.onPageChange(page, oldPage);
}
}
protected override initModEvents(): void {
super.initModEvents();
this.sync.mod('hidden', 'page', (v) => !Object.isTruly(v));
}
}