Nested & dynamic custom element composition

I’ve followed v2 since the beginning, and after alpha release have tried porting an application from v1, but haven’t gone very far because haven’t been able to make some very basic stuff working with custom elements.

  1. Using custom elements inside another custom element

Dumber gist: Dumber Gist

If there are two custom elements, both can be used separately, but it seems using custom element inside another custom element does not work. In the example, “custom element A” is rendered when used in top level, but not if used inside “custom element B”.

  1. Using au-compose

Dumber gist: Dumber Gist

In the example, there are 3 items which can be selected by clicking a button, and it is supposed to render associated custom element whenever an item is selected. Two things that don’t seem to work

  • I cannot pass/bind attributes which would be visible inside the custom element. In the example, au-compose has “model.bind” but this is not visible in the component, rendering “with model: undefined”
  • Whenever actual component class changes (items A/B → C or vice versa), the au-compose DOM element is not updated but instead a new one is added.

I’ve gone through issues in Github, but haven’t found ones that would cover these. Is there something I have missed in the way these should be implemented?

1 Like

The 2nd example worked in our first few versions of <au-compose/>, though after a few refactoring, it lagged behind a bit since we need to come up with a way to address the old usage of v1 <compose/>. Dynamic composition got some hiccups for now, will be fixed.
For the first example, the convention plugin is not working as intended, as deleting the decorator code in custom-element-b works https://gist.dumber.app/?gist=b0803983cdb504a8d96e2fa9364d5ab6

Thanks for the 2nd example, it reminds that we still need to finish the port of v1 compose

1 Like

Do I understand you correctly: if the customElement decorator is used, the import tag inside the template is ignored?

1 Like

It shouldn’t be ignored, should be more of “not recognized correctly”.
cc @huochunpeng

1 Like

There is nothing wrong with the conventions. Please do not write manual decorator unless necessary. The misunderstanding of the behaviour comes from an assumption that the Aurelia 2 html module behaves same as Aurelia 1 html module, which is not the case.

When you write manually, the template you imported is a string.

import template from "./custom-element-b.html";
@customElement({ name: "custom-element-b", template })

Which is

<import from="./custom-element-a"></import>
<!-- the line above is translated as another esm module named export,
NOT in the template string -->

<!-- template string only contains content below -->
<div style="border: 1px solid blue">
  <div style="color: blue">This is custom element B</div>

  <span>And here should be custom element A:</span>
  <custom-element-a></custom-element-a>
  <span>(but it is not rendered)</span>
</div>

The fix is very simple, never write decorator manually, unless you fully understand its behaviour.
Just write the custom-element-b.js plainly like following (do the same for other custom elements too).

export class CustomElementB {
}

More details
When you write custom element plainly, the Aurelia 2 conventions will fill up the boilerplate decorator as following:

import * as __au2ViewDef from './custom-element-b.html';
import { customElement } from '@aurelia/runtime-html';
@customElement(__au2ViewDef)
export class CustomElementB {
}

Note it imports the full namespace (import * as __au2ViewDef) from the html module, NOT just the default export (which is only a string containing the html part, not the meta data parts).

For reference, the custom-element-b.html is transpiled at compile time (this is very different from Aurelia 1 which does parsing template at app running time) to following code:
Note the export default template which was what you imported manually.

import { CustomElement } from '@aurelia/runtime-html';
import * as d0 from "./custom-element-a";
export const name = "custom-element-b";
export const template = "\n\n<div style=\"border: 1px solid blue\">\n  <div style=\"color: blue\">This is custom element B</div>\n\n  <span>And here should be custom element A:</span>\n  <custom-element-a></custom-element-a>\n  <span>(but it is not rendered)</span>\n</div>\n";
export default template;
export const dependencies = [ d0 ];
let _e;
export function register(container) {
  if (!_e) {
    _e = CustomElement.define({ name, template, dependencies });
  }
  container.register(_e);
}

In contrast, Aurelia 1 html module is the plain string of the original html file content. All the magic is done by Aurelia 1 at runtime, which includes finding dependencies, and also figures out the element name itself. Aurelia 1 parses the full html string at runtime for those magic. This runtime behaviour is exactly why we need such a big webpack plugin to teach webpack about Aurelia 1’s runtime behaviour (so that webpack knows how to find dependencies at compile time).

Aurelia 2 runtime engine is much simpler, we do all the magic in compile time. That’s why the compiled html file module is much more than such that plain string.

3 Likes

Great explication - thanks!

1 Like

Thanks for explanation! I wouldn’t have used the decorator at all, but in the early stages this seemed to be the only way get it working at all, got it somewhere from this forum and never tried it again without.

1 Like

Regarding au-compose, I’m still missing how should I pass data into component with au-compose?

Using the same gist (Dumber Gist), it now works in terms of switching the proper component, but I would like to bind some data to the component.

It would be intuitive to use
<au-compose view-model.bind="selected.component" name.bind="selected.name" />
in order to bind data into property called “name” inside the component, but this does not work.

1 Like

I am not quite sure if this kind of direct binding is working yet. By now you could catch the model just like in aurelia 1:

In app.html

<au-compose view-model.bind="selected.component" model.bind="{ name: selected.name}" />

And in the custom components

activate(model) {
    this.name = model?.name;
  }

Regards.

1 Like

Yes, that works, thanks!

But it would be nice to know if this is how it is intended, or will there be something like direct binding. I don’t know how that would work with activate lifecycle event, but for me this combination of “view-model.bind” and “model.bind” does seem a bit counter-intuitive.

1 Like

combination of “view-model.bind” and “model.bind” does seem a bit counter-intuitive.

Can you expand the comment on the part “the combination looks counter-intuitive”?

But it would be nice to know if this is how it is intended,

Yes it is a way to update the view model in a dynamic composition: initial and subsequent model update will all invoke the activate method with the model object

Well, this was quite a dumb mistake on my behalf. In my mind “view-model” was somehow translated as “view and model” (as if the custom element class acted as a model as well, containing the attributes being bound), so having “view-model” and “model” seemed a bit overlapping… but yeah, I did quite soon realize that this of course refers to “view model” in MVVM pattern, and things makes much more sense now. Don’t know how I missed such an obvious thing in the concept…

1 Like

We have a 3.2M LOC application (107 top-level routes, dynamic composition only within each of those routes) that we’re migrating from Aurelia 1.0 to 2.0 (the application actually began back in the days of Durandal, but it was much smaller then). As the first step in the journey, I used npx makes aurelia to scaffold a default Aurelia application (the first option).

I’m encountering issues that are discussed in this topic by others (minus the dynamic composition aspect). To synopsize, use of the @customElement decorator prevents the leaking of memory from one route to the next, but sacrifices nested custom elements. Omitting @customElement and simply running with conventions as discussed by @huochunpeng brings back nested custom elements, but causes the leaking of memory.

I can both cause and resolve these issues at will by going back and forth between @customElement on each component, and commenting them out. This is true without even using au-compose.

I’m using the nightly build (“dev”) of Aurelia, but the problem exists even in the “latest” build.

From main.js (configured for direct routing):

import Aurelia from 'aurelia';
import { MyApp } from './my-app';
import { RouterConfiguration } from '@aurelia/router';

Aurelia.register(RouterConfiguration.customize({ useUrlFragmentHash: false }))
    .app(MyApp)
    .start();

From my-app.html:

<template>
    <import from="./t1-shell"></import>

    <t1-shell></t1-shell>
</template>

From t1-shell.html:

<template>
    <import from="./some-component"></import>
    <import from="./another-component"></import>

    <a load="/some-component">Some Component</a>
    <a load="/another-component">Another Component</a>

    <au-viewport></au-viewport>
</template>

There is nothing significant about another-component.html, but some-component.html sports a nested custom element, like so:

<template>
    <import from="./nested-component"></import>

    <div class="some-component">We're in some component!</div>
    <nested-component></nested-component>
</template>

SCENARIO 1: Using @customElement on each component:

Decorator @customElement present

You can see above when I navigate to the some-component route, nested-component does not show up. Note the heap snapshots:

Heap snapshots with @customElement present

Off to the left (offscreen), I’m going to each route, and then making sure the previous route doesn’t show up in the snapshot.

So, then, with the decorator present, we don’t leak. Consider the next scenario.

SCENARIO 2: Commenting out @customElement

Decorator commented out (conventional approach)

You can see above the nested custom element when I navigate to some-component. However, take a look at the heap snapshots:

Heap snapshots (no decorator)

So, then, without the decorator, the nested component is present, but we leak from route to route.

(Just to clarify, when I typed in the component name to check the heap, I was typing in the component I navigated away from).

CONCLUSION

This seems like a bug to me (or perhaps I’m missing something). The same is true if I use dynamic composition. But I thought that for the sake of this post, I would keep it simple.

You can try router-lite (which is closer to au1) to see if it does not leak.

No difference: the scenarios I gave in my post play out the same way.

I would say that this is definitely a bug, and a rather serious one.

@huochunpeng @bigopon

I have solved the problem (or rather, the symptom). To avoid leaking AND see rendered nested custom elements, I have to do the following:

  • Use the @customElement decorator on each component, and
  • Globally register my nested custom elements

Going back to my post, once I registered NestedComponent globally, <nested-component> rendered in some-component.

Then, uncommenting @customElement on each of the three custom components (SomeComponent, AnotherComponent, and NestedComponent) eliminated leaking of the top-level routes.

I still think this is a bug since convention is destroyed in favor of configuration. Also, large applications, like the one I’m migrating, will have hundreds of nested components. With that many nested components it would be difficult to distinguish between leaking memory, and merely consuming it.

Could you guys test this on your side?

There’s got to be something wrong here. I’m showing up late to the party, so I have to wonder what everyone else has been experiencing.

Cc @Sayan751 @bigopon

cc @Sayan751 @bigopon

I noticed that, in registering NestedComponent globally, while some-component, the component in which nested-component is located, does not leak, I wind up with 2 detached nested-component references in the heap. I’m sure that’s related to the fact that I’m now globally registering NestedComponent.

It seems odd, though, but nested-component never increases beyond 2 detached instances in the heap (even if I visit the some-component route 100 times). And it never causes its parent, some-component, to leak.

1 Like

thanks @huochunpeng @estaylorco , this issue is more for @jwx and @Sayan751. To clarify, these are the steps we need to reproduce the issue:

  1. have an au-viewport
  2. use convention to register a component locally
  3. load the route with that component
  4. navigate away
  5. verify the previously rendered component not in the heap

In your post, it’s mentioned that globally registered a component helps remove the leak, so likely there’s something with the step (2). Can you help verify by registering the component locally, but without convention, using some code like this:

<template>
-   <import from="./some-component">
    ...
import { SomeComponent } from './some-component'

@customElement({
    ...,
    dependencies: [SomeComponent]
})
export class MyApp {
    ...
}

cc @jwx @Sayan751

Hello @bigopon. Good to see you on this project still after all of these years!

I had already tried what you suggested…with one exception: in the code fragment you provide, you actually declare dependencies with the dependencies property. That gave me food for thought. When I do the following in SomeComponent, and use @customElement explicitly, not only do I see NestedComponent rendered inside of SomeComponent correctly, but I also see no memory leaks:

import { customElement } from 'aurelia';
import template from './some-component.html';
import { NestedComponent } from './nested-component';

@customElement({
    name: 'some-component',
    template,
    dependencies: [NestedComponent],  <== I hadn't tried this before
})
export class SomeComponent {
    constructor() {}
}

I do have to use @customElement in every case, though. If I comment out @customElement, then that component leaks. I can create, and then resolve, the issue at will by going back and forth with @customElement commented in and commented out.

What’s not clear from the documentation, if indeed it was your intention in the design of the framework, is that dependencies must be declared in the decorator when taking over with @customElement. Importing them in the template will not work. Global registration only partially solves the problem in that the nested component will render, but will still cause its parent to leak.

In the post I made here, Nested & dynamic custom element composition - #13 by estaylorco, do I answer your question? (I wasn’t aware if you had seen it).

I still think this might be a bug. We should be able to use convention without fear of a leak. I’m not averse to declaring my dependencies in the component—might even be clearer than doing so in the template—but others may not feel that way.

Does this help?

UPDATE

(Further investigation beyond what I described above).

I’m not sure if what I’m seeing is a leak, but if I use a nested component concretely (as opposed to with dynamic composition), I see 2 detached instances of the component (in this case, <nested-component>) in a heap snapshot. It does not, however, cause the enclosing view to leak. In other words, some-component and SomeComponent do not show up in the heap snapshot.

This leaves behind 2 detached instances of the nested component (from some-component.html):

<div class="some-component">    
    <!-- <au-compose component.bind="currentView"></au-compose> -->
    <nested-component></nested-component>
</div>

What’s interesting, though, is that if I switch to dynamic composition using au-compose, the nested component does not show up at all in the heap snapshot, much less detached, and neither does some-component or SomeComponent.

Uncommenting the compose in some-component.html so that we have this:

<div class="some-component">        
    <au-compose component.bind="currentView"></au-compose>
    <!-- <nested-component></nested-component> -->
</div>

where currentView occurs on the component in the constructor this way:

import { customElement } from 'aurelia';
import template from './some-component.html';
import { NestedComponent } from './nested-component';

@customElement({
    name: 'some-component',
    template,
    dependencies: [NestedComponent],
})
export class SomeComponent {
    constructor() {
        this.currentView = NestedComponent;
        ...
    }
}

does not leave behind the nested component or the enclosing component in any way in the heap snapshot.

Just a footnote: Switching to local imports in the template (as opposed to defining them at the dependencies property of @customElement) does not resolve any of these issues. In fact, if I don’t declare dependencies in @customElement, nested components aren’t rendered.

1 Like