Revision code

Changes Version 3.12.0
Released on September 24, 2025 with 72 commits

This release adds asynchronous compilers and many other features requested by the community.
We also fixed a number of performance regressions introduced by Unpoly 3.11.

Breaking changes are marked with a ⚠️ emoji and polyfilled by unpoly-migrate.js.

Note

Our sponsor makandra funded this release ❤️
Please consider hiring makandra for Unpoly support.

Asynchronous compilers

Compiler functions can now be async. This is useful when a compiler needs to fetch network resources, or when calling a library with an asynchronous API:

up.compiler('textarea.wysiwyg', async function(textarea) { // mark: async
  let editor = await import('wysiwyg-editor') // mark: await
  editor.init(textarea) // mark: await
})

You can also use this to split up expensive tasks, giving the browser a chance to render and process user input:

up.compiler('.element', async function(element) {
  doRenderBlockingWork(element)
  await scheduler.yield() // mark-line
  doUserVisibleWork(element)
})

Cleaning up async work

Like synchronous compilers, async compiler functions can return a destructor function:

up.compiler('textarea.wysiwyg', async function(textarea) {
  let editor = await import('wysiwyg-editor')
  editor.init(textarea) // mark: await
  return () => editor.destroy(textarea) // mark-line
})

Unpoly guarantees that the destructor is called, even if the element gets destroyed before the compiler function terminates.

Timing render-blocking mutations

Unpoly will run the first task of every compiler function before allowing the browser to render DOM changes. If an async compiler function runs for multiple tasks, the browser will render between tasks. If you have render-blocking mutations that should be hidden from the user, these must happen in the first task.

Timing of compiler tasks and browser render frames

Async compilers will not delay the promise returned by rendering functions up.render() or up.layer.open().
Async compilers will delay the promise returned by up.render().finished and up.hello().

up.hello() is now async

⚠️ The up.hello() function now returns a promise that fulfills when all synchronous and asynchronous compilers have terminated:

let textarea = up.element.createFromHTML('<textarea class="wysiwyg"></textarea>')
await up.hello(textarea) // mark: await
// chip: WYISWYG editor is now initialized

The fulfillment value is the same element that was passed as an argument:

let html = '<textarea class="wysiwyg"></textarea>'
let textarea = await up.hello(up.element.createFromHTML(html))

Performance fixes

Unpoly 3.11 introduced a number of performance regressions that would be very noticable on pages with many elements, or many forms. To address this, this release includes a number of performance fixes:

  • Fix a performance regression where Unpoly would track the DOM for dynamically inserted [up-validate] fields for every form. Now fields are only tracked for forms that use [up-validate].
  • Features that need to track the insertion or removal of elements now only sync with the DOM once after a render pass.
  • Watching a single field no longer tracks dynamically inserted fields.
  • Improved the performance of internal form lookups.

HTML content-type required

⚠️ Unpoly now requires server responses with an HTML content-type, like text/html or application/xhtml+xml. Trying to render responses with a different type will throw an error, even if the response body contains HTML markup.

Restricting content types is a security precaution. It protects in a hypothetical scenario where an attacker can both upload a file and can use an existing XSS vulnerability to cause Unpoly to render that file. It doesn't affect applications that reliably escape user input.

You can configure which responses Unpoly will process by configuring a function in up.fragment.config.renderableResponse. To render any response regardless of content-type, configure a function that always returns true`:

up.fragment.config.renderableResponse = (response) => true

New guides

The documentation has been extended with new guides:

Submit buttons can override form attributes

Submit buttons can now supplement or override most Unpoly attributes from the form:

<form method="post" action="/proposal/accept" up-submit>
  <button type="submit" up-target="#success">Accept</button>
  <button type="submit" up-target="#failure" up-confirm="Really reject?">Reject</button> <!-- mark: up-confirm="Really reject?" -->
</form>

Individual submit buttons can now opt for a full page load, by setting an [up-submit="false"] attribute:

<form method="post" action="/report/update" up-submit>
  <button type="submit" name="command" value="save">Save report</button>
  <button type="submit" name="command" value="download" up-submit="false">Download PDF</button> <!-- mark: up-submit="false" -->
</form>

See [up-submit] for a list of overridable attributes

Sticky layout elements

When scrolling to reveal a target element, Unpoly will ensure that layout elements with [up-fixed=top] are not covering the revealed content.

You can now use [up-fixed] on elements with position: sticky. Unpoly will measure sticky element like permanently fixed elements. The current scroll position is not taken into account.

Support partial tables responses

In the past Unpoly didn't allow a server to optimize its response when the result was a single table row (or cell) without an enclosing <table>:

Content-type: text/html

<tr>
  <td>...</td>
</tr>

Unpoly can now parse responses that only contain a <tr>, <td> or <th> element, without an enclosing <table> (issue #91).

Expanding click areas

Unpoly lets you enlarge a link's click area using the [up-expand] attribute. This version addresses inconsistent (or impractical) assignment of the .up-active feedback class when an expanded link is clicked.

When either the [up-expand] container or the first link is clicked, the .up-active class is now assigned to both elements:

<div up-expand class="up-active"> <!-- mark: class="up-active" -->
  <a href="/foo" class="up-active">Foo</a> <!-- mark: class="up-active" -->
  <a href="/bar">Bar</a>
</div>

When a non-expanded link is clicked, now only that link becomes .up-active:

<div up-expand>
  <a href="/foo">Foo</a> <!-- chip: not active -->
  <a href="/bar" class="up-active">Bar</a> <!-- mark: class="up-active" -->
</div>

Preserving fragments

Two changes were made to preserving elements using the [up-keep] attribute:

  • Added an experimental event up:fragment:kept. This event is emitted after all keep conditions are evaluated and preservation can no longer be prevented. A listener can be sure that the element is going to be kept.
  • Fragments with both [up-poll] and [up-keep] now continue polling when the element is kept (fixes #763)

Closing overlays

  • When an overlay is closed, the overlay now remains in the layer stack until all destructors have run. This way destructor functions can still look up elements in their layer.
  • ⚠️ The method up.Layer#isOpen() has been deprecated. Use up.Layer#isAlive() instead.
  • ⚠️ The method up.Layer#isClosed() has been deprecated. Use !up.Layer#isAlive() instead.
  • When closing an overlay that is already closed, Unpoly now throws an AbortError instead of doing nothing.
  • Fix a crash when an [up-switch] input without a containing form is placed in an overlay, and that overlay is closed.

Manual booting

  • ⚠️ To boot manually, the [up-boot=manual] must now be set on the <html> element instead of on the <script> loading Unpoly.
  • Unpoly now supports manual booting when Unpoly is loaded as a <script type="module">.

Fragment API

  • The up.fragment.get() function now has a { destroying: true } option. This allows to find destroyed elements that are still playing out their exit animation. Note that all up.fragment functions normally ignore elements in an exit animation.
  • Added an experimental function up.fragment.isAlive(). It returns whether an element is both attached to the DOM and also not in an exit animation.

Smaller fixes and changes

  • Reverted the implementation of up.util.task() to again queue macrotasks using setTimeout() instead of postMessage(). Unpoly 3.11 only recently switched to postMessage() because of its tighter scheduling. Unfortunately message order is erratic with postMessage() in Safari, making it hard to reason about the sequence of asynchronous callbacks.
  • Fix a bug where Unpoly would no longer restore history entries after the page is reloaded (issue #773).
  • Added an experimental method up.Response#isHTML(). It returns whether the response has a content-type of text/html or application/xml+html. It doesn't test if the response body actual contains a valid HTML document.
  • Fix a crash when up.render({ response }) is called while another request is in flight.
  • When rendering, and request with the same { failTarget } was made while waiting for the network, and the first request responded with an error status and updates , the second request is now aborted.

Upgrading

If you're upgrading from an older Unpoly version you should load unpoly-migrate.js to polyfill deprecated APIs. Changes handled by unpoly-migrate.js are not considered breaking changes.

See our upgrading guide for details.