Unpoly lets you enhance server-rendered HTML with JavaScript behavior.
For example, every <div class="map"> could automatically start a map widget.
For this you register compilers: functions that are called whenever an element matching a CSS selector enters the DOM. Compilers run at the initial page load, and again whenever a new fragment is inserted later. This makes them a reliable home for all your custom JavaScript, on a page that persists through many navigations.
We want to insert the current time into elements with a .current-time class:
<div class="current-time">
<!-- chip: insert current time here -->
</div>
To achieve this, register a JavaScript function with up.compiler():
up.compiler('.current-time', function(element) {
let now = new Date()
element.textContent = now.toString()
})
The compiler function is called once for each matching element, when the page first loads and when a matching fragment is rendered later.
Compilers run in the order they were registered, each for all its matches in the new fragment.
To change the order, pass a { priority }.
When a compiler runs, the render pass has already changed the DOM:
up.fragment.get() and up.fragment.all() skip it.So a compiler can look at other parts of the page and see their new versions:
<a href="/folders/5" up-target="#files, #sidebar">Open folder</a>
up.compiler('#sidebar', function(sidebar) {
let count = up.fragment.all('#files .file').length // chip: counts the new files
sidebar.querySelector('.file-count').textContent = count
})
Compilers run in the order of the target selector, followed by the layer's hungry elements. Focus and scroll position are updated after all compilers have run, and animations start last.
Hungry elements on other layers are rendered as a separate group, after the current layer (or before it closes, when the render pass closes an overlay). Don't rely on fragments in other layers being updated when your compiler runs.
DOMContentLoaded
Without Unpoly, the .current-time enhancement might have been implemented
by listening to a DOMContentLoaded (or load) event:
document.addEventListener('DOMContentLoaded', function() {
for (let element of document.querySelectorAll('.current-time')) {
let now = new Date()
element.textContent = now.toString()
}
})
A big drawback of this strategy is that elements are only matched once, during the initial page load. Since Unpoly updates fragments without a new page load, elements that enter the page later are never enhanced. Compilers close this gap: they run for the initial page and for every new fragment.
When adding Unpoly to an existing application, we recommend to
convert your DOMContentLoaded listeners to compilers.
up.compiler() is a great way to integrate external JavaScript libraries, like
maps, date pickers or charts.
Let's say a date picker library wants you to call new DatePicker() on every input
that should show a calendar. You decide to do this for all inputs with a date-picker class:
<input name="birthday" class="date-picker">
We can register a compiler that creates a date picker for every matching input:
up.compiler('input.date-picker', function(input) {
new DatePicker(input)
})
If the library adds elements or listeners outside the input, like a calendar popup in the <body>,
return a destructor that calls the library's clean-up method.
To mount components from a frontend framework like React or Vue, see Framework islands.
You may attach data to an element using HTML5 data attributes
or encoded as relaxed JSON in an [up-data] attribute:
<span class="user" up-data="{ age: 31, name: 'Alice' }">Alice</span>
An object with the element's attached data will be passed to your compilers as a second argument:
up.compiler('.user', function(element, data) { // mark: data
console.log(data.age) // result: 31
console.log(data.name) // result: "Alice"
})
See Attaching data to elements for more details and examples.
In Unpoly the JavaScript environment persists through many page navigations. To prevent memory leaks, it is important that any compiler effects can be garbage collected when the element is destroyed.
When a compiler binds an event listener to the compiling element (or its descendants), it can be garbage collected once the element leaves the DOM, no further steps required:
// label: ✔️ Garbage collectable
up.compiler('.click-to-hide', function(element) {
let hide = () => element.style.display = 'none'
element.addEventListener('click', hide)
})
When your compiler registers effects outside the compiling element's subtree, that effect is not cleaned up automatically.
For example, this compiler registers a scroll listener on the global window object.
Every compilation will subscribe another listener that is never removed, causing a memory leak:
// label: ❌ Memory leak
up.compiler('.scroll-to-hide', function(element) {
let hide = () => element.style.display = 'none'
window.addEventListener('scroll', hide)
})
To address this, a compiler can return a destructor function that reverts its non-local effect. Unpoly will call this destructor when the element is destroyed:
// label: ✔️ Garbage collectable
up.compiler('.scroll-to-hide', function(element) {
let hide = () => element.style.display = 'none'
window.addEventListener('scroll', hide)
return () => window.removeEventListener('scroll', hide) // mark: return
})
This compiler function is now safe for garbage collection.
Destructors run when Unpoly removes the element, e.g. when its fragment is swapped, when it is destroyed
with up.destroy(), or when its overlay closes. When your own code removes an element, use up.destroy()
so its destructors run.
Important
The destructor function is not expected to remove the element from the DOM.
To run multiple functions when the element is destroyed, return an array of functions:
up.compiler('.auto-hide', function(element) {
let hide = () => element.style.display = 'none'
window.addEventListener('scroll', hide)
let offScroll = () => window.removeEventListener('scroll', hide)
window.addEventListener('load', hide)
let offLoad = () => window.removeEventListener('load', hide)
return [offScroll, offLoad]
})
Instead of returning a destructor function, you can register it with up.destructor().
This helps placing the clean-up logic close to the effect that it reverts:
up.compiler('.auto-hide', function(element) {
let hide = () => element.style.display = 'none'
window.addEventListener('scroll', hide)
up.destructor(element, () => window.removeEventListener('scroll', hide))
window.addEventListener('load', hide)
up.destructor(element, () => window.removeEventListener('load', hide))
})
Tip
Unlike
addEventListener(), theup.on()function returns a function that unbinds the listener.
Compilers may accept a third argument with information about the current render pass:
up.compiler('.user', function(element, data, meta) { // mark: meta
console.log(meta.layer.mode) // result: "root"
console.log(meta.ok) // result: true
console.log(meta.revalidating) // result: false
})
The following properties are available:
| Property | Type | Description |
|---|---|---|
meta.layer |
up.Layer |
The layer of the compiling fragment. This has the same value as up.layer.current. |
meta.ok |
boolean |
Whether the element was loaded from a successful response. |
meta.revalidating |
boolean |
Whether the element was reloaded for the purpose of cache revalidation. |
A macro is a compiler that runs before all other compilers, registered
with up.macro(). This lets a macro set [up-...] attributes that will be
compiled afterwards. A regular compiler may set such attributes too late,
e.g. when the attribute is itself processed by a compiler, like [up-poll].
Macros are useful to define a shorthand for a combination of attributes
that you keep repeating. This macro opens a modal with a custom animation shake:
up.macro('[shake-modal]', function(link) {
link.setAttribute('up-layer', 'new modal')
link.setAttribute('up-animation', 'shake')
})
With this macro, links can open a shaking modal with a single attribute:
<a href="/contracts/new" shake-modal>New contract</a>
When you render with Unpoly (by following a link, submitting a form or calling
a function like up.render()), new elements are compiled automatically.
When elements are created by other means, e.g. by setting an innerHTML property
or through a third-party library, pass the new element to up.hello():
let element = document.createElement('div')
element.innerHTML = '<a href="/path" up-follow>Click me</a>'
up.hello(element) // mark: up.hello
This runs all registered macros and compilers on the element and its subtree.
It is safe to call up.hello() multiple times: every compiler function is guaranteed
to run only once for each matching element.
Tip Your coding agent can search these docs locally with the Unpoly agent skill