Building a JavaScript Runtime for AJAX Navigation and Traditional Page Loads

Author Lee Timmins Date 4th Mar 2026 Comments Comments

In my previous post we discussed how to build your own AJAX navigation library.

One limitation we identified was that the implementation was tightly coupled to petite-vue, while not all of my projects will use AJAX navigation. To address this, we should extract the shared functionality into a separate library.

We'll call this the JavaScript runtime.

Creating the Runtime

Create a file called runtime.js with the following content:

import { createApp } from 'https://unpkg.com/petite-vue?module';

const registry = {};
let app = null;

export function process(element) {
    app.mount(element);
}

export function register(name, factory) {
    registry[name] = factory;
}

export function start() {
    app = createApp(registry);
    app.mount();
}

Next, update ajax-navigation.js to use the runtime:

import morphdom from 'https://cdn.jsdelivr.net/npm/morphdom@2.7.8/+esm';
import { process } from './runtime.js';

// Set the current url.
let currentUrl = window.location.href;

/// Events

document.addEventListener('click', e => {
    // Get the closest anchor, incase they clicked an element inside the anchor, e.g. span.
    const element = e.target.closest('a');

    if (!element || !isEnabled(e))
        return;

    performAjaxPageLoad(element.href);
});

document.addEventListener('submit', e => {
    if (!(e.target instanceof HTMLFormElement) || !isEnabled(e))
        return;

    performAjaxPageLoad(e.target.action, {
        method: e.target.method,
        body: new FormData(e.target)
    });
});

window.addEventListener('popstate', e => performAjaxPageLoad(window.location.href, {}, e.state ?? {}));

/// Public API

async function performAjaxPageLoad(url, options = {}, popstate = null) {
    const response = await fetch(url, options);

    // Make sure the response is valid.
    if (!response.ok)
        return;

    // The response url may be different than the request url, if the server issued a redirect.
    const finalUrl = response.url;

    // Only modify history if this is NOT a popstate navigation.
    if (popstate === null && finalUrl !== currentUrl)
        window.history.pushState({ ajaxNavigation: true }, '', finalUrl);

    // Set the current url.
    currentUrl = finalUrl;

    const contentType = response.headers.get('content-type') || '';

    // If not a HTML response do a full page load.
    if (!contentType.startsWith('text/html')) {
        window.location.href = url;
        return;
    }

    const html = await response.text();

    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');

    // Set the title.
    document.title = doc.title;

    // Morph the DOM.
    morphdom(document.body, doc.body, {
        getNodeKey: node => {
            if (node.nodeType !== Node.ELEMENT_NODE)
                return;

            // This makes sure script tags are not reused, which would cause them to not execute.
            if (node.tagName === 'SCRIPT')
                return node.outerHTML;
        }
    });
}

/// Helpers

function isEnabled(e) {
    // Make sure ajax navigation has not been disabled.
    if (e.target.closest('[data-ajax-navigation-disable]'))
        return false;

    // Make sure the default action hasn't already been prevented.
    if (e.defaultPrevented)
        return false;

    // Prevent the default action.
    e.preventDefault();

    return true;
}

function loadScript(script) {
    return new Promise((resolve, reject) => {
        // If the script is not connected to the DOM, then it won't execute, so just resolve.
        if (!script.isConnected) {
            resolve();
            return;
        }

        const newScript = document.createElement('script');

        // Copy all attributes.
        for (const { name, value } of script.attributes) {
            newScript.setAttribute(name, value);
        }

        const isModule = newScript.type === 'module';
        const hasSrc = newScript.hasAttribute('src');

        if (hasSrc || isModule) {
            // External or inline module converted to blob.
            if (isModule && !hasSrc) {
                const code = script.textContent;
                const blob = new Blob([code], { type: 'text/javascript' });
                newScript.src = URL.createObjectURL(blob);
                newScript.onload = () => {
                    URL.revokeObjectURL(newScript.src);
                    resolve();
                }
                newScript.onerror = reject;
            } else {
                newScript.onload = resolve;
                newScript.onerror = reject;
            }
        } else {
            newScript.textContent = script.textContent;
            resolve();
        }

        document.head.appendChild(newScript);
        script.remove();
    });
}

// Create a MutationObserver.
const observer = new MutationObserver(async mutations => {
    for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
            if (node.nodeType !== Node.ELEMENT_NODE)
                continue;

            // Check if the added node is a script tag.
            if (node.tagName?.toLowerCase() === 'script')
                await loadScript(node);
            else
                await Promise.all(Array.from(node.querySelectorAll('script')).map(scriptNode => loadScript(scriptNode)));

            process(node);
        }
    }
});

// Start observing changes in the document.
observer.observe(document.body, { childList: true, subtree: true });

With this change, the AJAX navigation library now depends on the runtime. This is intentional: the runtime will always be present, while AJAX navigation becomes optional.

Update your script references so that runtime.js is always loaded. You can then optionally import ajax-navigation.js from your application's global JavaScript file.

At this point everything should behave the same as before.

Adding Page Hooks

In another previous post I discussed why it is better to store application state rather than relying on the browser's bfcache when navigating back and forward in the browser.

That approach used the browser's pageshow and pagehide events. While this works for full page loads, it does not work when using AJAX navigation.

To solve this we need a consistent abstraction that works in both scenarios. We'll call these page hooks.

Add the following to runtime.js:

const pageHooks = {
    enter: {},
    leave: {}
};

export function onPageEnter(callback) {
    registerPageHook('enter', callback);
}

export function onPageLeave(callback) {
    registerPageHook('leave', callback);
}

export function pageEnter(url) {
    runPageHooks('enter', url);
}

export function pageLeave(url) {
    runPageHooks('leave', url);
}

/// Helpers

function getPageKey(url) {
    const u = new URL(url, document.baseURI);
    let path = u.pathname;

    // Remove any "index.*" or "default.*" at the end (case-insensitive).
    path = path.replace(/\/(?:index|default)\.[^\/]+$/i, '/');

    if (path === '')
        path = '/';

    // Remove trailing slash except root.
    if (path.length > 1 && path.endsWith('/'))
        path = path.slice(0, -1);

    return path;
}

function registerPageHook(type, callback) {
    const key = getPageKey(window.location.href);

    if (!pageHooks[type][key])
        pageHooks[type][key] = [];

    pageHooks[type][key].push(callback);
}

function runPageHooks(type, url) {
    const key = getPageKey(url);
    const callbacks = pageHooks[type][key] || [];

    for (const callback of callbacks) {
        callback();
    }
}

These hooks are keyed by the page path (excluding query strings), ensuring that callbacks only run for the relevant page.

To trigger them for full page loads, add the following:

window.addEventListener('pageshow', () => pageEnter(window.location.href));
window.addEventListener('pagehide', () => pageLeave(window.location.href));

Using Page Hooks

Update page.js (introduced in the previous post) as follows:

import { onPageEnter, onPageLeave, register } from '../lib/runtime.js';

const test = document.getElementById('test');

onPageEnter(() => {
    const saved = sessionStorage.getItem('count');

    if (saved !== null)
        test.$data.count = saved;
});

onPageLeave(() => {
    sessionStorage.setItem('count', test.$data.count);
});

register('PageCounter', props => ({
    count: props.initialCount,
    inc() {
        this.count++;
    }
}));

This works correctly for full page loads. However, once AJAX navigation is enabled, pageshow and pagehide are no longer triggered.

Instead we must invoke the hooks manually inside the AJAX navigation library.

First import the pageEnter and pageLeave functions and then after confirming the response is okay in performAjaxPageLoad, add:

pageLeave(currentUrl);

Then after morphing the DOM, add:

pageEnter(currentUrl);

This change exposes another issue: when the DOM is replaced during navigation, component references are lost. As a result we must update our page.js hook usage:

onPageEnter(() => {
    const saved = sessionStorage.getItem('count');

    if (saved !== null)
        document.getElementById('test').$data.count = saved;
});

onPageLeave(() => {
    sessionStorage.setItem('count', document.getElementById('test').$data.count);
});

Even with this change the code still fails because the onPageEnter hook fires before the component is mounted.

Initially I tried awaiting app.mount() and the process() call inside the MutationObserver, but this still resulted in a race condition. Because the observer processes nodes asynchronously, we cannot reliably wait for it before mounting the Vue instance.

The solution is to remove the MutationObserver entirely and handle DOM processing manually after the morphdom operation.

Update the morphdom call as follows:

const addedNodes = [];

morphdom(document.body, doc.body, {
    getNodeKey: node => {
        if (node.nodeType !== Node.ELEMENT_NODE)
            return;

        // This makes sure script tags are not reused, which would cause them to not execute.
        if (node.tagName === 'SCRIPT')
            return node.outerHTML;
    },
    onNodeAdded: node => addedNodes.push(node)
});

const addedElements = addedNodes.filter(n => n.nodeType === Node.ELEMENT_NODE);
const rootAddedElements = addedElements.filter(el => !addedElements.some(parent => parent !== el && parent.contains(el)));

for (const element of rootAddedElements) {
    // Check if the added node is a script tag.
    if (element.tagName?.toLowerCase() === 'script')
        await loadScript(element);
    else
        await Promise.all(Array.from(element.querySelectorAll('script')).map(script => loadScript(script)));

    await process(element);
}

This should now work as expected and also has the benefit of processing only root added elements, avoiding unnecessary work.

Handling Dynamically Added HTML

Since we removed the MutationObserver, newly inserted HTML will no longer be processed automatically.

Now that responsibility moves into the runtime's process() function.

The process() function already mounts components, so it becomes the natural place to also execute scripts for the element being processed. This keeps all DOM initialization logic in a single place and allows callers to explicitly process dynamically inserted HTML when needed.

For example:

export async function process(element) {
    if (element.tagName?.toLowerCase() === 'script')
        await loadScript(element);
    else
        await Promise.all(Array.from(element.querySelectorAll('script')).map(script => loadScript(script)));

    await app.mount(element);
}

Handling Timing Issues

One final edge case appears when the petite-vue application takes longer to mount during a full page load. In that situation the pageshow event fires before mounting completes.

The fix is to trigger the page enter hooks inside the start() function, after awaiting app.mount().

The pageshow handler should now only trigger hooks when returning from the bfcache (e.persisted === true), because the start() function is not called in that scenario.

Final Implementation

The final versions of runtime.js and ajax-navigation.js can be found in the following GitHub repository: https://github.com/nfplee/js-runtime.

Possible Improvements

Some additional ideas for improving the runtime:

  • Convert app creation into a factory so the petite-vue instance (or another framework) can be configured.
  • Support window.beforeunload to trigger page leave hooks and optionally cancel navigation.
  • Provide global hooks when process() runs, or alternatively emit custom DOM events.

Extending

This approach was largely inspired by htmx, Blazor, and Alpine.js. Here's an example of adding a $dispatch method, similar to the one provided by Alpine.js:

import { register } from './runtime.js';

register('$dispatch', (name, argument, bubbles = true, e = event) => {
    e.target.dispatchEvent(new CustomEvent(name, { bubbles: bubbles, detail: argument }));
});

This can then be used like this:

<div @notify="alert('Notified!')">
    <button @click="$dispatch('notify')">Notify</button>
</div>

This pattern allows you to recreate much of the declarative behaviour found in tools like htmx, while still using a familiar templating and UI framework such as Vue.

Conclusion

This runtime provides a simple foundation for building progressively enhanced applications. It centralises component registration, script execution, and page lifecycle hooks, while allowing optional features such as AJAX navigation to build on top of it.

This keeps the architecture simple and flexible. Applications can use traditional full page loads, AJAX navigation, or a mix of both, while sharing the same runtime behaviour and lifecycle model. Because the runtime operates entirely on the client, it is also server-side framework-agnostic, meaning it can work with ASP.NET, Node, PHP, or any other backend that renders HTML.

The runtime also makes it easy to extend functionality over time, whether by adding new helpers, global hooks, or integrations with other libraries.