import { isFunction, isPromise, type Constructable, IContainer, InstanceProvider, type MaybePromise, emptyArray, onResolve, resolve, transient } from '@aurelia/kernel'; import { IExpressionParser } from '@aurelia/expression-parser'; import { IObserverLocator, Scope } from '@aurelia/runtime'; import { HydrateElementInstruction, IInstruction, ITemplateCompiler, AttrSyntax } from '@aurelia/template-compiler'; import { IRenderLocation, convertToRenderLocation, registerHostNode } from '../../dom'; import { INode } from '../../dom.node'; import { IPlatform } from '../../platform'; import { Controller, HydrationContext, IController, ICustomElementController, IHydratedController, IHydrationContext, ISyntheticView, vmkCe } from '../../templating/controller'; import { IRendering } from '../../templating/rendering'; import { registerResolver } from '../../utilities-di'; import { CustomElement, CustomElementDefinition, CustomElementStaticAuDefinition, elementTypeName } from '../custom-element'; import { ErrorNames, createMappedError } from '../../errors'; import { fromView } from '../../binding/interfaces-bindings'; import { SpreadBinding } from '../../binding/spread-binding'; /** * An optional interface describing the dynamic composition activate convention. */ export interface IDynamicComponentActivate { /** * Implement this hook if you want to perform custom logic just before the component is is composed. * The returned value is not used. */ activate?(model?: T): unknown; } type ChangeSource = keyof Pick; // Desired usage: // // export class AuCompose { /** @internal */ public static readonly $au: CustomElementStaticAuDefinition> = { type: elementTypeName, name: 'au-compose', capture: true, containerless: true, bindables: [ 'template', 'component', 'model', { name: 'scopeBehavior', set: v => { if (v === 'scoped' || v === 'auto') { return v; } throw createMappedError(ErrorNames.au_compose_invalid_scope_behavior, v); }}, { name: 'composing', mode: fromView}, { name: 'composition', mode: fromView }, 'tag', { name: 'flushMode', set: v => { if (v === 'sync' || v === 'async') { return v; } throw createMappedError(ErrorNames.au_compose_invalid_flush_mode, v); }}, ] }; /* determine what template used to compose the component */ public template?: string | Promise; /** * Determine the component instance used to compose the component. * * - When a string is given as a value, it will be used as the name of the custom element to compose. * If there is no locally or globally registered custom element with that name, an error will be thrown. * * - When an object is given as a value, the object will be used as the component instance. * - When a constructor is given as a value, the constructor will be used to create the component instance. * - When a null/undefined is given as a value, the component will be composed as a template-only composition with an empty component instance. * - When a promise is given as a value, the promise will be awaited and the resolved value will be used as the value. */ public component?: string | Constructable | object | Promise; /* the model used to pass to activate lifecycle of the component */ public model?: unknown; /** * Control scoping behavior of the view created by the au-compose. * This only affects template-only composition. Does not have effects on custom element composition. * * auto = inherit parent scope * scoped = do not inherit parent scope */ public scopeBehavior: 'auto' | 'scoped' = 'auto'; /** @internal */ private _composing?: Promise | void; public get composing(): Promise | void { return this._composing; } /** @internal */ private _composition: ICompositionController | undefined = void 0; public get composition(): ICompositionController | undefined { return this._composition; } /** * The tag name of the element to be created for non custom element composition. * * `null`/`undefined` means containerless */ public tag: string | null | undefined = null; /** * Control whether the composition is performed synchronously or asynchronously. */ public flushMode: 'sync' | 'async' = 'sync'; /** @internal */ public readonly $controller!: ICustomElementController; /** @internal */ private readonly _container = resolve(IContainer); /** @internal */ private readonly parent = resolve(IController) as ISyntheticView | ICustomElementController; /** @internal */ private readonly _host = resolve(INode) as HTMLElement; /** @internal */ private readonly _location = resolve(IRenderLocation); /** @internal */ private readonly _platform = resolve(IPlatform); /** @internal */ private readonly _rendering = resolve(IRendering); /** @internal */ private readonly _instruction = resolve(IInstruction) as HydrateElementInstruction; /** @internal */ private readonly _contextFactory = resolve(transient(CompositionContextFactory, null!)); /** @internal */ private readonly _compiler = resolve(ITemplateCompiler); /** @internal */ private readonly _hydrationContext = resolve(IHydrationContext); /** @internal */ private readonly _exprParser = resolve(IExpressionParser); /** @internal */ private readonly _observerLocator = resolve(IObserverLocator); public attaching(initiator: IHydratedController, _parent: IHydratedController): void | Promise { return this._composing = onResolve( this.queue(new ChangeInfo(this.template, this.component, this.model, void 0), initiator), (context) => { if (this._contextFactory._isCurrent(context)) { this._composing = void 0; } } ); } public detaching(initiator: IHydratedController): void | Promise { const cmpstn = this._composition; const pending = this._composing; this._contextFactory.invalidate(); this._composition = this._composing = void 0; return onResolve(pending, () => cmpstn?.deactivate(initiator)); } /** @internal */ public propertyChanged(name: ChangeSource): void { if (name === 'composing' || name === 'composition') { return; } if (this.flushMode === 'sync') { if (name === 'model' && this._composition != null) { this._composition.update(this.model); return; } // tag change does not affect existing custom element composition if (name === 'tag' && this._composition?.controller.vmKind === vmkCe) { if (__DEV__) { console.warn('[DEV:aurelia] Changing tag name of a custom element composition is ignored.'); // eslint-disable-line } return; } this._handleChangeInfo(new ChangeInfo(this.template, this.component, this.model, name)); } } /** @internal */ public propertiesChanged(changes: { [key in ChangeSource]: { newValue: AuCompose[key]; oldValue: AuCompose[key] } }): void { if (this.flushMode === 'async') { if ('model' in changes && this._composition != null && Object.keys(changes).length === 1) { this._composition.update(this.model); return; } // tag change does not affect existing custom element composition if ('tag' in changes && this._composition?.controller.vmKind === vmkCe) { if (__DEV__) { console.warn('[DEV:aurelia] Changing tag name of a custom element composition is ignored.'); // eslint-disable-line } return; } this._handleChangeInfo(new ChangeInfo( this.template, this.component, this.model, 'model' in changes ? 'model' : undefined )); } } /** @internal */ private _handleChangeInfo(info: ChangeInfo): void { this._composing = onResolve(this._composing, () => onResolve( this.queue(info, void 0), (context) => { if (this._contextFactory._isCurrent(context)) { this._composing = void 0; } } ) ); } /** @internal */ private queue(change: ChangeInfo, initiator: IHydratedController | undefined): CompositionContext | Promise { const factory = this._contextFactory; const prevCompositionCtrl = this._composition; // todo: handle consequitive changes that create multiple queues return onResolve( factory.create(change), context => { // Don't compose [stale] template/component // by always ensuring that the composition context is the latest one if (factory._isCurrent(context)) { return onResolve(this.compose(context), (result) => { // Don't activate [stale] controller // by always ensuring that the composition context is the latest one if (factory._isCurrent(context)) { return onResolve(result.activate(initiator), () => { // Don't conclude the [stale] composition // by always ensuring that the composition context is the latest one if (factory._isCurrent(context)) { // after activation, if the composition context is still the most recent one // then the job is done this._composition = result; return onResolve(prevCompositionCtrl?.deactivate(initiator), () => context); } else { // the stale controller should be deactivated return onResolve( result.controller.deactivate(result.controller, this.$controller), // todo: do we need to deactivate? () => { result.controller.dispose(); return context; } ); } }); } result.controller.dispose(); return context; }); } return context; } ); } /** @internal */ private compose(context: CompositionContext): MaybePromise { // todo: when both component and template are empty // should it throw or try it best to proceed? // current: proceed const { _template: template, _component: component, _model: model } = context.change; const { _container: container, $controller, _location: loc, _instruction } = this; const vmDef = this._getDefinition(this._hydrationContext.controller.container, component); const childCtn: IContainer = container.createChild(); const compositionHost = this._platform.document.createElement(vmDef == null ? this.tag ?? 'div' : vmDef.name); loc.parentNode!.insertBefore(compositionHost, loc); let compositionLocation: IRenderLocation | null; if (vmDef == null) { compositionLocation = this.tag == null ? convertToRenderLocation(compositionHost) : null; } else { compositionLocation = vmDef.containerless ? convertToRenderLocation(compositionHost) : null; } const removeCompositionHost = () => { compositionHost.remove(); if (compositionLocation != null) { let curr = compositionLocation.$start!.nextSibling; let next: ChildNode | null = null; while (curr !== null && curr !== compositionLocation) { next = curr.nextSibling; curr.remove(); curr = next; } compositionLocation.$start?.remove(); compositionLocation.remove(); } }; const comp = this._createComponentInstance( childCtn, typeof component === 'string' ? vmDef!.Type : component, compositionHost, compositionLocation ); const compose: () => ICompositionController = () => { const aucomposeCapturedAttrs = _instruction.captures! ?? emptyArray; // custom element based composition if (vmDef !== null) { const capture = vmDef.capture; const [capturedBindingAttrs, transferedToHostBindingAttrs] = aucomposeCapturedAttrs .reduce((attrGroups: [AttrSyntax[], AttrSyntax[]], attr) => { const shouldCapture = !(attr.target in vmDef.bindables) && (capture === true || isFunction(capture) && !!capture(attr.target)); attrGroups[shouldCapture ? 0 : 1].push(attr); return attrGroups; }, [[], []]); const controller = Controller.$el( childCtn, comp, compositionHost, { projections: _instruction.projections, captures: capturedBindingAttrs }, vmDef, compositionLocation ); // Theoretically these bindings aren't bindings of the composed custom element // Though they are meant to be activated (bound)/ deactivated (unbound) together // with the custom element controller, so it's practically ok to let the composed // custom element manage these bindings this._createSpreadBindings(compositionHost, vmDef, transferedToHostBindingAttrs).forEach(b => controller.addBinding(b)); return new CompositionController( controller, (attachInitiator) => controller.activate(attachInitiator ?? controller, $controller, $controller.scope.parent!), // todo: call deactivate on the component component (deactachInitiator) => onResolve( controller.deactivate(deactachInitiator ?? controller, $controller), removeCompositionHost ), // casting is technically incorrect // but it's ignored in the caller anyway (model) => comp.activate?.(model), context, ); } else { const targetDef = CustomElementDefinition.create({ name: CustomElement.generateName(), template: template, }); const compiledDef = this._rendering.compile(targetDef, childCtn); const viewFactory = this._rendering.getViewFactory(compiledDef, childCtn); const controller = Controller.$view( viewFactory, $controller, compositionHost, ); const scope = this.scopeBehavior === 'auto' ? Scope.fromParent(this.parent.scope, comp) : Scope.create(comp); controller.setHost(compositionHost); if (compositionLocation == null) { // only spread the bindings if there is an actual host // otherwise we may accidentally do unnecessary work this._createSpreadBindings(compositionHost, viewFactory.def, aucomposeCapturedAttrs).forEach(b => controller.addBinding(b)); } else { controller.setLocation(compositionLocation); } return new CompositionController( controller, (attachInitiator) => controller.activate(attachInitiator ?? controller, $controller, scope), // todo: call deactivate on the component // a difference with composing custom element is that we leave render location/host alone // as they all share the same host/render location (detachInitiator) => onResolve( controller.deactivate(detachInitiator ?? controller, $controller), removeCompositionHost ), // casting is technically incorrect // but it's ignored in the caller anyway (model) => comp.activate?.(model), context, ); } }; if ('activate' in comp) { // todo: try catch // req: ensure synchronosity of compositions that dont employ promise return onResolve(comp.activate!(model), () => compose()); } else { return compose(); } } /** @internal */ private _createComponentInstance( container: IContainer, comp: Constructable | object | undefined, host: HTMLElement | IRenderLocation, location: IRenderLocation | null, ): IDynamicComponentActivate { if (comp == null) { return new EmptyComponent(); } if (typeof comp === 'object') { return comp; } const p = this._platform; registerHostNode(container, host, p); registerResolver( container, IRenderLocation, new InstanceProvider('IRenderLocation', location) ); const instance = container.invoke(comp); registerResolver(container, comp, new InstanceProvider('au-compose.component', instance)); return instance; } /** @internal */ private _getDefinition(container: IContainer, component?: string | object | Constructable) { if (typeof component === 'string') { const def = CustomElement.find(container, component); if (def == null) { throw createMappedError(ErrorNames.au_compose_component_name_not_found, component); } return def; } const Ctor = (isFunction(component) ? component : component?.constructor) as Constructable; return CustomElement.isType(Ctor, void 0) ? CustomElement.getDefinition(Ctor, null) : null; } /** @internal */ private _createSpreadBindings(host: HTMLElement, def: CustomElementDefinition, capturedAttrs: AttrSyntax[]) { const transferHydrationContext = new HydrationContext( this.$controller, { projections: null, captures: capturedAttrs }, this._hydrationContext.parent ); return SpreadBinding.create( transferHydrationContext, host, def, this._rendering, this._compiler, this._platform, this._exprParser, this._observerLocator, ); } } class EmptyComponent { } export interface ICompositionController { readonly controller: IHydratedController; readonly context: CompositionContext; activate(initiator?: IHydratedController): void | Promise; // deactivation is done differently, compared to activation // when the `` is deactivated, initiator will be an ancestor controller // // while when the value of the @bindables changes, initiator should be // the controller wrapped in this composition controller deactivate(detachInitator?: IHydratedController): void | Promise; update(model: unknown): unknown; } class CompositionContextFactory { private id = 0; public _isCurrent(context: CompositionContext): boolean { return context.id === this.id; } public create(changes: ChangeInfo): MaybePromise { return onResolve(changes.load(), (loaded) => new CompositionContext(++this.id, loaded)); } // simplify increasing the id will invalidate all previously created context public invalidate(): void { this.id++; } } class ChangeInfo { public constructor( public _template: MaybePromise | undefined, public _component: MaybePromise | undefined, public _model: unknown, public _src: ChangeSource | undefined, ) { } public load(): MaybePromise { if (isPromise(this._template) || isPromise(this._component)) { return Promise .all([this._template, this._component]) .then(([template, component]) => { return new LoadedChangeInfo(template, component, this._model, this._src); }); } else { return new LoadedChangeInfo(this._template, this._component, this._model, this._src); } } } class LoadedChangeInfo { public constructor( public readonly _template: string | undefined, public readonly _component: string | Constructable | object | undefined, public readonly _model: unknown, public readonly _src: ChangeSource | undefined, ) { } } class CompositionContext { public constructor( public readonly id: number, public readonly change: LoadedChangeInfo, ) { } } class CompositionController implements ICompositionController { private state: /* stopped */-1 | /* initial */0 | /* started */1 = 0; public constructor( public readonly controller: ISyntheticView | ICustomElementController, private readonly start: (attachInitiator?: IHydratedController) => void | Promise, private readonly stop: (detachInitator?: IHydratedController) => void | Promise, public readonly update: (model: unknown) => unknown, public readonly context: CompositionContext, ) { } public activate(initiator?: IHydratedController) { if (this.state !== 0) { throw createMappedError(ErrorNames.au_compose_invalid_run, this); } this.state = 1; return this.start(initiator); } public deactivate(detachInitator?: IHydratedController) { switch (this.state) { case 1: this.state = -1; return this.stop(detachInitator); case -1: throw createMappedError(ErrorNames.au_compose_duplicate_deactivate); default: this.state = -1; } } }