Unpoly 3.15.0 released

Posted . Visible to the public. Auto-destruct in 60 days

A lot has changed for everyone who builds software since our last release. Many of us now write code together with AI agents.

This release makes Unpoly work better for this new way of building software. A completely rewritten documentation site Show archive.org snapshot helps humans and agents learn Unpoly best practices and look up API details. It is available as HTML, as Markdown and as an offline skill. A new contributor guide helps both humans and agents contribute to the project. We believe that hypermedia-driven apps (HDAs) fit this way of working well. They need less code, fewer dependencies and fewer moving parts, which makes them straightforward to understand and change for humans and agents alike.

We also made some changes to Unpoly itself. A major new feature is the ability to implement custom form fields as web components ("form-associated custom elements"). We fixed a ton of minor bugs and inconsistencies that had accumulated over Unpoly's 10+ years, and meaningfully improved our test coverage.

Changes that may need your attention are marked with 🧩 when unpoly-migrate.js Show archive.org snapshot polyfills them and logs where you use the old API, with ⚠️ when they are not polyfilled but rare or minor, and with ❌ when you should check your code in any case.

A relaunched documentation site

You can now learn Unpoly on unpoly.com from start to finish, and look up details when you need them.

  • The new Learn Show archive.org snapshot area covers all of Unpoly with best practices and examples, for beginners and pros alike.
  • Getting started Show archive.org snapshot is a new guide that takes you from how Unpoly works to your first links, forms, overlays and compilers.
  • We checked the code examples against the source code and fixed the ones that did not work. We split long guides into focused pages, merged guides that overlapped, and added pages for missing topics.
  • The new search looks through the full text of every page. It ranks the feature you most likely mean first and favors matches in titles. Each result shows whether it is a guide, an attribute, a function or an event, and which chapter or module it belongs to. It also lists the sections that matched, so you can jump to the right heading. Press / to search from any page. Search also works on phones.
  • The API reference Show archive.org snapshot is now separate from the Learn guides. Every API page links to the guide that explains its topic.

Old URLs redirect to the new pages, so your links and bookmarks keep working.

Docs for coding agents

An agent skill Show archive.org snapshot gives your coding agent the same documentation as you: every guide and the full API reference, with a search script that works offline.
With it, your agent follows Unpoly's common patterns for links, forms and overlays, and can look up the exact attributes, options and edge cases.
The skill also contains the release notes of every version, for when your agent upgrades an app.

Install the skill with the skills CLI Show archive.org snapshot :

npx skills add --global https://unpoly.com

If you use Claude Code, you can also install the skill as a plugin Show archive.org snapshot .

Agents can also read the docs without the skill:

Upgrades without human help

Coding agents can now upgrade Unpoly without human help. Give your agent the Unpoly skill Show archive.org snapshot and ask it to upgrade your app. The skill contains the upgrade guide Show archive.org snapshot and the release notes of every version.

To make sure this runs smoothly, we improved the release notes and unpoly-migrate.js, so your agent finds the changes your app needs. See unpoly-migrate.js Show archive.org snapshot for the details.

A guide for contributors

If you want to change Unpoly itself, start with CONTRIBUTING.md Show archive.org snapshot .
It links 13 guides, written for people and for coding agents. They explain how to set up a development environment, where code lives, and how we test, document and release Unpoly. Two deep-dives explain the rendering core and the selector engine.
A walkthrough takes you through a first change: a new [up-*] attribute, its spec, its documentation and its commit.

Coding agents find the guide on their own, because AGENTS.md and CLAUDE.md point to the same file.

The guides come with new tools:

  • bin/dev starts a build watcher, a test server and the documentation site with one command.
  • bin/test runs the specs in a real browser from the terminal, and bin/find-spec finds the specs relevant to your change, so you don't need to run the whole suite.
  • A scratch server shows your change on a few real pages, for things a spec cannot show, like how an overlay looks.

Custom form fields

Custom controls like date pickers or tag editors can now take part in Unpoly forms like a native <input>.
Unpoly now builds request params with the browser's own form-data algorithm, so a
form-associated custom element Show archive.org snapshot
is submitted without any configuration.
When it also has a value property, Unpoly can watch Show archive.org snapshot , validate Show archive.org snapshot and switch Show archive.org snapshot it like any other field, and [up-disable] Show archive.org snapshot disables it while the form is submitting.

Here is a minimal date picker that reports its value to the form:

class DatePicker extends HTMLElement {
  static formAssociated = true // mark: formAssociated
  #internals = this.attachInternals()
  #value = ''

  get value() {
    return this.#value
  }

  set value(newValue) {
    this.#value = newValue
    this.#internals.setFormValue(newValue) // mark: setFormValue
  }

  // Called when the element is inserted into the page
  connectedCallback() {
    // Render a calendar with a button for each day
    this.innerHTML = `
      <button type="button" data-day="2026-11-01">1</button>
      <button type="button" data-day="2026-11-02">2</button>
      …
    `
    this.addEventListener('click', (event) => {
      let dayButton = event.target.closest('[data-day]')
      if (dayButton) this.onDayPicked(dayButton.dataset.day)
    })
  }

  // Called when the user clicks a day
  onDayPicked(day) {
    this.value = day
    this.dispatchEvent(new Event('change', { bubbles: true }))
  }
}

customElements.define('date-picker', DatePicker)

You can use it in a form like a native field, including with [up-validate] Show archive.org snapshot :

<form method="post" action="/events" up-submit up-validate>
  <date-picker name="starts_at"></date-picker> <!-- mark: date-picker -->
  <input type="text" name="title">
</form>

An element that is defined after the form was rendered, e.g. by a lazily loaded component, is picked up as soon as it is defined.
Values appended by a formdata Show archive.org snapshot listener are now sent as well. See Adding values with a formdata listener Show archive.org snapshot .

The new guide Custom form fields Show archive.org snapshot compares four ways to build a custom control, from a hidden native field to a plain <div> that you register as a field.

Thanks to @kmmbvnr for contributing form-associated custom elements and formdata support.

Quality-of-life improvements

Some smaller changes in this release remove friction you may have worked around:

Mistakes are easier to find

When something is wrong, Unpoly now says so earlier and more clearly.
This also helps coding agents: clear errors and plain-text logs in automated browsers let an agent notice a mistake and fix it on its own.

  • ⚠️ When Unpoly boots, it now logs an error for every setting that doesn't exist, like a misspelled key or one that was renamed in an earlier version. It checks the top-level keys of every up.*.config object, but not nested objects like up.layer.config.modal Show archive.org snapshot , and only once, right after booting. If your tests fail on console errors, fix or remove these settings.
  • ⚠️ In a browser controlled by automation software, like Selenium, Playwright or Puppeteer, Unpoly now logs plain text without CSS formatting. Test runners, CI and coding agents that read the browser console get readable lines instead of %c placeholders and style strings. Normal browsers still format the log. To always format, set up.log.config.format = true Show archive.org snapshot .
  • Error messages, warnings and unformatted log output Show archive.org snapshot now print objects more readably. A plain object prints as { foo: "bar" }, a request as up.Request(GET /users/5) Show archive.org snapshot and an error as up.CannotMatch(message). A circular reference prints (circular) where the cycle closes, instead of replacing the whole value.
  • Fix: When an app's bundler minified unpoly.js, errors could lose their names. For example, an up.CannotMatch error was named up.. Errors now keep their names under any minifier.

Enlarged click areas

A click on an [up-expand] Show archive.org snapshot area now does exactly what a click on its link does:

  • When Unpoly follows the link, it follows the link itself. The link's attributes, events and origin Show archive.org snapshot all apply, including your own event listeners on the link and Rails' [data-method] and [data-confirm].
  • A link with [download] downloads its file, and a link with [target=_blank] opens a new tab.
  • A link that Unpoly doesn't follow makes a full page load, as it would when clicked directly.
  • After an overlay opened from the area closes, focus goes back to the link.

Fields in an expanded area keep handling their own clicks, and buttons, labels and <summary> elements now do too, instead of following the link.

Many apps we looked at had worked around the old limitations. You can now remove these workarounds. See Custom links and buttons Show archive.org snapshot for examples.

Form submissions now match the browser

Unpoly now uses the browser's form-data algorithm, so edge cases behave like a native form submission.
These changes cannot be polyfilled. If your server or your tests depend on the exact params of a submission, review them:

There is one exception: unlike the browser, a form only reads controls from its own layer. See Forms stay within their layer Show archive.org snapshot .

Forms stay within their layer

A form and its fields and buttons now always belong to one layer Show archive.org snapshot . Before, some features looked across layers: a form read the fields of a popup that was attached inside it, and a button in an overlay could submit a form below it. To let an overlay affect a form in another layer, have the overlay return a value Show archive.org snapshot when it closes, and let the layer below submit or update its form in an { onAccepted } Show archive.org snapshot callback.

A clear order of render steps

A render pass now inserts all new content first, then runs compilers, then updates focus and scroll position, and starts animations last. This order now holds for every placement, and when a render pass updates multiple fragments Show archive.org snapshot . A compiler can now see the new version of every other fragment updated in the same layer, including hungry elements Show archive.org snapshot . See Compilers see the DOM after the change Show archive.org snapshot .

  • ⚠️ Fragments are now inserted and compiled in the order of the target selector, followed by hungry elements. Since Unpoly 3.0 Show archive.org snapshot they were compiled in reverse order, which the 3.0 notes did not mention until now. up:fragment:inserted Show archive.org snapshot and up:fragment:destroyed Show archive.org snapshot are now also emitted in target order. When fragments are swapped without a transition, all their destructors now run before the first compiler, and up:fragment:destroyed Show archive.org snapshot is emitted after the last compiler.
  • ⚠️ When :content, :before or :after is updated with a transition or animation, focus now moves right away, like when swapping a fragment. It used to move when the animation ended. With :after and { focus: 'target' }, Unpoly now focuses the first appended element. It used to focus the container's first child, which could be an existing element.
  • ⚠️ When an error is thrown after new fragments were inserted, e.g. for an invalid { focus } or { scroll } selector, up.render() Show archive.org snapshot now fulfills and up.render().finished rejects. This was already the case when swapping a fragment, but :before, :after and fully kept Show archive.org snapshot fragments rejected up.render() Show archive.org snapshot .
  • Fix: When multiple fragments played a transition and the render pass scrolled, only the first old element kept its position on the screen.

Security note for apps on Unpoly 3.14

The 3.14.0 release notes named the setting for callback nonces up.script.config.callbackPolicy. The correct name is up.script.config.evalCallbackPolicy Show archive.org snapshot . The error was only in the release notes. The API reference and the guides always used the correct name.

If you copied the wrong name, Unpoly ignored your setting and used its default policy. Without a <meta name="csp-nonce"> tag, the default policy runs callbacks without checking their nonces. Your Content Security Policy then decides what runs. Injected HTML can run code through Unpoly callbacks if your CSP allows 'unsafe-eval', if it uses 'strict-dynamic' (which trusts every script that Unpoly inserts), or if you have no CSP.

To fix this, rename the setting to evalCallbackPolicy, then check that your callbacks still run. You don't need to upgrade to 3.15 for this.

Starting with 3.15, Unpoly logs an error when it boots for every setting that doesn't exist, so a misspelled or renamed setting no longer goes unnoticed. See Mistakes are easier to find Show archive.org snapshot .

Forms

Overlays

Caching

Rendering

  • ⚠️ An [up-on-offline] Show archive.org snapshot callback now receives the up:fragment:offline event as event, so it can call event.retry(). It used to receive the event as error.
  • URL patterns Show archive.org snapshot now match full URLs on the page's own origin, like https://example.com/users/*. Such patterns never matched before.
  • Fix: Pressing the Space key didn't activate a faux button Show archive.org snapshot , e.g. an element with [up-emit] Show archive.org snapshot or [up-clickable] Show archive.org snapshot . Only the Enter key worked, although Space was meant to work for buttons too.
  • Fix: When the user pressed the mouse button on a preloading Show archive.org snapshot link while its preload request was still loading after hovering, Unpoly aborted the request and sent the same request again. This bug was introduced in Unpoly 3.8.
  • Fix: When the user clicked a preloading Show archive.org snapshot link while its preload request was still loading, a newer render pass for the same fragment could not abort Show archive.org snapshot the click. Once the preload responded, the click rendered the old response over the newer content, and aborted the newer render pass. The click can now be aborted like any other request, while the preload keeps loading for the cache.
  • ⚠️ A click on an [up-expand] Show archive.org snapshot area now does what a click on its link does. When Unpoly follows the link, it now follows the link itself, not the area: events like up:link:follow are emitted on the link, the link is the origin Show archive.org snapshot , and its [data-method] and [data-confirm] attributes apply. When Unpoly doesn't follow the link, the area clicks it, and the browser does what the link does: a [download] link downloads its file, a [target=_blank] link opens a new tab, and a link without [up-follow] makes a full page load. To keep updating a fragment, give the link an [up-follow] attribute or follow all links Show archive.org snapshot .
  • Fix: Clicking a button, a <label> or a <summary> inside an [up-expand] Show archive.org snapshot area, or inside another followable element that isn't a link, followed the link. A submit button didn't submit its form, a label didn't toggle its checkbox, and a <details> element didn't open. These elements now handle their own clicks, like links and fields already did. Inside an <a> element, buttons and labels still belong to the link.
  • ⚠️ Clicks made by JavaScript, like element.click(), now always emit up:click Show archive.org snapshot . When a script clicks a link, Unpoly now follows it, also while handling another click or within a popup. To have the browser follow the link instead, set [up-follow=false] on it.
  • Fix: Clicking a form field inside an [up-instant] Show archive.org snapshot link or area, e.g. an [up-expand] Show archive.org snapshot area around an [up-instant] link, did nothing. Unpoly didn't follow the link, but also prevented the click, so a checkbox didn't toggle. Fields now handle their own clicks.
  • Fix: An [up-instant] Show archive.org snapshot link did nothing when another script prevented its mousedown event, e.g. to keep the focus in a search field. Unpoly now follows such a link on click, and emits up:click Show archive.org snapshot on click instead of mousedown.

unpoly-migrate.js

  • ⚠️ unpoly-migrate.js Show archive.org snapshot now logs deprecations as errors, so test suites that fail on console errors find them without extra configuration. To log warnings as before, set up.migrate.config.logLevel = 'warn'.
  • unpoly-migrate.js warnings about renamed render, request or layer options now name where the option was used, like the origin element or URL, instead of printing the options object. Each place is reported once.
  • The unpoly-migrate.js warning for the deprecated origin shorthand & now names the target and the origin element. When the origin is a button or field in a form, it also reminds you that a form submission's :origin Show archive.org snapshot is now the button or changed field, not the form, and that form:has(:origin) targets the form.
  • Fix: unpoly-migrate.js logged deprecations that the app never caused when a script enumerated an object with deprecated properties, e.g. an analytics script walking window.up, or another deprecation message printing a config object.
  • Fix: The unpoly-migrate.js polyfills for up.popup.attach() Show archive.org snapshot , up.modal.follow() Show archive.org snapshot and up.modal.extract() Show archive.org snapshot now open a new overlay again.
  • Fix: The unpoly-migrate.js polyfill for up.network.shouldReduceRequests() now logs a deprecation message instead of throwing an error.
  • Fix: The unpoly-migrate.js polyfill for up.util.microtask() Show archive.org snapshot threw an error. This bug was introduced in Unpoly 3.8.
  • Fix: unpoly-migrate.js ignored renamed layer keys like closable, dismissable, closeLabel or dismissAriaLabel when they were set in up.layer.config Show archive.org snapshot . They are now renamed, like when passed as options.
  • Fix: With unpoly-migrate.js, old layer attributes like [up-flavor], [up-closable] or [up-history-visible] are now also translated on forms that open an overlay.
  • Fix: With unpoly-migrate.js, a popup position like 'bottom-right' is now split into { position: 'bottom', align: 'right' } before the overlay opens. Before, up.Layer#position Show archive.org snapshot and the <up-popup> attributes showed the combined value.
  • Fix: With unpoly-migrate.js, a link with only an [up-href] Show archive.org snapshot attribute is now given [up-follow] Show archive.org snapshot . Before, unpoly-migrate.js and Unpoly could disagree about whether the link was followable. It also no longer warns about a link that another attribute already makes followable, like <a up-href="/path" up-target=".target">.

Smaller fixes and changes

Note

Our sponsor makandra funded this release ❤️
You can hire makandra for Unpoly support Show archive.org snapshot .

Profile picture of Henning Koch
Henning Koch
Last edit
Henning Koch
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2026-10-26 14:41)