Guidance Needed: I've gotten Aurelia 2.0 templates (through enhance) to work with Devextreme's widget integration point

Hello Everyone,

@bigopon @Sayan751

(For this post tsi-dx-button and my-dx-button are synonymous).

Following on from a topic I posted at the beginning of the year (Migrating Au1's #enhance to Au2's #enhance), I was finally able to get Aurelia 2.0 and Devextreme to play well in terms of templating (so that a Devextreme widget hosts one or more dx-templates as Aurelia-enhanced HTML fragments). I had this working flawlessly in Aurelia 1.0 with no memory leaks, but have had trouble getting to that same level in Aurelia 2.0.

NOTE: I’m not referring to simply providing a render function to a template prop (such as itemTemplate, fieldTemplate, rowTemplate, etc.). I’m referring to providing templates in the markup (HTML) with dx-template tags, harvesting those fragments, enhancing them, removing them from the DOM, and then supplying them to a Devextreme widget through the integrationOptions prop (according to the Devextreme documentation).

BACKGROUND

Consider the following example (my-dx-button.html):

<my-dx-button text.bind="userValue" click.trigger="handleButtonClick()">
    <dx-template name="content">
        <div class="tsi-test-class">Button: ${data.text}</div>
    </dx-template>
</my-dx-button>

The my-dx-button component wraps Devextreme’s Button widget. Button is one of those widgets that has a *template prop, the default name of which is content. There can be an unlimited number of dx-templates, all with different names, and either targeting different props of the widget, or offering polymorphic options where one will be selected at runtime (think of a template selector pattern).

FIRST PASS

This first pass doesn’t address events yet, and it involves only the Button’s text prop. Also, I’m not at a point where I can begin to factor all of this out into a service, as so forth. I would appreciate commentary on my approach here, which is essentially the same as the approach I took with Aurelia 1.0.

NOTE: Devextreme calls the render function associated with the template in the templateDict each time data changes, which precipitates an Aurelia-based re-enhancement of the HTML.

Here’s the entirety of my-dx-button.js, with code comments:

import Aurelia, { bindable, customElement, inject, processContent, resolve } from 'aurelia';
import { IInstruction } from '@aurelia/template-compiler';

import Button from 'devextreme/ui/button';
// This is my own model of the dx-template
import { DxTemplateModel } from './DxTemplateModel';

// processContent callback
function harvestDxTemplates(node, _, instructionData) {
    instructionData.templates = node.querySelectorAll('dx-template');

    // Now that we've harvested the dx-templates from the DOM, we don't need them anymore; we must do this here in the processContent callback
    instructionData.templates.forEach((_template) => {
        node.removeChild(_template);
    });

    // Signals the absence of content when we're done
    // Equivalent to processContent(false) in Aurelia 1.0
    return false;
}

@customElement({
    name: 'tsi-dx-button',
    template: null, // Equivalent to @noView in Aurelia 1.0
})
@processContent(harvestDxTemplates)
@inject(Element, CustomEventDispatcher)
export class MyDxButton {
    // Curated bindable for this example
    @bindable text = 'Default Text';

    constructor($el, eventDispatcher) {
        this.$el = $el;
        this.eventDispatcher = eventDispatcher;

        this.widgetHost;
        this.widgetInstance;

        this.templateDict = {};
        this.enhancedTemplates = [];

        const { data } = resolve(IInstruction);
        this.templates = data.templates; // Pushed into the instructionData from harvestDxTemplates above

        this.templates.forEach((_template) => {
            this.ingestTemplate(_template);
        });

    binding() {
        // With Devextreme, NEVER host right off of Element, which is this.$el above; we host one additional level down instead
        this.widgetHost = document.createElement('div');
        this.$el.appendChild(this.widgetHost);

        // According to the Devextreme documentation
        const integrationOptions = {
            templates: this.templateDict, // Built up in the constructor where we iterate over the harvested templates and ingest them
        };

        // Mount Devextreme component on the widget DOM host
        this.widgetInstance = new Button(this.widgetHost, {
            template: 'content',           
            text: this.text,
            type: this.type ?? 'default',
            integrationOptions: integrationOptions, // Devextreme's integration point
        });
    }

    unbinding() {
        this.enhancedTemplates.forEach((_template) => {
            _template.dispose();
        });

        this.enhancedTemplates.splice(0);
        this.enhancedTemplates = null;

        this.widgetInstance.dispose(); // If we don't do this, we'll leak memory
        this.widgetHost.remove(); // If we don't do this, we'll leak memory
        this.widgetInstance = null;
        this.widgetHost = null;

        this.templateDict = null;
    }
  
    // TODO: Train Aurelia how to bind to a Devextreme component so we can eliminate the need for this...possibly
    textChanged(newValue) {
        this.widgetInstance.option('text', newValue);
    }

    ingestTemplate(template) {
        // Devextreme expects dx-templates to have a name
        const templateName = template.getAttribute('name');
        const templateRenderer = (renderData) => {
            return this.createRenderer(template, renderData.container, renderData.model);
        };

        // Devextreme expects, simply, that for each named template, we have a render function assigned to the *render* property;
        // This is the dictionary we pass to the Devextreme component at the integration point
        this.templateDict[templateName] = {
            render: templateRenderer,
        };
    }

    createRenderer(rawTemplate, container, model) {
        // Append the harvested raw markup (template) to the container provided by Devextreme, and then enhance it
        const $enhancementHost = rawTemplate.cloneNode(true);
        container.appendChild($enhancementHost);

        const templateModel = new DxTemplateModel();
        templateModel.data = model;

        templateModel.templateName = rawTemplate.getAttribute('name');
        const enhancedTemplate = Aurelia.enhance({
            host: $enhancementHost,
            component: templateModel,
        });

        const ti = this.enhancedTemplates.findIndex(
            (_template) =>
                _template.controller.viewModel.templateName ===
                enhancedTemplate.controller.viewModel.templateName
        );

        if (ti >= 0) {
            this.enhancedTemplates[ti].dispose();
            this.enhancedTemplates.splice(ti, 1);
        }

        this.enhancedTemplates.push(enhancedTemplate);

        // // Register a Dx remove event, dxremove, so that we have an opportunity
        // // to call the Aurelia "destroy" lifecycle callbacks (careful to call
        // // #detaching first, then #unbinding, then #dipose)
        // dxEventOn($enhancementHost, 'dxremove', () => {
        //     enhancedTemplate?.detaching?.();
        //     enhancedTemplate?.unbinding?.();
        //     enhancedTemplate?.dispose?.(); // If we don't call this, we'll leak memory
        // });

        // Return the host of the enhancement
        return $enhancementHost;
    }
}

QUESTIONS AND PROBLEMS

My solution above suffers from the following problems:

  • I’m leaking memory, which I suspect comes from how I’m storing the enhanced templates for disposal later
  • I’m unable to use custom components within the dx-template, whether I register the custom components globally or locally
  • The DxTemplateModel’s lifecycle hooks never get called, nor does its constructor

DxTemplateModel.js looks like this:

export class DxTemplateModel {
    constructor() {
        this.templateName = '';
        this.data = null;
    }

    unbinding() {
        console.log('DX template model unbinding...');
    }
}

As for my questions:

  • In Aurelia 1.0, one has to register a dxremove event on the enhancement host in order to tear down the component associated with the dx-template (Devextreme fires the event and we can then hook into it). Is this necessary in Aurelia 2.0? dxremove fires, but I don’t know how to cause the lifecycle hooks to fire.
  • Am I handling the enhanced templates correctly by storing them on the instance, and then iterating over them to dispose of each one?
  • How do I get custom components to work within the enhanced templates?

Concerning the latter question, consider the following in the About page, which is where I’m testing my-dx-button:

<import from="./tsi-dx-button"></import>
<!-- <import from="./button-text-provider"></import> -->

<h1>The about landing page.</h1>

<input value.bind="userValue" />

<tsi-dx-button
    text.bind="userValue"
    click.trigger="handleButtonClick()"
>
    <dx-template name="content">
        <div class="tsi-test-class">Button: ${data.text}</div>
        <!-- <button-text-provider text.bind="data.text"></button-text-provider> -->
    </dx-template>
</tsi-dx-button>

I can’t get the commented code above to work. When I supply the button’s text with a custom component, it doesn’t work.

Here is a GIF to show that the uncommented code is working (the HTML with ${data.text} as the text):

Aurelia 2.0 and Devextreme

I set up a two-way binding with an input just to prove to myself that I can effect a change of the button’s text. Changing the text causes the textChanged handler to fire on the wrapper, a handler that uses the Devextreme widget’s API to push the change into the widget, which then causes the template render function to be called by the widget.

NOTE: The dx-template HTML fragment that you’re seeing in the inspector at the right is the version that is reinserted by Devextreme (which is Aurelia-enhanced by then), not the original dx-template in the markup that is harvested, removed, and then processed by the processContent callback.

I would appreciate commentary and insight. I’m so close!

Thank you in advance!

@estaylorco it’s a bit of a long post, so maybe we should have a few back&forth to go over the points you wanted to talk about.

First & foremost issue I see is this

Aurelia.enhance({ ... })

You are creating a new aurelia instance, along with its own new container. This means whatever registered globally or locally won’t ever reach the enhanced template. What could have been done is:

class MyComponent {
    aurelia = resolve(Aurelia);

    render() {
        this.aurelia.enhance({ ... })
        // or
        // Aurelia.enhance({ container: this.aurelia.container })
    }
}

The key is to pass the right container to the .enhance call for looking up resources.

Hello,

(Please see my UPDATE #1 in the form of a reply to this post).

So my understanding grew considerably in the few days following that long post. I apologize for the length, but I wanted to do a brain dump on this issue. Also, thank you for all of your posts at the beginning of the year, including the dx-report post. All of them gave me food for thought and guided me in coming to a better understanding.

I’ve included below my currently working Au2 solution using synthetic views. I’ve also included my working Au1 approach at the bottom of this post, and there’s where I pose my question to you.

Just some points since that post:

  • I came to realize that I was missing the container, and adding it actually got me to a working solution (albeit with some issues);
  • I have since watched Dwayne’s wonderful YouTube video on synthetic views, to which I have now switched (and away from enhance);
  • I’ve commented out the processContent code (and, therefore, harvesting dx-templates from the markup), for now, in favor of synthesizing the view directly in a template render function (without using Devextreme’s integrationOptions integration point);
  • I will return to harvesting dx-templates (using processContent) from markup once I understand how to pass local resources to either synthetic views or enhance (I’ve reached the point now where I can harvest templates from markup using the example you gave earlier in the year as a guide, remove them from the DOM, etc., successfully);
  • I fully understand that, with a synthetic view created from an inline template, I would have to globally register my resources (or create an ad hoc DI container and register them in it); but I’ll be moving back to harvesting templates from markup as that is what I prefer.

WORKING VERSION (SYNTHETIC VIEW, INLINE TEMPLATE, AND RENDER FUNCTION)

Consider what I have now in Au2, which is actually working without memory leaks, as far as I can tell:

import { bindable, customElement, IContainer, inject, resolve, Scope } from 'aurelia';
import {
    convertToRenderLocation,
    CustomElement,
    CustomElementDefinition,
    IController,
    ViewFactory,
} from '@aurelia/runtime-html';

import Button from 'devextreme/ui/button';
import { on as dxEventOn } from 'devextreme/events';

@customElement({
    name: 'tsi-dx-button',
    template: null,
})
@inject(Element)
export class TsiDxButton {
    // Curated bindable for this exploration
    @bindable text = 'Default Text';

    constructor($el) {
        this.$el = $el;       

        this.widgetHost;
        this.widgetInstance;
        this.view;

        this.container = resolve(IContainer);
        this.$controller = resolve(IController);
    }

    attached() {
        this.widgetHost = document.createElement('div');
        this.$el.appendChild(this.widgetHost);        

        // Mount Devextreme component on the widget DOM host
        this.widgetInstance = new Button(this.widgetHost, {
            template: (data, element) => {
                const $template = document.createElement('template');
                $template.innerHTML = `<button-text-provider text.bind="data.text"></button-text-provider>`;

                element.append($template);

                const renderLocation = convertToRenderLocation($template);

                const viewFactory = new ViewFactory(
                    this.container,
                    CustomElementDefinition.create({
                        name: CustomElement.generateName(),
                        template: $template,
                    })
                );

                // Register a Dx remove event, dxremove
                dxEventOn(element, 'dxremove', () => {
                    if (!this.view) {
                        return;
                    }

                    this.view.deactivate(this.view, this.$controller);
                    this.view.dispose();
                    this.view = undefined;
                });

                this.view = viewFactory.create(this.$controller).setLocation(renderLocation);
                const viewModel = {
                    data: data,
                };

                this.view.activate(this.view, this.$controller, Scope.create(viewModel));
            },
            text: this.text,
            type: this.type ?? 'default',            
        });
    }

    unbinding() {
        this.widgetInstance.option('template', undefined);
        this.widgetInstance.dispose();
        this.widgetHost.remove();
    }

    // TODO: Train Aurelia how to bind to a Devextreme component so we can eliminate the need for this...possibly
    textChanged(newValue) {
        this.widgetInstance.option('text', newValue);
    }

QUESTION AND PREFERRED APPROACH (TAKEN FROM AU1 SOLUTION)

Consider this from my Au1 app, a component that represents popup content (with some elisions for brevity and clarity):

<template>
    <require from="./bid-pricing-line-item.css"></require>
    <require from="./tsi-model-editor-command-bar.css"></require>
    <require from="common/elements/dx/tsi-dx-popup"></require>
    <require from="common/elements/dx/tsi-dx-button"></require>

    <tsi-dx-popup title="Too Many Levels">
        <dx-template name="title">TOO MANY LEVELS</dx-template>
        <dx-template name="content">            
            <div class="tsi-dialog__buttons-wrapper">
                <div class="tsi-dialog__buttons">
                    <tsi-dx-button type="default" text="OK">
                    </tsi-dx-button>
                </div>
            </div>
        </dx-template>
    </tsi-dx-popup>
</template>

In the second dx-template, I reference tsi-dx-button and require it locally. In my Au1 templating bridge (between Au and Devextreme), I harvest the dx-templates and then remove them, as we’ve discussed, but I pass the owningView’s local resources to enhance, which are the view resources collocated with the dx-templates.

This is a natural and preferred way for me.

My question, which was the one at the beginning of the year: How do we do that now? Globally registering, say, tsi-dx-button, simply because it’s referenced in a dx-template that’s highly local seems awkward.

Also, creating a DI container and registering resources would work, yes, but I think would defy encapsulation. I would have to pass those resources in from the outside of what will be my new templating bridge (Au2 version). I shouldn’t have to do that if the resources are sitting there in the markup and collocated with the dx-templates.

UPDATE #2 (WORKING VERSION - REGISTERING RESOURCES ON THE CHILD CONTAINER)

You can see from my DxTemplateService in Update #1 below that I can now supply resources to my compileTemplates method and register them in the child container created in the constructor.

That works, but it’s less than desirable because I have to pass those resources in twice (once through a bindable on the Devextreme widget wrapper, and once from the wrapper to the template service I’ve factored out). I would have to do something like this on the wrapper:

<tsi-dx-button button-text.bind="userValue" view-resources.bind=[...]></tsi-dx-button>

which then would get passed to my template service:

compileTemplates(rawTemplates, resources = [], templateName) {
        if (resources && resources.length) {
            this.container.register(resources);
        }
        ...
}

Ideally, I’d like to simply import resources in the markup where the dx-templates are defined, and then let my service pick those up (in Au1, I express this as owningView.viewResources, which gets passed into enhance).

I would think that inheritParentResources would pull that off, but it doesn’t. I still have to explicitly register (in this case, ButtonTextProvider).

Question: Referencing about-page.html below, which is the owning view of dx-template, tsi-dx-button, or about-page?

UPDATE #1 (WORKING VERSION - processContent and Aurelia.enhance)

I have moved back to processContent and harvesting dx-templates from markup, and am no longer taking the synthetic-view approach.

This is what I have for the use case:

about-page.html

<import from="./tsi-dx-button"></import>
// This import is ignored (supplied instead via registration against the child container)
<import from="./button-text-provider"></import>

<h1>The about landing page.</h1>
<input value.bind="userValue" />

<tsi-dx-button button-text.bind="userValue">
    <dx-template name="content">
        <div style="background-color: yellow !important">
            <button-text-provider button-text.bind="data.text"></button-text-provider>
        </div>
    </dx-template>
</tsi-dx-button>

For the Devextreme Button wrapper (viewless):

tsi-dx-button.js

import { bindable, customElement, IContainer, inject, resolve } from 'aurelia';
import { IInstruction } from '@aurelia/template-compiler';

import harvestDxTemplates from './harvestDxTemplates.js';
import { DxTemplateService } from './DxTemplateService.js';

import Button from 'devextreme/ui/button';
// This is simply to test a custom element in a dx-template
import { ButtonTextProvider } from './button-text-provider';

@customElement({
    name: 'tsi-dx-button',
    template: null, // Equivalent to @noView in Aurelia 1.0
    processContent(node, _, instructionData) {
        return harvestDxTemplates(node, _, instructionData);
    },
})
@inject(Element, DxTemplateService)
export class TsiDxButton {
    // Curated bindable for this proof of concept
    @bindable buttonText = 'Default Text';

    constructor($el, templateService) {
        this.$el = $el;
        this.templateService = templateService;

        this.widgetHost;
        this.widgetInstance;

        const { data } = resolve(IInstruction);
        this.templates = data.templates;
    }

    async attached() {
        this.widgetHost = document.createElement('div');
        this.$el.appendChild(this.widgetHost);

        const integrationOptions = {
            templates: this.templateService.compileTemplates(this.templates, [ButtonTextProvider]),
        };

        this.widgetInstance = new Button(this.widgetHost, {
            text: this.buttonText,
            type: this.type ?? 'default',
            integrationOptions, // Devextreme's integration point
        });
    }

    unbinding() {
        this.widgetInstance.dispose();
        this.widgetHost.remove();
    }

    // TODO: Train Aurelia how to bind to a Devextreme component so we can eliminate the need for this...possibly
    buttonTextChanged(newValue) {
        this.widgetInstance.option('text', newValue);
    }
}

Finally, this is my template bridge service:

DxTemplateService.js

import { IController } from '@aurelia/runtime-html';
import Aurelia, { IContainer, resolve, transient } from 'aurelia';

import { on as dxEventOn } from 'devextreme/events';

@transient()
export class DxTemplateService {
    constructor() {
        this.container = resolve(IContainer).createChild({ inheritParentResources: true });
        this.$controller = resolve(IController);
    }

    compileTemplates(rawTemplates, resources = [], templateName) {
        if (resources && resources.length) {
            this.container.register(resources);
        }

        const templateDict = {};

        rawTemplates.forEach((_template) => {
            this.ingestTemplate(_template, templateDict, templateName);
        });

        return templateDict;
    }

    ingestTemplate(rawTemplate, templateDict, templateName) {
        // Devextreme expects DX-TEMPLATEs to have a name
        const _templateName = templateName ?? rawTemplate.getAttribute('name');
        const templateRenderer = (renderData) => {
            // renderData is kicked out by the Devextreme widget
            return this.createRenderer(rawTemplate, renderData.container, renderData.model);
        };

        // Devextreme expects, simply, that for each named template, we have a render function assigned to the *render* property;
        // This is the dictionary we pass to the Devextreme component at the integration point
        templateDict[_templateName] = {
            render: templateRenderer,
        };
    }

    createRenderer(rawTemplate, container, model) {
        const $template = rawTemplate.cloneNode(true);
        container.appendChild($template);

        const enhancedView = Aurelia.enhance({
            container: this.container,
            host: $template,
            component: {
                data: model,
            },
        });

        // Register a Dx remove event, dxremove
        dxEventOn($template, 'dxremove', async () => {
            if (!enhancedView) return;

            await enhancedView.deactivate(enhancedView, this.$controller);
            if (!enhancedView) return;

            enhancedView.dispose();
        });

        return $template;
    }
}

Nice work figuring it out :+1:

For

@inject(Element, DxTemplateService)

...
@transient()
class DxTemplateService { ... }

you can also do

@inject(Element, @newInstanceOf(DxTemplateService))

For

            await enhancedView.deactivate(enhancedView, this.$controller);
            if (!enhancedView) return;

            enhancedView.dispose();

if you ever have detached or unbound returning a promise, you’ll need to wait for it before disposing.

import { onResolve } from 'aurelia';
...

            onResolve(enhancedView.deactivate(enhancedView, this.$controller),
              () => enhancedView.dispose()
            );

For

Also, creating a DI container and registering resources would work, yes, but I think would defy encapsulation

Did you mean that the resources used inside a template will have to be registered by the places that consume it? In your last working version, it’s kind of ok because that’s where the resources should be registered. Like the button-text-provider as an example.

Excellent pointers on other ways to do part of what I’m doing, and I really need to look at what you’re doing with dispose on the enhanced view.

So, ButtonTextProvider was just a contrived proof of concept where passing in resources works because of the simplicity and contrivance.

The last piece of the puzzle is this, then. Consider the following usage site:

about-page.html

<import from="./tsi-dx-button"></import>
<import from="./button-text-provider"></import> 

<tsi-dx-button button-text.bind="buttonText">
    <dx-template name="content">
        <button-text-provider button-text.bind="data.text">
        </button-text-provider>
    </dx-template>
</tsi-dx-button>

I simply wish to import the resources where the dx-templates are defined, as you can see with button-text-provider, and then pick them as owningView resources at the point in my code where I enhance. In Au1, about-page.html was considered the owningView, which I would get a hold of in the created lifecycle hook, store it on the instance (in this case, my Devextreme widget wrapper) at this.owningView, and then just pass in this.owningView.resources to enhance as viewResources.

I don’t want to have to do this:

integrationOptions = {
    templates: this.templateService.compileTemplates(
        this.templates, 
        [ButtonTextProvider]
    )};

I guess we can’t do this in Au2:

const enhancedView = Aurelia.enhance({
    container: this.container,
    host: $template,
    component: {
        data: model,
    },
    viewResources: this.owningView.viewResources  // <== this, ideally
});

That’s why I was asking back in January about what happened to viewResources. It seemed like that piece of the puzzle was missing.

QUESTION

How can achieve this in Au2?

what you are doing is similar like what the <au-slot> is doing. I think you can just remove process content and use projections from the IInstruction. Doing it this way you have the right encapsulation: button-text-provider should be declared in the consuming component, not the “slotted” component. The code may look something like this:

class MyButton {
  instruction = resolve(IInstruction);
  
  render() {
    const compiledContentTemplate = instruction.projections.default;
  }
}

In the block above, compiledContentTemplate is compiled with the right resources.

Once you have the compiled template, you can create view your way, or pass it as the component for the enhance call, or get the factory and chunk out views as needed etc…

I would probably just go for the enhance since it’s the least involved.

Thank you for getting back to me, but I’m getting a little frustrated…

The solution you’ve provided won’t work, for a number of reasons:

  • My wrappers are viewless (Devextreme widgets take over the DOM and handle the view);
  • While what I’m doing might look like slotted content, it’s not (the templates are removed from the DOM in processContent after harvesting, which isn’t something we do with slotted content);
  • If by “consuming component” you mean my wrapper component around a Devextreme widget, well they’re general-purpose and have no view, as I said, so the content cannot go there;
  • If by “slotted component” you mean the usage site of my “consuming component”—where the consuming component is actually used—that’s what Au1 called owningView;

I would point out that owning and parent are not necessarily synonymous, as you’ve hit upon by recognizing a quasi-slotted scenario.

No matter what path I take to enhance, if I show up a the doorstep without viewResources, without a means to pull them in, and no API by which to provide them to enhance, I wind up with an obtrusive solution. It’s basically PLATFORM, but spelled a little differently. I’m now having to tell Aurelia about something it should already know (and did in Au1).

You have removed the viewResources API from enhance and eliminated access to the owning view, both of which are present in Au1. I don’t think you should have done that. That makes enhance Au2 fundamentally different, and impaired, compared to enhance Au1, no matter how much better Au2’s enhance is technically.

I have roughly half of the Devextreme widgets now wrapped and working properly in a sort of showcase app (a testbed, if you will). But this is what I have wherever I need to push Aurelia templates into Devextreme widget at the integrationOptions key (where b-t-p is short for button-text-provider):

welcome-page.html

...
<tsi-dx-button type="default"
    text.bind="text"
    click.trigger="onClickButton($event)"
    option-changed.trigger="onOptionChanged($event)"    
        template-resources.bind="templateResources"  <= anti-pattern
    >
        <tsi-dx-template name="content">
            <b-t-p text.bind="data.text"></b-t-p>
        </tsi-dx-template>
</tsi-dx-button>
...

On the component (formerly, viewModel), I have this:

welcome-page.js

this.templateResources = [ButtonTextProvider]; // <= anti-pattern

Within my wrapper, tsi-dx-button (actually, its base class), I have to do this:

via TsiDxWidgetBase.js

...
async attached() {
    ...
    check: Array.isArray(this.templateResources), 'Template resources is of type Array';
    const compilerOptions = {
      resources: this.templateResources, // <= anti-pattern
      container: this.diContainer,
      controller: this.$controller,
    };

    // Compile templates, if there are any
    this.integrationOptions = this.templateService.compileTemplates(compilerOptions);
    ...
}

If we had a template like this

// about-page.html
<my-component>
  <my-slotted-content>

then about-page is the consuming component, or owning component, and my-component is the slotted component, or consumed component, and everything inside <my-component>is slotted content. Maybe the names I used could be confusing, we can just use the pair consuming/consumed or owning/consumed.

My wrappers are viewless (Devextreme widgets take over the DOM and handle the view);

What I mentioned was about getting a compiled template, with proper resources. How to create views from that template is your choice, if you have a Stackblitz repro (use this base https://stackblitz.com/edit/au2-conventions-tvyh8c?file=src%2Fmy-app.html), I should be able to make it clearer.

You have removed the viewResources API from enhance and eliminated access to the owning view

Owning view is called hydration context in v2, and can be injected by IHydrationContext. The property controller of a hydration context points to the owning view. It’s actually better in v2, where you don’t have to wait until created lifecycle to do something.

For the part “removed the viewResources API” from enhance, I don’t think that’s true. Whatever you did with viewResources, now should be done with container.register(), it’s practically the same thing. The only difference is one should not register new resources with the container of some other components.


For

class AboutPage {
    ...
    this.templateResources = [ButtonTextProvider]; // <= anti-pattern
}

I don’t see it as an anti pattern. About page is what uses ButtonTextProvider, so the resources should be declared there.

UPDATE #1

(How I got to this update is given down below).

I just realized, and determined, that I don’t even have to register ButtonTextProvider in about-page.js!

This alone

...
this.diContainer = resolve(IContainer).parent.createChild({inheritParentResources: true });
...

in tsi-dx-widget-base.js made the resources available to enhance (since I pass the diContainer above to my template compiler encapsulated in DxAuTemplateService).

This is what I see now:

Take a look at the first one!

But I’m still hoping for a better way than

...
this.diContainer = resolve(IContainer).parent.createChild({inheritParentResources: true });
...

********

I was out on my two-hour walk just now when your reply came in. I pulled over to read it, and as I was walking, it all clicked…I think! I will need your advice here (because I think my solution is a bit awkward).

As soon as I got back here to the office, I implemented what I had in mind based on a number of things you said. It worked…and I was able to eliminate the templateResources @bindable, which was the obtrusion that was bothering me.

So, then, this is where I am. Let’s go all the way up to about-page.js (the consuming or owning component, as you say). I did this there:

this.container = resolve(IContainer).register(ButtonTextProvider);

With that, I’m registering in the consuming component. This is the context where I would have perfect knowledge of the dependencies. Whether I express them in code (above in about-page.js), or in about-page.html as imports, I still have to express them.

Now in tsi-dx-button.js (actually, tsi-dx-widget-base.js, which tsi-dx-button extends), I have this in the constructor:

...
this.diContainer = resolve(IContainer).parent.createChild({inheritParentResources: true });
...

When I did this and poked around the container instance, I saw a property called res…and in it was my button-text-provider component! NOW I understand the purpose and usefulness of inheritParentResources.

QUESTION

There has to be a more elegant approach to

... = resolve(IContainer).parent.createChild({...})

What should I be doing?

container.parentdoes not guarantee that you will always get the container of the owning view. It’s better that you start using IHydrationContext, for the it’s the context about the owning view, where you have the right resources etc…

So the code would look like this

resolve(IHydrationContext).controller.container.createChild({ inheritParentResources: true });

and with that, you kind of get an idea what au-slot is doing internally.

That did it!

Now I understand what you said earlier when you said that viewResources is consolidated. I couldn’t get my mind around why you were using that word. I no longer have to supply the viewResources to enhance…they’re just there in the container so long as I bring them in through inheritParentResources. The trick is to get the right container for the context.

I’m still not too fond of having to work with the container directly in this way. But at least I can encapsulate access into a base class, as I have done in this case.

Thank you for all of your work in helping me out, and the community since this journey is recorded here, and for sticking with me on this! This was really helpful as it allowed me to see some of the more technical aspects of the framework.

I will post the end result in the form of the Dx wrappers, along with copious notes on design choices that I made.

Until next time…

I’m still not too fond of having to work with the container directly in this way. But at least I can encapsulate access into a base class, as I have done in this case.

Maybe the trick is to see it as viewResources. Regardless of design choice, there always needs to be something to hold the resources registrations. At the start of v2, we experimented with the consolidation. Later on it was deemed ok, and expensive to change so we left it alone. I think unless there is some good feedback, and direction on what a better API design is, we will likely only be going around if we are to move to another API similar to viewResources.

And looking towards to your design notes.