Blue background with pattern

Hyva: Improve wire:model in the Hyva Checkout

Jerke CombeeOrange dotJul 22, 2024

Usecase

While working on Hyva Checkout for a client, I came across an issue where the client requested better performance on a specific field. To paint the picture, in the address form we have a field for the VAT number. This field has a custom hook that includes server-side validation of dirty VAT. This field gets validated very often and therefore creates a lot of requests. We have already minimized this by adding the debounce modifier with a debounce time of 1500ms. This results in the request being sent only 1500ms after the last keystroke on that field.

However, this did not adequately meet our client's requirements, so we had to dive a little deeper into the functionality of the model directive (wire).

What options do we have

To improve how this field works, we need to look at the options we have. Since the Hyva Checkout is created with Magewire, which itself is built from Livewire, we can look at the options you have.

Debounce Modifier

As we discussed in the introduction, we use the debounce modifier. That waits a certain number of milliseconds after the last keystroke before sending the request to the server.

Implementation

To apply a debounce, add a modifier .debounce.1000ms to the end of the wire. This will delay the request for 1 second. You can adjust the debounce time by replacing the 1000. This is to customize it to your needs. The end result looks like this:

<input name="foo" wire:key,lazy="foo"/>

Reason we didn't use it

When you enter an input element and you use this debounce function, the client has to do it within a certain time. The client will not always be so fast with the input, and then it will already send the request.

In many cases this won't be a huge problem, but in this case the validation of the field was done in a third-party service. This makes the request be sent to Magento first, then to this service. This creates a lot of overhead if this happens too often. So our client did not approve this option.

Lazy Modifier

To keep it short, you could, for example, use another modifier, such as the .lazy modifier. This modifier activates only when leaving a field (when you press outside the field).

This allows you to completely type out the field at your own pace without being rushed by the debounce. This way, only one call is made and you have a decent experience.

Implementation

To implement the lazy modifier, simply place .lazy at the end of the wire. It looks like this:

<input name="foo" wire:key,lazy="foo"/>

Reason we didn't use it

However, this did not fully meet our needs, as it would be less desirable because when you leave that field, you already start typing in another field. This is because the validation continues through a third-party API after it passes through our API. Then when the request returns it will result in a validation error on the field while you are already halfway through typing in the next field.

Custom Directory

In some cases like ours, there is a lack of coverage by the standard features of a framework. In these cases, you are required to build custom solutions for this.

To do this with Magewire, you do it primarily through directives. A directive has the ability to add features that you can reuse throughout the application.

Directives are applied to all kinds of HTML elements. These can range from form elements where you place listeners for events or input fields where you attach component data.

Implementation

To create a new directive we must first specify a new layout where we can place it. But before we do this, we need to create a file that will contain our script. Since we are creating this for a specific module, we will place it in this location: Elgentos_HyvaCheckoutViesVat::form/field/script.phtml.

Start with the file and give it a simple bootstrap content:

<?php

/**
 * Copyright Elgentos BV. All rights reserved.
 * https://www.elgentos.com/
 */

declare(strict_types=1);

?>

Now that we have a file, we can start creating the corresponding layout file. Since this will only be applied to the checkout, we can use the handle: hyva_checkout_components.xml to insert our directive. For directives, magewire reserves a specific container named: magewire.directive.scripts . So your layout file will look something like this:

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="magewire.directive.scripts">
            <block name="magewire.directive.model.vat" template="Elgentos_HyvaCheckoutViesVat::form/field/script.phtml"/>
        </referenceContainer>
    </body>
</page>

We now have a template that is loaded when the customer is in checkout. So this would be the time when we start writing our first part of the directive. To begin, we'll write a simple directive to see how we can get started with it. We switch back to the script.phtml file and add this code to their:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        // ...
    });
</script>

This section takes care of initializing the directive we are building. You can now add this directive to a field to which you want to add it. You can add it as follows:

<input type="text" wire:model.lazy="vat" wire:model-char-limit.1500ms="7"/>

This will initialize our char limit on the actual model. We still need the model directive to define that it is actually a model. The directive we build will only support the defined model.

As you can see, our directive consists of two parts, it has the modifier after the directive itself: .1500ms and the value 7 .

First, let's focus on retrieving the value. We do this by adding it to the directive code:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        console.log(limit);
    });
</script>

This code results in the case where we have defined the directive in the input element we created earlier to 7. Now let's retrieve the modifier:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        const time = directive.modifiers.length > 0
            ? Number.parseInt(directive.modifiers[0].substr(
                0,
                directive.modifiers[0].length-2)
            )
            : 150;
        console.log(time);
</script>

Again based on the input from earlier, this will now give us 1500. This is how to extract the data you define in the elements. Next we need to find what the model has defined for us. To support the model directory in its functionality, we need to have the path it has defined in it. We do this by adding the following:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        const time = directive.modifiers.length > 0
            ? Number.parseInt(directive.modifiers[0].substr(
                0,
                directive.modifiers[0].length-2)
            )
            : 150;
        const path = el.getAttributeNames()
            .filter(attr => attr.substr(0, 10) === 'wire:model' && attr.substr(0, 21) !== 'wire:model-char-limit')
            .map(attr => el.getAttribute(attr)).pop();
        console.log(path);
    });
</script>

Now we have a script that gives us the path we defined in the model itself. We can now start using this path to create our functionality. The first step we need to take is to take control of the actions of the elements. Since the core of the library does this with Alpine's x-model-directive, we will do the same. It creates this model-directive pragmatically in JavaScript, like this:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        const time = directive.modifiers.length > 0
            ? Number.parseInt(directive.modifiers[0].substr(
                0,
                directive.modifiers[0].length-2)
            )
            : 150;
        const path = el.getAttributeNames()
            .filter(attr => attr.substr(0, 10) === 'wire:model' && attr.substr(0, 21) !== 'wire:model-char-limit')
            .map(attr => el.getAttribute(attr)).pop();

        Alpine.bind(el, {
            ['x-model']() {
                return {
                    get() {
                        return component.$wire.get(path);
                    },
                    set(value) {
                        //
                    },
                }
            },
        });
    });
</script>

Now that that part is complete, we have a component that can be connected. As you can see, we have inserted return component.$wire.get(path);. This ensures that the magewire model is connected to the alpine model. Otherwise, one will just overwrite the other and not work coherently.

Next, we are going to use the actual input data from the clients. Let's start by reading the input and checking if it exceeds the character limit:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        const time = directive.modifiers.length > 0
            ? Number.parseInt(directive.modifiers[0].substr(
                0,
                directive.modifiers[0].length-2)
            )
            : 150;
        const path = el.getAttributeNames()
            .filter(attr => attr.substr(0, 10) === 'wire:model' && attr.substr(0, 21) !== 'wire:model-char-limit')
            .map(attr => el.getAttribute(attr)).pop();

        let sharedValue;
        function update(value) {
            [el, directive, component]
            if (!sharedValue || sharedValue.length <= limit) {
                return;
            }

            component.$wire.sync(path, sharedValue);
        }

        Alpine.bind(el, {
            ['x-model']() {
                return {
                    get() {
                        return component.$wire.get(path);
                    },
                    set(value) {
                        update(value);
                    },
                }
            },
        });
    });
</script>

Now we have actually created our first real feature with the method data. For the next step, we also need to implement debounce. Since the scope of the debounce code from magewire blocks us from reusing it, I took the liberty of copying it and using it in this directive. By doing this, we have also completed the final step of implementing the directive in our codebase. So here is the completed directive:

<script>
    Magewire.directive('model-char-limit', (el, directive, component) => {
        const limit = Number.parseInt(directive.method);
        const time = directive.modifiers.length > 0
            ? Number.parseInt(directive.modifiers[0].substr(
                0,
                directive.modifiers[0].length-2)
            )
            : 150;
        const path = el.getAttributeNames()
            .filter(attr => attr.substr(0, 10) === 'wire:model' && attr.substr(0, 21) !== 'wire:model-char-limit')
            .map(attr => el.getAttribute(attr)).pop();

        let sharedValue;
        function update() {
            [el, directive, component]
            if (!sharedValue || sharedValue.length <= limit) {
                return;
            }

            component.$wire.sync(path, sharedValue);
        }

        // Trigger a network request (only if .live or .lazy is added to wire:model)...
        const debouncedUpdate = debounce(update, time);

        Alpine.bind(el, {
            ['x-model']() {
                return {
                    get() {
                        return component.$wire.get(path);
                    },
                    set(value) {
                        sharedValue = value;

                        debouncedUpdate();
                    },
                }
            },
        });
    });

    function debounce(func, wait) {
        var timeout;

        return function () {
            var context = this, args = arguments;

            var later = function () {
                timeout = null;

                func.apply(context, args);
            }

            clearTimeout(timeout);

            timeout = setTimeout(later, wait);

            return () => clearTimeout(timeout);
        }
    }
</script>

Conclusion

In conclusion, improving the performance and functionality of a specific VAT number field within Hyva Checkout required a customized approach beyond the available standard options. By examining and experimenting with the debounce and other modifiers, it became clear that neither fully met the customer's needs due to the complexity of server-side validation with third-party services. Therefore, a custom directive was created that provided a more sophisticated solution by combining character limit enforcement with debounced input handling. This approach ensured that the customer's requirements were met, providing a smoother and more efficient user experience while maintaining robust validation processes.