Revision code

Changes Version 3.11.0
Released on July 03, 2025 with 319 commits

This is a big release, shipping many features and quality-of-life improvements requested by the community. Highlights include a complete overhaul of history handling, form state switching and the preservation of [up-keep] elements. We also reworked major parts of the documentation and stabilized 89 experimental features.

We had to make some breaking changes, which are marked with a ⚠️ emoji in this CHANGELOG.
Most incompatibilities are polyfilled by unpoly-migrate.js.

Note

Our sponsor makandra funded this release ❤️
Please take a minute to check out makandra's services for web development, DevOps or UI/UX.

Professional support options

We're introducing optional commercial support for businesses that depend on Unpoly. You can now sponsor bug fixes, commission new features, or get direct help from Unpoly’s core developers.

Support commissions will fund Unpoly’s ongoing development while keeping it fully open source for everyone.
The Discussions board remains available for free community support, and the maintainers will also remain active there.

Learn more about support options at unpoly.com/support.

History handling

Improved history restoration

When pressing the back button, Unpoly used to only restore history entries that it created itself. This sometimes caused the back button to do nothing when a state was pushed by a user interacting with the browser UI, or when an external script replaced an entry.

Starting with this version, Unpoly will handle restoration for most history entries:

  • Unpoly will now restore history entries created by clicking an in-page link to another #hash. Going back to such an entry will now reveal a matching fragment, scrolling far enough to ignore any obstructing elements in the layout.
  • Unpoly will now restore history entries created by the user changing the #hash in the browser's address bar (without also changing the path or search query).
  • Unpoly will now restore its own history entries that were later replaced by external scripts (through history.replaceState()).

When an external script pushes a history entry with a new path unknown to Unpoly, that external script is still responsible for restoration.

Listeners to up:location:changed can now inspect and control which history changes Unpoly should handle:

  • A new experimental property { willHandle } shows if Unpoly thinks it is responsible for restoring the new location state.
  • A new experimental property { alreadyHandled } shows if Unpoly thinks the change has already been handled (e.g. after calls to history.pushState()).

Unpoly now handles most clicks on a link to a #hash within the current page, taking great care to emulate the browser's native scrolling behavior:

  • Hash links will now honor the viewport's scroll-behavior: smooth style.
  • Hash links can now override their scroll behavior using an [up-scroll-behavior] attribute. Valid values are instant, smooth and auto (uses CSS behavior).
  • Hash links will now always scroll to a fragment in link's layer, ignoring matching fragments in other layers.
  • Hash links will no longer scroll when another script prevented the click event.
  • Hash links that are followable will now scroll the page without re-rendering.
  • Hash links will now reliably scroll far enough to ignore any obstructing elements in the layout.

Every location change is now tracked

up:location:changed (and up:layer:location:changed) used to only be emitted when history changed during rendering.
⚠️ These events are now emitted when the URL changes for any reason, including:

  • When a script calls history.pushState() or up.history.push().
  • When a script calls history.replaceState() or up.history.replace().
  • When the user presses the back or forward button in the browser UI.
  • When the user changes the #hash in the browser's address bar.
  • When the user clicks on a #hash link.

Reacting to #hash changes usually involves scrolling, not rendering. To better signal this case, the { reason } property of up:location:changed can now be the string 'hash' if only the location #hash was changed from the previous location.

Other improvements to history handling

  • The log now shows a purple event badge when the user navigates within history. This helps to correlate e.g. a popstate event with the logging output from a subsequent history restoration.
  • When a fragment update closes an overlay and then navigates the parent layer to a new location, Unpoly will no longer push a redundant history entry of the parent layer's location before navigating.
  • Published an experimental function up.history.replace() to change the URL of the current history state.
  • The up:layer:location:changed event now has a { previousLocation } property.
  • Fix a bug where history wasn't updated when a response contains comments before the <!DOCTYPE> or <html> tag (fixes #726)

Watching fields for changes

When watching fields using [up-watch], [up-autosubmit], [up-switch] or [up-validate], the following cases are now addressed:

  • Fixed all cases where a watched field with [up-keep] is transported to a new <form> element by a fragment update.
  • Fixed all cases where a watched field outside its form (with [form] attribute) is added or removed dynamically.
  • When a watched field runs a callback, a purple event badge is now logged to help correlating cause and effect.
  • If a watched field with [up-watch-delay] was detached by an external script during the delay, watchers will no longer fire callbacks or send requests.
  • ⚠️ Watching an individual radio button will now throw an error. Watch a container for the entire radio group instead.
  • Directly watching a field without a [name] will now throw an error explaining that this attribute is required. In earlier versions callbacks were simply never called.

Switching form state

The [up-switch] attribute has been reworked to be more powerful and flexible.

Also see our new guide Switching form state.

Disabling or enabling fields

You can now disable dependent fields using the new [up-disable-for] and [up-enable-for] attributes.

Let's say you have a <select> for a user role. Its selected value should enable or disable other. You begin by setting an [up-switch] attribute with an selector targeting the controlled fields:

<select name="role" up-switch=".role-dependent"> <!-- mark: up-switch -->
  <option value="trainee">Trainee</option>
  <option value="manager">Manager</option>
</select>

The target elements can use [up-enable-for] and [up-disable-for] attributes to indicate for which values they should be shown or hidden:

<!-- The department field is only shown for managers -->
<input class="role-dependent" name="department" up-enable-for="manager"> <!-- mark: up-enable-for -->

<!-- The mentor field is only shown for trainees -->
<input class="role-dependent" name="mentor" up-disable-for="manager"> <!-- mark: up-disable-for -->

See Disabling or enabling fields.

Custom switching effects

You can now implement custom, client-side switching effects by listening to the up:form:switch event on any element targeted by [up-switch].

For example, we want a custom [highlight-for] attribute. It draws a bright outline around the department field when the manager role is selected:

<select name="role" up-switch=".role-dependent">
  <option value="trainee">Trainee</option>
  <option value="manager">Manager</option>
</select>

<input class="role-dependent" name="department" highlight-for="manager"> <!-- mark: highlight-for -->

When the role select changes, an up:form:switch event is emitted on all elements matching .role-dependent. We can use this event to implement our custom [highlight-for] effect:

up.on('up:form:switch', '[highlight-for]', (event) => {
  let highlightedValue = event.target.getAttribute('highlight-for')
  let isHighlighted = (event.field.value === highlightedValue)
  event.target.style.highlight = isHighlighted ? '2px solid orange' : ''
})

See Custom switching effects.

New switching modifiers

The [up-switch] attribute itself has been reworked with new modifiying attributes:

More [up-switch] changes

Form validation

The [up-validate] attribute has been reworked.

Validating against other URLs

By default Unpoly will submit validation requests to the form's [action] attribute, setting an additional X-Up-Validate header to allow the server distinguish a validation request from a regular form submission.

Unpoly can now validate forms against other URLs. You can do so with the new [up-validate-url] and [up-validate-method] attributes on individudal fields or on entire forms:

<form method="post" action="/order" up-validate-url="/validate-order"> <!-- mark: up-validate-url -->
  ...
</form>

To have individual fields validate against different URLs, you can also set [up-validate-url] on a field:

<form method="post" action="/register">
  <input name="email" up-validate-url="/validate-email"> <!-- mark: /validate-email -->
  <input name="password" up-validate-url="/validate-password"> <!-- mark: /validate-password -->
</form>

Even with multiple URLs, Unpoly still guarantees eventual consistency in a form with many concurrent validations. This is done by separating request batches by URL and ensuring that only a single validation request per form will be in flight at the same time.

For instance, let's assume the following four validations:

up.validate('.foo', { url: '/path1' })
up.validate('.bar', { url: '/path2' })
up.validate('.baz', { url: '/path1' })
up.validate('.qux', { url: '/path2' })

This will send a sequence of two requests:

  1. A request to /path targeting .foo, .baz. The other validations are queued.
  2. Once that request finishes, a second request to /path2 targeting .bar, .qux.

Other validation changes

  • You can now disable validation batching globally with up.form.config.batchValidate = false, or for individual forms or fields with an [up-validate-batch="false"] attribute.
  • Fields or forms can add additional params to the validation request using the [up-validate-params] attribute.
  • Fields or forms can add additional headers to the validation request using the [up-validate-headers] attribute.
  • Validation targets can now refer to the changed field with :origin. This was possible before, but was never documented.
  • [up-validate] can now be set on any container of fields, not just an individual field or an entire form. This was possible before, but never documented.

Layers

Opening overlays from the server

The server can now force its response to open an overlay using an X-Up-Open-Layer: { ...options } response header:

Content-Type: text/html
X-Up-Open-Layer: { target: '#menu', mode: 'drawer', animation: 'move-to-right' }

<div id="menu">
  Overlay content
</div>

See Opening overlays from the server.

Closing overlays from forms

Forms can now have an [up-dismiss] or [up-accept] attribute to close their overlay when submitted. This will immediately close the overlay on submission, without making a network request:

<form up-accept> <!-- mark: up-accept -->
  <input name="email" value="foo@bar.de">
  <input type="submit">
</form>

The form's field values become the overlay's result value, encoded as an up.Params instance:

up.layer.open({
  url: '/form',
  onAccepted: ({ value }) => {
    console.log(value.get('email')) // result: "foo@bar.de"
  }
})

See Closing when a form is submitted.

Detecting the origin layer

The server can now detect if an interaction (e.g. clicking a link or submitting a form) originated from an overlay, by using the X-Up-Origin-Mode request header. This is opposed to the targeted layer, which is still sent as an X-Up-Mode header.

For example, we have the following link in a modal overlay. The link targets the root layer:

<!-- label: Link within an overlay -->
<a href="/" up-follow up-layer="root">Click me</a> <!-- mark: up-layer -->

When the link is clicked, the following request headers are sent:

X-Up-Mode: root
X-Up-Origin-Mode: modal

Other layer changes

  • Fix a bug where overlays allowed scrolling of a background layer.

Script security

This version revises mechanisms to prevent cross-site scripting and handle strict content security policies.

Scripts in fragments are no longer executed

⚠️ Unpoly no longer executes <script> elements in new fragments.
This default can by changed by configuring up.fragment.config.runScripts.

Unfortunately our the default for this setting has changed a few times now. It took us a while to find the right balance between secure defaults and compatibility with legacy apps. We have finally decided to err on the side of caution here.

See Migrating legacy JavaScripts for techniques to remove inline <script> elements.

Mandatory nonces for script-dynamic CSP

A CSP with strict-dynamic allows any allowed script to load additional scripts. Because Unpoly is already an allowed script, this would allow any Unpoly-rendered script to execute.

To prevent this, Unpoly requires matching CSP nonces in any response with a strict-dynamic CSP, even with runScripts = true.

If you cannot use nonces for some reasons, you can configure up.fragment.config.runScripts to a function that returns true for allowed scripts only:

up.fragment.config.runScripts = (script) => {
  return script.src.startsWith('https://myhost.com/')
}

See CSPs with strict-dynamic for details.

Other CSP changes

  • Unpoly now uses CSP nonces from a default-src directive if no script-src directive is found in the policy.
  • ⚠️ Unpoly now ignores CSP nonces from the script-src-elem directive. Since nonces are used to allow attribute callbacks, using script-src-elem is not appropriate.
  • Fix a bug where <script> elements in new fragments would lose their [nonce] attribute. That attribute is now rewritten to the current page's nonce if it matches a nonce from the response that inserted the fragment.
  • When up:assets:changed listeners inspect event.newAssets, any asset nonces are now already rewritten to the current page's nonce if they a nonce from the response that caused the event.

Reworked documentation

Parameters are organized into sections

It was sometimes hard to find documentation for a given parameter (or attribute) for features with many options. To address this, options have now been organized in sections like Request or Animation:

Parameters organized into sections

Inherited parameters are documented

You may discover that functions and attributes have a lot more documented options now.

This is because most features end up calling up.render(), inheriting most available render options in the process. We used to document this with a note like "Other up.render() options may also be used", which was often overlooked.

Now most inherited options are now explicitly documented with the inheriting feature.

New guides

A number of guides have been added or overhauled:

When there is a guide with more context, the documentation for attributes or functions now show a link to that guide:

Link to guide with more context

Caching

  • When a POST request redirects to a GET route, that final GET request is now cached.
  • up.reload() can now restore a fragment to a previously cached state using an { cache: true } option. This was possible before, but was never documented.
  • ⚠️ Any [up-expire-cache] and [up-evict-cache] attributes are now executed before the request is sent. In previous version, the cache was only changed after a response was loaded. This change allows the combined use of [up-evict-cache] and [up-cache] to clear and re-populate the cache with a single render pass.
  • ⚠️ The server can no longer prevent expiration with an X-Up-Expire-Cache: false response header.
  • Requests now clear out their { bindLayer } property after loading, allowing layer objects to be garbage-collected while the request is cached.
  • Links with both [up-hungry] and [up-preload] no longer throw an error after rendering cached, but expired content.

Navigational containers can now match the current location of other layers by setting an [up-layer] attribute. The .up-current class will be set when the matching layer is already at the link's [href].

For example, this navigation bar in an overlay will highlight links whose URL matches the location of any layer:

<!-- label: Navigation bar in an overlay -->
<nav up-layer="any"> <!-- mark: any -->
  <a href="/users" up-layer="root">Users</a>
  <a href="/posts" up-layer="root">Posts</a>
  <a href="/sitemap" up-layer="current">Full sitemap</a>
</nav>

See Matching the location of other layers.

Preserving elements

The [up-keep] element now gives you more control over how long an element is kept.

Also see our new guide Preserving elements.

Keeping an element until its HTML changes

To preserve an element as long as its outer HTML remains the same, set an [up-keep="same-html"] attribute. Only when the element's attributes or children changes between versions, it is replaced by the new version.

The example below uses a JavaScript-based <select> replacement like Tom Select. Because initialization is expensive, we want to preserve the element as long is possible. We do want to update it when the server renders a different value, different options, or a validation error. We can achieve this by setting [up-keep="same-html"] on a container that contains the select and eventual error messages:

<fieldset id="department-group" up-keep="same-html"> <!-- mark: same-html -->
  <label for="department">Department</label>
  <select id="department" name="department" value="IT">
    <option>IT</option>
    <option>Sales</option>
    <option>Production</option>
    <option>Accounting</option>
  </select>
  <!-- Eventual errors go here -->
</fieldset>

Unpoly will compare the element's initial HTML as it is rendered by the server.
Client-side changes to the element (e.g. by a compiler) are ignored.

Keeping an element until its data changes

To preserve an element as long as its data remains the same, set an [up-keep="same-data"] attribute. Only when the element's [up-data] attribute changes between versions, it is replaced by the new version. Changes in other attributes or its children are ignored.

The example below uses a compiler to render an interactive map into elements with a .map class. The initial map location is passed as an [up-data] attribute. Because we don't want to lose client-side state (like pan or zoom ettings), we want to keep the map widget as long as possible. Only when the map's initial location changes, we want to re-render the map centered around the new location. We can achieve this by setting an [up-keep="same-data"] attribute on the map container:

<div class="map" up-data="{ location: 'Hofbräuhaus Munich' }" up-keep="same-data"></div> <!-- mark: same-data -->

Instead of [up-data] we can also use HTML5 [data-*] attributes:

<div class="map" data-location="Hofbräuhaus Munich" up-keep="same-data"></div> <!-- mark: data-location -->

Unpoly will compare the element's initial data as it is rendered by the server.
Client-side changes to the data object (e.g. by a compiler) are ignored.

Custom keep conditions

We're providing [up-keep="same-html"] and [up-keep="same-data"] as shortcuts for common keep constraints.

You can still implement arbitrary keep conditions by listening to the up:fragment:keep event or setting an [up-on-keep] attribute.

Form data handling

  • ⚠️ Submitting or validating a form with a { params } option now overrides existing params with the same name. Formerly, a new param with the same name was added. This made it impossible to override array fields (like name[]).
  • You can now configure which params are treated as an array with multiple values, by setting up.form.config.arrayParam. By default, only field names ending in "[]" are treated as arrays. (by @apollo13)
  • Calling up.network.loadPage() will now remove binary values (from file inputs) from a given { params } option. JavaScript cannot make a full page load with binary params.
  • Fix the method up.Params#getAll() not returning the correct results or causing a stack overflow.

Focus ring visibility

You can now override focus ring visibility for individual links or forms, by setting an [up-focus-visible] attribute or by passing a { focusVisible } render option.

For global visibility rules, use the existing up.viewport.config.autoFocusVisible configuration.

Scrolling to the top or bottom

This release adds a new scroll option [up-scroll='bottom']. This scrolls viewports around the targeted fragment to the bottom.

⚠️ For symmetry, the option [up-scroll='reset'] was changed to [up-scroll='top'].

Long-pressing an [up-instant] link (to open the context menu) will no longer follow the link on iOS (issue #271).

Also long-pressing an instant link will no longer emit an up:click event.

Utility functions

  • Fixed an error with relaxed JSON parsing when the input string contains a section reference (like "§1") (#752).
  • ⚠️ The up.util.task() implementation now uses postMessage() instead of setTimeout(). This causes the new task to be scheduled earlier, ideally before the browser renders the next frame. The task is still guaranteed to run after all microtasks, such as settled promise callbacks.
  • ⚠️ The experimental function up.util.pickBy() no longer passes the entire object as a third argument to the callback function.
  • up.element.subtree() now prevents redundant array allocations.

Accessibility

Unpoly now prevents interactions with elements that are being destroyed (and playing out their exit animation).
To achieve this, destroying elements are marked as [inert].

Polling

An [up-poll] fragment now stops polling if an external script detaches the element.

Animation

Fixed a bug where prepending or appending an insertion could not be animated using an { animate } option or [up-animate] option. In earlier versions, Unpoly wrongly expected animations in a { transition } option or [up-transition] attribute.

JavaScript rendering API

Network requests

Listeners to up:request:load can now inspect or mutate request options before it is sent:

up.on('up:request:load', (event) => {
  if (event.request.url === '/stocks') {
    event.request.headers['X-Client-Time'] = Date.now().toString()
    event.request.timeout = 10_000
  }
})

This was possible before, but was never documented.

Developer experience

We modernized the codebase so that contributing to Unpoly is now simpler:

  • You can now run headless tests (without a browser window) by running npm run test.
  • CI: Tests now run automatically for every pull request.
  • All remaining CoffeeScript has been hosed off the test suite.
  • All tests now use async / await instead of our legacy asyncSpec() helper.
  • The release process was migrated from Ruby to Node.js.
  • The CI setup now automatically runs tests against various integration styles, such as using an ES6 build, the migration polyfills or various CSP settings.

Stabilization of experimental features

Many experimental features have now been declared as stable:

Migration polyfills

  • unpoly-migrate now allows to disable all deprecation warnings with up.migrate.config.logLevel = 'none'. This allows to keep polyfills installed without noise in the console.
  • Fix a bug in unpoly-migrate.js where a { style } string passed to up.element.affix() or up.element.createFromSelector() would sometimes be transformed incorrectly.
  • Added polyfills for most breaking changes in this 3.11.0 release.

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.