µJS (muJS) is a lightweight JavaScript library for AJAX navigation, similar to pjax, Turbo (Hotwire) and HTMX. It intercepts link clicks and form submissions to load pages via fetch() and inject content into the current page without full browser reload.
- Official name: µJS (Unicode) / muJS (ASCII)
- Website: https://mujs.org (domain owned)
- Related project: µCSS (mucss.org, CSS framework derived from PicoCSS)
- npm package:
@digicreon/mujs - License: MIT
- Author: Digicreon (https://github.com/Digicreon)
- Predecessor: Vik (https://github.com/Digicreon/Vik) — this is a full rewrite, not a refactor
mujs/
├── lib/
│ └── mu.js # Source file (single file, fully documented)
├── dist/
│ └── mu.min.js # Minified version (generated by make)
├── Makefile # Build: minify with terser
├── package.json # npm package definition
├── README.md # User-facing documentation
├── LICENSE # MIT license
└── CLAUDE.md # This file
These conventions come from the original Vik codebase and MUST be followed strictly:
- Indentation: tabs (not spaces)
- Return style:
return (value);with parentheses - Private members: prefixed with
_(e.g.this._cfg,mu._morph) - Section separators:
/* ********** SECTION NAME ********** */ - Documentation: JSDoc on every object, method, function and variable
- No ES6+ syntax: no arrow functions, no
let/const, no template literals, no destructuring. Usevar,function(){}, string concatenation. The code targets broad browser compatibility without transpilation. - Global variable: the library exposes a single
var muglobal - Architecture: single
new function()pattern (same as Vik) - Self-reference: methods use
mu.(notthis.) to avoid context issues in callbacks - No dependencies: zero runtime dependencies. Idiomorph is optional and auto-detected.
- No build system: no webpack, rollup, vite. Just
make+terserfor minification.
µJS uses a single click listener and a single submit listener on document for click/submit triggers. No per-element binding for those, no re-binding after page load. For other triggers (change, blur, focus, load), per-element listeners are attached by _initTriggers() after each render. This replaces Vik's approach of iterating over all links/forms and adding onclick/onsubmit attributes, then re-running init() after each page load.
_resolveUrl(url) resolves any URL to a local path using new URL(url, document.baseURI). Returns the pathname+search+hash for same-origin URLs, or null for external/invalid/hash-only URLs. Uses document.baseURI (not window.location.href) so that the <base> tag is respected when present. Note: only the <base> tag from the initially loaded page is used; <base> tags in dynamically fetched pages are not applied.
µJS skips elements that should not be intercepted. _shouldProcess() returns false for:
mu-disabledordata-mu-disabledattribute presentmu="false"ordata-mu="false"attributetargetattribute present (e.g.target="_blank")downloadattribute present (file downloads)<a>withonclickattribute<form>withonsubmitattribute- External URLs (resolved via
_resolveUrl)
Modifier keys (ctrl, meta, shift, alt) on click are also ignored, allowing native browser behavior (open in new tab, etc.).
All HTML attributes use the mu- prefix (e.g. mu-mode, mu-target). The data-mu- variant is also supported for W3C validation. The helper function _attr(el, name) checks both.
Uses native fetch() with AbortController for request cancellation. Replaces Vik's XMLHttpRequest. In-flight requests are automatically aborted when a new navigation starts.
Vik had preCallback/postCallback in config + data-vik-pre-callback attributes + vik:preLoad/vik:postLoad events (3 redundant mechanisms). µJS uses only CustomEvent on document. Events: mu:init, mu:before-fetch, mu:before-render, mu:after-render, mu:fetch-error.
overloadObject()→ replaced byObject.assign()onDocumentReady()→ unnecessary (event delegation)fetchHttp()→ replaced by nativefetch()execCallback()/createMergedArray()→ removed (no more callback config)
The attribute mu-mode controls how content is injected. Default: replace.
| Mode | DOM operation |
|---|---|
replace |
target.replaceWith(source) (default) |
update |
target.innerHTML = source.innerHTML |
prepend |
target.prepend(source) |
append |
target.append(source) |
before |
target.before(source) |
after |
target.after(source) |
remove |
target.remove() |
none |
no DOM change |
patch |
multi-fragment mode (see below) |
Naming rationale: replace/update/prepend/append/before/after/remove from Turbo Streams. The attribute name mu-mode was chosen over mu-swap (HTMX), strategy (Vik), action (Turbo) and method (too close to HTTP method).
replace is the default because it's the fastest (single DOM operation) and the most predictable behavior.
Patch mode (mu-mode="patch") allows a single response to update multiple DOM targets. The server returns HTML where each fragment has a mu-patch-target attribute (CSS selector) and an optional mu-patch-mode attribute.
Key design decisions:
- No special
<mu-stream>custom element. Fragments are regular HTML elements. - The node carrying
mu-patch-targetIS the element that gets injected (not its children). mu-patch-*attributes are NOT removed after injection (useful for debugging).- Default patch mode is
replace. - Patch does not modify browser history by default (
mu-patch-historydefaults tofalse). Usemu-patch-history="true"to enable history. - Patch does not scroll to top.
- Page title, CSS, scripts can be updated via patch by targeting
title,head, etc.
DOM morphing (preserving focus, scroll, video state during replace/update) is:
- Enabled by default in config (
morph: true) - Auto-detected:
init()checks forwindow.Idiomorph.morph - Fallback: if no morph lib is loaded, falls back to direct DOM replacement silently
- Overridable:
mu.setMorph(fn)for custom morph libraries - Disablable:
morph: falsein config ormu-morph="false"per-element - Morphing only applies to
replaceandupdatemodes (not append/prepend/before/after)
- Enabled by default in config (
transition: true) - Uses
document.startViewTransition()when available - Falls back silently on unsupported browsers
- Disablable:
transition: falsein config ormu-transition="false"per-element
<head>scripts: merged additively by_mergeHead(). Already-present scripts (matched by_elKey) are not re-added. New scripts are added and executed once.<body>scripts: re-executed by_runScripts()on each navigation. External scripts (withsrc) are tracked in_jsIncludesand loaded only once. Inline scripts are re-executed every time.mu-disabledon a<script>tag prevents_runScriptsfrom re-executing it- Third-party analytics (Plausible, GA) in
<head>are never re-executed — they detect SPA navigations via their ownpushStateinterception
- HTML5 validation via
reportValidity()before any fetch - Optional custom validation via
mu-validate="functionName" - GET forms: data serialized as query string, behaves like a link
- Non-GET forms (POST, PUT, PATCH, DELETE): data sent as
URLSearchParams(application/x-www-form-urlencoded) by default, orFormData(multipart/form-data) if the form hasenctype="multipart/form-data". History disabled by default mu-methodattribute overrides the form'smethodattribute (supportsput,patch,delete)- Submit button name/value: included in form data via
e.submitter(modern browsers) with fallback tomu._submitter(tracked via click event delegation) for older browsers - Quit-page confirmation via
mu-confirm-quitattribute on<form>(usesinputevent delegation)
mu-history controls whether the URL is added to browser history.
mu-scroll controls whether the page scrolls to top (independent from mu-history).
Defaults depend on mode and context:
- Modes
replace/update+ GET link/form:history=true,scroll=true - Modes
replace/update+ POST/PUT/PATCH/DELETE form:history=false,scroll=true - Modes
replace/update+ triggers (change/blur/focus/load):history=false,scroll=false - Modes
append/prepend/before/after/remove/none:history=false,scroll=false - Patch mode:
history=false,scroll=false(override withmu-patch-history="true") - Redirections always add the URL to browser history
mu-history="false"on links/forms, orhistory: falsein config
mu-methodattribute sets the HTTP method:get,post,put,patch,delete,sse- If absent on a link/button: defaults to
get - If absent on a form: uses the form's
methodattribute (defaultget) - Non-GET methods send
X-Mu-Methodheader - For forms, non-GET methods send FormData as body (same as POST)
- For non-form elements (buttons, divs), non-GET methods send an empty body
sseis a special value that opens an EventSource instead of using fetch
- Enabled by default (
prefetch: true) - GET only: elements with
mu-methodother thangetare never prefetched - Triggered on
mouseover(event delegation), with 50ms delay to filter accidental hover-throughs - One entry per URL in
_prefetchCache(Map) - Cache is consumed and deleted on click (no persistent cache)
- Low-priority fetch (
priority: "low") - Disablable:
prefetch: falsein config ormu-prefetch="false"per-link
X-Requested-With: XMLHttpRequest— identifies AJAX requests (de facto standard, compatible with Rails, Laravel, Django, etc.)X-Mu-Mode: <mode>— current injection modeX-Mu-Method: <METHOD>— HTTP method, sent for non-GET requests (POST, PUT, PATCH, DELETE)X-Mu-Prefetch: 1— sent on prefetch requests (server can return lighter content)
- Thin bar (3px) at top of page, id
mu-progress - Styled inline (no external CSS needed), customizable via CSS
- Animates to 70% during fetch, 100% on completion, then resets
- Disablable:
progress: falsein config
Even minor version numbers (1.2, 1.4, 1.6…) indicate stable releases. Odd minor versions (1.3, 1.5…) are reserved for development/unstable.
makeormake dist— minify with tersermake size— show file sizes (source, minified, gzipped)make hash— compute SRI hash (sha384) of dist/mu.min.jsmake check— dry-run npm packmake publish— minify + npm publishmake clean— remove dist/
Implemented. Before each navigation, _saveScroll() stores scrollX/scrollY in the current history.state via replaceState. On popstate (back/forward), the stored position is restored after rendering instead of scrolling to top.
Implemented in v1.2. Allows any element with mu-url to trigger a fetch on events
beyond click/submit. Supports debounce and polling.
| Attribute | Values | Description |
|---|---|---|
mu-method |
get, post, put, patch, delete, sse |
HTTP method |
mu-trigger |
click, submit, change, blur, focus, load |
Event trigger |
mu-repeat |
number (ms) | Polling interval |
mu-debounce |
number (ms) | Debounce delay |
| Element | Default trigger |
|---|---|
<a> |
click |
<form> |
submit |
<input>, <textarea>, <select> |
change |
| Any other element | click |
| Trigger | Browser event(s) |
|---|---|
click |
click (event delegation) |
submit |
submit (event delegation) |
change |
input |
blur |
change + blur (deduplicated via timestamp) |
focus |
focus |
load |
fires immediately when rendered |
_getTrigger(el)— returns effective trigger for an element_debounce(fn, delay)— classic debounce utility_triggerAction(el)— common action handler for non-delegation triggers_initTriggers(container)— scans and binds per-element listeners_cleanupTriggers(container)— clears intervals and SSE before DOM replacement
clickandsubmitremain handled by event delegation (no per-element binding)- Other triggers (
change,blur,focus,load) use per-element listeners attached by_initTriggers(), called after each render - Elements marked with
_mu_bound = trueto prevent duplicate binding - Polling via
setInterval, stored asel._mu_interval, cleaned up by_cleanupTriggers - Non-click/submit triggers default to history=false, scroll=false
Implemented in v1.2 via mu-method="sse". Opens an EventSource connection.
Incoming messages are parsed as HTML and rendered via _renderPatch or _renderPage.
_openSSE(url, el, cfg)— creates EventSource, handles messages and errors
- Each element with
mu-method="sse"gets its own EventSource connection - Connection stored as
el._mu_sse, closed by_cleanupTriggers - EventSource does not support custom headers — documented limitation
- Browser limit: ~6 SSE connections per domain in HTTP/1.1
- WebSocket support not implemented (SSE covers most real-time use cases)
-
Publish to npm— published as@digicreon/mujs -
Create mujs.org website— live at https://mujs.org -
Document how to install and use idiomorph with µJS (CDN, npm, local file) on mujs.org− live at https://mujs.org -
— implemented in v1.2mu-triggerattribute -
SSE integration for server-pushed patches— implemented in v1.2 - Consider:
mu-indicatorattribute (see details below) - Consider: WebSocket integration (complement to SSE)
- Consider:
revealedtrigger via IntersectionObserver (infinite scroll) - Write unit tests
- Write integration tests with a simple PHP backend
Per-element loading indicator. The global progress bar (#mu-progress) already covers most cases. mu-indicator would add per-element feedback for pages with multiple independently-loadable zones.
Possible syntax:
<a href="/page" mu-indicator="#spinner">
Load
<span id="spinner" style="display:none">⏳</span>
</a>Behavior: µJS shows the indicator (display="") on fetch start, hides it (display="none") after render. Simple to implement but low priority — the global progress bar covers 90% of use cases.