From 998b91caf3becbed88fe3d3355451a40bfb98da7 Mon Sep 17 00:00:00 2001 From: bigopon Date: Thu, 19 Aug 2021 14:20:54 +1000 Subject: [PATCH] feat(attr-transfer): implement attr capturing & spreading --- .../__tests__/3-runtime-html/spread.spec.ts | 281 ++++++++++++++++++ .../3-runtime-html/template-compiler.spec.ts | 20 +- packages/platform-browser/src/index.ts | 2 +- packages/runtime-html/src/configuration.ts | 11 +- packages/runtime-html/src/create-element.ts | 2 +- packages/runtime-html/src/renderer.ts | 186 ++++++++++-- .../src/resources/attribute-pattern.ts | 7 + .../src/resources/binding-command.ts | 54 ++-- .../src/resources/custom-element.ts | 5 + .../runtime-html/src/template-compiler.ts | 215 ++++++++++++++ .../runtime-html/src/templating/controller.ts | 5 + packages/testing/src/test-context.ts | 6 + 12 files changed, 743 insertions(+), 51 deletions(-) create mode 100644 packages/__tests__/3-runtime-html/spread.spec.ts diff --git a/packages/__tests__/3-runtime-html/spread.spec.ts b/packages/__tests__/3-runtime-html/spread.spec.ts new file mode 100644 index 0000000000..c824ceed7b --- /dev/null +++ b/packages/__tests__/3-runtime-html/spread.spec.ts @@ -0,0 +1,281 @@ +import { Constructable } from '@aurelia/kernel'; +import { BindingMode, CustomAttribute, CustomElement, ICustomElementViewModel, INode } from '@aurelia/runtime-html'; +import { assert, createFixture } from '@aurelia/testing'; + +// all the tests are using a common with a spreat on its internal +describe('3-runtime-html/spread.spec.ts', function () { + const $it = (title: string, args: ISpreadTestCase) => runTest(title, args, false); + $it.only = (title: string, args: ISpreadTestCase) => runTest(title, args, true); + + $it('works single layer of ...attrs', { + template: '', + component: { message: 'Aurelia' }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + }, + }); + + $it('preserves attr syntaxes', { + template: '', + component: { message: 'Aurelia' }, + assertFn: ({ ctx, component, appHost }) => { + ctx.type(appHost, 'input', 'hello'); + assert.strictEqual(component.message, 'hello'); + component.message = 'Aurelia'; + ctx.platform.domWriteQueue.flush(); + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + }, + }); + + $it('does not throw when capture: false', { + template: '', + component: { message: 'Aurelia' }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, ''); + }, + }); + + $it('does not throw when there are nothing to capture', { + template: '', + component: { message: 'Aurelia' }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, ''); + }, + }); + + $it('works with pass-through ...$attrs', { + template: '', + component: { message: 'Aurelia' }, + registrations: [ + CustomElement.define({ + name: 'input-field', + template: '', + capture: true, + }), + ], + assertFn: ({ platform, component, appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + component.message = 'hello'; + platform.domWriteQueue.flush(); + assert.strictEqual(appHost.querySelector('input').value, 'hello'); + }, + }); + + $it('does not capture template controller', { + template: '', + component: { hasInput: false, message: 'Aurelia' }, + assertFn: ({ appHost }) => { + assert.html.innerEqual(appHost, ''); + }, + }); + + $it('spreads event bindings', { + template: '', + component: { message: 'Aurelia', focused: false }, + assertFn: ({ ctx, component, appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + ctx.type(appHost, 'input', 'hello'); + assert.strictEqual(component.message, 'hello'); + appHost.querySelector('input').dispatchEvent(new ctx.CustomEvent('focus')); + assert.strictEqual(component.focused, true); + }, + }); + + $it('spreads interpolation', { + template: ``, + component: { message: 'Aurelia', focused: false }, + assertFn: ({ ctx, component, appHost }) => { + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + ctx.type(appHost, 'input', 'hello'); + assert.strictEqual(component.message, 'Aurelia'); + }, + }); + + $it('spreads plain class attribute', { + template: '', + component: { message: 'Aurelia', focused: false }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').className, 'au abc'); + }, + }); + + $it('spreads plain style attribute', { + template: '', + component: { message: 'Aurelia', focused: false }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').style.display, 'block'); + }, + }); + + $it('spreads plain attributes', { + template: '', + component: { message: 'Aurelia', focused: false }, + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').size, 20); + assert.strictEqual(appHost.querySelector('input').getAttribute('aria-label'), 'input'); + }, + }); + + describe('custom attribute', function () { + const MyAttr = CustomAttribute.define({ name: 'my-attr', bindables: ['value'] }, class { + public static inject = [INode]; + public value: any; + public constructor(private readonly host: HTMLElement) { } + public binding() { + this.host.setAttribute('size', this.value); + } + }); + + $it('spreads custom attribute (with literal value)', { + template: '', + component: { }, + registrations: [MyAttr], + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').getAttribute('size'), '20'); + assert.strictEqual(appHost.querySelector('my-input').getAttribute('size'), null); + } + }); + + $it('spreads custom attribute (with interpolation)', { + template: ``, + component: { size: 20 }, + registrations: [MyAttr], + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').getAttribute('size'), '20'); + assert.strictEqual(appHost.querySelector('my-input').getAttribute('size'), null); + } + }); + + $it('spreads custom attribute (primary binding syntax)', { + template: '', + component: { size: 20 }, + registrations: [MyAttr], + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').getAttribute('size'), '20'); + assert.strictEqual(appHost.querySelector('my-input').getAttribute('size'), null); + } + }); + + $it('spreads custom attribute (multi binding syntax)', { + template: '', + component: { size: 20 }, + registrations: [MyAttr], + assertFn: ({ appHost }) => { + assert.strictEqual(appHost.querySelector('input').getAttribute('size'), '20'); + assert.strictEqual(appHost.querySelector('my-input').getAttribute('size'), null); + } + }); + }); + + describe('custom element', function () { + const testElements = [ + CustomElement.define({ + name: 'form-field', + template: '', + capture: true, + }), + CustomElement.define({ + name: 'form-input', + template: '', + bindables: { + value: { property: 'value', attribute: 'value', mode: BindingMode.twoWay } + } + }), + ]; + + $it('spreads plain binding to custom element bindable', { + template: '', + component: {}, + registrations: testElements, + assertFn: ({ appHost }) => { + assert.notStrictEqual((appHost.querySelector('form-input') as any).value, 'Aurelia'); + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + }, + }); + + $it('spreads binding to custom element bindables', { + template: '', + component: { message: 'Aurelia' }, + registrations: testElements, + assertFn: ({ appHost }) => { + assert.notStrictEqual((appHost.querySelector('form-input') as any).value, 'Aurelia'); + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + }, + }); + + $it('spreads interpolation to custom element bindables', { + template: ``, + component: { message: 'Aurelia' }, + registrations: testElements, + assertFn: ({ appHost }) => { + assert.notStrictEqual((appHost.querySelector('form-input') as any).value, 'Aurelia'); + assert.strictEqual(appHost.querySelector('input').value, 'Aurelia'); + }, + }); + + $it('spreads shorthand element bindable props', { + template: '', + component: { prop1: 'prop 1', prop2: 'prop 2', prop3: 'prop 3', prop4: 'prop 4' }, + registrations: [ + CustomElement.define({ + name: 'form-field', + template: '', + capture: true, + }), + CustomElement.define({ + name: 'form-input', + template: '', + bindables: ['prop1', 'prop2', 'prop3', 'prop4'] + }), + ], + assertFn: ({ component, appHost }) => { + const formInput = CustomElement.for(appHost.querySelector('form-input')).viewModel as typeof component; + assert.strictEqual(formInput.prop1, 'prop 1'); + assert.strictEqual(formInput.prop2, 'prop 2'); + assert.strictEqual(formInput.prop3, 'prop 3'); + assert.strictEqual(formInput.prop4, undefined); + formInput.prop4 = 'prop 5'; + assert.strictEqual(component.prop4, 'prop 5'); + }, + }); + }); + + function runTest(title: string, { template, assertFn, component, registrations }: ISpreadTestCase, only?: boolean) { + return (only ? it.only : it)(title, async function () { + const fixture = createFixture( + template, + (typeof component === 'object' + ? class { public constructor() { Object.assign(this, component); } } + : component + ) as Constructable ? Constructable : Constructable>, + [ + ...(registrations ?? []), + CustomElement.define({ + name: 'my-input', + template: '', + capture: true, + }, class MyInput { }), + CustomElement.define({ + name: 'no-capture-input', + template: '', + capture: false, + }, class NoCaptureInput { }) + ], + ); + const { startPromise, tearDown } = fixture; + + await startPromise; + + await assertFn(fixture as any); + + await tearDown(); + }); + } + + interface ISpreadTestCase { + component: Constructable | T; + template: string; + registrations?: any[]; + assertFn(arg: ReturnType & { component: ICustomElementViewModel & T }): void | Promise; + } +}); diff --git a/packages/__tests__/3-runtime-html/template-compiler.spec.ts b/packages/__tests__/3-runtime-html/template-compiler.spec.ts index 8e62ff4708..8fd666838c 100644 --- a/packages/__tests__/3-runtime-html/template-compiler.spec.ts +++ b/packages/__tests__/3-runtime-html/template-compiler.spec.ts @@ -720,6 +720,7 @@ function createTemplateController(ctx: TestContext, resolveRes: boolean, attr: s instructions: [[childInstr]], needsCompile: false, enhance: false, + capture: false, processContent: null, }, props: createTplCtrlAttributeInstruction(attr, value), @@ -734,6 +735,7 @@ function createTemplateController(ctx: TestContext, resolveRes: boolean, attr: s instructions: [[instruction]], needsCompile: false, enhance: false, + capture: false, processContent: null, } as unknown as PartialCustomElementDefinition; return [input, output]; @@ -758,6 +760,7 @@ function createTemplateController(ctx: TestContext, resolveRes: boolean, attr: s instructions, needsCompile: false, enhance: false, + capture: false, processContent: null, }, props: createTplCtrlAttributeInstruction(attr, value), @@ -773,6 +776,7 @@ function createTemplateController(ctx: TestContext, resolveRes: boolean, attr: s instructions: [[instruction]], needsCompile: false, enhance: false, + capture: false, processContent: null, } as unknown as PartialCustomElementDefinition; return [input, output]; @@ -798,6 +802,7 @@ function createCustomElement( auSlot: null, containerless: false, projections: null, + captures: [], }; const def = typeof tagNameOrDef === 'string' ? ctx.container.find(CustomElement, tagNameOrDef) @@ -842,6 +847,7 @@ function createCustomElement( needsCompile: false, enhance: false, watches: [], + capture: false, processContent: null, }; return [input, output]; @@ -877,7 +883,7 @@ function createCustomAttribute( // new behavior: if it's custom attribute, remove const outputMarkup = ctx.createElementFromMarkup(`
${(childOutput && childOutput.template.outerHTML) || ''}
`); outputMarkup.classList.add('au'); - const output = { + const output: PartialCustomElementDefinition & { key: string } = { ...defaultCustomElementDefinitionProperties, name: 'unnamed', key: 'au:resource:custom-element:unnamed', @@ -885,6 +891,7 @@ function createCustomAttribute( instructions: [[instruction, ...siblingInstructions], ...nestedElInstructions], needsCompile: false, enhance: false, + capture: false, watches: [], processContent: null, }; @@ -998,13 +1005,14 @@ describe(`TemplateCompiler - combinations`, function () { instructions: [], surrogates: [], } as unknown as PartialCustomElementDefinition; - const expected = { + const expected: PartialCustomElementDefinition = { ...defaultCustomElementDefinitionProperties, template: ctx.createElementFromMarkup(``), instructions: [[i1]], surrogates: [], needsCompile: false, enhance: false, + capture: false, processContent: null, }; @@ -1023,13 +1031,14 @@ describe(`TemplateCompiler - combinations`, function () { instructions: [], surrogates: [], } as unknown as PartialCustomElementDefinition; - const expected = { + const expected: PartialCustomElementDefinition = { ...defaultCustomElementDefinitionProperties, template: ctx.createElementFromMarkup(``), instructions: [[i1]], surrogates: [], needsCompile: false, enhance: false, + capture: false, processContent: null, }; @@ -1073,6 +1082,7 @@ describe(`TemplateCompiler - combinations`, function () { surrogates: [], needsCompile: false, enhance: false, + capture: false, processContent: null, }; @@ -1151,6 +1161,7 @@ describe(`TemplateCompiler - combinations`, function () { surrogates: [], needsCompile: false, enhance: false, + capture: false, watches: [], processContent: null, }; @@ -1448,12 +1459,13 @@ describe(`TemplateCompiler - combinations`, function () { ); sut.resolveResources = resolveRes; - const output = { + const output: PartialCustomElementDefinition = { ...defaultCustomElementDefinitionProperties, template: ctx.createElementFromMarkup(``), instructions: [output1.instructions[0], output2.instructions[0], output3.instructions[0]], needsCompile: false, enhance: false, + capture: false, watches: [], processContent: null, }; diff --git a/packages/platform-browser/src/index.ts b/packages/platform-browser/src/index.ts index 38ba1f0fc6..eb0c50e3ad 100644 --- a/packages/platform-browser/src/index.ts +++ b/packages/platform-browser/src/index.ts @@ -49,7 +49,7 @@ export class BrowserPlatform { - (this as any)[prop] = prop in overrides ? (overrides as any)[prop] : (g as any)[prop].bind(g) ?? notImplemented(prop); + (this as any)[prop] = prop in overrides ? (overrides as any)[prop] : ((g as any)[prop]?.bind(g) ?? notImplemented(prop)); }); this.flushDomRead = this.flushDomRead.bind(this); diff --git a/packages/runtime-html/src/configuration.ts b/packages/runtime-html/src/configuration.ts index b06b30486a..6169d17223 100644 --- a/packages/runtime-html/src/configuration.ts +++ b/packages/runtime-html/src/configuration.ts @@ -2,6 +2,7 @@ import { DI, IContainer, IRegistry } from '@aurelia/kernel'; import { AtPrefixedTriggerAttributePattern, ColonPrefixedBindAttributePattern, + SpreadAttributePattern, DotSeparatedAttributePattern, RefAttributePattern, } from './resources/attribute-pattern.js'; @@ -20,6 +21,7 @@ import { RefBindingCommand, StyleBindingCommand, TriggerBindingCommand, + SpreadBindingCommand, } from './resources/binding-command.js'; import { TemplateCompiler } from './template-compiler.js'; import { @@ -40,6 +42,7 @@ import { TextBindingRenderer, SetClassAttributeRenderer, SetStyleAttributeRenderer, + SpreadRenderer, } from './renderer.js'; import { FromViewBindingBehavior, @@ -107,6 +110,7 @@ export const AtPrefixedTriggerAttributePatternRegistration = AtPrefixedTriggerAt export const ColonPrefixedBindAttributePatternRegistration = ColonPrefixedBindAttributePattern as unknown as IRegistry; export const RefAttributePatternRegistration = RefAttributePattern as unknown as IRegistry; export const DotSeparatedAttributePatternRegistration = DotSeparatedAttributePattern as unknown as IRegistry; +export const SpreadAttributePatternRegistration = SpreadAttributePattern as unknown as IRegistry; /** * Default binding syntax for the following attribute name patterns: @@ -115,7 +119,8 @@ export const DotSeparatedAttributePatternRegistration = DotSeparatedAttributePat */ export const DefaultBindingSyntax = [ RefAttributePatternRegistration, - DotSeparatedAttributePatternRegistration + DotSeparatedAttributePatternRegistration, + SpreadAttributePatternRegistration, ]; /** @@ -142,6 +147,7 @@ export const CaptureBindingCommandRegistration = CaptureBindingCommand as unknow export const AttrBindingCommandRegistration = AttrBindingCommand as unknown as IRegistry; export const ClassBindingCommandRegistration = ClassBindingCommand as unknown as IRegistry; export const StyleBindingCommandRegistration = StyleBindingCommand as unknown as IRegistry; +export const SpreadBindingCommandRegistration = SpreadBindingCommand as unknown as IRegistry; /** * Default HTML-specific (but environment-agnostic) binding commands: @@ -165,6 +171,7 @@ export const DefaultBindingLanguage = [ ClassBindingCommandRegistration, StyleBindingCommandRegistration, AttrBindingCommandRegistration, + SpreadBindingCommandRegistration, ]; export const SanitizeValueConverterRegistration = SanitizeValueConverter as unknown as IRegistry; @@ -258,6 +265,7 @@ export const SetClassAttributeRendererRegistration = SetClassAttributeRenderer a export const SetStyleAttributeRendererRegistration = SetStyleAttributeRenderer as unknown as IRegistry; export const StylePropertyBindingRendererRegistration = StylePropertyBindingRenderer as unknown as IRegistry; export const TextBindingRendererRegistration = TextBindingRenderer as unknown as IRegistry; +export const SpreadRendererRegistration = SpreadRenderer as unknown as IRegistry; /** * Default renderers for: @@ -294,6 +302,7 @@ export const DefaultRenderers = [ SetStyleAttributeRendererRegistration, StylePropertyBindingRendererRegistration, TextBindingRendererRegistration, + SpreadRendererRegistration, ]; /** diff --git a/packages/runtime-html/src/create-element.ts b/packages/runtime-html/src/create-element.ts index 11c490db78..127726599b 100644 --- a/packages/runtime-html/src/create-element.ts +++ b/packages/runtime-html/src/create-element.ts @@ -125,7 +125,7 @@ function createElementForType( dependencies.push(Type); } - instructions.push(new HydrateElementInstruction(definition, void 0, childInstructions, null, false)); + instructions.push(new HydrateElementInstruction(definition, void 0, childInstructions, null, false, void 0)); if (props) { Object.keys(props) diff --git a/packages/runtime-html/src/renderer.ts b/packages/runtime-html/src/renderer.ts index 1c0d2cc474..d659e8fe99 100644 --- a/packages/runtime-html/src/renderer.ts +++ b/packages/runtime-html/src/renderer.ts @@ -8,6 +8,8 @@ import { BindingBehaviorExpression, BindingBehaviorFactory, ExpressionKind, + IBinding, + Scope, } from '@aurelia/runtime'; import { CallBinding } from './binding/call-binding.js'; import { AttributeBinding } from './binding/attribute.js'; @@ -21,7 +23,7 @@ import { CustomElement, CustomElementDefinition } from './resources/custom-eleme import { AuSlotsInfo, IAuSlotsInfo, IProjections } from './resources/slot-injectables.js'; import { CustomAttribute, CustomAttributeDefinition } from './resources/custom-attribute.js'; import { convertToRenderLocation, IRenderLocation, INode, setRef } from './dom.js'; -import { Controller, ICustomElementController, ICustomElementViewModel, IController, ICustomAttributeViewModel } from './templating/controller.js'; +import { Controller, ICustomElementController, ICustomElementViewModel, IController, ICustomAttributeViewModel, IHydrationContext, ViewModelKind } from './templating/controller.js'; import { IPlatform } from './platform.js'; import { IViewFactory } from './templating/view.js'; import { IRendering } from './templating/rendering.js'; @@ -40,6 +42,7 @@ import type { } from '@aurelia/runtime'; import type { IHydratableController } from './templating/controller.js'; import type { PartialCustomElementDefinition } from './resources/custom-element.js'; +import { AttrSyntax } from './resources/attribute-pattern.js'; export const enum InstructionType { hydrateElement = 'ra', @@ -60,6 +63,8 @@ export const enum InstructionType { setAttribute = 'he', setClassAttribute = 'hf', setStyleAttribute = 'hg', + spreadBinding = 'hs', + spreadElementProp = 'hp', } export type InstructionTypeName = string; @@ -157,6 +162,10 @@ export class HydrateElementInstruction { * Indicates whether the usage of the custom element was with a containerless attribute or not */ public containerless: boolean, + /** + * A list of captured attr syntaxes + */ + public captures: AttrSyntax[] | undefined, ) { } } @@ -285,6 +294,17 @@ export class AttributeBindingInstruction { ) {} } +export class SpreadBindingInstruction { + public get type(): InstructionType.spreadBinding { return InstructionType.spreadBinding; } +} + +export class SpreadElementPropBindingInstruction { + public get type(): InstructionType.spreadElementProp { return InstructionType.spreadElementProp; } + public constructor( + public readonly innerInstruction: IInstruction, + ) {} +} + export const ITemplateCompiler = DI.createInterface('ITemplateCompiler'); export interface ITemplateCompiler { /** @@ -305,6 +325,21 @@ export interface ITemplateCompiler { context: IContainer, compilationInstruction: ICompliationInstruction | null, ): CustomElementDefinition; + + /** + * Compile a list of captured attributes as if they are declared in a template + * + * @param requestor - the context definition where the attributes is compiled + * @param attrSyntaxes - the attributes captured + * @param container - the container containing information for the compilation + * @param host - the host element where the attributes are spreaded on + */ + compileSpread( + requestor: PartialCustomElementDefinition, + attrSyntaxes: AttrSyntax[], + container: IContainer, + host: Element, + ): IInstruction[]; } export interface ICompliationInstruction { @@ -1043,19 +1078,12 @@ export class SetStyleAttributeRenderer implements IRenderer { /** @internal */ export class StylePropertyBindingRenderer implements IRenderer { /** @internal */ protected static inject = [IExpressionParser, IObserverLocator, IPlatform]; - /** @internal */ private readonly _exprParser: IExpressionParser; - /** @internal */ private readonly _observerLocator: IObserverLocator; - /** @internal */ private readonly _platform: IPlatform; public constructor( - exprParser: IExpressionParser, - observerLocator: IObserverLocator, - p: IPlatform, - ) { - this._exprParser = exprParser; - this._observerLocator = observerLocator; - this._platform = p; - } + /** @internal */ private readonly _exprParser: IExpressionParser, + /** @internal */ private readonly _observerLocator: IObserverLocator, + /** @internal */ private readonly _platform: IPlatform, + ) {} public render( renderingCtrl: IHydratableController, @@ -1075,16 +1103,11 @@ export class StylePropertyBindingRenderer implements IRenderer { /** @internal */ export class AttributeBindingRenderer implements IRenderer { /** @internal */ protected static inject = [IExpressionParser, IObserverLocator]; - /** @internal */ private readonly _exprParser: IExpressionParser; - /** @internal */ private readonly _observerLocator: IObserverLocator; public constructor( - exprParser: IExpressionParser, - observerLocator: IObserverLocator, - ) { - this._exprParser = exprParser; - this._observerLocator = observerLocator; - } + /** @internal */ private readonly _exprParser: IExpressionParser, + /** @internal */ private readonly _observerLocator: IObserverLocator, + ) {} public render( renderingCtrl: IHydratableController, @@ -1108,6 +1131,124 @@ export class AttributeBindingRenderer implements IRenderer { } } +@renderer(InstructionType.spreadBinding) +export class SpreadRenderer implements IRenderer { + /** @internal */ protected static get inject() { return [ITemplateCompiler, IRendering]; } + public constructor( + /** @internal */ private readonly _compiler: ITemplateCompiler, + /** @internal */ private readonly _rendering: IRendering, + ) {} + + public render( + renderingCtrl: IHydratableController, + target: HTMLElement, + instruction: SpreadBindingInstruction, + ): void { + const container = renderingCtrl.container; + const hydrationContext = container.get(IHydrationContext); + const renderers = this._rendering.renderers; + const getHydrationContext = (ancestor: number) => { + let currentLevel = ancestor; + let currentContext: IHydrationContext | undefined = hydrationContext; + while (currentContext != null && currentLevel > 0) { + currentContext = currentContext.parent; + --currentLevel; + } + if (currentContext == null) { + throw new Error('No scope context for spread binding.'); + } + return currentContext as IHydrationContext; + }; + const renderSpreadInstruction = (ancestor: number) => { + const context = getHydrationContext(ancestor); + const spreadBinding = createSurrogateBinding(context); + const instructions = this._compiler.compileSpread( + context.controller.definition, + context.instruction?.captures ?? emptyArray, + context.controller.container, + target, + ); + let inst: IInstruction; + for (inst of instructions) { + switch (inst.type) { + case InstructionType.spreadBinding: + renderSpreadInstruction(ancestor + 1); + break; + case InstructionType.spreadElementProp: + renderers[(inst as SpreadElementPropBindingInstruction).innerInstruction.type].render( + spreadBinding, + CustomElement.for(target), + (inst as SpreadElementPropBindingInstruction).innerInstruction, + ); + break; + default: + renderers[inst.type].render(spreadBinding, target, inst); + } + } + renderingCtrl.addBinding(spreadBinding); + }; + renderSpreadInstruction(0); + } +} + +class SpreadBinding implements IBinding { + public interceptor = this; + public $scope?: Scope | undefined; + public isBound: boolean = false; + public readonly locator: IServiceLocator; + + public readonly ctrl: ICustomElementController; + + public get container() { + return this.locator; + } + + public get definition(): CustomElementDefinition | CustomElementDefinition { + return this.ctrl.definition; + } + + public get isStrictBinding() { + return this.ctrl.isStrictBinding; + } + + public constructor( + /** @internal */ private readonly _innerBindings: IBinding[], + /** @internal */ private readonly _hydrationContext: IHydrationContext, + ) { + this.ctrl = _hydrationContext.controller; + this.locator = this.ctrl.container; + } + + public $bind(flags: LifecycleFlags, scope: Scope): void { + if (this.isBound) { + return; + } + this.isBound = true; + const innerScope = this.$scope = this._hydrationContext.controller.scope.parentScope ?? void 0; + if (innerScope == null) { + throw new Error('Invalid spreading. Context scope is null/undefined'); + } + + this._innerBindings.forEach(b => b.$bind(flags, innerScope)); + } + + public $unbind(flags: LifecycleFlags): void { + this._innerBindings.forEach(b => b.$unbind(flags)); + this.isBound = false; + } + + public addBinding(binding: IBinding) { + this._innerBindings.push(binding); + } + + public addChild(controller: IController) { + if (controller.vmKind !== ViewModelKind.customAttribute) { + throw new Error('Spread binding does not support spreading custom attributes/template controllers'); + } + this.ctrl.addChild(controller); + } +} + // http://jsben.ch/7n5Kt function addClasses(classList: DOMTokenList, className: string): void { const len = className.length; @@ -1124,6 +1265,8 @@ function addClasses(classList: DOMTokenList, className: string): void { } } +const createSurrogateBinding = (context: IHydrationContext) => + new SpreadBinding([], context) as SpreadBinding & IHydratableController; const controllerProviderName = 'IController'; const instructionProviderName = 'IInstruction'; const locationProviderName = 'IRenderLocation'; @@ -1215,6 +1358,9 @@ function invokeAttribute( ctn.registerResolver(INode, new InstanceProvider('ElementResolver', host)) ) ); + renderingCtrl = renderingCtrl instanceof Controller + ? renderingCtrl + : (renderingCtrl as unknown as SpreadBinding).ctrl; ctn.registerResolver(IController, new InstanceProvider(controllerProviderName, renderingCtrl)); ctn.registerResolver(IInstruction, new InstanceProvider(instructionProviderName, instruction)); ctn.registerResolver(IRenderLocation, location == null diff --git a/packages/runtime-html/src/resources/attribute-pattern.ts b/packages/runtime-html/src/resources/attribute-pattern.ts index b8016d8c4c..35cb89c0bd 100644 --- a/packages/runtime-html/src/resources/attribute-pattern.ts +++ b/packages/runtime-html/src/resources/attribute-pattern.ts @@ -580,3 +580,10 @@ export class AtPrefixedTriggerAttributePattern { return new AttrSyntax(rawName, rawValue, parts[0], 'trigger'); } } + +@attributePattern({ pattern: '...$attrs', symbols: '' }) +export class SpreadAttributePattern { + public '...$attrs'(rawName: string, rawValue: string, parts: string[]): AttrSyntax { + return new AttrSyntax('', '', '', '...$attrs'); + } +} diff --git a/packages/runtime-html/src/resources/binding-command.ts b/packages/runtime-html/src/resources/binding-command.ts index 59f704066b..cd416a4527 100644 --- a/packages/runtime-html/src/resources/binding-command.ts +++ b/packages/runtime-html/src/resources/binding-command.ts @@ -8,9 +8,10 @@ import { IteratorBindingInstruction, RefBindingInstruction, ListenerBindingInstruction, + SpreadBindingInstruction, } from '../renderer.js'; import { DefinitionType } from './resources-shared.js'; -import { appendResourceKey, defineMetadata, getAnnotationKeyFor, getOwnMetadata, getResourceKeyFor, hasOwnMetadata } from '../shared.js'; +import { appendResourceKey, defineMetadata, getAnnotationKeyFor, getOwnMetadata, getResourceKeyFor } from '../shared.js'; import type { Constructable, @@ -66,12 +67,12 @@ export type BindingCommandInstance = { export type BindingCommandType = ResourceType; export type BindingCommandKind = IResourceKind & { - isType(value: T): value is (T extends Constructable ? BindingCommandType : never); + // isType(value: T): value is (T extends Constructable ? BindingCommandType : never); define(name: string, Type: T): BindingCommandType; define(def: PartialBindingCommandDefinition, Type: T): BindingCommandType; define(nameOrDef: string | PartialBindingCommandDefinition, Type: T): BindingCommandType; - getDefinition(Type: T): BindingCommandDefinition; - annotate(Type: Constructable, prop: K, value: PartialBindingCommandDefinition[K]): void; + // getDefinition(Type: T): BindingCommandDefinition; + // annotate(Type: Constructable, prop: K, value: PartialBindingCommandDefinition[K]): void; getAnnotation(Type: Constructable, prop: K): PartialBindingCommandDefinition[K]; }; @@ -136,9 +137,9 @@ const getCommandAnnotation = ( export const BindingCommand = Object.freeze({ name: cmdBaseName, keyFrom: getCommandKeyFrom, - isType(value: T): value is (T extends Constructable ? BindingCommandType : never) { - return typeof value === 'function' && hasOwnMetadata(cmdBaseName, value); - }, + // isType(value: T): value is (T extends Constructable ? BindingCommandType : never) { + // return typeof value === 'function' && hasOwnMetadata(cmdBaseName, value); + // }, define>(nameOrDef: string | PartialBindingCommandDefinition, Type: T): T & BindingCommandType { const definition = BindingCommandDefinition.create(nameOrDef, Type as Constructable); defineMetadata(cmdBaseName, definition, definition.Type); @@ -147,20 +148,20 @@ export const BindingCommand = Object.freeze({ return definition.Type as BindingCommandType; }, - getDefinition(Type: T): BindingCommandDefinition { - const def = getOwnMetadata(cmdBaseName, Type); - if (def === void 0) { - if (__DEV__) - throw new Error(`No definition found for type ${Type.name}`); - else - throw new Error(`AUR0758:${Type.name}`); - } - - return def; - }, - annotate(Type: Constructable, prop: K, value: PartialBindingCommandDefinition[K]): void { - defineMetadata(getAnnotationKeyFor(prop), value, Type); - }, + // getDefinition(Type: T): BindingCommandDefinition { + // const def = getOwnMetadata(cmdBaseName, Type); + // if (def === void 0) { + // if (__DEV__) + // throw new Error(`No definition found for type ${Type.name}`); + // else + // throw new Error(`AUR0758:${Type.name}`); + // } + + // return def; + // }, + // annotate(Type: Constructable, prop: K, value: PartialBindingCommandDefinition[K]): void { + // defineMetadata(getAnnotationKeyFor(prop), value, Type); + // }, getAnnotation: getCommandAnnotation, }); @@ -525,7 +526,12 @@ export class RefBindingCommand implements BindingCommandInstance { } } -// @bindingCommand('...$attrs') -// export class SpreadCaptureBindingCommand implements BindingCommandInstance { +@bindingCommand('...$attrs') +export class SpreadBindingCommand implements BindingCommandInstance { + public readonly type: CommandType = CommandType.IgnoreAttr; + public get name(): string { return '...$attrs'; } -// } + public build(info: ICommandBuildInfo): IInstruction { + return new SpreadBindingInstruction(); + } +} diff --git a/packages/runtime-html/src/resources/custom-element.ts b/packages/runtime-html/src/resources/custom-element.ts index 06828b4795..ef9f9eb791 100644 --- a/packages/runtime-html/src/resources/custom-element.ts +++ b/packages/runtime-html/src/resources/custom-element.ts @@ -44,6 +44,7 @@ declare module '@aurelia/kernel' { export type PartialCustomElementDefinition = PartialResourceDefinition<{ readonly cache?: '*' | number; + readonly capture?: boolean; readonly template?: null | string | Node; readonly instructions?: readonly (readonly IInstruction[])[]; readonly dependencies?: readonly Key[]; @@ -214,6 +215,7 @@ export class CustomElementDefinition im public readonly aliases: string[], public readonly key: string, public readonly cache: '*' | number, + public readonly capture: boolean, public readonly template: null | string | Node, public readonly instructions: readonly (readonly IInstruction[])[], public readonly dependencies: readonly Key[], @@ -273,6 +275,7 @@ export class CustomElementDefinition im mergeArrays(def.aliases), fromDefinitionOrDefault('key', def as CustomElementDefinition, () => CustomElement.keyFrom(name)), fromDefinitionOrDefault('cache', def, returnZero), + fromDefinitionOrDefault('capture', def, returnFalse), fromDefinitionOrDefault('template', def, returnNull), mergeArrays(def.instructions), mergeArrays(def.dependencies), @@ -301,6 +304,7 @@ export class CustomElementDefinition im mergeArrays(getElementAnnotation(Type, 'aliases'), Type.aliases), CustomElement.keyFrom(nameOrDef), fromAnnotationOrTypeOrDefault('cache', Type, returnZero), + fromAnnotationOrTypeOrDefault('capture', Type, returnFalse), fromAnnotationOrTypeOrDefault('template', Type, returnNull), mergeArrays(getElementAnnotation(Type, 'instructions'), Type.instructions), mergeArrays(getElementAnnotation(Type, 'dependencies'), Type.dependencies), @@ -339,6 +343,7 @@ export class CustomElementDefinition im mergeArrays(getElementAnnotation(Type, 'aliases'), nameOrDef.aliases, Type.aliases), CustomElement.keyFrom(name), fromAnnotationOrDefinitionOrTypeOrDefault('cache', nameOrDef, Type, returnZero), + fromAnnotationOrDefinitionOrTypeOrDefault('capture', nameOrDef, Type, returnFalse), fromAnnotationOrDefinitionOrTypeOrDefault('template', nameOrDef, Type, returnNull), mergeArrays(getElementAnnotation(Type, 'instructions'), nameOrDef.instructions, Type.instructions), mergeArrays(getElementAnnotation(Type, 'dependencies'), nameOrDef.dependencies, Type.dependencies), diff --git a/packages/runtime-html/src/template-compiler.ts b/packages/runtime-html/src/template-compiler.ts index e335f8bcb7..b0f1bbebed 100644 --- a/packages/runtime-html/src/template-compiler.ts +++ b/packages/runtime-html/src/template-compiler.ts @@ -16,6 +16,7 @@ import { TextBindingInstruction, ITemplateCompiler, PropertyBindingInstruction, + SpreadElementPropBindingInstruction, } from './renderer.js'; import { IPlatform } from './platform.js'; import { Bindable, BindableDefinition } from './bindable.js'; @@ -101,6 +102,195 @@ export class TemplateCompiler implements ITemplateCompiler { }); } + public compileSpread( + definition: CustomElementDefinition, + attrSyntaxs: AttrSyntax[], + container: IContainer, + el: Element, + ): IInstruction[] { + const context = new CompilationContext(definition, container, emptyCompilationInstructions, null, null, void 0); + const instructions: IInstruction[] = []; + const elDef = context._findElement(el.nodeName.toLowerCase()); + const exprParser = context._exprParser; + const ii = attrSyntaxs.length; + let i = 0; + let attrSyntax: AttrSyntax; + let attrDef: CustomAttributeDefinition | null = null; + let attrInstructions: (HydrateAttributeInstruction | HydrateTemplateController)[] | undefined; + let attrBindableInstructions: IInstruction[]; + // eslint-disable-next-line + let bindablesInfo: BindablesInfo<0> | BindablesInfo<1>; + let bindable: BindableDefinition; + let primaryBindable: BindableDefinition; + let bindingCommand: BindingCommandInstance | null = null; + let expr: AnyBindingExpression; + let isMultiBindings: boolean; + let attrTarget: string; + let attrValue: string; + + for (; ii > i; ++i) { + attrSyntax = attrSyntaxs[i]; + + attrTarget = attrSyntax.target; + attrValue = attrSyntax.rawValue; + + bindingCommand = context._createCommand(attrSyntax); + if (bindingCommand !== null && (bindingCommand.type & CommandType.IgnoreAttr) > 0) { + // when the binding command overrides everything + // just pass the target as is to the binding command, and treat it as a normal attribute: + // active.class="..." + // background.style="..." + // my-attr.attr="..." + + commandBuildInfo.node = el; + commandBuildInfo.attr = attrSyntax; + commandBuildInfo.bindable = null; + commandBuildInfo.def = null; + instructions.push(bindingCommand.build(commandBuildInfo)); + + // to next attribute + continue; + } + + attrDef = context._findAttr(attrTarget); + if (attrDef !== null) { + if (attrDef.isTemplateController) { + if (__DEV__) + throw new Error(`Spreading template controller ${attrTarget} is not supported.`); + else + throw new Error(`AUR0703:${attrTarget}`); + } + bindablesInfo = BindablesInfo.from(attrDef, true); + // Custom attributes are always in multiple binding mode, + // except when they can't be + // When they cannot be: + // * has explicit configuration noMultiBindings: false + // * has binding command, ie:
. + // In this scenario, the value of the custom attributes is required to be a valid expression + // * has no colon: ie:
+ // In this scenario, it's simply invalid syntax. + // Consider style attribute rule-value pair:
+ isMultiBindings = attrDef.noMultiBindings === false + && bindingCommand === null + && hasInlineBindings(attrValue); + if (isMultiBindings) { + attrBindableInstructions = this._compileMultiBindings(el, attrValue, attrDef, context); + } else { + primaryBindable = bindablesInfo.primary; + // custom attribute + single value + WITHOUT binding command: + // my-attr="" + // my-attr="${}" + if (bindingCommand === null) { + expr = exprParser.parse(attrValue, ExpressionType.Interpolation); + attrBindableInstructions = [ + expr === null + ? new SetPropertyInstruction(attrValue, primaryBindable.property) + : new InterpolationInstruction(expr, primaryBindable.property) + ]; + } else { + // custom attribute with binding command: + // my-attr.bind="..." + // my-attr.two-way="..." + + commandBuildInfo.node = el; + commandBuildInfo.attr = attrSyntax; + commandBuildInfo.bindable = primaryBindable; + commandBuildInfo.def = attrDef; + attrBindableInstructions = [bindingCommand.build(commandBuildInfo)]; + } + } + + (attrInstructions ??= []).push(new HydrateAttributeInstruction( + // todo: def/ def.Type or def.name should be configurable + // example: AOT/runtime can use def.Type, but there are situation + // where instructions need to be serialized, def.name should be used + this.resolveResources ? attrDef : attrDef.name, + attrDef.aliases != null && attrDef.aliases.includes(attrTarget) ? attrTarget : void 0, + attrBindableInstructions + )); + continue; + } + + if (bindingCommand === null) { + expr = exprParser.parse(attrValue, ExpressionType.Interpolation); + + // reaching here means: + // + maybe a bindable attribute with interpolation + // + maybe a plain attribute with interpolation + // + maybe a plain attribute + if (elDef !== null) { + bindablesInfo = BindablesInfo.from(elDef, false); + bindable = bindablesInfo.attrs[attrTarget]; + if (bindable !== void 0) { + expr = exprParser.parse(attrValue, ExpressionType.Interpolation); + instructions.push( + new SpreadElementPropBindingInstruction( + expr == null + ? new SetPropertyInstruction(attrValue, bindable.property) + : new InterpolationInstruction(expr, bindable.property) + ) + ); + + continue; + } + } + + if (expr != null) { + instructions.push(new InterpolationInstruction( + expr, + // if not a bindable, then ensure plain attribute are mapped correctly: + // e.g: colspan -> colSpan + // innerhtml -> innerHTML + // minlength -> minLength etc... + context._attrMapper.map(el, attrTarget) ?? camelCase(attrTarget) + )); + } else { + switch (attrTarget) { + case 'class': + instructions.push(new SetClassAttributeInstruction(attrValue)); + break; + case 'style': + instructions.push(new SetStyleAttributeInstruction(attrValue)); + break; + default: + // if not a custom attribute + no binding command + not a bindable + not an interpolation + // then it's just a plain attribute + instructions.push(new SetAttributeInstruction(attrValue, attrTarget)); + } + } + } else { + if (elDef !== null) { + // if the element is a custom element + // - prioritize bindables on a custom element before plain attributes + bindablesInfo = BindablesInfo.from(elDef, false); + bindable = bindablesInfo.attrs[attrTarget]; + if (bindable !== void 0) { + commandBuildInfo.node = el; + commandBuildInfo.attr = attrSyntax; + commandBuildInfo.bindable = bindable; + commandBuildInfo.def = elDef; + instructions.push(new SpreadElementPropBindingInstruction(bindingCommand.build(commandBuildInfo))); + continue; + } + } + + commandBuildInfo.node = el; + commandBuildInfo.attr = attrSyntax; + commandBuildInfo.bindable = null; + commandBuildInfo.def = null; + instructions.push(bindingCommand.build(commandBuildInfo)); + } + } + + resetCommandBuildInfo(); + + if (attrInstructions != null) { + return (attrInstructions as IInstruction[]).concat(instructions); + } + + return instructions; + } + /** @internal */ private _compileSurrogate(el: Element, context: CompilationContext): IInstruction[] { const instructions: IInstruction[] = []; @@ -433,6 +623,8 @@ export class TemplateCompiler implements ITemplateCompiler { const nextSibling = el.nextSibling; const elName = (el.getAttribute('as-element') ?? el.nodeName).toLowerCase(); const elDef = context._findElement(elName); + const shouldCapture = !!elDef?.capture; + const captures: AttrSyntax[] = shouldCapture ? [] : emptyArray; const exprParser = context._exprParser; const removeAttr = this.debug ? noop @@ -511,7 +703,29 @@ export class TemplateCompiler implements ITemplateCompiler { } continue; } + attrSyntax = context._attrParser.parse(attrName, attrValue); + if (shouldCapture) { + bindablesInfo = BindablesInfo.from(elDef!, false); + // if capture is on, capture everything except: + // - as-element + // - containerless + // - bindable properties + // - template controller + // - custom attribute + if (bindablesInfo.attrs[attrSyntax.target] == null) { + bindingCommand = context._createCommand(attrSyntax); + // when the binding command ignores custom attribute + // it means the binding is targeting the host element + // it should also be captured + if (bindingCommand?.type === CommandType.IgnoreAttr + || !context._findAttr(attrSyntax.target)?.isTemplateController + ) { + captures.push(attrSyntax); + continue; + } + } + } bindingCommand = context._createCommand(attrSyntax); if (bindingCommand !== null && bindingCommand.type & CommandType.IgnoreAttr) { @@ -697,6 +911,7 @@ export class TemplateCompiler implements ITemplateCompiler { (elBindableInstructions ?? emptyArray) as IInstruction[], null, hasContainerless, + captures, ); // 2.1 prepare fallback content for diff --git a/packages/runtime-html/src/templating/controller.ts b/packages/runtime-html/src/templating/controller.ts index db11a127b6..b0b33789d3 100644 --- a/packages/runtime-html/src/templating/controller.ts +++ b/packages/runtime-html/src/templating/controller.ts @@ -41,6 +41,7 @@ import type { IObservable, IsBindingBehavior, } from '@aurelia/runtime'; +import type { AttrSyntax } from '../resources/attribute-pattern.js'; import type { IProjections } from '../resources/slot-injectables.js'; import type { BindableDefinition } from '../bindable.js'; import type { LifecycleHooksLookup } from './lifecycle-hooks.js'; @@ -1767,6 +1768,10 @@ export interface IControllerElementHydrationInstruction { */ readonly hydrate?: boolean; readonly projections: IProjections | null; + /** + * A list of captured attributes/binding in raw format + */ + readonly captures?: AttrSyntax[]; } function callDispose(disposable: IDisposable): void { diff --git a/packages/testing/src/test-context.ts b/packages/testing/src/test-context.ts index 28030971a7..b7056f435c 100644 --- a/packages/testing/src/test-context.ts +++ b/packages/testing/src/test-context.ts @@ -88,6 +88,12 @@ export class TestContext { attr.value = value; return attr; } + + public type(host: HTMLElement, selector: string, value: string): void { + const el = host.querySelector(selector) as HTMLElement & { value: string }; + el.value = value; + el.dispatchEvent(new this.CustomEvent('change', { bubbles: true })); + } } // Note: our tests shouldn't rely directly on this global variable, but retrieve the platform from a container instead.