The main concern of Unpoly 3 is to fix all the concurrency issues arising from real-world production use:
Unpoly 3 also ships numerous quality of life improvements based on community feedback:
up.hello()
[up-hungry]
In addition to this CHANGELOG, there is also a slide deck explaining the most relevant changes in more detail.
Finally we have reworked Unpoly's documentation in our ongoing efforts to evolve it an API reference to a long-form guide.
unpoly-migrate.js
, which automatically logs instructions for migrating affected code.unpoly-migrate.js
keeps polyfills for deprecated APIs going back to 2016.
You may upgrade from v1 to v3 without going through v2 first.When a user clicks faster than a server can respond, multiple concurrent requests may be targeting the fragments. Over the years Unpoly has attempted different strategies to deal with this:
That said, Unpoly 3 makes the following changes to the way conflicting fragment updates are handled:
{ solo }
was replaced with a new option { abort }
.[up-solo]
was replaced with a new attribute [up-abort]
.A new default render option is { abort: 'target' }
. This aborts earlier requests
targeting fragments within your targeted fragments.
For instance, the following would abort all requests targeting .region
(or a descendant of .region
) when the link is clicked:
<a href="/path" up-target=".region">
⚠️ If your Unpoly 2 app uses a lot of { solo: false }
options or [up-solo=false]
attributes, these may no longer be necessary now that Unpoly 3 is more selective about what it aborts.
up.fragment.abort(selector)
.{ abort }
option.up:fragment:aborted
event.up.fragment.onAborted()
is also provided.[up-abortable=false]
attribute on the updating link, or by passing an { abortable: false }
render option.Imperative preloading with up.link.preload()
is no longer abortable by default.
This makes it easy to eagerly preload links like this:
up.compiler('a[rel=next]', up.link.preload)
You can make preload requests abortable again by passing an { abortable: true }
option.
up.request({ solo })
was removed. To abort existing requests, use up.fragment.abort()
or up.network.abort()
.Target selectors can now mark optional fragments as :maybe
.
For example, the following link will update the fragments .content
(required) and .details
(optional):
<a href="/cards/5" up-target=".content, .details:maybe">...</a>
Unpoly often needs to derive a target selector from an element, e.g. for [up-hungry]
, [up-poll]
or up.reload(element)
. Unpoly 2 would sometimes guess the wrong target, causing the wrong fragment to be updated. This is why target derivation has been reworked to be more strict in Unpoly 3:
⚠️ A longer, but stricter list of possible patterns is used to derive a target selector.
The following patterns are configured by default:
up.fragment.config.targetDerivers = [
'[up-id]', // [up-id="foo"]
'[id]', // #foo
'html', // html
'head', // head
'body', // body
'main', // main
'[up-main]', // [up-main="root"]
'link[rel]', // link[rel="canonical"]
'meta[property]', // meta[property="og:image"]
'*[name]', // input[name="email"]
'form[action]', // form[action="/users"]
'a[href]', // a[href="/users/"]
'[class]', // .foo (filtered by up.fragment.config.badTargetClasses)
]
Note that an element's tag name is no longer considered a useful target selector, except for unique elements like <body>
or <main>
.
⚠️ Before a target selector is used, Unpoly 3 will verify whether it would actually match the targeted element.
If it matches another element, another target derivation pattern is attempted. If no pattern matches, an error up.CannotTarget
is thrown.
If you see an up.CannotTarget
error while upgrading to Unpoly 3, this probably indicates a bug in your app concerning elements with ambiguous selectors. You should fix it by giving those elements a unique [id]
attribute.
Verification of derived targets may be disabled with up.fragment.config.verifyDerivedTarget = false
.
[up-poll]
will only work on elements for which we can derive a good target selector.[up-hungry]
will only work on elements for which we can derive a good target selector.up.fragment.isTargetable()
. It returns whether we can derive a good target selector for the given element.up.fragment.toTarget()
is called with a string, the string is now returned unchanged.[up-keep]
now preserves the playback state of started <audio>
or <video>
elements.[up-keep]
within [up-hungry]
elements (reported by @foobear).up:fragment:kept
was removed. There is still up:fragment:keep
.{ keep }
was renamed to { useKeep }
. An [up-use-keep]
attribute for links and forms was added.[up-keep]
to a selector for matching new content is no longer supportedUnpoly 3 expands your options to hook into specific stages of the rendering process in order to change the result or handle error cases:
Rendering functions now accept a wide range of callback functions. Using a callback you may intervene at many points in the rendering lifecycle:
up.render({
url: '/path',
onLoaded(event) { /* Content was loaded from cache or server */ },
focus(fragment, opts) { /* Set focus */ },
scroll(fragment, opts) { /* Set scroll positions */ },
onRendered(result) { /* Fragment was updated */ },
onFailRendered(result) { /* Fragment was updated from failed response */ },
onRevalidated(result) { /* Stale content was re-rendered */ },
onFinished(result) { /* All finished, including animation and revalidation */ }
onOffline(event) { /* Disconnection or timeout */ },
onError(error) { /* Any error */ }
})
[up-on-rendered]
or [up-on-error]
.await up.render().finished
. The existing callback { onFinished }
remains available.up:fragment:loaded
event has new properties { revalidating, expiredRequest }
. This is useful to handle revalidation requests.up:fragment:loaded
gets a new event.skip()
which finishes the render pass without changes. Programmatic callers are fulfilled with an empty up.RenderResult
.
This is in contrast to event.preventDefault()
m which aborts the render pass and rejects programmatic callers with an up.AbortError
.up.fragment.config.skipResponse
to configure global rules for responses that should be skipped. By default Unpoly skips:
Responses without text in their body.
Such responses occur when a conditional request in answered with HTTP status 304 Not Modified
or 204 No Content
.
When revalidating, if the expired response and fresh response have the exact same text.
onFail
, e.g. { failOnFinished }
becomes { onFailFinished }
.:origin
. The previous shorthand &
has been deprecated.{ origin }
to resolve ambiguous selectors, that origin is now also rediscovered in the server response. If the origin could be rediscovered, Unpoly prefers matching new content closest to that.up.RenderResult#fragment
which returns the first updated fragment.up.RenderResult#fragments
now only contains newly rendered fragments. It will no longer contain:
{ content }
).up.fragment.matches()
. It returns whether the given element matches the given CSS selector or other element.up.fragment.closest()
is now stable.up.render({ content })
and up.render({ fragment })
.::before
and ::after
pseudos (double colon) in addition to :before
and :after
.<script>
(fixes #462)Unpoly 3 makes it easier to work with element data:
[data-*]
attributes (in addition to [up-data]
).The data argument passed to a compiler is merged from both [data-*]
and [up-data]
attributes. These three elements produce the same compiler data:
<div up-data='{ "foo": "one", "bar": "two" }'></div>
<div data-foo='one' data-bar='two'></div>
<div up-data='{ "foo": "one" }' data-bar='bar'></div>
{ data }
option:
up.render({ data })
up.reload({ data })
up.validate({ data })
{ keepData }
option:
up.reload({ keepData })
up.validate({ keepData })
[up-poll]
gets a new attribute [up-keep-data]
up.hello()
is now idempotent.
You can call up.hello()
on the same element tree multiple times without the fear of side effects.
Unpoly guarantees that each compiler only ever runs once for a matching elements.
You can now register compilers after content was rendered.
New compilers registered after booting automatically run on current elements. This makes it easier to split your compilers into multiple files that are then loaded as-needed.
Note that compilers with a { priority }
will only be called for new content, but not for existing content.
Compilers now accept an optional third argument with information about the current render pass:
up.compiler('.user', function(element, data, meta) {
console.log(meta.response.text.length) // => 160232
console.log(meta.response.header('X-Course')) // => "advanced-ruby"
console.log(meta.layer.mode) // => "root"
console.log(meta.revalidating) // => true
})
⚠️ Unpoly now executes <script>
tags in new fragments.
You may disable this behavior with up.fragment.config.runScripts = false
(this was the default in Unpoly 2).
Note if you include your application bundle in your <body>
it may now be executed multiple times if you're swapping the <body>
element with Unpoly. We recommend moving your <script>
tags into the head with <script defer>
.
up.render()
or up.submit()
now reject with an error.<head>
were not compiled during the initial page load.The overlays of Unpoly 2 would sometimes clash with overlays from a third party library ("foreign overlay"). E.g. clicking a foreign overlay would closes an Unpoly overlay, or Unpoly would steal focus from a foreign overlay.
Unpoly 3 lets you configure selectors matching foreign overlays using up.layer.config.foreignOverlaySelectors
. Within a foreign overlay Unpoly will no longer have opinions regarding layers or focus.
up.fragment.get(selector, { layer: 0 })
) would always match in the current layer instead of the root layer.up:layer:location:changed
now has a property { layer }
. It returns the layer that had its location changed.You may now update [up-hungry]
elements for updates of any layer by setting an [up-if-layer=any]
attribute.
A use case for this are notification flashes that are always rendered within the application layout on the root layer.
You may now restrict updating of [up-hungry]
elements for updates that change history by setting an [up-if-history]
attribute.
A use case is a <link rel="canonical">
element that is related to the current history entry.
{ hungry }
was renamed to { useHungry }
. An [up-use-hungry]
attribute for links and forms was added.[up-keep]
within [up-hungry]
elements (reported by @foobear).up.radio.config.stretchPollInterval
.[up-poll]
now prints fatal errors to the log.[up-poll]
now logs a message when it skips polling, e.g. when the tab is hidden or a fragment is on a background layer.[up-poll]
gets new attribute [up-keep-data]
to preserve the data of the polling fragmentTargeted fragments are now marked with an .up-loading
class while a request is loading.
By styling elements with this class you can highlight the part of the screen that's loading.
Note that .up-loading
is added in addition to the existing .up-active
class, which is assigned to the link, form or field that triggered a request.
up.emit()
now only prints user events when the user has enabled logging.up:link:follow
or up:form:submit
has prevented a render pass.Unpoly now emits an event up:location:restore
when the user is restoring a previous history entry, usually by pressing the back button.
Listeners may prevent up:location:restore
and substitute their own restoration behavior.
up:location:changed
event's { url }
property to { location }
.Smooth scrolling with { behavior: 'smooth' }
now uses the browser's native smooth scrolling implementation.
This gives us much better performance, at the expense of no longer being able to control the scroll speed, or the detect the end of the scrolling motion.
up.viewport.config.scrollSpeed
without replacement.{ scrollSpeed }
without replacement.up.reveal()
no longer returns a promise for the end of a smooth scrolling animation.up.viewport.restoreScroll()
no longer returns a promise for the end of a smooth scrolling animation. Instead if returns a boolean value indicating whether scroll positions could be restored{ behavior: 'instant' }
instead of { behavior: 'auto' }
.You may now attempt multiple scrolling strategies in an [up-scroll]
attribute.
The strategies can be separated by an or
e.g. [up-scroll="hash or :main"]
. Unpoly will use the first applicable strategy.
You may now pass alternate strategies when scroll position could not be restored.
E.g. { scroll: ['restore', 'main' ] }
or [up-scroll="restore or main"]
{ scroll }
option now is still processed.You may now attempt multiple focus strategies in an [up-focus]
attribute.
The strategies can be separated by an or
e.g. [up-focus="hash or :main"]
. Unpoly will use the first applicable strategy.
up.viewport.saveFocus()
.up.render({ saveFocus: false })
up.viewport.restoreFocus()
.up.render({ focus: false })
.[up-instant]
attribute are now focused when being followed on mousedown
. This is to mimic the behavior of standard links.{ focus }
option now is still processed.<label for="id">
elements now always focus a matching field in the same layer, even when fields with the same IDs exist in other layers.Forms can now be disabled while they are submitting. To do so set an [up-disable]
attribute to the <form>
element or pass a { disable }
option to a render function.
By default all fields and buttons in that forms are disabled.
To only disable submit buttons, pass a selector like [up-disable="button"]
.
To only disable some fields or buttons, pass a selector that matches these fields or their container, e.g. [up-disable=".money-fields"]
).
Fields being observed with [up-validate]
and [up-watch]
may also disable form elements using an [up-watch-disable]
attribute:
<select up-validate=".employees" up-watch-disable=".employees">
Sometimes we don't want to disable forms while working, either because of optics (gray fields) or to not prevent user input.
Unpoly 3 has a second solution for forms with many [up-validate]
dependencies that does not require disabling:
⚠️ Multiple elements targeted by [up-validate]
are now batched into a single render pass with multiple targets. Duplicate or nested target elements are consolidated.
This behavior cannot be disabled.
See Dependent fields for a full example.
Various changes make it easier to watch fields for changes:
up.observe()
has been renamed to up.watch()
.[up-observe]
has been renamed to [up-watch]
.[up-watch-event]
attribute or by passing a { watch }
option.[up-watch-feedback]
attribute or by passing a { feedback }
option.[up-watch-delay]
attribute or by passing a { delay }
option.[up-watch-deisable]
attribute or by passing a { disable }
option.change
and input
. You may now use up.form.config.watchInputEvents
and up.form.config.watchCangeEvents
to normalize field events so they become observable as change
or input
.<input type="date">
) are now (by default) validated on blur
rather than on change
(fixes #336).up.form.config.observeDelay
has been renamed to up.form.config.watchInputDelay
.this
in an [up-watch]
callback is now always bound to the element that has the attribute (fixes #442).up.watch()
function (formerly up.observe()
) no longer accepts an array of elements. It only accepts a single field, or an element containing multiple fields.up.validate()
function now rejects when the server responds with an error code.up:form:validate
event has a new property { params }
. Listeners may inspect or mutate params before they are sent.up.form.submitButtons(form)
that returns a list of submit buttons in the given form.up.form.group(input)
that returns the form group (tuples of label, input, error, hint) for the given input element.[up-fieldset]
with [up-form-group]
.up.form.config.validateTargets
with up.form.config.groupSelectors
. Configured selectors must no longer contain a :has(:origin)
suffix, as this is now added automatically when required.{ params }
option now also accept a FormData
value.[up-show-for]
and [up-hide-for]
attributes now accept values with spaces. Such values must be encoded as a JSON array, e.g. <element up-show-for='["John Doe", "Jane Doe"]'>
. Fixes #78.{ origin }
is now the element that triggered the submission:
:origin
is now the submit button (instead of the <form>
element)Enter
within a focused field sets that field as the { origin }
, the :origin
is now the focused field (instead of the <form>
element):origin
is now the field that was changed (instead of the <form>
element)form.up-active, form:has(.up-active) { ... }
.up.watch()
(formerly up.observe()
) crashing when passed an input outside a form.In Unpoly 3, cache entries are only considered fresh for 15 seconds. When rendering older cache content, Unpoly automatically reloads the fragment to ensure that the user never sees expired content. This process is called cache revalidation.
When re-visiting pages, Unpoly now often renders twice:
This caching technique allows for longer cache times (90 minutes by default) while ensuring that users always see the latest content.
Servers may observe conditional request headers to skip the second render pass if the underlying data has not changed, making revalidation requests very inexpensive.
That said, the following changes were made:
⚠️ After rendering stale content from the cache, Unpoly now automatically renders a second time with fresh content from the server (revalidation).
To disable cache revalidation, set up.fragment.config.navigateOptions.revalidate = false
.
The configuration up.network.config.clearCache
has been renamed to up.network.config.expireCache
.
It can be used to configure which requests should expire existing cache entries.
By default Unpoly will expire the entire cache after a request with an unsafe HTTP method.
up.network.config.cacheExpiry
has been renamed to up.network.config.cacheExpireAge
.The default for up.network.config.expireCacheAge
is now 15 seconds (down from 5 minutes in Unpoly 2).
⚠️ If you have previously configured a custom value for up.network.config.clearCache
(now .expireCache
) to
prevent the display of stale content, check if that configuration is still needed with revalidation.
X-Up-Clear-Cache
has been renamed to X-Up-Expire-Cache
.X-Up-Clear-Clache: false
has been removed.up.cache.clear(pattern)
has been renamed to up.cache.expire(pattern)
.[up-clear-cache]
has been renamed to [up-expire-cache]
.up.render({ clearCache })
has been renamed to { expireCache }
.up.request({ clearCache })
has been renamed to { expireCache }
.up.network.config.expireCache
lets you define which requests evict existing cache entries.⚠️ By default Unpoly will expire, but not evict any cache entries when a request is made.
To restore Unpoly 2's behavior of evicting the entire cache after a request with an unsafe HTTP method, configure the following:
up.network.config.evictCache = (request) => !request.isSafe()
up.network.config.cacheEvictAge
(default is 90 minutes).X-Up-Evict-Cache
.up.cache.evict(pattern)
.up.network
Cache revalidation happens after up.render()
settles.
To run code once all render passes have finished, pass an { onFinished }
callback or await up.render(..).finished
.
Cache revalidation can be controlled through a render option { revalidate }
or
a link attribute [up-revalidate]
.
The default option value is { revalidate: 'auto' }
, which revalidates if up.fragment.config.autoRevalidate(response)
returns true
. By default this configuration returns true
if a response is older than up.network.config.expireAge
.
Unpoly now supports conditional requests. This allows your server to skip rendering and send an empty response if the underlying data has not changed.
Common use cases for conditional requests are polling or cache revalidation.
Unpoly now remembers the standard Last-Modified
and E-Tag
headers a fragment was delivered with.
Header values are set as [up-time]
and [up-etag]
attributes on updated fragment. Users can also set these attributes manually in their views, to use different ETags for individually reloadable fragments.
If-Modified-Since
or If-None-Match
request headers.304 Not Modified
or status 204 No Content
.X-Up-Reload-From-Time
was deprecated in favor of the standard If-Modified-Since
.Unpoly lets you handle many types of connection problems. The objective is to keep your application accessible as the user's connection becomes slow, flaky or goes away entirely.
Unpoly 3 lets you handle connection loss with an { onOffline }
or [up-on-offline]
callback:
<a href="..." up-on-offline="if (confirm('You are offline. Retry?')) event.retry()">Post bid</a>
You may also configure a global handler by listening to up:request:offline
(renamed from up:request:fatal
):
:
up.on('up:fragment:offline', function(event) {
if (confirm('You are offline. Retry?')) event.retry()
})
You may also do something other than retrying, like substituting content:
up.on('up:fragment:offline', function(event) {
up.render(event.renderOptions.target, { content: "You are offline." })
})
Often our device reports a connection, but we're effectively offline:
Unpoly 3 handles Lie-Fi with timeouts:
up.network.config.timeout
).onOffline()
and use your offline handling.{ timeout }
option or setting an [up-timeout]
attribute.With Unpoly 3, apps remain partially accessible when the user loses their connection:
onOffline()
.onOffline()
.While Unpoly 3 lets you handle disconnects, it's not full "offline" support:
For a comprehensive offline experience (cold start) we recommend a service worker or a canned solution like UpUp (no relation to Unpoly).
You may now demote requests to the background by using { background: true }
or [up-background]
when rendering or making a request
Background requests are de-prioritized when the network connection is saturated.
Background requests don't trigger up:network:late
or show the progress bar.
You may now set a custom response times over which a request is considered late by using { badResponseTime }
or [up-bad-response-time]
when rendering or making a request
This allows you to delay the up:network:late
event or show the progress bar later or earlier.
The default up.network.config.badResponseTime
can now also be a Function(up.Request): number
instead of a constant number value.
Unpoly has always allowed server-side code to inspect request headers to customize or shorten responses, e.g. by omitting content that isn't targeted. Unpoly makes some changes how optimized responses are cached:
X-Up-Target
) now share the same cache entry.⚠️ If a server optimizes its response, all request headers that influenced the response should be listed in a Vary
response header.
A Vary
header tells Unpoly to partition its cache for that URL so that each request header value gets a separate cache entries.
You can set a Vary
header manually from your server-side code. You may also be using a library like unpoly-rails that sets the Vary
header automatically.
Vary
headers also prevents browsers from using an optimized response for full page loads.up.network.config.requestMetaKeys
has been removed.X-Up-Title
is now a JSON-encoded string, surrounded by JSON quotes.Unpoly requires servers to send an HTTP error code to signal failure. E.g. an invalid form should render with HTTP 422 (Unprocessable Entity).
However, Misconfigured server endpoints may send HTTP 200 (OK) for everything. This is not always easy to fix, e.g. when screens are rendered by libraries outside your control. Unpoly 3 addresses this with the following changes:
up:fragment:loaded
can now can force failure by setting event.renderOptions.fail = true
.up.network.config.fail
to configure a global rule for when a response is considered to have failed.FormData
values to all functions that also accept an up.Params
object.up.network.config.concurrency
now defaults to 6 (3 while reducing requests)⚠️ When calling up.request()
manually, the request is now only associated with the current layer if either { origin, layer, target }
option was passed.
If neither of these options are given, the request will send no headers like X-Up-Target
or X-Up-Mode
.
Also, since the request is no longer associated with the layer, it will not be aborted if the layer closes.
up.network.isIdle()
function has been deprecated. Use !up.network.isBusy()
instead.up:request:late
and up:request:recover
were renamed to up:network:late
and up:network:recover
respectively. We may eventually re-introduce up:request:late
and up:request:recover
to support tracking individual requests (but not now).up.Request#header()
to access a given header.up.Response#getHeader()
was renamed to up.Response#header()
. It is now stable.up.Response.prototype.request
is now internal API and should no longer be used.up.network.config.cacheSize = 0
is no longer supported. To disable automatic caching during navigation, set up.fragment.config.navigateOptions.cache = false
instead.up.util.map()
now accepts any iterable object.up.util.each()
now accepts any iterable object.up.util.filter()
now accepts any iterable object.up.util.every()
now accepts any iterable object.up.util.findResult()
now accepts any iterable object.up.util.flatMap()
now accepts any iterable object.up.util.assign()
. Use Object.assign()
instead.up.util.values()
. Use Object.values()
instead.up.element.remove()
. Use Element#remove()
instead.up.element.matches()
. Use Element#matches()
instead.up.element.closest()
. Use Element#closest()
instead.up.element.replace()
. Use Element#replaceWith()
instead.up.element.all()
. Use document.querySelectorAll()
or Element#querySelectorAll()
instead.up.element.toggleClass()
. Use Element#classList.toggle()
instead.up.element.isDetached()
. Use !Element#isConnected
instead.⚠️ up.element.booleanAttr()
now returns true
for a attribute value that is present but non-boolean.
For example, the attribut value [up-instant='up-instant']
is now considered true
.
Previously it returned undefined
.
up:motion:finish
.In our ongoing efforts to evolve Unpoly's documentation from an API reference to a guide, we have added several documentation pages:
All existing documentation pages from Unpoly 2 remain available:
unpoly-migrate.js
migrates a renamed attribute, the old attribute is now removed.unpoly-migrate.js
would not rewrite the deprecated { reveal }
option when navigating.⚠️ The legacy option up.render({ data })
and up.request({ data })
is no longer renamed to { params }
(renamed in Unpoly 0.57).
Unpoly now uses the { data }
option to preserve element data through reloads.
up:framework:booted
is emitted when the framework has booted and the initial page has been compiled.All errors thrown by Unpoly now inherit from up.Error
.
This makes it easier to detect the type of exception in a catch()
clause:
```js try { await up.render('main', { url: '/foo' }) } catch (exception) { if (exception instanceof up.Error) { // handle Unpoly exception } else { // re-throw unhandled exception throw exception } }
⚠️ Unpoly 3 drops support for Internet Explorer 11 and legacy Edge (EdgeHTML).
Unlike other breaking changes, support cannot be restored through unpoly-migrate.js
. If you need to support IE11, use Unpoly 2.
The new compatibility targets for Unpoly 3 are major evergreen browsers (Chrome, Firefox, Edge) as well as last two major versions of Safari / Mobile Safari.
⚠️ Unpoly no longer ships with an version transpiled down to ES5 (unpoly.es5.js
). Instead there is now a ES6 version (unpoly.es6.js
).
Since most modern browsers now have great JavaScript support, we encourage you to try out the untranspiled distribution (unpoly.js
), which has the smallest file size.
jQuery helper functions have been moved to unpoly-migrate.js
:
up.$compiler()
was deprecated.up.$macro()
was deprecated.up.$on()
was deprecated.2.x-stable
branch.
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.