Magento 2.3 - Topmenu tabs with wrong (cached) 'active' class

From my understanding, there is no perfect solution to this issue. Whatever fix you decide to implement will have its drawbacks.

I think there may also be 2 other techniques that you can use which are ESI (Edge Side Includes) or hole punching. Although I don't have any experience or knowledge about these, so if these are valid techniques for this purpose. I'll let someone else with a better understanding of them mention explain them.

TLDR

If you want the nav to always show the correct active state and don't mind taking a small performance hit, then disable caching on the navigation block and try to keep it as close to the root block as possible.

If you want maximum performance, then disable server-side rendering and hope the visitor has JS enabled.


Overview

In core Magento, there is both server side and client side logic to set the active state on nav items. And by default, both server and client side logic is active for some reason.

Server Side Logic

The issue with using server-side logic for this is that you cannot cache the block since you are going to be caching that active state as well. When you disable caching for the navigation menu, best case scenario is that you only invalidate full page cache, worse case is you invalidate some other blocks such as the header, etc depending on how your theme is set up.

The server side logic can be found in Magento\Theme\Block\Html\Topmenu::_getMenuItemClasses() It uses the is_active and has_active properties on the passed tree node attribute, which are set in Magento\Catalog\Plugin\Block\Topmenu::getCategoryAsArray

Client Side Logic

The downside to this method is, there is no active state marked until the JS loads. And if the client has JS disabled then they will have no active indicator at all.

The function that handles the client-side logic is lib/web/mage/menu.js::_setActiveMenu() the way this function works is that it searches the href of the nav items checking if one matches the current URL.


Solutions

Disable caching

You can disable caching on the navigation block, this will also disable caching on the parent blocks. So if your navigation block is within your header block, your header won't be cached either.

You can do this, by setting the TTL on the catalog.topnav xml node to 0 which is the same as overwriting the block and overwriting the cache lifetime as you have done is your edit.

<reference name="catalog.topnav" ttl="0"/>

Use the client side logic

The other method you have is to disable the active state being rendered on the server side and rely on the client side logic.

Since the Magento\Theme\Block\Html\Topmenu::_getMenuItemClasses() method is private, we cannot use a plugin to modify the result of it. You could overwrite the class using a preference and either

  • call the parent method and remove active and has-active from the final array
  • redeclare the method excluding the logic where it adds the active class.

@arno solution in the original question is working perfectly for me but I wanted to expand a bit on how to implement the code. As well as simplifying the unneeded code.

Create a custom module for the changes

app/code/VendorName/ModuleName/registration.php

<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'VendorName_ModuleName',
__DIR__
);

app/code/VendorName/ModuleName/etc/module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="VendorName_ModuleName" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Theme"/>
        </sequence>
    </module>
</config>

app/code/VendorName/ModuleName/etc/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Magento\Theme\Block\Html\Topmenu" type="VendorName\ModuleName\Block\Html\Topmenu" />
</config>

app/code/VendorName/ModuleName/Block/Html/Topmenu.php

<?php

namespace VendorName\ModuleName\Block\Html;

class Topmenu extends \Magento\Theme\Block\Html\Topmenu
{
    /**
     * Get block cache life time
     *
     * @return int
     * @since 100.1.0
     */
    protected function getCacheLifetime()
    {
        return 0;
    }
}