---
area: "Changes"
parent: "Changes"
url: "https://unpoly.com/changes/3.15.0"
released: "2026-10-26"
---
<nav aria-label="Unpoly docs">[All docs](https://unpoly.com/index.md) · [Changes](https://unpoly.com/changes.md)</nav>

# Version 3.15.0 (Released on October 26, 2026 with [1059 commits](https://github.com/unpoly/unpoly/commits/v3.15.0))

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](https://unpoly.com/index.md) 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`](https://unpoly.com/changes/upgrading.md) 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.

<nav aria-label="Contents">

#### Contents

- [A relaunched documentation site](#a-relaunched-documentation-site)
- [Docs for coding agents](#docs-for-coding-agents)
- [Upgrades without human help](#upgrades-without-human-help)
- [A guide for contributors](#a-guide-for-contributors)
- [Custom form fields](#custom-form-fields)
- [Quality-of-life improvements](#quality-of-life-improvements)
- [Mistakes are easier to find](#mistakes-are-easier-to-find)
- [Enlarged click areas](#enlarged-click-areas)
- [Form submissions now match the browser](#form-submissions-now-match-the-browser)
- [Forms stay within their layer](#forms-stay-within-their-layer)
- [A clear order of render steps](#a-clear-order-of-render-steps)
- [Security note for apps on Unpoly 3.14](#security-note-for-apps-on-unpoly-314)
- [Forms](#forms)
- [Overlays](#overlays)
- [Caching](#caching)
- [Rendering](#rendering)
- [Links and URLs](#links-and-urls)
- [unpoly-migrate.js](#unpoly-migratejs)
- [Smaller fixes and changes](#smaller-fixes-and-changes)
- [Upgrading](#upgrading)

</nav>

## A relaunched documentation site {#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](https://unpoly.com/learn.md) area covers all of Unpoly with best practices and examples, for beginners and pros alike.
- [Getting started](https://unpoly.com/learn/start/overview.md) 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](https://unpoly.com/api.md) 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 {#docs-for-coding-agents}

An [agent skill](https://unpoly.com/learn/skill.md) 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](https://github.com/vercel-labs/skills):

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

If you use Claude Code, you can also [install the skill as a plugin](https://unpoly.com/learn/skill.md#claude-code).

Agents can also read the docs without the skill:

- Every page is available as Markdown. Append `.md` to its URL, e.g. [`https://unpoly.com/api/up.render.md`](https://unpoly.com/api/up.render.md). Agents that ask for `text/markdown` first in their `Accept` header get Markdown at the page's own URL.
- [`/llms.txt`](https://unpoly.com/llms.txt) lists all guides and API modules, with links an agent can follow.
- To give a chat a single page, use the copy button next to a page's title. It copies the page as Markdown.

## Upgrades without human help {#upgrades-without-human-help}

Coding agents can now upgrade Unpoly without human help. Give your agent the [Unpoly skill](https://unpoly.com/learn/skill.md) and ask it to upgrade your app. The skill contains the [upgrade guide](https://unpoly.com/changes/upgrading.md) 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](#unpoly-migratejs) for the details.

## A guide for contributors {#a-guide-for-contributors}

If you want to change Unpoly itself, start with [`CONTRIBUTING.md`](https://github.com/unpoly/unpoly/blob/master/CONTRIBUTING.md). 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-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](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#form-associated_custom_elements) is submitted without any configuration. When it also has a `value` property, Unpoly can [watch](https://unpoly.com/api/up.watch.md), [validate](https://unpoly.com/learn/validation.md) and [switch](https://unpoly.com/learn/switching-form-state.md) it like any other field, and [`[up-disable]`](https://unpoly.com/learn/disabling-forms.md) disables it while the form is submitting.

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

```js
class DatePicker extends HTMLElement {
  static formAssociated = true
  #internals = this.attachInternals()
  #value = ''

  get value() {
    return this.#value
  }

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

  // 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]`](https://unpoly.com/api/up-validate.md):

```html
<form method="post" action="/events" up-submit up-validate>
  <date-picker name="starts_at"></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`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/formdata_event) listener are now sent as well. See [Adding values with a `formdata` listener](https://unpoly.com/learn/custom-form-fields.md#formdata).

The new guide [Custom form fields](https://unpoly.com/learn/custom-form-fields.md) 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](https://github.com/kmmbvnr) for contributing form-associated custom elements and `formdata` support.

## Quality-of-life improvements {#quality-of-life-improvements}

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

- To reload an element after an overlay closes, set the new experimental [`[up-on-accepted-reload]`](https://unpoly.com/api/up-layer-new.md#up-on-accepted-reload) attribute to its selector. You no longer need a JavaScript callback or a CSP nonce. See [Overlays](#overlays).
- When Unpoly boots, it logs an error for a misspelled or renamed `up.*.config` setting, instead of ignoring it. See [Mistakes are easier to find](#mistakes-are-easier-to-find).
- Pages with many watched forms no longer freeze for a moment after each fragment update. See [Forms](#forms).
- Test runners, CI and coding agents now read Unpoly's log as plain text. See [Mistakes are easier to find](#mistakes-are-easier-to-find).

## Mistakes are easier to find {#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`](https://unpoly.com/api/up.layer.config.md#config.modal), 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`](https://unpoly.com/api/up.log.config.md#config.format).
- Error messages, warnings and [unformatted log output](https://unpoly.com/api/up.log.config.md#config.format) now print objects more readably. A plain object prints as `{ foo: "bar" }`, a request as [`up.Request(GET /users/5)`](https://unpoly.com/api/up.Request.md) 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 {#enlarged-click-areas}

A click on an [`[up-expand]`](https://unpoly.com/api/up-expand.md) 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](https://unpoly.com/api/origin.md) 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](https://unpoly.com/learn/faux-interactive-elements.md) for examples.

## Form submissions now match the browser {#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:

- ⚠️ A form with an `<input type="file">` is now submitted as `multipart/form-data`, even when no file is selected.
- ⚠️ An `<input type="image">` used as the submit button now sends its `name.x` and `name.y` coordinates instead of its `[value]`.
- ⚠️ A `<button type="reset">` or `<input type="reset">` with a `[name]` no longer sends a param. It is no longer watched or validated, but [`[up-disable]`](https://unpoly.com/api/up-submit.md#up-disable) still disables it.
- ⚠️ A field inside a `<fieldset disabled>` is no longer submitted or validated. This includes custom elements in [`up.form.config.fieldSelectors`](https://unpoly.com/api/up.form.config.md#config.fieldSelectors). [Watchers](https://unpoly.com/api/up.watch.md) and [`[up-switch]`](https://unpoly.com/learn/switching-form-state.md) still see the field.
- ⚠️ A `[form]` attribute now takes precedence over a surrounding `<form>`, like in the browser. A field or button within one form that is associated with another form no longer belongs to the surrounding form. It is no longer watched, validated or [disabled](https://unpoly.com/api/up-submit.md#up-disable) with that form, and it is no longer that form's default submit button.
- A form-associated custom element is only submitted when it calls `setFormValue()`, even when it is listed in [`up.form.config.fieldSelectors`](https://unpoly.com/api/up.form.config.md#config.fieldSelectors).
- ⚠️ Params now arrive in the order the browser produces them: the form's fields in document order, then values from `formdata` listeners, then custom elements registered in [`up.form.config.fieldSelectors`](https://unpoly.com/api/up.form.config.md#config.fieldSelectors). A submit button's `[name]` and `[value]` now appear at the button's position, not after the form's `[up-params]`.
- ⚠️ A [validation](https://unpoly.com/learn/validation.md) request no longer sends the params of the form's first submit button, and no longer uses that button's `[formaction]`, `[formmethod]`, `[up-params]` or `[up-headers]`. Nobody pressed the button, so only the `<form>` decides where a validation goes.
- ⚠️ [`up.Params.fromForm()`](https://unpoly.com/api/up.Params.fromForm.md) now requires a `<form>` element, as documented. To read params from any other container, use [`up.Params.fromContainer()`](https://unpoly.com/api/up.Params.fromContainer.md).
- ⚠️ [`up.Params.fromForm()`](https://unpoly.com/api/up.Params.fromForm.md) now includes the `[name]` and `[value]` of the form's first submit button, like a submission would. This shows most when you build a URL from the params that users see in the address bar, e.g. for a search. Pass `{ submitButton: false }` to only include fields, as before, or pass a different button element.
- ⚠️ [`up.submit()`](https://unpoly.com/api/up.submit.md) and [`up.Params.fromForm()`](https://unpoly.com/api/up.Params.fromForm.md) now throw an error when the `{ submitButton }` option is a button that doesn't belong to the form, as the browser's [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) does. Pass `{ submitButton: false }` to read a form without a button.
- ⚠️ When a form with [`[up-accept]` or `[up-dismiss]`](https://unpoly.com/learn/closing-overlays.md#result-values) closes an overlay, the result value now also contains the `[name]` and `[value]` of the submit button the user pressed.
- ⚠️ [`up.form.config.submitButtonSelectors`](https://unpoly.com/api/up.form.config.md#config.submitButtonSelectors) may only match native submit buttons. A custom element or a `<button type="button">` matched as a submit button now makes submitting throw an error.

There is one exception: unlike the browser, a form only reads controls from its own layer. See [Forms stay within their layer](#forms-stay-within-their-layer).

## Forms stay within their layer {#forms-stay-within-their-layer}

A form and its fields and buttons now always belong to one [layer](https://unpoly.com/api/up.layer.md). 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](https://unpoly.com/learn/subinteractions.md#result-values) when it closes, and let the layer below submit or update its form in an [`{ onAccepted }`](https://unpoly.com/learn/subinteractions.md#callbacks) callback.

- ⚠️ [`up.form.fields()`](https://unpoly.com/api/up.form.fields.md) and [`up.form.submitButtons()`](https://unpoly.com/api/up.form.submitButtons.md) now ignore elements in another [layer](https://unpoly.com/api/up.layer.md) and elements that are being [destroyed](https://unpoly.com/api/up.destroy.md), like other [`up.fragment`](https://unpoly.com/api/up.fragment.md) lookups. A field in a popup that is attached inside a form is no longer watched by that form or submitted with it.
- ⚠️ When a submit button or a focused field has a `[form]` attribute that points to a form in another layer, submitting now throws an error: "Prevented … from submitting a form in another layer". The browser would submit the other layer's form, e.g. when the same form ID exists in the layer below an overlay that shows the same page. To fix this, keep a form and its `[form]` controls in the same layer, and use form IDs that are unique across layers.
- Fix: While an overlay was closing, an [`[up-switch]`](https://unpoly.com/learn/switching-form-state.md) field in it could show or hide elements in the layer behind it. This happened for a field outside a `<form>`, and for an [`[up-switch-region]`](https://unpoly.com/api/up-switch.md#up-switch-region) that also matched in that layer.

## A clear order of render steps {#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](https://unpoly.com/learn/targeting-fragments.md#multiple). A compiler can now see the new version of every other fragment updated in the same layer, including [hungry elements](https://unpoly.com/learn/hungry-elements.md). See [Compilers see the DOM after the change](https://unpoly.com/learn/enhancing-elements.md#dom-after-change).

- ⚠️ Fragments are now inserted and compiled in the order of the target selector, followed by hungry elements. Since [Unpoly 3.0](https://unpoly.com/changes/3.0.0.md) they were compiled in reverse order, which the 3.0 notes did not mention until now. [`up:fragment:inserted`](https://unpoly.com/api/up:fragment:inserted.md) and [`up:fragment:destroyed`](https://unpoly.com/api/up:fragment:destroyed.md) 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`](https://unpoly.com/api/up:fragment:destroyed.md) 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()`](https://unpoly.com/api/up.render.md) now fulfills and `up.render().finished` rejects. This was already the case when swapping a fragment, but `:before`, `:after` and fully [kept](https://unpoly.com/learn/keeping-elements.md) fragments rejected [`up.render()`](https://unpoly.com/api/up.render.md).
- 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 {#security-note-for-apps-on-unpoly-314}

The 3.14.0 release notes named the setting for callback nonces `up.script.config.callbackPolicy`. The correct name is [`up.script.config.evalCallbackPolicy`](https://unpoly.com/api/up.script.config.md#config.evalCallbackPolicy). 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](#mistakes-are-easier-to-find).

## Forms {#forms}

- ⚠️ While a [validation](https://unpoly.com/learn/validation.md) request is loading, [feedback classes](https://unpoly.com/learn/feedback-classes.md) like [`.up-loading`](https://unpoly.com/api/up-loading.md) are now set by default, as for every other request. To turn them off, set `[up-watch-feedback="false"]` or pass `{ feedback: false }`.
- A custom control now only needs a `value` property to work as a [custom form field](https://unpoly.com/learn/custom-form-fields.md). It used to also need `name` and `disabled` properties. Unpoly now falls back to the `[name]` and `[disabled]` attributes.
- A `formdata` listener changes what Unpoly sends, not what it watches. See [Adding values with a `formdata` listener](https://unpoly.com/learn/custom-form-fields.md#formdata).
- New experimental config [`up.form.config.noFieldSelectors`](https://unpoly.com/api/up.form.config.md#config.noFieldSelectors) excludes elements from being treated as fields, e.g. a form-associated custom element that renders a button.
- When Unpoly disables a custom control that has no `disabled` property, it now sets the `[disabled]` attribute.
- When Unpoly [focuses](https://unpoly.com/learn/focus.md) a form-associated custom element, it now sets [`.up-focus-visible`](https://unpoly.com/api/up-focus-visible.md), even after a mouse or touch interaction. Such an element is now a field, and [`up.viewport.config.autoFocusVisible`](https://unpoly.com/api/up.viewport.config.md#config.autoFocusVisible) shows a [focus ring](https://unpoly.com/learn/focus-visibility.md) for every field.
- A custom control with a `[name]` but no readable value no longer reports an `undefined` value to watchers.
- Pages with many watched forms are much faster. Before, every [`[up-autosubmit]`](https://unpoly.com/api/up-autosubmit.md), [`[up-validate]`](https://unpoly.com/api/up-validate.md), [`[up-switch]`](https://unpoly.com/api/up-switch.md) or [`up.watch()`](https://unpoly.com/api/up.watch.md) looked at every field on the page after each fragment update. A page with hundreds of small forms could freeze for a second after each update.
- ⚠️ [`[up-switch]`](https://unpoly.com/learn/switching-form-state.md) now switches immediately, ignoring [`up.form.config.watchInputDelay`](https://unpoly.com/api/up.form.config.md#config.watchInputDelay) and `[up-watch-delay]`. Switching effects are client-side and don't benefit from debouncing. In a form that also [autosubmits](https://unpoly.com/api/up-autosubmit.md) with a delay, switching now settles before the submission reads the form. If you set `[up-watch-delay]` on a switching field only to make it react faster, you can remove it. Keep it when the same field also validates or autosubmits.
- Fix: When a form was submitted or validated again while a request had disabled its fields, e.g. with [`[up-disable]`](https://unpoly.com/api/up-submit.md#up-disable), the second request sent no params for the disabled fields. Fields and buttons that a [preview](https://unpoly.com/api/up.Preview.prototype.disable.md) disabled now still count as part of the form when Unpoly reads params or watches values.
- ⚠️ Since Unpoly 3.12, submit buttons honor a `[data-confirm]` attribute, like links and forms do. It is an alias of [`[up-confirm]`](https://unpoly.com/api/up-submit.md#up-confirm), so Rails helpers like `link_to` or `button_to` work without changes. If your app handles `[data-confirm]` itself, users see two dialogs, and specs may fail with an `UnexpectedAlertOpenError`. Have your handler use another attribute instead. Unpoly only confirms for forms it submits, so buttons in an `[up-submit=false]` form or in a form that Unpoly doesn't handle still need your handler.
- A nested [`[up-autosubmit]`](https://unpoly.com/api/up-autosubmit.md), e.g. on a field inside a form that already has [`[up-autosubmit]`](https://unpoly.com/api/up-autosubmit.md), is now ignored when it belongs to the same form. It used to submit the form a second time for the same change.
- New experimental class [`.up-booted`](https://unpoly.com/api/up-booted.md) on the `<html>` element after Unpoly has booted. It lets CSS [hide switched elements until Unpoly has processed them](https://unpoly.com/learn/switching-form-state.md#before-booting).
- Fix: When a focused field or button had no [form group](https://unpoly.com/api/up-form-group.md) that Unpoly could target, disabling it with [`[up-disable]`](https://unpoly.com/api/up-submit.md#up-disable) threw a `TypeError`. This happened e.g. for a submit button that shares its `[name]` with another button, in a form without an `[id]`.
- ⚠️ [`up.form.group()`](https://unpoly.com/api/up.form.group.md) now returns the closest group, even when Unpoly cannot derive a target selector for it. To only get a group that Unpoly can target, pass the new `{ targetable: true }` option.
- 🧩 `up.form.config.genericButtonSelectors` was renamed to [`up.form.config.anyButtonSelectors`](https://unpoly.com/api/up.form.config.md#config.anyButtonSelectors). It now matches every kind of button, including submit and reset buttons. The old name is polyfilled by `unpoly-migrate.js`.
- Fix: After clicking a submit button that Unpoly cannot [target](https://unpoly.com/learn/target-derivation.md), e.g. a button without `[id]` or `[name]`, an [error response](https://unpoly.com/learn/error-responses.md) moved the focus to the `<body>`. The re-rendered form now receives the focus, like when other focused elements are replaced. The same happened in other render passes that replaced a focused element without a derivable target.

## Overlays {#overlays}

- New experimental [`[up-on-accepted-reload]`](https://unpoly.com/api/up-layer-new.md#up-on-accepted-reload) and [`[up-on-dismissed-reload]`](https://unpoly.com/api/up-layer-new.md#up-on-dismissed-reload) attributes reload an element in the parent layer after an overlay closes, e.g. `up-on-accepted-reload=".company-list"`. Unlike `up-on-accepted="up.reload('.company-list')"`, they run no JavaScript, so they need no CSP nonce. The matching options [`{ onAcceptedReload }`](https://unpoly.com/api/up.layer.open.md#options.onAcceptedReload) and [`{ onDismissedReload }`](https://unpoly.com/api/up.layer.open.md#options.onDismissedReload) also work in an [`X-Up-Open-Layer`](https://unpoly.com/api/X-Up-Open-Layer.md#callbacks) header. See [Reloading on acceptance](https://unpoly.com/learn/subinteractions.md#reloading-on-acceptance).
- New [`[up-on-accept]`](https://unpoly.com/api/up-layer-new.md#up-on-accept) and [`[up-on-dismiss]`](https://unpoly.com/api/up-layer-new.md#up-on-dismiss) attributes run code before an overlay closes, and can keep it open with `event.preventDefault()`. The matching [`{ onAccept }`](https://unpoly.com/api/up.layer.open.md#options.onAccept) and [`{ onDismiss }`](https://unpoly.com/api/up.layer.open.md#options.onDismiss) options are now documented.
- When an overlay that was opened without an `{ origin }` closes, focus now moves to the parent layer, as described in [focus in overlays](https://unpoly.com/learn/focus.md#overlays). It used to fall back to the `<body>`.
- Fix: [`[up-focus-visible]`](https://unpoly.com/api/up-follow.md#up-focus-visible) and the `{ focusVisible }` option were ignored when opening an overlay.
- Fix: Pressing Escape dismissed an overlay even when an element in it had already handled the key with `event.preventDefault()`, e.g. a dropdown closing its list. A prevented Escape keydown now keeps the overlay open.
- ⚠️ [Aborting](https://unpoly.com/learn/aborting-requests.md) the root layer, e.g. with [`up.fragment.abort({ layer: 'root' })`](https://unpoly.com/api/up.fragment.abort.md) or a render pass with `{ abort: 'layer' }`, no longer stops [polling](https://unpoly.com/api/up-poll.md), pending [validations](https://unpoly.com/learn/validation.md) or a scheduled [preload](https://unpoly.com/learn/preloading.md) in an open overlay. Requests in overlays were already kept. [`up.fragment.onAborted()`](https://unpoly.com/api/up.fragment.onAborted.md) and [`up.fragment.onKept()`](https://unpoly.com/api/up.fragment.onKept.md) now only react to ancestors in the same layer. To do the same in your own code, pass the new `{ layer }` option to [`up.event.onClosest()`](https://unpoly.com/api/up.event.onClosest.md).
- Fix: The `{ duration }` and `{ easing }` options of [`up.layer.open()`](https://unpoly.com/api/up.layer.open.md) were ignored for the opening animation. This also affected `[up-duration]` and `[up-easing]` on a link that opens an overlay. Only [`up.layer.config`](https://unpoly.com/api/up.layer.config.md) was used.
- Fix: Double-clicking a link that opens an overlay right away, e.g. a [styled confirmation dialog](https://unpoly.com/learn/confirmation-dialogs.md) or an overlay with [preloaded](https://unpoly.com/learn/preloading.md) content, dismissed the new overlay at once. The second click landed outside the overlay. Double-clicking a link that opens a popup opened and closed the popup. The overlay now stays open.
- Fix: Callbacks like `onAccepted` or `onDismissed` passed to [`up.layer.ask()`](https://unpoly.com/api/up.layer.ask.md) were ignored. They now run when the overlay closes, before the promise settles.

## Caching {#caching}

- 🧩 New config [`up.network.config.cacheEnabled`](https://unpoly.com/api/up.network.config.md#config.cacheEnabled) turns off the [cache](https://unpoly.com/learn/caching.md) entirely, e.g. for debugging. When it is `false`, no response is read from or written to the cache, and [preloading](https://unpoly.com/learn/preloading.md) is disabled. If you used `up.network.config.cacheSize = 0` to turn off caching, [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading.md) now disables the cache and tells you to use this setting instead. It used to throw an error.
- An [`X-Up-Expire-Cache: false`](https://unpoly.com/api/X-Up-Expire-Cache.md) response header now expires no cache entries, like [`X-Up-Evict-Cache: false`](https://unpoly.com/api/X-Up-Evict-Cache.md). It used to be treated as a URL pattern matching `/false`.
- Fix: When a compiler requested the URL of a request that had just been aborted or had just finished, it sometimes got the old request from the cache. It now makes a new request.

## Rendering {#rendering}

- Fix: The `{ onFailFinished }` callback and `[up-on-fail-finished]` attribute are now called after an [error response](https://unpoly.com/learn/error-responses.md) has finished rendering. They used to never be called.
- Fix: [`[up-reveal-padding]`](https://unpoly.com/api/up-follow.md#up-reveal-padding) and the `{ revealPadding }` render option were ignored. Only [`up.viewport.config.revealPadding`](https://unpoly.com/api/up.viewport.config.md#config.revealPadding) was used.
- Fix: [Target derivation](https://unpoly.com/learn/target-derivation.md) for an SVG element with a camelCase tag name, like `<clipPath>`, now produces a selector that matches the element.
- Fix: While an overlay was open, [`up.fragment.get(root, selector)`](https://unpoly.com/api/up.fragment.get.md) could return an element outside the given root.
- Fix: When an error was thrown after a render pass had started to change the page, Unpoly could render a [fallback target](https://unpoly.com/learn/targeting-fragments.md#providing-a-fallback-target) on top of the update. The render promise now rejects with that error.
- ⚠️ When the server responds to [polling](https://unpoly.com/api/up-poll.md) or a [deferred](https://unpoly.com/api/up-defer.md) placeholder with an error code, Unpoly no longer throws an uncaught `up.CannotMatch` error. The response is not rendered: polling tries again after the next interval, and a deferred placeholder keeps its fallback content. A promise returned by [`up.reload()`](https://unpoly.com/api/up.reload.md) or [`up.deferred.load()`](https://unpoly.com/api/up.deferred.load.md) still rejects, now with an [`up.RenderResult`](https://unpoly.com/api/up.RenderResult.md). To render error responses anyway, set [`[up-fail=false]`](https://unpoly.com/learn/error-responses.md) on the [`[up-poll]`](https://unpoly.com/api/up-poll.md) or [`[up-defer]`](https://unpoly.com/api/up-defer.md) element, or render them into another fragment with [`[up-fail-target]`](https://unpoly.com/api/up-defer.md#up-fail-target).
- ⚠️ With [`up.history.config.enabled = false`](https://unpoly.com/api/up.history.config.md#config.enabled), a render pass no longer changes the window title, meta tags or `html[lang]`. Before, only the browser URL stayed unchanged, so the page could show the title of one page with the URL of another. To still update some aspects, set [`up.history.config.enabled`](https://unpoly.com/api/up.history.config.md#config.enabled) to an array of the [history aspects](https://unpoly.com/learn/updating-history.md#disabling) Unpoly may update: `'location'`, `'title'`, `'metaTags'` or `'lang'`. For example, `up.history.config.enabled = ['lang']` never changes the browser URL, but still updates `html[lang]`. When an aspect is turned off, layers report their rendered value for it, so [`[up-nav]`](https://unpoly.com/learn/navigation-bars.md) follows the content. Closing an overlay no longer restores a turned-off aspect, and [`up.history.push()`](https://unpoly.com/api/up.history.push.md) and [`up.history.replace()`](https://unpoly.com/api/up.history.replace.md) do nothing without `'location'`. When the initial page was loaded with a `POST` request and `'location'` is turned off, Unpoly no longer falls back to full page loads, no longer disables preloading, and no longer warns at boot.
- 🧩 [`up.history.config.updateMetaTags`](https://unpoly.com/api/up.history.config.md#config.updateMetaTags) is deprecated. If you set it to `true`, remove the line. To keep meta tags unchanged, omit `'metaTags'` from an array of aspects in [`up.history.config.enabled`](https://unpoly.com/api/up.history.config.md#config.enabled). `unpoly-migrate.js` polyfills the old setting. It also no longer warns at boot when you haven't set [`up.history.config.updateMetaTags`](https://unpoly.com/api/up.history.config.md#config.updateMetaTags).
- Fix: An explicit list of elements passed as [`{ metaTags }`](https://unpoly.com/api/up.render.md#options.metaTags) was replaced by the meta tags from the response.
- Fix: [`[up-scroll-behavior]`](https://unpoly.com/api/up-follow.md#up-scroll-behavior) and the `{ scrollBehavior }` option were ignored when scrolling to the `top` or `bottom`, to a pixel position, to a `#hash` or when restoring a scroll position. Only revealing an element honored them.
- Fix: When the server sent an [`X-Up-Location`](https://unpoly.com/api/X-Up-Location.md) header with a `#hash`, Unpoly updated the browser URL but did not reveal the matching element. When the requested URL also had a `#hash`, the browser URL ended up with both.
- Fix: A [combined transition](https://unpoly.com/learn/transitions.md#combining-animations) like `move-to-bottom/none` swapped the elements instantly. When one half is `none`, the other half is now animated.
- Fix: With [animations disabled](https://unpoly.com/api/up.motion.config.md#config.enabled), animations and transitions no longer wait for the next animation frame before they finish. This bug was introduced in Unpoly 3.14.

## Links and URLs {#links-and-urls}

- ⚠️ An [`[up-on-offline]`](https://unpoly.com/api/up-follow.md#up-on-offline) callback now receives the [`up:fragment:offline`](https://unpoly.com/api/up:fragment:offline.md) event as `event`, so it can call `event.retry()`. It used to receive the event as `error`.
- [URL patterns](https://unpoly.com/api/url-patterns.md) 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](https://unpoly.com/learn/faux-interactive-elements.md), e.g. an element with [`[up-emit]`](https://unpoly.com/api/up-emit.md) or [`[up-clickable]`](https://unpoly.com/api/up-clickable.md). 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](https://unpoly.com/learn/preloading.md) 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](https://unpoly.com/learn/preloading.md) link while its preload request was still loading, a newer render pass for the same fragment could not [abort](https://unpoly.com/learn/aborting-requests.md) 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]`](https://unpoly.com/api/up-expand.md) 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`](https://unpoly.com/api/up:link:follow.md) are emitted on the link, the link is the [origin](https://unpoly.com/api/origin.md), 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]`](https://unpoly.com/api/up-follow.md) makes a full page load. To keep updating a fragment, give the link an [`[up-follow]`](https://unpoly.com/api/up-follow.md) attribute or [follow all links](https://unpoly.com/learn/handling-all-links.md).
- Fix: Clicking a button, a `<label>` or a `<summary>` inside an [`[up-expand]`](https://unpoly.com/api/up-expand.md) 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`](https://unpoly.com/api/up:click.md). 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]`](https://unpoly.com/api/up-instant.md) link or area, e.g. an [`[up-expand]`](https://unpoly.com/api/up-expand.md) area around an [`[up-instant]`](https://unpoly.com/api/up-instant.md) 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]`](https://unpoly.com/api/up-instant.md) 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`](https://unpoly.com/api/up:click.md) on `click` instead of `mousedown`.

## unpoly-migrate.js {#unpoly-migratejs}

- ⚠️ [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading.md) 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`](https://unpoly.com/api/origin.md) 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()`](https://unpoly.com/api/up.popup.attach.md), [`up.modal.follow()`](https://unpoly.com/api/up.modal.follow.md) and [`up.modal.extract()`](https://unpoly.com/api/up.modal.extract.md) 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()`](https://unpoly.com/api/up.util.microtask.md) 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`](https://unpoly.com/api/up.layer.config.md). 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`](https://unpoly.com/api/up.Layer.prototype.position.md) and the `<up-popup>` attributes showed the combined value.
- Fix: With `unpoly-migrate.js`, a link with only an [`[up-href]`](https://unpoly.com/api/up-href.md) attribute is now given [`[up-follow]`](https://unpoly.com/api/up-follow.md). 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 {#smaller-fixes-and-changes}

- ❌ The [3.14.0 release notes](https://unpoly.com/changes/3.14.0.md#callbacks) named the setting for callback nonces `up.script.config.callbackPolicy`. The correct name is [`up.script.config.evalCallbackPolicy`](https://unpoly.com/api/up.script.config.md#config.evalCallbackPolicy). If you copied the wrong name, Unpoly ignored your setting and used the default policy `'auto'`. Search your code for `callbackPolicy` and rename it. Renaming makes [your policy](https://unpoly.com/learn/script-security.md#callback-policy) take effect. Check that your callbacks still run. `unpoly-migrate.js` now forwards the wrong name and logs a deprecation message. Without `unpoly-migrate.js`, Unpoly now logs an error for the wrong name when it boots, like for any other setting that doesn't exist. See the [security note](#security-note-for-apps-on-unpoly-314).
- ⚠️ `unpoly.js` is now compiled using ES2024 (up from ES2021). It needs Chrome 119, Firefox 121 or Safari 17.4, all released by March 2024. The [ES6 build](https://unpoly.com/learn/legacy-browsers.md#es6-build) for legacy browsers remains available.
- ⚠️ A `click` event dispatched by a script is no longer passed to [`up.on()`](https://unpoly.com/api/up.on.md) callbacks when the element has a `[disabled]` attribute, even for elements that browsers cannot disable. Before, only a `disabled` property had this effect.
- The npm package now also includes source maps for the unminified files, like `unpoly.js.map`.
- [`up.template.clone()`](https://unpoly.com/api/up.template.clone.md) with an `{ origin }` now also finds a template in an ancestor layer, as described in [template lookup](https://unpoly.com/learn/templates.md#lookup). It used to only look in the origin's layer.
- Fix: [`up.viewport.restoreFocus()`](https://unpoly.com/api/up.viewport.restoreFocus.md) threw an error when called without a `{ layer }` option, or with a layer name like `{ layer: 'root' }`.
- Fix: [`up.viewport.restoreScroll()`](https://unpoly.com/api/up.viewport.restoreScroll.md) ignored its `{ location }` option. It always restored the positions saved for the layer's current location.
- Fix: [`up.fragment.matches()`](https://unpoly.com/api/up.fragment.matches.md) with `{ layer: 'new', mode }` options expanded a selector like [`:main`](https://unpoly.com/api/main.md) for the root layer, or threw an error while an overlay was open.

> #### Note
>
> Our sponsor [makandra](https://makandra.de/en) funded this release ❤️
> You can hire makandra for [Unpoly support](https://unpoly.com/support.md).

## Upgrading {#upgrading}

If you're upgrading from an older Unpoly version you should load [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading.md) to polyfill deprecated APIs. Changes handled by `unpoly-migrate.js` are not considered breaking changes.

See our [upgrading guide](https://unpoly.com/changes/upgrading.md) for details.

