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.
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.
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:
#hash
. Going back to such an entry will now reveal a matching fragment, scrolling far enough to ignore any obstructing elements in the layout.#hash
in the browser's address bar (without also changing the path or search query).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:
{ willHandle }
shows if Unpoly thinks it is responsible for restoring the new location state.{ alreadyHandled }
shows if Unpoly thinks the change has already been handled (e.g. after calls to history.pushState()
).#hash
linksUnpoly 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:
scroll-behavior: smooth
style.[up-scroll-behavior]
attribute. Valid values are instant
, smooth
and auto
(uses CSS behavior).click
event.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:
history.pushState()
or up.history.push()
.history.replaceState()
or up.history.replace()
.#hash
in the browser's address bar.#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.
popstate
event with the logging output from a subsequent history restoration.up.history.replace()
to change the URL of the current history state.up:layer:location:changed
event now has a { previousLocation }
property.<!DOCTYPE>
or <html>
tag (fixes #726)When watching fields using [up-watch]
, [up-autosubmit]
, [up-switch]
or [up-validate]
, the following cases are now addressed:
[up-keep]
is transported to a new <form>
element by a fragment update.[form]
attribute) is added or removed dynamically.[up-watch-delay]
was detached by an external script during the delay, watchers will no longer fire callbacks or send requests.[name]
will now throw an error explaining that this attribute is required. In earlier versions callbacks were simply never called.The [up-switch]
attribute has been reworked to be more powerful and flexible.
Also see our new guide Switching form state.
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.
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' : ''
})
The [up-switch]
attribute itself has been reworked with new modifiying attributes:
[up-switch-region]
attribute allows to expand or narrow the region where elements are switched.[up-switch]
can now react to other events, by setting an [up-watch-event]
attribute.[up-switch]
can now debounce their switching effects with an [up-watch-delay]
attribute.[up-switch]
changes[up-switch]
on a text field will now switch while the user is typing (as opposed to when the field is blurred).[up-switch]
now works on a container for a radio button group.[up-switch]
now works on a container of multiple checkboxes for a single array param, like category[]
.[up-switch]
on an individual radio button will now throw an error. Watch a container for the entire radio group instead.[up-switch]
now require a [name]
attribute.[up-switch]
when that element has neither [up-show-for]
nor [up-hide-for]
attributes. This was an undocumented side effect of the old implementation.The [up-validate]
attribute has been reworked.
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:
/path
targeting .foo, .baz
. The other validations are queued./path2
targeting .bar, .qux
.up.form.config.batchValidate = false
, or for individual forms or fields with an [up-validate-batch="false"]
attribute.[up-validate-params]
attribute.[up-validate-headers]
attribute.: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.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.
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.
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
This version revises mechanisms to prevent cross-site scripting and handle strict content security policies.
⚠️ 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.
script-dynamic
CSPA 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.
default-src
directive if no script-src
directive is found in the policy.script-src-elem
directive. Since nonces are used to allow attribute callbacks, using script-src-elem
is not appropriate.<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.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.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:
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.
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:
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.[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.X-Up-Expire-Cache: false
response header.{ bindLayer }
property after loading, allowing layer objects to be garbage-collected while the request is cached.[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.
The [up-keep]
element now gives you more control over how long an element is kept.
Also see our new guide Preserving elements.
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.
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.
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.
{ 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[]
).up.form.config.arrayParam
. By default, only field names ending in "[]"
are treated as arrays. (by @apollo13)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.up.Params#getAll()
not returning the correct results or causing a stack overflow.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.
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.
"§1"
) (#752).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.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.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]
.
An [up-poll]
fragment now stops polling if an external script detaches the element.
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.
up.RenderResult#ok
. It indicated whether the render pass has rendered a successful response.up.RenderResult#renderOptions
. It contains the effective render options used to produce this result.up.RenderJob#options
to up.RenderJob#renderOptions
up.hello()
is called on an element that has been compiled before, up:fragment:inserted
is no longer emitted a second time.up:fragment:inserted
is emitted after compilation.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.
We modernized the codebase so that contributing to Unpoly is now simpler:
npm run test
.async
/ await
instead of our legacy asyncSpec()
helper.Many experimental features have now been declared as stable:
up.deferred.load()
functionup:deferred:load
eventup.event.build()
functionup.form.fields()
functionup.fragment.config.skipResponse
configuration[up-etag]
attributeup.fragment.etag()
function[up-time]
attributeup.fragment.time()
functionevent.skip()
method for up:fragment:loaded
eventup:fragment:offline
eventup.fragment.subtree()
functionup.fragment.isTargetable()
function:layer
selectorup.fragment.matches()
functionup.fragment.abort()
functionup:fragment:aborted
eventup.template.clone()
functionup:template:clone
eventup.history.location
propertyup.history.previousLocation
propertyup.history.isLocation()
functionup:layer:location:changed
eventevent.response
property for up:layer:accept
, up:layer:dismiss
, up:layer:accepted
and up:layer:dismissed
events[up-defer]
attributeup.network.config.lateDelay
configuration{ lateDelay }
option for up.request()
up.network.loadPage()
functionup:request:aborted
eventup:fragment:hungry
event{ ifLayer }
option for up.radio.startPolling()
[up-if-layer]
modifier for [up-poll]
up:fragment:poll
eventup.script.config.scriptSelectors
and up.script.config.noScriptSelectors
configurationevent.preventDefault()
for up:assets:changed
eventup.util.noop()
functionup.util.normalizeURL()
functionup.util.isBlank.key
propertyup.util.wrapList()
functionup.util.copy.key
propertyup.util.findResult()
functionup.util.every()
functionup.util.evalOption()
functionup.util.pluckKey()
functionup.util.flatten()
functionup.util.flatMap()
functionup.util.isEqual()
functionup.util.isEqual.key
propertyup.focus()
functionup.viewport.get()
functionup.viewport.root
propertyup.viewport.saveScroll()
functionup.viewport.restoreScroll()
functionup.viewport.saveFocus()
functionup.viewport.restoreFocus()
functionnew up.Params
constructorup.Params#clear()
methodup.Params#toFormData()
methodup.Params#toQuery()
methodup.Params#add()
methodup.Params#addAll()
methodup.Params#set()
methodup.Params#delete()
methodup.Params#get()
methodup.Params.fromForm()
static methodup.Params.fromURL()
static methodup.RenderResult#none
propertyup.RenderResult#ok
propertyup.Request#layer
propertyup.Request#failLayer
propertyup.Request#origin
propertyup.Request#background
propertyup.Request#lateDelay
propertyup.Request#fragments
propertyup.Request#fragment
propertyup.Request#loadPage()
functionup.Request#abort()
functionup.Request#ended
propertyup.Response#contentType
propertyup.Response#lastModified
propertyup.Response#etag
propertyup.Response#age
propertyup.Response#expired
proprty{ response }
option for up.Layer#accept()
method and up.layer.accept()
funcitonup.Layer#asCurrent()
up.layer.location
and up.Layer#location
properties[up-abortable]
attribute for [up-follow]
links[up-late-delay]
attribute for [up-follow]
links{ lateDelay }
option for up.render()
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.unpoly-migrate.js
where a { style }
string passed to up.element.affix()
or up.element.createFromSelector()
would sometimes be transformed incorrectly.
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.