[SalesForce] Lightning rerendering, inheritance, and forceCommunity:navigationMenuBase

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.

Best Answer

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?