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;

    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;
    }

    // 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 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 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 restoring application state as a fallback for back and forward navigation when the browser does not restore the page from its bfcache. 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 = {
    load: {},
    unload: {}
};

export function onPageLoad(callback, {
    includeCacheRestore = false
} = {}) {
    registerPageHook('load', context => {
        if (!includeCacheRestore && context.isCacheRestore)
            return;

        return callback(context);
    });
}

export function onPageUnload(callback) {
    registerPageHook('unload', callback);
}

export function pageLoad(url, isCacheRestore = false) {
    runPageHooks('load', { url, isCacheRestore });
}

export function pageUnload(url, fromCache = false) {
    runPageHooks('unload', { url, isCacheRestore });
}

/// Helpers

export function getActiveModuleKeys() {
    return [...new Set([...document.querySelectorAll('script[type="module"][src]')]
        .map(script => script?.src)
        .filter(Boolean))];
}

function inferActiveModuleKey() {
    const activeKeys = new Set(getActiveModuleKeys());

    if (activeKeys.size === 0)
        return null;

    const stack = new Error().stack;

    if (!stack)
        return null;

    return (stack.match(/https?:\/\/[^\s)\]]+/g) || [])
        .map(match => match.replace(/:\d+(?::\d+)?$/, ''))
        .find(key => activeKeys.has(key)) ?? null;
}

function registerPageHook(type, callback) {
    const key = inferActiveModuleKey();

    if (!key)
        throw new Error('Page hooks must be registered from a module script.');

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

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

function runPageHooks(type, context) {
    const callbacks = getActiveModuleKeys().flatMap(key => pageHooks[type][key] || []);

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

The onPageLoad function will automatically skip execution when a page is restored from the bfcache, since the page state is already preserved in that scenario.

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

window.addEventListener('pageshow', e => pageLoad(window.location.href, e.persisted));
window.addEventListener('pagehide', e => pageUnload(window.location.href, e.persisted));

Using Page Hooks

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

import { onPageLoad, onPageUnload, register } from '../lib/runtime.js';

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

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

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

onPageUnload(() => {
    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 pageLoad and pageUnload functions and then after confirming the response is okay in performAjaxPageLoad, add:

pageUnload(currentUrl);

Then after morphing the DOM, add:

pageLoad(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:

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

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

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

Even with this change the code still fails because the onPageLoad 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 triggering the page hooks.

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.

When using AJAX navigation, page hooks have the added benefit of providing a place to attach event handlers to newly inserted DOM elements. However, if AJAX navigation is not being used and the page is restored from the bfcache, those handlers will already exist because the page is restored with its previous state intact. In that case, there is no need to attach them again, and the hook's execution will be skipped.

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.

While the process() function now mounts components and executes scripts automatically, it should also allow for custom processing logic. This can be achieved by dispatching an event during processing; this will invoke a load hook to run any additional behaviour. For example, imagine you want to initialise a jQuery plugin called myPlugin for newly inserted HTML. You could do the following:

onLoad(context => $(context.element).myPlugin());

Here is the final process() function that enables this:

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);

    runHooks('load', { element });
}

const hooks = {
    load: []
};

export function onLoad(callback) {
    hooks.load.push(callback);
}

async function runHooks(type, context) {
    for (const callback of hooks[type]) {
        await callback(context);
    }
}

Note: The onLoad hook trigger has been extracted into a function named runHooks, as it also needs to be called within the start function after the application has been mounted.

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 load hooks inside the start() function, after awaiting app.mount().

The pageshow handler should now only trigger hooks when returning from the bfcache (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:

  • Add auto startup using the DOMContentLoaded event.
  • Convert app creation into a factory so the petite-vue instance (or another framework) can be configured.
  • Support window.beforeunload to trigger page unload hooks and optionally cancel navigation.
  • Await the page hooks triggers allowing you to have asynchronous page hooks.

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.