3

I've been working on a heavily customised Lightning Community where a lot of the UI effects are achieved using Javascript supplied by a design agency. These are included in the Lightning Community by adding them as static resources, then initialising them after the component has rendered.

One component that I've been having difficulty with is the navigation menu. As per the documentation (https://developer.salesforce.com/docs/atlas.en-us.lightning.meta/lightning/aura_compref_forceCommunity_navigationMenuBase.htm), I extended the forceCommunity:navigationMenuBase component.

With that component, I need to initialise some JS after the menu has been rendered, so I used a custom renderer (the supplied JS doesn't work with v40, so I can't use the render event). The problem is that the menu items are not ready when the component are first rendered. I can only assume that forceCommunity:navigationMenuBase is doing an Apex call to get the menu.

It seems that when the menuItems are loaded by forceCommunity:navigationMenuBase, that does not trigger a rerender event in component.

To investigate, I built some small test components. A GetHelloWorld component which just fetches a string via Apex:

<aura:component controller="GetHelloWorldController" extensible="true">
    <aura:attribute name="helloWorld" type="String" />
    <aura:handler name="init" value="{!this}" action="{!c.doInit}"/>  

    {!v.body}
</aura:component>
({
    doInit : function(component, event, helper) {
        var getHelloWorldAction = component.get('c.getHelloWorld');

        getHelloWorldAction.setCallback(this, function(response){
            var state = response.getState();
            if (state === "SUCCESS") {
                console.log('Setting helloWorld');
                component.set('v.helloWorld', response.getReturnValue());
            }
        });

        $A.enqueueAction(getHelloWorldAction);                
    }
})
({
    rerender : function(cmp, helper){
        console.log('GetHelloWorld.rerender');
        this.superRerender();
    },
    render : function(cmp, helper) {
        console.log('GetHelloWorld.render');
        var ret = this.superRender();
        return ret;
    },
    afterRender: function (component, helper) {
        console.log('GetHelloWorld.afterRender');
        this.superAfterRender();
    }    
})

And then two components two display the result of that. First, DisplayHelloWorldExtension:

<aura:component >
    <aura:attribute name="helloWorld" type="String" />

    {!v.helloWorld} 
</aura:component>
({
    rerender : function(cmp, helper){
        console.log('DisplayHelloWorldComposition.rerender');
        this.superRerender();
    },
    render : function(cmp, helper) {
        console.log('DisplayHelloWorldComposition.render');
        var ret = this.superRender();
        return ret;
    },
    afterRender: function (component, helper) {
        console.log('DisplayHelloWorldComposition.afterRender');
        this.superAfterRender();
    }    
})

Second, DisplayHelloWorldComposition:

<aura:component extends="c:GetHelloWorld">
    {!v.helloWorld}
</aura:component>
({
    rerender : function(cmp, helper){
        console.log('DisplayHelloWorldExtension.rerender');
        this.superRerender();
    },
    render : function(cmp, helper) {
        console.log('DisplayHelloWorldExtension.render');
        var ret = this.superRender();
        return ret;
    },
    afterRender: function (component, helper) {
        console.log('DisplayHelloWorldExtension.afterRender');
        this.superAfterRender();
    }    
})

And then a host app:

<aura:application >

    <aura:attribute name="helloWorld" type="String" />

    <c:DisplayHelloWorldExtension />
    <c:GetHelloWorld helloWorld="{!v.helloWorld}" />
    <c:DisplayHelloWorldComposition helloWorld="{!v.helloWorld}" />
</aura:application>

When I run this app, the log messages are:

DisplayHelloWorldExtension.js:18 DisplayHelloWorldExtension.render
GetHelloWorld.js:29 GetHelloWorld.render
GetHelloWorld.js:29 GetHelloWorld.render
DisplayHelloWorldComposition.js:18 DisplayHelloWorldComposition.render
DisplayHelloWorldExtension.js:23 DisplayHelloWorldExtension.afterRender
GetHelloWorld.js:34 GetHelloWorld.afterRender
GetHelloWorld.js:34 GetHelloWorld.afterRender
DisplayHelloWorldComposition.js:23 DisplayHelloWorldComposition.afterRender
GetHelloWorld.js:15 Setting helloWorld
GetHelloWorld.js:15 Setting helloWorld
GetHelloWorld.js:25 GetHelloWorld.rerender
DisplayHelloWorldComposition.js:14 DisplayHelloWorldComposition.rerender

So, we can see that only the composition version is being fully rerendered. I guess Lightning is doing a partial rerender on the extension version - just rerending the part that has changed. This makes a kind of sense. Although it could reasonably be argued that the whole of DisplayHelloWorldExtension should be rerendered, Lightning doesn't do that.

The question (after all this, there had to be one), is:

Is it wrong to use the forceCommunity:navigationMenuBase by composition?

I've written my navigation component to directly create an instance. It then shares the menuItems by variable-binding to another component which loads the external libraries. The library component then calls back to my custom navigation component when the dependencies are satisfied, and on rerender (you'll just have to trust that one, too much code to post here). Since menuItems are passed into the library component as an attribute, a change in them rerenders the library component. Since they are also an attribute of custom navigation component, a change in them rerenders the whole component:

<aura:attribute name="menuItems" type="Object[]" required="false"/>    
<c:ExternalLibs initJsComponent="{!this}" 
                hasOtherDependency="true"
                otherDependencyLoaded="{!and(v.recordTypeMetadatas.length > 0, v.menuItems.length > 0)}"
                                  )}"/>
<forceCommunity:navigationMenuBase aura:id="menuBase" menuItems="{!v.menuItems}" />
({
    onClick : function(component, event, helper) {
        var id = event.target.dataset.menuItemId;
        if (id) {
            component.find('menuBase').navigate(id);
        }
    },
})

What happens here is that the initialisation is called once when menuItems is first populated - but it fails because the DOM elements for the menuItems are not yet available. Then, it gets called again for the rerender event happens for the navigation component and its children - at this point the initialisation succeeds.

As seems to happen often with Lightning, it works, but I'm not sure whether or not I can rely on it continuing to work.

--edit for the sake of proving that this is actually a problem--

John Au claims below that this whole scenario isn't a problem, and rerender is called after the menuItems are loaded in a custom navigation component. So here's a complete executable demonstration. Start with navigation menu extension as in the developer docs:

<aura:component extends="forceCommunity:navigationMenuBase" implements="forceCommunity:availableForAllPageTypes" access="global">
    <ul onclick="{!c.onClick}">
        <aura:iteration items="{!v.menuItems}" var="item" >
            <aura:if isTrue="{!item.subMenu}">
                <li class="nav-menu-item">{!item.label}</li>
                <ul>
                    <aura:iteration items="{!item.subMenu}" var="subItem">
                        <li><a data-menu-item-id="{!subItem.id}" href="">{!subItem.label}</a></li>
                    </aura:iteration>
                </ul>
            <aura:set attribute="else">
                <li class="nav-menu-item"><a data-menu-item-id="{!item.id}" href="">{!item.label}</a></li>
            </aura:set>
            </aura:if>
        </aura:iteration>
    </ul>
</aura:component> 

Add a custom renderer:

({
    rerender : function(component, helper){
        var menuItems = component.get('v.menuItems');

        if (menuItems.length > 0) {
            console.log("[menuItems.length > 0]document.getElementsByClassName('nav-menu-item').length");
            console.log(document.getElementsByClassName('nav-menu-item').length);
        }

        this.superRerender();
        console.log('NavMenuTestRenderer.rerender after superRerender()');
        console.log("document.getElementsByClassName('nav-menu-item').length");
        console.log(document.getElementsByClassName('nav-menu-item').length);
    },
    render : function(component, helper) {
        var ret = this.superRender();
        console.log('NavMenuTestRenderer.render after superRender()');
        return ret;
    },
    afterRender: function (component, helper) {
        this.superAfterRender();
        console.log('NavMenuTestRenderer.afterRender after superAfterRender()');
        console.log("document.getElementsByClassName('nav-menu-item').length");
        console.log(document.getElementsByClassName('nav-menu-item').length);
    }    
})

Load the community and observe the console log statements from the menu component:

components/c/NavMenuTest.js:25 NavMenuTestRenderer.render after superRender()
components/c/NavMenuTest.js:30 NavMenuTestRenderer.afterRender after superAfterRender()
components/c/NavMenuTest.js:31 document.getElementsByClassName('nav-menu-item').length
components/c/NavMenuTest.js:32 0

Judging by the log, rerender is not called and the menuItem elements are not available to be modified in the DOM.

  • DisplayHelloWorldExtension uses a local variable that isn't modified by GetHelloWorld- the logs look pretty sensible to me? – John Au Aug 2 '17 at 5:56
  • Also... the forceCommunity:navigationMenuBase is an ABSTRACT component. You're supposed to implement it like this- developer.salesforce.com/docs/atlas.en-us.lightning.meta/… – John Au Aug 2 '17 at 5:58
  • @JohnAu DisplayHelloWorldExtension displays the attribute from GetHelloWorld. Initially, it renders an empty string, then it is partially re-rendered to show "Hello World" after the Apex action returns. There are dependencies between GetHelloWorld.helloWorld -> DisplayHelloWorldExtension and GetHelloWorld.helloWorld -> DisplayHelloWorldComposition. Changing GetHelloWorld.helloWorld only results in a component rerender for one of them - which is odd, but OK – Aidan Aug 2 '17 at 7:59
  • @JohnAu forceCommunity:navigationMenuBase is not abstract. The framework stops you from instantiating an abstract component. You get an app error if you try. Which is rather the point. Did SF mean to make forceCommunity:navigationMenuBase abstract, but didn't and might change that in the future? – Aidan Aug 2 '17 at 8:01
2

Right, looks like you're right about the menuItem var not being available until some magic happens. A consistent way around this would be to check that v.menuItems is a length greater than 0 in the rerender method, so something like

rerender: function(component, helper) {
    var menuItems = component.get('v.menuItems');

    if (menuItems.length > 0) {
        //you're good to go with external JS DOM shenanigans
    }

    this.superRerender();
}

At this point it's reasonable to assume that:

  1. External JS dependencies have been fully loaded
  2. menuItems is populated
  3. The navigation menu is 'ready' on the DOM

The 'dirty' part is where you have to call back to your external JS from Lightning- so you'll probably have to bind a function to the window.

Not to deviate too much from the original question- but this is the best way to work with external JS that requires the DOM to be 'ready'- have Lightning call a function on the window.

--- Edit for visibility ---

As mentioned in my comment below, I've tested the exact case on a Community, and confirmed that rerender is RELIABLY called when menuItems is populated from within a Community context, when implemented following the instructions.

Anyway:

  1. rerender is called after menuItems is populated. There is no disputing this fact. This meets your use case of needing to reliably interact with the DOM when it's "ready".
  2. In the initial part of your question, I noted that you had a variable in the Extension component that was local to it- and you were expecting a rerender to be called once GetHelloWorld initialises.

    Unless you link the variable using the same parameter syntax as you do for the Composition component- it will remain LOCAL to the component. It doesn't get touched by ANY of your other components. It's not going to rerender.

--- Second edit ---

Well, looks like I got owned by Lightning, very very frightening again.

So- ignore everything I wrote above- here's what's happening:

  1. rerender isn't called by default- I think the reason it was calling it on my org is due to the CSS classes being loaded via ltng:require.
  2. v.menuItems isn't reliably populated even if rerender is called (I refreshed the page 2-3 times prior to writing the first edit, and walked off triumphant)
  3. The valueChange event is useless because there's no render type event to signal that the menu is in the DOM
  4. Lightning is horrible

Apologies if I got the wrong end of the stick earlier- it looks like you're plum out of luck if you're looking for a clean solution to manipulate the navigation menu post render. Suggest maybe using click event or CSS to achieve said effects?

  • You're really not listening or understanding the question. The point is that a change in menuItems does not trigger a rerender event. So, your custom renderer never gets called unless something else causes the rerender. But that is neither guaranteed, or something that is acceptable to wait for. – Aidan Aug 2 '17 at 8:07
  • @Aidan, I've tested that exact case on a Community. Do you want screenshots? render is called once when menuItems is null, then rerender is called when menuItems is populated. Please see my comments on your question for notes on why your implementation might be completely topsy turvy. Thanks for the down-vote by the way? – John Au Aug 2 '17 at 23:19
  • The downvote was unnecessary - I've upvoted it away. However, you're still wrong - I've tested again with the code you gave (see edit to the question) - and rerender is not called when the menuItems are loaded. There seems to be very little room for ambiguity on that. The only place where I can see our code diverging to get different results is maybe through version number... mine is on 40.0 – Aidan Aug 3 '17 at 6:15
  • 1
    I made a test community just for this scenario, you can access it publicly here: nebula-lightning-developer-edition.eu6.force.com/napilitest/s. In this case, I've just dragged the custom menu component into the content area. I don't think that the way the navigation component is included is relevant - I had the same setup as you on the real system and the same problem. – Aidan Aug 3 '17 at 8:36
  • 1
    @Aidan you're right- made an edit to my post... again. Lightning is fantastic. Would suggest raising a case for this, but wouldn't hold my breath for any quick resolution. – John Au Aug 4 '17 at 1:03
0

I'm not 100% certain about everything in your question, but to this portion:

The problem is that the menu items are not ready when the component are first rendered. I can only assume that forceCommunity:navigationMenuBase is doing an Apex call to get the menu.

It seems that when the menuItems are loaded by forceCommunity:navigationMenuBase, that does not trigger a rerender event in component.

Correct, the {v.menuItems} attribute provided by extending forceCommunity:navigationMenuBase are not immediately available. At some point later in the components lifecycle they get populated.

In my components, I register a change handler like you did in your final code snippet to know when the menu items have been generated and are ready:

<aura:handler name="change" value="{!v.menuItems}" action="{!c.doInit}"/>

Altogether then a trivial markup might look like:

<aura:component extends="forceCommunity:navigationMenuBase"
                implements="forceCommunity:availableForAllPageTypes">

    <aura:attribute name="menuItems"
                    type="Object[]"
                    access="public"
                    description="The navigation menu items created in Community Builder. Automatically passed in by Community."/>

    <!--
        Since the menu items are populated via parent component async, we monitor
        for when they get populated and that's when we perform further initialization.
     -->
    <aura:handler name="change" value="{!v.menuItems}" action="{!c.doInit}"/>

</aura:component>
  • Thanks Doug - I did try that approach, but the tricky thing is that I need the rendered elements so that I can hit them with the external JS. In the change handler for menuItems, they aren't rendered yet, so that's not the right time to be running the external JS. I had over-simplified because I was trying to trim down the example - I've added more details now. – Aidan Jul 27 '17 at 6:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.