UNPKG

@v4fire/client

Version:

V4Fire client core library

2,069 lines (1,757 loc) • 66.4 kB
/* eslint-disable max-lines,@typescript-eslint/unified-signatures */ /*! * V4Fire Client Core * https://github.com/V4Fire/Client * * Released under the MIT license * https://github.com/V4Fire/Client/blob/master/LICENSE */ /** * [[include:super/i-block/README.md]] * @packageDocumentation */ import symbolGenerator from 'core/symbol'; import SyncPromise from 'core/promise/sync'; import log, { LogMessageOptions } from 'core/log'; import { deprecated } from 'core/functools/deprecation'; import { EventEmitter2 as EventEmitter } from 'eventemitter2'; import InfoRender from 'super/i-block/modules/info-render'; import config from 'config'; import Async, { wrapWithSuspending, AsyncOptions, ClearOptionsId, ProxyCb, BoundFn, EventId } from 'core/async'; //#if runtime has core/helpers import * as helpers from 'core/helpers'; //#endif //#if runtime has core/browser import * as browser from 'core/browser'; //#endif import * as presets from 'presets'; import type bRouter from 'base/b-router/b-router'; import type { TransitionMethod } from 'base/b-router/b-router'; import type iStaticPage from 'super/i-static-page/i-static-page'; import { component, getComponentName, PARENT, globalEmitter, customWatcherRgxp, resolveRefs, bindRemoteWatchers, WatchPath, RawWatchHandler, Hook, ComponentInterface, UnsafeGetter, VNode } from 'core/component'; import remoteState from 'core/component/state'; import * as init from 'core/component/construct'; import 'super/i-block/directives'; import { statuses } from 'super/i-block/const'; import Cache from 'super/i-block/modules/cache'; import Opt from 'super/i-block/modules/opt'; import Daemons, { DaemonsDict } from 'super/i-block/modules/daemons'; import Analytics from 'super/i-block/modules/analytics'; import DOM from 'super/i-block/modules/dom'; import VDOM from 'super/i-block/modules/vdom'; import Lfc from 'super/i-block/modules/lfc'; import AsyncRender from 'super/i-block/modules/async-render'; import Sync, { AsyncWatchOptions } from 'super/i-block/modules/sync'; import Block from 'super/i-block/modules/block'; import Field from 'super/i-block/modules/field'; import Provide, { classesCache, Classes, Styles } from 'super/i-block/modules/provide'; import State, { ConverterCallType } from 'super/i-block/modules/state'; import Storage from 'super/i-block/modules/storage'; import ModuleLoader, { Module } from 'super/i-block/modules/module-loader'; import { wrapEventEmitter, EventEmitterWrapper, ReadonlyEventEmitterWrapper } from 'super/i-block/modules/event-emitter'; import { initGlobalListeners, initRemoteWatchers } from 'super/i-block/modules/listeners'; import { readyStatuses, activate, deactivate } from 'super/i-block/modules/activation'; import type { Stage, ComponentStatus, ComponentStatuses, ComponentEvent, InitLoadOptions, InitLoadCb, ParentMessage, UnsafeIBlock } from 'super/i-block/interface'; import { mergeMods, initMods, getWatchableMods, ModVal, ModsDecl, ModsTable, ModsNTable } from 'super/i-block/modules/mods'; import { p, prop, field, system, computed, watch, hook, wait, WaitDecoratorOptions, DecoratorMethodWatcher } from 'super/i-block/modules/decorators'; export * from 'core/component'; export * from 'super/i-block/const'; export * from 'super/i-block/interface'; export * from 'super/i-block/modules/block'; export * from 'super/i-block/modules/field'; export * from 'super/i-block/modules/state'; export * from 'super/i-block/modules/module-loader'; export * from 'super/i-block/modules/daemons'; export * from 'super/i-block/modules/event-emitter'; export * from 'super/i-block/modules/info-render'; export * from 'super/i-block/modules/sync'; export * from 'super/i-block/modules/async-render'; export * from 'super/i-block/modules/decorators'; export { default as Friend } from 'super/i-block/modules/friend'; export { Cache, Classes, ModVal, ModsDecl, ModsTable, ModsNTable }; export const $$ = symbolGenerator(); /** * Superclass for all components */ @component() export default abstract class iBlock extends ComponentInterface { override readonly Component!: iBlock; override readonly Root!: iStaticPage; // @ts-ignore (override) override readonly $root!: this['Root']; /** * If true, the component will log info messages, but not only errors and warnings */ @prop(Boolean) readonly verbose: boolean = false; /** * Component unique identifier */ @system({ atom: true, unique: (ctx, oldCtx) => !ctx.$el?.classList.contains(oldCtx.componentId), init: () => `uid-${Math.random().toString().slice(2)}` }) override readonly componentId!: string; /** * A unique or global name of the component. * It's used to enable synchronization of component data with different storages: local, router, etc. */ @prop({type: String, required: false}) readonly globalName?: string; /** * Type of the component' root tag */ @prop(String) readonly rootTag: string = 'div'; /** * Dictionary with additional attributes for the component' root tag */ get rootAttrs(): Dictionary { return this.field.get<Dictionary>('rootAttrsStore')!; } /** * A component render cache key. * It's used to cache the component vnode. */ @prop({required: false}) readonly renderKey?: string; /** * An initial component stage value. * * The stage property can be used to mark different states of the component. * For example, we have a component that implements a form of image uploading, * and we have two variants of the form: upload by a link or upload from a computer. * * Therefore, we can create two-stage values: 'link' and 'file' to separate the component template by two variants of * a markup depending on the stage value. */ @prop({type: [String, Number], required: false}) readonly stageProp?: Stage; /** * Component stage value * @see [[iBlock.stageProp]] */ @computed({replace: false}) get stage(): CanUndef<Stage> { return this.field.get('stageStore'); } /** * Sets a new component stage value. * By default, it clears all async listeners from the group of `stage.${oldGroup}`. * * @see [[iBlock.stageProp]] * @emits `stage:${value}(value: CanUndef<Stage>, oldValue: CanUndef<Stage>)` * @emits `stageChange(value: CanUndef<Stage>, oldValue: CanUndef<Stage>)` */ set stage(value: CanUndef<Stage>) { const oldValue = this.stage; if (oldValue === value) { return; } this.async.clearAll({group: this.stageGroup}); this.field.set('stageStore', value); if (value != null) { this.emit(`stage:${value}`, value, oldValue); } this.emit('stageChange', value, oldValue); } /** * Group name of the current stage */ @computed({replace: false}) get stageGroup(): string { return `stage.${this.stage}`; } /** * Initial component modifiers. * The modifiers represent API to bind component state properties directly with CSS classes * without unnecessary component re-rendering. */ @prop({type: Object, required: false}) readonly modsProp?: ModsTable; /** * Component modifiers * @see [[iBlock.modsProp]] */ @system({ replace: false, merge: mergeMods, init: initMods }) readonly mods!: ModsNTable; /** * If true, the component is activated. * The deactivated component won't load data from providers on initializing. */ @prop(Boolean) readonly activatedProp: boolean = true; /** * If true, then is enabled forcing of activation handlers (only for functional components). * By default, functional components don't execute activation handlers: router/storage synchronization, etc. */ @prop(Boolean) readonly forceActivation: boolean = false; /** * If true, then the component will try to reload data on re-activation. * This parameter can be helpful if you are using a keep-alive directive within your template. * For example, you have a page within keep-alive, and after back to this page, the component will be forcibly drawn * from a keep-alive cache, but after this page will try to update data in silence. */ @prop(Boolean) readonly reloadOnActivation: boolean = false; /** * If true, then the component will force rendering on re-activation. * This parameter can be helpful if you are using a keep-alive directive within your template. */ @prop(Boolean) readonly renderOnActivation: boolean = false; /** * List of additional dependencies to load. * These dependencies will be dynamically loaded during the `initLoad` invoking. * * @example * ```js * { * dependencies: [ * {name: 'b-button', load: () => import('form/b-button')} * ] * } * ``` */ @prop({type: Array, required: false}) readonly dependenciesProp: Module[] = []; /** * List of additional dependencies to load * @see [[iBlock.dependenciesProp]] */ @system((o) => o.sync.link((val) => { const componentStaticDependencies = config.componentStaticDependencies[o.componentName]; return Array.concat([], componentStaticDependencies, val); })) dependencies!: Module[]; /** * If true, the component is marked as a remote provider. * It means, that a parent component will wait for the loading of the current component. */ @prop(Boolean) readonly remoteProvider: boolean = false; /** * If true, the component will listen for the special event of its parent. * It's used to provide a common functionality of proxy calls from the parent. */ @prop(Boolean) readonly proxyCall: boolean = false; /** * If true, the component state will be synchronized with a router after initializing. * For example, you have a component that uses the `syncRouterState` method to create two-way binding with the router. * * ```typescript * @component() * class Foo { * @field() * stage: string = 'defaultStage'; * * syncRouterState(data?: Dictionary) { * // This notation means that if there is a value within `route.query` * // it will be mapped to the component as `stage`. * // If a route was changed, the mapping repeat. * // Also, if the `stage` field of the component was changed, * // it will be mapped to the router query parameters as `stage` by using `router.push`. * return {stage: data?.stage || this.stage}; * } * } * ``` * * But, if in some cases we don't have `stage` within `route.query`, and the component have the default value, * we trap in a situation where exists route, which wasn't synchronized with the component, and * it can affect to the "back" logic. Sometimes, this behavior does not match our expectations. * But if we toggle `syncRouterStoreOnInit` to true, the component will forcibly map its own state to * the router after initializing. */ @prop(Boolean) readonly syncRouterStoreOnInit: boolean = false; /** * Method that will be used for transitions when router synchronizes its state with the component's state * by using syncRouterState */ @prop(String) readonly routerStateUpdateMethod: Exclude<TransitionMethod, 'event'> = 'push'; /** * If true, the component will skip waiting of remote providers to avoid redundant re-renders. * This prop can help optimize your non-functional component when it does not contain any remote providers. * By default, this prop is calculated automatically based on component dependencies. */ @prop({type: Boolean, required: false}) readonly dontWaitRemoteProvidersProp?: boolean; /** @see [[iBlock.dontWaitRemoteProvidersProp]] */ @system((o) => o.sync.link((val) => { if (val == null) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (o.dontWaitRemoteProviders != null) { return o.dontWaitRemoteProviders; } const isRemote = /\bremote-provider\b/; return !config.components[o.componentName]?.dependencies.some((dep) => isRemote.test(dep)); } return val; })) dontWaitRemoteProviders!: boolean; /** * A map of remote component watchers. * The usage of this mechanism is similar to the "@watch" decorator: * *) As a key, we declare a name of a component method that we want to call; * *) As a value, we declare a path to a property/event we want to watch/listen. * Also, the method can take additional parameters of watching. * Mind, the properties/events are taken from a component that contents the current. * * @see [[iBlock.watch]] * @example * ```js * // We have two components: A and B. * // We want to declare that component B must call its own `reload` method on an event from component A. * * { * // If we want to listen for events, we should use the ":" syntax. * // Also, we can provide a different event emitter as `link:`, * // for instance, `document:scroll` * reload: ':foo' * } * ``` * * @example * ```js * // We can attach multiple watchers for one method * * { * reload: [ * // Listens `foo` events from `A` * ':foo', * * // Watches for changes of the `A.bla` property * 'bla', * * // Listens `window.document` scroll event, * // does not provide event arguments to `reload` * { * path: 'document:scroll', * provideArgs: false * } * ] * } * ``` */ @prop({type: Object, required: false}) readonly watchProp?: Dictionary<DecoratorMethodWatcher>; /** * If true, then is enabled a dispatching mode of component events. * * It means that all component events will bubble to a parent component: * if the parent also has this property as true, then the events will bubble to the next (from the hierarchy) * parent component. * * All dispatching events have special prefixes to avoid collisions with events from other components, * for example: bButton `click` will bubble as `b-button::click`. * Or if the component has globalName parameter, it will additionally bubble as `${globalName}::click`. */ @prop(Boolean) readonly dispatching: boolean = false; /** * If true, then all events that are bubbled from child components * will be emitted as component self events without any prefixes */ @prop(Boolean) readonly selfDispatching: boolean = false; /** * Additional component parameters. * This parameter can be useful if you need to provide some unstructured additional parameters to a component. */ @prop({type: Object, required: false}) readonly p?: Dictionary; /** * Additional classes for the component elements. * It can be useful if you need to attach some extra classes to internal component elements. * Be sure you know what you are doing because this mechanism is tied to an internal component markup. * * @example * ```js * // Key names are tied with component elements, * // and values contain a CSS class or list of classes we want to add * * { * foo: 'bla', * bar: ['bla', 'baz'] * } * ``` */ @prop({type: Object, required: false}) readonly classes?: Dictionary<CanArray<string>>; /** * Additional styles for the component elements. * It can be useful if you need to attach some extra styles to internal component elements. * Be sure you know what you are doing because this mechanism is tied to an internal component markup. * * @example * ```js * // Key names are tied with component elements, * // and values contains a CSS style string, a style object or list of style strings * * { * foo: 'color: red', * bar: {color: 'blue'}, * baz: ['color: red', 'background: green'] * } * ``` */ @prop({type: Object, required: false}) readonly styles?: Styles; /** * A Link to the remote state object. * * The remote state object is a special watchable object that provides some parameters * that can't be initialized within a component directly. You can modify this object outside of components, * but remember that these mutations may force the re-rendering of all components. */ @computed({watchable: true}) get remoteState(): typeof remoteState { return remoteState; } /** * A component status. * This parameter is pretty similar to the `hook` parameter. * But, the hook represents a component status relative to its MVVM instance: created, mounted, destroyed, etc. * Opposite to "hook", "componentStatus" represents a logical component status: * * *) unloaded - a component was just created without any initializing: * this status can intersect with some hooks, like `beforeCreate` or `created`. * * *) loading - a component starts to load data from its own providers: * this status can intersect with some hooks, like `created` or `mounted`. * If the component was mounted with this status, you can show by using UI that the data is loading. * * *) beforeReady - a component was fully loaded and started to prepare to render: * this status can intersect with some hooks like `created` or `mounted`. * * *) ready - a component was fully loaded and rendered: * this status can intersect with the `mounted` hook. * * *) inactive - a component is frozen by keep-alive mechanism or special input property: * this status can intersect with the `deactivated` hook. * * *) destroyed - a component was destroyed: * this status can intersect with some hooks, like `beforeDestroy` or `destroyed`. */ @computed({replace: false}) get componentStatus(): ComponentStatus { return this.shadowComponentStatusStore ?? this.field.get<ComponentStatus>('componentStatusStore') ?? 'unloaded'; } /** * Sets a new component status. * Notice, not all statuses emit component' re-rendering: `unloaded`, `inactive`, `destroyed` will emit only an event. * * @param value * @emits `componentStatus:{$value}(value: ComponentStatus, oldValue: ComponentStatus)` * @emits `componentStatusChange(value: ComponentStatus, oldValue: ComponentStatus)` */ set componentStatus(value: ComponentStatus) { const oldValue = this.componentStatus; if (oldValue === value && value !== 'beforeReady') { return; } const isShadowStatus = this.isNotRegular || value === 'ready' && oldValue === 'beforeReady' || value === 'inactive' && !this.renderOnActivation || (<typeof iBlock>this.instance.constructor).shadowComponentStatuses[value]; if (isShadowStatus) { this.shadowComponentStatusStore = value; } else { this.shadowComponentStatusStore = undefined; this.field.set('componentStatusStore', value); if (this.isReady && this.dependencies.length > 0) { void this.forceUpdate(); } } // @deprecated this.emit(`status-${value}`, value); this.emit(`componentStatus:${value}`, value, oldValue); this.emit('componentStatusChange', value, oldValue); } override get hook(): Hook { return this.hookStore; } protected override set hook(value: Hook) { const oldValue = this.hook; this.hookStore = value; if ('lfc' in this && !this.lfc.isBeforeCreate('beforeDataCreate')) { this.emit(`componentHook:${value}`, value, oldValue); this.emit('componentHookChange', value, oldValue); } } /** * True if the component is already activated * @see [[iBlock.activatedProp]] */ @system((o) => { void o.lfc.execCbAtTheRightTime(() => { if (o.isFunctional && !o.field.get<boolean>('forceActivation')) { return; } if (o.field.get<boolean>('isActivated')) { o.activate(true); } else { o.deactivate(); } }); return o.sync.link('activatedProp', (val: CanUndef<boolean>) => { val = val !== false; if (o.hook !== 'beforeDataCreate') { o[val ? 'activate' : 'deactivate'](); } return val; }); }) isActivated!: boolean; /** * True if the component was in `ready` status at least once */ @system({unique: true}) isReadyOnce: boolean = false; /** * Link to the component root */ get r(): this['$root'] { const r = this.$root; return r.$remoteParent?.$root ?? r; } /** * Link to an application router */ get router(): CanUndef<bRouter> { return this.field.get('routerStore', this.r); } /** * Link to an application route object */ get route(): CanUndef<this['r']['CurrentPage']> { return this.field.get('route', this.r); } /** * True if the current component is completely ready to work. * The `ready` status is mean, that component was mounted an all data provider are loaded. */ @computed({replace: false}) get isReady(): boolean { return Boolean(readyStatuses[this.componentStatus]); } /** * True if the current component is a functional */ @computed({replace: false}) get isFunctional(): boolean { return this.meta.params.functional === true; } /** * True if the current component is a functional or flyweight */ @computed({replace: false}) get isNotRegular(): boolean { return Boolean(this.isFunctional || this.isFlyweight); } /** * True if the current component is rendered by using server-side rendering */ @computed({replace: false}) get isSSR(): boolean { return this.$renderEngine.supports.ssr; } /** * Base component modifiers. * These modifiers are automatically provided to child components. * So, for example, you have a component that uses another component within your template, * and you specify to the outer component some theme modifier. * This modifier will recursively provide to all child components. */ @computed({replace: false}) get baseMods(): CanUndef<Readonly<ModsNTable>> { const m = this.mods; let res; if (m.theme != null) { res = {theme: m.theme}; } return res != null ? Object.freeze(res) : undefined; } /** * API for info rendering */ @system({ atom: true, unique: true, init: (ctx) => new InfoRender(ctx) }) readonly infoRender!: InfoRender; /** * API for analytic engines */ @system({ atom: true, unique: true, init: (ctx) => new Analytics(ctx) }) readonly analytics!: Analytics; /** * API for component value providers. * This property gives a bunch of methods to provide component classes/styles to another component, etc. */ @system({ atom: true, unique: true, init: (ctx) => new Provide(ctx) }) readonly provide!: Provide; /** * API for the component life cycle */ @system({ atom: true, unique: true, init: (ctx) => new Lfc(ctx) }) readonly lfc!: Lfc; /** * API for component field accessors. * This property provides a bunch of methods to access a component property safely. * * @example * ```js * this.field.get('foo.bar.bla') * ``` */ @system({ atom: true, unique: true, init: (ctx) => new Field(ctx) }) readonly field!: Field; /** * API to synchronize component properties. * This property provides a bunch of methods to organize a "link" from one component property to another. * * @example * ```typescript * @component() * class Foo { * @prop() * blaProp: string; * * @field((ctx) => ctx.sync.link('blaProp')) * bla: string; * } * ``` */ @system({ atom: true, unique: true, init: (ctx) => new Sync(ctx) }) readonly sync!: Sync; /** * API to render component template chunks asynchronously * * @example * ``` * < .bla v-for = el in asyncRender.iterate(veryBigList, 10) * {{ el }} * ``` */ @system({ atom: true, unique: true, init: (ctx) => new AsyncRender(ctx) }) readonly asyncRender!: AsyncRender; /** * API to work with a component' VDOM tree */ @system({ atom: true, unique: true, init: (ctx) => new VDOM(ctx) }) readonly vdom!: VDOM; override get unsafe(): UnsafeGetter<UnsafeIBlock<this>> { return Object.cast(this); } /** * The special link to a parent component. * This parameter is used with the static declaration of modifiers to refer to parent modifiers. * * @example * ```js * @component() * class Foo extends iBlock { * static mods = { * theme: [ * ['light'] * ] * }; * } * * @component() * class Bar extends Foo { * static mods = { * theme: [ * Bar.PARENT, * ['dark'] * ] * }; * } * ``` */ static readonly PARENT: object = PARENT; /** * A map of component shadow statuses. * These statuses don't emit re-rendering of a component. * * @see [[iBlock.componentStatus]] */ static readonly shadowComponentStatuses: ComponentStatuses = { inactive: true, destroyed: true, unloaded: true }; /** * Static declaration of component modifiers. * This declaration helps to declare the default value of a modifier: wrap the value with square brackets. * Also, all modifiers that are declared can be provided to a component not only by using `modsProp`, but as an own * prop value. In addition to previous benefits, if you provide all available values of modifiers to the declaration, * it can be helpful for runtime reflection. * * @example * ```js * @component() * class Foo extends iBlock { * static mods = { * theme: [ * 'dark', * ['light'] * ] * }; * } * ``` * * ``` * < foo :theme = 'dark' * ``` * * @see [[iBlock.modsProp]] */ static readonly mods: ModsDecl = { diff: [ 'true', 'false' ], theme: [], exterior: [], stage: [] }; /** * A map of static component daemons. * A daemon is a special object that can watch component properties, * listen to component events/hooks and do some useful payload, like sending analytic or performance events. */ static readonly daemons: DaemonsDict = {}; /** * Internal dictionary with additional attributes for the component' root tag * @see [[iBlock.rootAttrsStore]] */ @field() protected rootAttrsStore: Dictionary = {}; /** * API for daemons */ @system({ unique: true, init: (ctx) => new Daemons(ctx) }) protected readonly daemons!: Daemons; /** * API for the component local storage */ @system({ atom: true, unique: true, init: (ctx) => new Storage(ctx) }) protected readonly storage!: Storage; /** * API to wrap async operations */ @system({ atom: true, unique: true, init: (ctx) => new Async(ctx) }) protected readonly async!: Async<this>; /** * API for the component state. * This property provides a bunch of helper methods to initialize the component state. */ @system({ atom: true, unique: true, init: (ctx) => new State(ctx) }) protected readonly state!: State; /** * API to work with a component' DOM tree */ @system({ atom: true, unique: true, init: (ctx) => new DOM(ctx) }) protected readonly dom!: DOM; /** * API for BEM like develop. * This property provides a bunch of methods to get/set/remove modifiers of the component. */ @system({unique: true}) protected block?: Block; /** * API for optimization and debugging. * This property provides a bunch of helper methods to optimize some operations. */ @system({ atom: true, unique: true, init: (ctx) => new Opt(ctx) }) protected readonly opt!: Opt; /** * API for the dynamic dependencies. * This property provides a bunch of methods to load the dynamic dependencies of the component. */ @system({ atom: true, unique: true, init: (ctx) => new ModuleLoader(ctx) }) protected readonly moduleLoader!: ModuleLoader; @system() protected override renderCounter: number = 0; /** * Component stage store * @see [[iBlock.stageProp]] */ @field({ replace: false, forceUpdate: false, functionalWatching: false, init: (o) => o.sync.link<CanUndef<Stage>>((val) => { o.stage = val; return o.field.get('stageStore'); }) }) protected stageStore?: Stage; /** * Component hook store * @see [[iBlock.hook]] */ protected hookStore: Hook = 'beforeRuntime'; /** * Component initialize status store * @see [[iBlock.componentStatus]] */ @field({ unique: true, forceUpdate: false, functionalWatching: false }) protected componentStatusStore: ComponentStatus = 'unloaded'; /** * Component initialize status store for unwatchable statuses * @see [[iBlock.componentStatus]] */ @system({unique: true}) protected shadowComponentStatusStore?: ComponentStatus; /** * Store of component modifiers that can emit re-rendering of the component */ @field({ merge: true, replace: false, functionalWatching: false, init: () => Object.create({}) }) protected watchModsStore!: ModsNTable; /** * True if the component context is based on another component via `vdom.bindRenderObject` */ protected readonly isVirtualTpl: boolean = false; /** * Special getter for component modifiers: * on the first touch of a property from that object will be registered a modifier by the property name * that can emit re-rendering of the component. * Don't use this getter outside the component template. */ @computed({cache: true, replace: false}) protected get m(): Readonly<ModsNTable> { return getWatchableMods(this); } /** * Cache object for `opt.ifOnce` */ @system({merge: true, replace: false}) protected readonly ifOnceStore: Dictionary<number> = {}; /** * A temporary cache. * Mutation of this object don't emits re-rendering of the component. */ @system({ merge: true, replace: false, init: () => Object.createDict() }) protected tmp!: Dictionary; /** * A temporary cache. * Mutation of this object emits re-rendering of the component. */ @field({merge: true}) protected watchTmp: Dictionary = {}; /** * A render temporary cache. * It's used with the `renderKey` directive. */ @system({ merge: true, replace: false, init: () => Object.createDict() }) protected override renderTmp!: Dictionary<VNode>; /** * Cache of watched values */ @system({ merge: true, replace: false, init: () => Object.createDict() }) protected watchCache!: Dictionary; /** * Link to the current component */ @computed({replace: false}) protected get self(): this { return this; } /** * Self event emitter */ @system({ atom: true, after: 'async', unique: true, init: (o, d) => wrapEventEmitter(<Async>d.async, o) }) protected readonly selfEmitter!: EventEmitterWrapper<this>; /** * Local event emitter: all events that are fired from this emitter don't bubble */ @system({ atom: true, after: 'async', unique: true, init: (o, d) => wrapEventEmitter(<Async>d.async, new EventEmitter({ maxListeners: 1e3, newListener: false, wildcard: true }), {suspend: true}) }) protected readonly localEmitter!: EventEmitterWrapper<this>; /** * @deprecated * @see [[iBlock.localEmitter]] */ @deprecated({renamedTo: 'localEmitter'}) get localEvent(): EventEmitterWrapper<this> { return this.localEmitter; } /** * Event emitter of a parent component */ @system({ atom: true, after: 'async', unique: true, init: (o, d) => wrapEventEmitter(<Async>d.async, () => o.$parent, true) }) protected readonly parentEmitter!: ReadonlyEventEmitterWrapper<this>; /** * @deprecated * @see [[iBlock.parentEmitter]] */ @deprecated({renamedTo: 'parentEmitter'}) get parentEvent(): ReadonlyEventEmitterWrapper<this> { return this.parentEmitter; } /** * Event emitter of the root component */ @system({ atom: true, after: 'async', unique: true, init: (o, d) => wrapEventEmitter(<Async>d.async, o.r) }) protected readonly rootEmitter!: EventEmitterWrapper<this>; /** * @deprecated * @see [[iBlock.rootEmitter]] */ @deprecated({renamedTo: 'rootEmitter'}) get rootEvent(): ReadonlyEventEmitterWrapper<this> { return this.rootEmitter; } /** * The global event emitter of an application. * It can be used to provide external events to a component. */ @system({ atom: true, after: 'async', unique: true, init: (o, d) => wrapEventEmitter(<Async>d.async, globalEmitter) }) protected readonly globalEmitter!: EventEmitterWrapper<this>; /** * @deprecated * @see [[iBlock.globalEmitter]] */ @deprecated({renamedTo: 'globalEmitter'}) get globalEvent(): ReadonlyEventEmitterWrapper<this> { return this.globalEmitter; } /** * A map of extra helpers. * It can be useful to provide some helper functions to a component. */ @system({ atom: true, unique: true, replace: true, init: () => { //#if runtime has core/helpers return helpers; //#endif //#unless runtime has core/helpers // eslint-disable-next-line no-unreachable return {}; //#endunless } }) protected readonly h!: typeof helpers; /** * API to check a browser */ @system({ atom: true, unique: true, replace: true, init: () => { //#if runtime has core/browser return browser; //#endif //#unless runtime has core/browser // eslint-disable-next-line no-unreachable return {}; //#endunless } }) protected readonly browser!: typeof browser; /** * Map of component presets */ @system({ atom: true, unique: true, replace: true, init: () => presets }) protected readonly presets!: typeof presets; /** @see [[iBlock.presets]] */ @deprecated({renamedTo: 'presets'}) get preset(): typeof presets { return this.presets; } /** * A function for internationalizing texts used in the component */ get i18n(): ReturnType<typeof i18n> { return i18n(this.componentI18nKeysets); } /** * An alias for `i18n` */ get t(): ReturnType<typeof i18n> { return this.i18n; } /** * Number of `beforeReady` event listeners: * it's used to optimize component initializing */ @system({unique: true}) protected beforeReadyListeners: number = 0; /** * A list of `blockReady` listeners: * it's used to optimize component initializing */ @system({unique: true}) protected blockReadyListeners: Function[] = []; /** * Link to the console API */ @system({ atom: true, unique: true, replace: true, init: () => console }) protected readonly console!: Console; /** * Link to `window.location` */ @system({ atom: true, unique: true, replace: true, init: () => location }) protected readonly location!: Location; /** * Link to the global object */ @system({ atom: true, unique: true, replace: true, init: () => globalThis }) protected readonly global!: Window; /** * A list of keyset names used to internationalize the component */ @system({atom: true, unique: true}) protected componentI18nKeysets: string[] = (() => { const res: string[] = []; let keyset: CanUndef<string> = getComponentName(this.constructor); while (keyset != null) { res.push(keyset); keyset = config.components[keyset]?.parent; } return res; })(); /** * Sets a watcher to a component/object property or event by the specified path. * * When you watch for some property changes, the handler function can take the second argument that refers * to the old value of a property. If the object watching is non-primitive, the old value will be cloned from the * original old value to avoid having two links to one object. * * ```typescript * @component() * class Foo extends iBlock { * @field() * list: Dictionary[] = []; * * @watch('list') * onListChange(value: Dictionary[], oldValue: Dictionary[]): void { * // true * console.log(value !== oldValue); * console.log(value[0] !== oldValue[0]); * } * * // When you don't declare the second argument in a watcher, * // the previous value isn't cloned * @watch('list') * onListChangeWithoutCloning(value: Dictionary[]): void { * // true * console.log(value === arguments[1]); * console.log(value[0] === oldValue[0]); * } * * // When you watch a property in a deep and declare the second argument * // in a watcher, the previous value is cloned deeply * @watch({path: 'list', deep: true}) * onListChangeWithDeepCloning(value: Dictionary[], oldValue: Dictionary[]): void { * // true * console.log(value !== oldValue); * console.log(value[0] !== oldValue[0]); * } * * created() { * this.list.push({}); * this.list[0].foo = 1; * } * } * ``` * * You need to use the special delimiter ":" within a path to listen to an event. * Also, you can specify an event emitter to listen to by writing a link before ":". * For instance: * * 1. `':onChange'` - a component will listen to its own event `onChange`; * 2. `'localEmitter:onChange'` - a component will listen to an event `onChange` from `localEmitter`; * 3. `'$parent.localEmitter:onChange'` - a component will listen to an event `onChange` from `$parent.localEmitter`; * 4. `'document:scroll'` - a component will listen to an event `scroll` from `window.document`. * * A link to the event emitter is taken from component properties or the global object. * The empty link '' is a link to a component itself. * * Also, if you listen to an event, you can manage when to start to listen to the event by using special characters * at the beginning of a path string: * * 1. `'!'` - start to listen to an event on the "beforeCreate" hook, for example: `'!rootEmitter:reset'`; * 2. `'?'` - start to listen an event on the "mounted" hook, for example: `'?$el:click'`. * * By default, all events start to listen on the "created" hook. * * To listen for changes of another watchable object, you need to specify the watch path as an object: * * ``` * { * ctx: linkToWatchObject, * path?: pathToWatch * } * ``` * * @param path - path to a component property to watch or event to listen * @param opts - additional options * @param handler * * @example * ```js * // Watch for changes of `foo` * this.watch('foo', (val, oldVal) => { * console.log(val, oldVal); * }); * * // Watch for changes of another watchable object * this.watch({ctx: anotherObject, path: 'foo'}, (val, oldVal) => { * console.log(val, oldVal); * }); * * // Deep watch for changes of `foo` * this.watch('foo', {deep: true}, (val, oldVal) => { * console.log(val, oldVal); * }); * * // Watch for changes of `foo.bla` * this.watch('foo.bla', (val, oldVal) => { * console.log(val, oldVal); * }); * * // Listen to `onChange` event of the current component * this.watch(':onChange', (val, oldVal) => { * console.log(val, oldVal); * }); * * // Listen to `onChange` event of `parentEmitter` * this.watch('parentEmitter:onChange', (val, oldVal) => { * console.log(val, oldVal); * }); * ``` */ watch<T = unknown>( path: WatchPath, opts: AsyncWatchOptions, handler: RawWatchHandler<this, T> ): void; /** * Sets a watcher to a component property/event by the specified path * * @param path - path to a component property to watch or event to listen * @param handler * @param [opts] - additional options */ watch<T = unknown>( path: WatchPath, handler: RawWatchHandler<this, T>, opts?: AsyncWatchOptions ): void; /** * Sets a watcher to the specified watchable object * * @param obj * @param opts - additional options * @param handler * * @example * ```js * this.watch(anotherObject, {deep: true}, (val, oldVal) => { * console.log(val, oldVal); * }); * ``` */ watch<T = unknown>( obj: object, opts: AsyncWatchOptions, handler: RawWatchHandler<this, T> ): void; /** * Sets a watcher to the specified watchable object * * @param obj * @param handler * @param [opts] - additional options * * @example * ```js * this.watch(anotherObject, (val, oldVal) => { * console.log(val, oldVal); * }); * ``` */ watch<T = unknown>( obj: object, handler: RawWatchHandler<this, T>, opts?: AsyncWatchOptions ): void; @p({replace: false}) watch<T = unknown>( path: WatchPath | object, optsOrHandler: AsyncWatchOptions | RawWatchHandler<this, T>, handlerOrOpts?: RawWatchHandler<this, T> | AsyncWatchOptions ): void { const {async: $a} = this; if (this.isFlyweight || this.isSSR) { return; } let handler, opts; if (Object.isFunction(optsOrHandler)) { handler = optsOrHandler; opts = handlerOrOpts; } else { handler = handlerOrOpts; opts = optsOrHandler; } opts ??= {}; if (Object.isString(path) && RegExp.test(customWatcherRgxp, path)) { bindRemoteWatchers(this, { async: $a, watchers: { [path]: [ { handler: (ctx, ...args: unknown[]) => handler.call(this, ...args), ...opts } ] } }); return; } void this.lfc.execCbAfterComponentCreated(() => { // eslint-disable-next-line prefer-const let link, unwatch; const emitter = (_, wrappedHandler: Function) => { wrappedHandler['originalLength'] = handler['originalLength'] ?? handler.length; handler = wrappedHandler; $a.worker(() => { if (link != null) { $a.off(link); } }, opts); return () => unwatch?.(); }; link = $a.on(emitter, 'mutation', handler, wrapWithSuspending(opts, 'watchers')); unwatch = this.$watch(Object.cast(path), opts, handler); }); } /** * Returns true, if the specified event can be dispatched as an own component event (`selfDispatching`) * @param event */ canSelfDispatchEvent(event: string): boolean { return !/^component-(?:status|hook)(?::\w+(-\w+)*|-change)$/.test(event); } /** * Emits a component event. * Notice, this method always emits two events: * * 1) `${event}`(self, ...args) * 2) `on-${event}`(...args) * * @param event * @param args */ @p({replace: false}) emit(event: string | ComponentEvent, ...args: unknown[]): void { const eventDecl = Object.isString(event) ? {event} : event, eventName = eventDecl.event.dasherize(); eventDecl.event = eventName; this.$emit(eventName, this, ...args); this.$emit(`on-${eventName}`, ...args); if (this.dispatching) { this.dispatch(eventDecl, ...args); } const logArgs = args.slice(); if (eventDecl.type === 'error') { for (let i = 0; i < logArgs.length; i++) { const el = logArgs[i]; if (Object.isFunction(el)) { logArgs[i] = () => el; } } } this.log(`event:${eventName}`, this, ...logArgs); } /** * Emits a component error event * (all functions from arguments will be wrapped for logging) * * @param event * @param args */ @p({replace: false}) emitError(event: string, ...args: unknown[]): void { this.emit({event, type: 'error'}, ...args); } /** * Emits a component event to a parent component * * @param event * @param args */ @p({replace: false}) dispatch(event: string | ComponentEvent, ...args: unknown[]): void { const eventDecl = Object.isString(event) ? {event} : event, eventName = eventDecl.event.dasherize(); eventDecl.event = eventName; let { componentName, $parent: parent } = this; const globalName = (this.globalName ?? '').dasherize(), logArgs = args.slice(); if (eventDecl.type === 'error') { for (let i = 0; i < logArgs.length; i++) { const el = logArgs[i]; if (Object.isFunction(el)) { logArgs[i] = () => el; } } } while (parent) { if (parent.selfDispatching && parent.canSelfDispatchEvent(eventName)) { parent.$emit(eventName, this, ...args); parent.$emit(`on-${eventName}`, ...args); parent.log(`event:${eventName}`, this, ...logArgs); } else { parent.$emit(`${componentName}::${eventName}`, this, ...args); parent.$emit(`${componentName}::on-${eventName}`, ...args); parent.log(`event:${componentName}::${eventName}`, this, ...logArgs); if (globalName !== '') { parent.$emit(`${globalName}::${eventName}`, this, ...args); parent.$emit(`${globalName}::on-${eventName}`, ...args); parent.log(`event:${globalName}::${eventName}`, this, ...logArgs); } } if (!parent.dispatching) { break; } parent = parent.$parent; } } /** * Attaches an event listener to the specified component event * * @see [[Async.on]] * @param event * @param handler * @param [opts] - additional options */ @p({replace: false}) on<E = unknown, R = unknown>(event: string, handler: ProxyCb<E, R, this>, opts?: AsyncOptions): void { event = event.dasherize(); if (opts) { this.async.on(this, event, handler, opts); return; } this.$on(event, handler); } /** * Attaches a disposable event listener to the specified component event * * @see [[Async.once]] * @param event * @param handler * @param [opts] - additional options */ @p({replace: false}) once<E = unknown, R = unknown>(event: string, handler: ProxyCb<E, R, this>, opts?: AsyncOptions): void { event = event.dasherize(); if (opts) { this.async.once(this, event, handler, opts); return; } this.$once(event, handler); } /** * Returns a promise that is resolved after emitting the specified component event * * @see [[Async.promisifyOnce]] * @param event * @param [opts] - additional options */ @p({replace: false}) promisifyOnce<T = unknown>(event: string, opts?: AsyncOptions): Promise<T> { return this.async.promisifyOnce(this, event.dasherize(), opts); } /** * Detaches an event listeners from the component * * @param [event] * @param [handler] */ off(event?: string, handler?: Function): void; /** * Detaches an event listeners from the component * * @see [[Async.off]] * @param [opts] - additional options */ off(opts: ClearOptionsId<EventId>): void; @p({replace: false}) off(eventOrParams?: string | ClearOptionsId<EventId>, handler?: Function): void { const e = eventOrParams; if (e == null || Object.isString(e)) { this.$off(e?.dasherize(), handler); return; } this.async.off(e); } /** * Returns a promise that will be resolved when the component is toggled to the specified status * * @see [[Async.promise]] * @param status * @param [opts] - additional options */ waitStatus(status: ComponentStatus, opts?: WaitDecoratorOptions): Promise<void>; /** * Executes a callback when the component is toggled to the specified status. * The method returns a promise resulting from invoking the function or raw result without wrapping * if the component is already in the specified status. * * @see [[Async.promise]] * @param status * @param cb * @param [opts] - additional options */ waitStatus<F extends BoundFn<this>>( status: ComponentStatus, cb: F, opts?: WaitDecoratorOptions ): CanPromise<ReturnType<F>>; @p({replace: false}) waitStatus<F extends BoundFn<this>>( status: ComponentStatus, cbOrOpts?: F | WaitDecoratorOptions, opts?: WaitDecoratorOptions ): CanPromise<undefined | ReturnType<F>> { let needWrap = true; let cb; if (Object.isFunction(cbOrOpts)) { cb = cbOrOpts; needWrap = false; } else { opts = cbOrOpts; } opts = {...opts, join: false}; if (!needWrap) { return wait(status, {...opts, fn: cb}).call(this); } let isResolved = false; const promise = new SyncPromise((resolve) => wait(status, { ...opts, fn: () => { isResolved = true; resolve(); } }).call(this)); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (isResolved) { return promise; } return this.async.promise<undefined>(promise); } /** * A function for internationalizing texts inside traits. * Due to the fact that traits are called in the context of components, the standard i18n is not suitable, * and you must explicitly pass the name of the set of keys (trait names). * * @param traitName - the trait name * @param text - text for internationalization * @param [opts] - additional internationalization options */ i18nTrait(traitName: string, text: string, opts?: I18nParams): string { return i18n(traitName)(text, opts); } /** * Executes the specified function on the next render tick * * @see [[Async.proxy]] * @param fn * @param [opts] - additional options */ nextTick(fn: BoundFn<this>, opts?: AsyncOptions): void; /** * Returns a promise that will be resolved on the next render tick * * @see [[Async.promise]] * @param [opts] - additional options */ nextTick(opts?: AsyncOptions): Promise<void>; nextTick(fnOrOpts?: BoundFn<this> | AsyncOptions, opts?: AsyncOptions): CanPromise<void> { const {async: $a} = this; if (Object.isFunction(fnOrOpts)) { this.$nextTick($a.proxy(fnOrOpts, opts)); return; } return $a.promise(this.$nextTick(), fnOrOpts); } /** * Forces the component' re-rendering */ @wait({defer: true, label: $$.forceUpdate}) forceUpdate(): Promise<void> { this.$forceUpdate(); return Promise.resolve(); } /** * Loads initial data to the component * * @param [data] - data object (for events) * @param [opts] - additional options * @emits `initLoadStart(options: CanUndef<InitLoadOptions>)` * @emits `initLoad(data: CanUndef<unknown>, options: CanUndef<InitLoadOptions>)` */ @hook('beforeDataCreate') initLoad(data?: unknown | InitLoadCb, opts: InitLoadOptions = {}): CanPromise<void> { if (!this.isActivated) { return; } this.beforeReadyListeners = 0; const {async: $a} = this; const label = <AsyncOptions>{ label: $$.initLoad, join: 'replace' }; const done = () => { const get = () => { if (Object.isFunction(data)) { try { return data.call(this); } catch (err) { stderr(err); return; } } return data; }; this.componentStatus = 'beforeReady'; void this.lfc.execCbAfterBlockReady(() => { this.isReadyOnce = true; this.componentStatus = 'ready'; if (this.beforeReadyListeners > 1) { this.nextTick() .then(() => { this.beforeReadyListeners = 0; this.emit('initLoad', get(), opts); }) .catch(stderr); } else { this.emit('initLoad', get(), opts);