Custom Disconnect Popup in Blazor

Author: Lance Wright (Magic Coding Man)

Published: October 10, 2024

Sorry Image Not Found!

One common frustration among Blazor developers is the default disconnect overlay. When users leave a Blazor server or hybrid site open for too long, they may get disconnected and presented with an overlay message, which can appear intimidating and misleading.

This message is essential when there's a real connection issue, but often, it's just a simple case of the user being inactive for too long. Most users don't realize a simple refresh will resolve the issue, and I’ve received many support calls because of this confusion.

A Lightweight Solution

After testing several options to replace this overlay, many solutions felt too invasive or required significant maintenance with Blazor framework updates. I wanted a simple, non-intrusive solution that could automatically handle reconnections without custom reconnect logic. Here’s how I tackled it.

The disconnect overlay and message have a unique ID, “components-reconnect-modal.” Using CSS and JavaScript, I created a system to detect this ID’s visibility, display a custom popup, and hide it when the connection is restored. The result is a smooth, user-friendly experience that’s easy to implement.

Here's the actual visual I created for this website when the user is disconnected:

Sorry Image Not Found!

The Code

First, here's the CSS I used to design the popup that appears when the disconnect occurs:

/* Fullscreen popup overlay */
        #disconnect-popup {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.8); /* Black background with opacity */
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 100000 !important; /* Ensure it's above everything */
            visibility: hidden;
        }

        /* Popup content */
        #disconnect-popup-content {
            text-align: center;
            color: white;
            background-color: #333;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
        }

        /* Refresh button style */
        #refresh-button {
            margin-top: 20px;
            padding: 10px 20px;
            font-size: 16px;
            background-color: #007BFF;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
        }

            #refresh-button:hover {
                background-color: #0056b3;
            }

        /* Image div styling */
        #disconnect-image-container {
            display: flex;
            justify-content: center;
            align-items: center;
            margin-top: 20px;
            width: 100%;
            height: auto; /* Ensure the image resizes with the screen */
        }

        #disconnect-image {
            display: block;
            max-width: 100%;
            height: auto;
        }

Next, the JavaScript checks for the disconnect state and shows/hides the popup accordingly:

const imageCacheKey = 'cachedSleepyRobotImage'; // Key to store/retrieve from localStorage

window.checkUserConnectionWithoutDotnet = () => {
    console.log('Initializing connection check...');

    let isUserDisconnected = false;

    // Function to dynamically insert the image when showing the popup
    function showDisconnectPopup() {
        console.log('Showing disconnect popup...');
        const popup = document.getElementById('disconnect-popup');
        popup.style.visibility = 'visible';

        // Dynamically insert the image into the container
        insertImageFromCache();

        hideReconnectModal();
    }

    // Function to hide the disconnect popup
    function hideDisconnectPopup() {
        console.log('Hiding disconnect popup...');
        const popup = document.getElementById('disconnect-popup');
        popup.style.visibility = 'hidden';
    }

    // Function to preload the image from the cache or load from localStorage if not found
    function checkAndRetrieveCachedImage() {
        console.log('Checking if cachedImageDataUrl is available...');

        // First, check if the cached image exists in localStorage
        let cachedImageDataUrl = localStorage.getItem(imageCacheKey);

        if (!cachedImageDataUrl || cachedImageDataUrl.trim() === "") {
            console.error('No valid cached image found in localStorage.');
            return null; // If nothing in the cache, return null
        }

        console.log('Cached image found in localStorage.');
        return cachedImageDataUrl; // Return the cached Data URL
    }

    // Function to insert the image from cache into the DOM dynamically
    function insertImageFromCache() {
        const imageContainer = document.getElementById('disconnect-image-container');
        const cachedImageDataUrl = checkAndRetrieveCachedImage();

        if (cachedImageDataUrl) {
            // Check if an existing <img> with this ID already exists, if so, remove it
            let existingImage = document.getElementById('disconnect-image');
            if (existingImage) {
                console.log('Removing existing image.');
                imageContainer.removeChild(existingImage);
            }

            // Dynamically create a new <img> element
            const newImg = document.createElement('img');
            newImg.id = 'disconnect-image'; // Set the ID for the new image
            newImg.src = cachedImageDataUrl;
            newImg.style.maxWidth = '100%';
            newImg.style.height = 'auto';

            // Append the new image into the container
            console.log('Inserting new image into container.');
            imageContainer.appendChild(newImg);
        } else {
            console.error('No image to insert, the cache is empty.');
        }
    }

    // Function to preload the image and store it in localStorage (only called once when the page is first loaded)
    function preloadImageAsDataUrl(src) {
        const img = new Image();
        img.src = src;
        img.crossOrigin = "Anonymous"; // Important if loading images from different origins
        img.loading = 'lazy'; // Lazy loading to minimize impact on page load

        img.onload = () => {
            console.log('Image successfully loaded from source:', src);

            // Create a canvas to draw the image
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            // Set canvas dimensions to match the image
            canvas.width = img.width;
            canvas.height = img.height;

            // Draw the image onto the canvas
            ctx.drawImage(img, 0, 0);

            // Convert the canvas to a Data URL (Base64 encoded image)
            const dataUrl = canvas.toDataURL('image/webp');
            console.log('Image converted to Base64 Data URL:', dataUrl);

            // Store the preloaded image Data URL into localStorage for persistent caching
            localStorage.setItem(imageCacheKey, dataUrl);
            console.log('Image Data URL stored in localStorage');
        };

        // Handle image loading errors
        img.onerror = (err) => {
            console.error('Error preloading image:', err);
        };
    }

    // Function to periodically check the status of the reconnect modal
    function checkConnectionStatus() {
        const reconnectModal = document.getElementById('components-reconnect-modal');

        if (reconnectModal) {
            const visibility = window.getComputedStyle(reconnectModal).visibility;

            // Check if the modal is visible (user is disconnected)
            if (visibility === 'visible' && !isUserDisconnected) {
                console.log('User is disconnected, showing popup.');
                showDisconnectPopup();
                isUserDisconnected = true;
            }

            // If the modal is no longer visible, the user reconnected
            else if (visibility !== 'visible' && isUserDisconnected) {
                console.log('User reconnected, hiding popup.');
                hideDisconnectPopup();
                isUserDisconnected = false;
            }
        } else if (isUserDisconnected) {
            // If the modal element is not found, the user reconnected
            console.log('Reconnect modal not found, assuming user is reconnected.');
            hideDisconnectPopup();
            isUserDisconnected = false;
        }

        // Continue checking every second
        setTimeout(checkConnectionStatus, 1000);
    }

    // Preload the image and cache it in localStorage when the page first loads
    preloadImageAsDataUrl('/Images/Sleepy_Robot_Dark_Mode__438x428_50c.webp');

    // Start the connection check loop
    checkConnectionStatus();
};

function hideReconnectModal() {
    var modal = document.getElementById("components-reconnect-modal");
    if (modal) {
        modal.style.opacity = "0";
        console.log('Hiding reconnect modal.');
    }
}

// Start the connection check without needing Blazor interop
window.checkUserConnectionWithoutDotnet();

How It Works

The CSS handles the popup’s appearance, while the JavaScript watches for the Blazor disconnect modal’s visibility. When the user is disconnected, my popup is displayed, along with a pre-cached image for quicker load times and a simple button to refresh the page.

Finally, I invoke the JavaScript in my `NavMenu.razor` on the first render:

protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JS.InvokeVoidAsync("checkUserConnectionWithoutDotnet");
        }
    }

This approach provides a lightweight solution, letting Blazor handle reconnections seamlessly while offering users a much friendlier message and experience when disconnected.

Conclusion

With this simple solution, you can ensure a smoother user experience for your Blazor applications. It’s easy to implement, non-intrusive, and customizable to your needs. I don't believe this is the best solution or an end game level solution. But I really do believe it's an easy and simple method to implement! With only slight alterations to my code, you can create the same thing for yourself. I hope this helps you improve your Blazor projects as well!

An unhandled error has occurred. Reload 🗙

Sorry you were disconnected!

You were likely inactive. Or we may be doing routine maintenance. Either way, click the refresh button and like magic you'll be back on the site!

Sleepy Robot