unpoly
Version:
Progressive enhancement for HTML
1,117 lines (688 loc) • 315 kB
Markdown
Changelog
=========
Changes to this project will be documented in this file.
If you're upgrading from an older Unpoly version, you should load [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading) to polyfill deprecated APIs. Changes handled by `unpoly-migrate.js` are not considered breaking changes.
You may browse a formatted and hyperlinked version of this file at <https://unpoly.com/changes>.
3.14.3
------
- [Revalidation of expired content](/caching#revalidation) will now preserve the scroll positions of any viewports contained in the revalidated area.
3.14.2
------
You can now use `:has()` selectors with Unpoly-specific suffixes like `:maybe`, [`:before`](/targeting-fragments#appending-or-prepending) and [`:after`](/targeting-fragments#appending-or-prepending).
For example, this target selector is now valid:
```css
.container:has(.child):maybe
```
3.14.1
------
- Source maps for minified are now included in the npm package.
3.14.0
------
This release delivers many requested features while filing off some long-standing sharp edges throughout the framework.
Breaking changes are marked with a ⚠️ emoji and polyfilled by [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading).
> [note]
> Our sponsor [makandra](https://makandra.de/en) funded this release ❤️\
> You can hire makandra for [Unpoly support](https://unpoly.com/support).
### Global scripting policies
This version introduces settings to globally control whether Unpoly will execute JavaScript on your page.
#### Script elements
The boolean `up.fragment.config.runScripts` has been replaced with a more flexible setting `up.script.config.scriptElementPolicy`. This lets you control whether to run scripts in new fragments:
```html
<div id="fragment">
<script>
<!-- chip: ❓ Will this script run? -->
</script>
</div>
```
⚠️ By default Unpoly will now run any `<script>` that passes your CSP checks, but requires a nonce for viral CSPs with `strict-dynamic`.
You can configure `up.script.config.scriptElementPolicy` to block all script elements, or to only allow [scripts with a valid `[nonce]` attribute](/script-security#script-element-nonces):
| `scriptElementPolicy` | Runs without CSP? | Runs with CSP? | Runs with `strict-dynamic` CSP? |
|-----------------------|--------------------|--------------------|---------------------------------|
| `auto` (default) | Always | If passes CSP | With allowed nonce |
| `pass` | Always | If passes CSP | 🔥 Always |
| `block` | Never | Never | Never |
| `nonce` | With allowed nonce | With allowed nonce | With allowed nonce |
See [Security for script elements](/script-security#script-elements).
#### Callbacks
Added a new setting `up.script.config.callbackPolicy`. This lets you control whether Unpoly will execute string callbacks in attributes or response headers:
```html
<a
href="/path"
up-follow
up-on-loaded="console.log('Will this callback run?')"> <!-- mark: up-on-loaded -->
Click link
</>
<form>
<input
type="text"
name="title"
up-watch="console.log('Will this callback run?')"> <!-- mark: up-watch -->
</form>
```
⚠️ By default Unpoly will parse and execute callbacks, but require a nonce once you set a `<meta name="csp-nonce">` in your `<head>`.
You can configure `up.script.config.callbackPolicy` to block all callbacks, or to only allow [callbacks with a valid nonce](/script-security#callback-nonces):
| `evalCallbackPolicy` | Runs without CSP? | Runs with CSP? | Runs with CSP and [`<meta name="csp-nonce">`](/script-security#meta-csp-nonce)? |
|----------------------|--------------------|-----------------------|----------------------------------------------------------------------------------|
| `auto` (default) | Always | 🔥 With `unsafe-eval` | With allowed nonce |
| `pass` | Always | 🔥 With `unsafe-eval` | 🔥 With `unsafe-eval` |
| `block` | Never | Never | Never |
| `nonce` | With allowed nonce | With allowed nonce | With allowed nonce |
See [Security for callbacks](/script-security#callbacks).
#### Warnings for dangerous settings
Unpoly will now log warnings for configurations or CSP headers it considers overly permissive (marked with 🔥 above).
```text
An 'unsafe-eval' CSP allows arbitrary [up-on...] callbacks. Consider setting up.script.config.callbackPolicy = 'nonce'.
```
You can disable these warnings with `up.script.config.cspWarnings = false`.
#### Other changes
⚠️ Renamed `up.protocol.config.cspNonce` to `up.script.config.cspNonce`.\
It still defaults to reading `<meta name="csp-nonce">`.
### Closing overlays when a fragment matches a selector
You can now auto-close an overlay once it reaches a fragment that matches a CSS selector. This is an alternative close condition similar to observing [events](/closing-overlays#event-condition) or [locations](/closing-overlays#location-condition).
To wait for a fragment, set an [`[up-accept-fragment]`](/up-layer-new#up-accept-fragment) attribute on the link that opens an overlay:
```html
<a href="/users/new"
up-layer="new"
up-accept-fragment=".user-profile"
up-on-accepted="alert('Hello user #' + value.id)">
Add a user
</a>
```
When an element in the new overlay matches the `.user-profile` selector, the overlay is closed automatically. The fragment's [data](/data) becomes the overlay's acceptance value:
```html
<div class="user-profile" data-id="123">
...
</div>
```
See [Closing when a fragment is detected](/closing-overlays#fragment-condition).
### Overlay peel intent
When a link or form from an overlay targets a background layer, the overlay will [dismiss](/closing-overlays#intents) when the parent layer is updated. This behavior is called *peeling*.
By default, peeled overlays will be [dismissed](/closing-overlays#intents). You can now choose to [accept](/closing-overlays#intents) them instead, by setting an `[up-peel="accept"]` attribute
on the link or form that is targeting a background layer:
```html
<form method="post" action="/users" up-layer="parent" up-peel="accept"> <!-- mark: up-peel="accept" -->
...
</form>
```
When rendering from JavaScript, pass an [`{ peel: 'accept' }`](/up.render#options.peel) option for the same effect.
### Setting overlay callbacks from the server
Servers can send an `X-Up-Open-Layer` response header to force its response to [open a new overlay](/opening-overlays).
Callbacks like `{ onAccepted }` or `{ onDismissed }` can now be passed as a string of JavaScript:
```http
Content-Type: text/html
X-Up-Open-Layer: { onAccepted: 'up.reload("#users-list")' }
```
With a strict CSP you can [prefix your callback with a nonce](/script-security#callback-nonces):
```http
Content-Type: text/html
X-Up-Open-Layer: { onAccepted: 'nonce-secret123 up.reload("#users-list")' }
```
### Source maps
The minified source files (like `unpoly.min.js`) are now shipped with source maps for easier debugging.
### Styling revalidating fragments
When rendering content from a stale [cache](/caching) entry, Unpoly [automatically reloads the fragment](/caching#revalidation) to ensure that the user never sees expired content.
Unpoly will now assign revalidating fragments the `.up-revalidating` class while the revalidation request is in flight:
```html
<div id="target" class="up-revalidating"> <!-- mark: class="up-revalidating" -->
Possibly stale content
</div>
```
You can style revalidating fragments to convey that content might be stale:
```css
.up-revalidating {
filter: grayscale(80%);
opacity: 0.5;
}
```
Note that the `.up-loading` and `.up-active` classes are *not* set during cache revalidation.
You can configure custom revalidation classes in `up.status.config.revalidatingClasses`.
### Setting URL aliases from macros
`[up-nav]` links can now set `[up-alias]` from a [macro](/up.macro).
This can be useful to link nested navigation trees programmatically.\
Let's say we have a main navigation linking to two sections:
```html
<nav class="main-nav">
<a href="/companies" data-section="companies"> <!-- mark: data-section="companies"-->
<a href="/users" data-section="users"> <!-- mark: data-section="users"-->
</nav>
```
We also have a sub-navigation for each section:
```html
<nav class="sub-nav" data-section="companies"> <!-- mark: data-section="companies"-->
<a href="/companies">All companies</a>
<a href="/companies/sync">Sync CRM</a>
<a href="/companies/export">Export</a>
</nav>
<nav class="sub-nav" data-section="users"> <!-- mark: data-section="users"-->
<a href="/users">All users</a>
<a href="/users/online">Now online</a>
<a href="/users/profile">Your profile</a>
</nav>
```
We want the main navigation section to be `.up-current` for any sub-section URL.
We can do that with a macro that finds the respective sub-navigation, and sets an `[up-alias]` attribute at the main navigation link:
```js
up.macro('.main-nav a', function(link, { section }) {
let subLinks = document.querySelectorAll(`.sub-nav[data-section="${section}"]`)
let subURLs = up.util.map(subLinks, 'href')
link.setAttribute('up-alias', subURLs.join())
})
```
### Setting data for multiple fragments
Links and forms can now use an [`[up-use-data-map]`](/up-follow#up-use-data) attribute or [`{ dataMap }`](/up.render#options.data) option to map selectors to data objects. When a selector matches any element within an updated fragment, the matching element is compiled with the mapped data:
```html
<a
href="/score"
up-target="#stats"
up-use-data-map="{ '#score': { startScore: 1500 }, '#message': { max: 3 } }"> <!-- mark: up-use-data-map="{ '#score': { startScore: 1500 }, '#message': { max: 3 } }" -->
Load score
</a>
<div id="stats">
<div id="score">
<!-- chip: Will compile with data { startScore: 1500 } -->
</div>
<div id="message">
<!-- chip: Will compile with data { max: 3 } -->
</div>
</div>
```
⚠️ When rendering multiple fragments, any `[up-use-data]` attribute or `{ data }` option will only apply to the first fragment.
To apply data to multiple fragments, use a data map as shown above.
### Keeping current scroll positions
Scroll positions will reset when you insert a new viewport element (as opposed to updating a child element). This is default browser behavior for newly inserted elements.
You can now ask Unpoly to preserve the scroll positions of all [viewports](/up.viewport) around the updated fragment. To do so, set `[up-scroll="keep"]`:
```html
<a href="/list" up-follow up-scroll="keep">Reload list</a> <!-- mark: up-scroll="keep" -->
```
Internally, Unpoly will measure scroll positions before the update, and restore the same positions after the update.
`up.reload()` now uses this feature to preserve scroll positions by default.
See [Keeping current scroll positions](/scrolling#keep).
### Scrolling multiple viewports
You can now scroll multiple viewports with a single render pass, by using an [`[up-scroll-map]`](/up-follow#up-scroll-map) attribute or `{ scrollMap }` option. Its value is an object mapping selectors to [scroll options](/scrolling):
```html
<a
href="/dashboard"
up-target="#viewport1, #viewport2"
up-scroll-map="{ '#viewport1': 'top', '#viewport2': 'bottom' }"
>
Update fragments
</a>
<div id="viewport1" up-viewport>
<!-- chip: ✔ Will be scrolled to the top -->
</div>
<div id="viewport2" up-viewport>
<!-- chip: ✔ Will be scrolled to the bottom -->
</div>
```
### Scrolling to a pixel position
To scroll a specific pixel position from the top, you can now use a number value for the `[up-scroll]` attribute or `{ scroll }` option:
```html
<a href="/list" up-follow up-scroll="35">Back to list</a> <!-- mark: up-scroll="35" -->
```
To scroll to the bottom, but leave a margin of some pixels, set a <i>negative</i> number value:
```html
<a href="/messages" up-follow up-scroll="-40">Latest messages</a> <!-- mark: up-scroll="-40" -->
```
See [Scrolling to a pixel position](/scrolling#pixel-position).
### Detecting success or failure from a compiler or event
[Compilers](/enhancing-elements) now receive a `meta.ok` argument. It indicates if the fragment is being rendered from a successful response (`200 OK`).
```js
up.compiler('#result', function(element, data, meta) { // mark: meta
if (meta.ok) { // mark: meta.ok
console.log("Rendering from successful response")
} else {
console.log("Rendering from failed response")
}
})
```
The `up:fragment:inserted` event now includes `{ layer, revalidating, ok }` properties, matching what compilers receive as `meta`:
```js
up.on('up:fragment:inserted', function(event) {
console.log(event.layer)
console.log(event.revalidating)
console.log(event.ok)
})
```
[Rendering HTML from a string](/providing-html#string) is always considered successful.
### Animations
- Unpoly [animations and transitions](/up.motion) now use the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) internally (instead of CSS transitions). The public API did not change.
- Unpoly animations no longer pause existing CSS transitions on the animated element. Both play simultaneously.
- ⚠️ The `fade-out` animation now starts from the element's current opacity, rather than always starting from `1.0`.
- ⚠️ The function `up.motion.isEnabled()` has been deprecated. Use `up.motion.config.enabled` instead.
- When [motion is disabled](/up.motion.config#config.enabled), all animations and transitions now instantly jump to the last frame (instead of doing nothing). This makes it easier to reason about the effects of animations, independent on the user's preferences.
### Fragment rendering
- New default reload options can be configured in `up.fragment.config.reloadOptions`.
- The `up.RenderResult#target` property now reflects the **actual resolved target selector** used, rather than the originally requested one (e.g. resolving `:main` to the concrete selector).
- When an `[up-keep]` element is not targetable, Unpoly now prints a warning instead of crashing the render pass.
### Focus and accessibility
- When revealing a `#hash` fragment from the address bar or a link, Unpoly now also focuses the matching element (#787).
- When [appending or prepending](/targeting-fragments#appending-or-prepending), focus is now placed on the first new element instead of the container element.
- Overlays are now focused before the opening animation starts, rather than after.
- Unpoly will no longer try to [preserve focus](/focus#keep) when calling the low-level `up.render()` function. Unpoly will still [be smart about setting focus](/focus#auto) when [navigating](/navigation). You can restore the old behavior by setting `up.fragment.config.renderOptions.focus = 'keep'`.
### Overlays
- Fixed duplicate scrollbars when opening overlays on pages where `<html>` does not have `overflow-x: hidden`, particularly on Firefox (#795).
- Fixed scrolling the overlay background in Safari (#790, #795).
- When the [global animation duration](/up.motion.config#config.duration) is set to zero, overlay animations now correctly use the duration configured at the overlay.
- ⚠️ The option `{ dismissable }` has been renamed to `{ dismissible }`.
- ⚠️ The attribute `[up-dismissable]` has been renamed to `[up-dismissible]`.
### Forms
- `[up-switch]` now switches disabled fields. This is useful when you re-use your (disabled) forms as read-only views, but also rely on `[up-switch]` to control dependent form sections.
- `[up-switch]` effects are now consistently applied before `[up-validate]` requests.
- Unpoly no longer sends duplicate validation requests when using `[up-validate][up-watch-event=input][up-keep]` to validate a field while the user is typing in it.
- Form-external submit buttons (using the HTML `[form]` attribute) are now supported consistently.
- Fix a race condition where, when the same form field was both watched and changed by compilers in the same render pass, that change wasn't always detected. This affected features like `[up-switch]` or `up.watch()` when another compiler changed the initial value of the observed field.
### Event utilities
- New experimental function `up.event.onClosest()`. This runs a callback when an event is observed on an element or its ancestors.
- New experimental function `up.fragment.onKept()`. This runs a callback when an element or its ancestors are [kept](/preserving-elements) during a render pass.
### Frontend assets
- The `up:assets:changed` event now has a `{ response }` property. This is the `up.Response` that contained [new asset versions](/handling-asset-changes) not found on the current page.
### History
- When an overlay [close condition](/closing-overlays#close-conditions) is reached, Unpoly no longer pushes a history entry for the closing response, preventing phantom entries in the browser history.
- The `up:location:changed` event now has a `{ previousLocation }` property.
### Utilities
- New function `up.util.mapObject()`. It creates an object from a given array and mapping function.
- Removed function `up.util.reverse()`. Use [`Array#toReversed()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toReversed) instead.
- New experimental function `up.util.parseNumber()`. Parses a string as a number, supporting negative numbers, negative zero, underscores for digit grouping, and floats.
### Network
- New experimental method `up.Request#isSafe()`. It returns whether the request uses a safe HTTP method like `GET`.
- Fixed event message for `up:fragment:offline` showing `"undefined"` as the reason.
### Documentation improvements
- Cleaned up typos and wording everywhere.
- New guide: [Script security](/script-security).
- Fixed docs incorrectly describing `up.viewport.root` as a function, when it is really a property.
- Fix incorrect deprecation of `up.Request#loadPage()` (it was `#navigate()` that was deprecated)
### Rails UJS compatibility
For a long time Unpoly has migrated links with Rails UJS attributes (`[data-method]`, `[data-confirm]`) to their Unpoly counterparts.\
This migration is now also applied to forms and submit buttons, not just links.
3.12.1
------
- Fix a bug where Unpoly did not [rewrite CSP nonces within new fragments](https://unpoly.com/csp#nonce-rewriting).
3.12.0
------
This release adds [asynchronous compilers](/up.compiler#async) and many other features requested by the community.\
We also fixed a number of [performance regressions](#performance-fixes) introduced by Unpoly [3.11](/changes/3.11.0).
Breaking changes are marked with a ⚠️ emoji and polyfilled by [`unpoly-migrate.js`](https://unpoly.com/changes/upgrading).
> [note]
> Our sponsor [makandra](https://makandra.de/en) funded this release ❤️\
> Please consider hiring makandra for [Unpoly support](https://unpoly.com/support).
### Asynchronous compilers
Compiler functions can now be [`async`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function). This is useful when a compiler needs to fetch network resources, or when calling a library with an asynchronous API:
```js
up.compiler('textarea.wysiwyg', async function(textarea) { // mark: async
let editor = await import('wysiwyg-editor') // mark: await
editor.init(textarea) // mark: await
})
```
You can also use this to split up expensive tasks, giving the browser a chance to render and process user input:
```js
up.compiler('.element', async function(element) {
doRenderBlockingWork(element)
await scheduler.yield() // mark-line
doUserVisibleWork(element)
})
```
#### Cleaning up async work {#async-destructors}
Like synchronous compilers, async compiler functions can return a [destructor function](#destructor):
```js
up.compiler('textarea.wysiwyg', async function(textarea) {
let editor = await import('wysiwyg-editor')
editor.init(textarea) // mark: await
return () => editor.destroy(textarea) // mark-line
})
```
Unpoly guarantees that the destructor is called, even if the element gets destroyed before the compiler function terminates.
#### Timing render-blocking mutations {#async-timing}
Unpoly will run the first [task](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) of every compiler function before allowing the browser to render DOM changes. If an async compiler function runs for multiple tasks, the browser will render between tasks. If you have render-blocking mutations that should be hidden from the user, these must happen in the first task.
{:width='670'}
Async compilers will not delay the promise returned by rendering functions `up.render()` or `up.layer.open()`.\
Async compilers *will* delay the promise returned by [`up.render().finished`](/render-lifecycle#postprocessing)
and `up.hello()`.
#### `up.hello()` is now async
⚠️ The `up.hello()` function now returns a promise that fulfills when all synchronous and asynchronous compilers have terminated:
```js
let textarea = up.element.createFromHTML('<textarea class="wysiwyg"></textarea>')
await up.hello(textarea) // mark: await
// chip: WYISWYG editor is now initialized
```
The fulfillment value is the same element that was passed as an argument:
```js
let html = '<textarea class="wysiwyg"></textarea>'
let textarea = await up.hello(up.element.createFromHTML(html))
```
### Performance fixes
Unpoly 3.11 introduced a number of performance regressions that would be very noticable on pages with many elements, or many forms. To address this, this release includes a number of performance fixes:
- Fix a performance regression where Unpoly would track the DOM for dynamically inserted `[up-validate]` fields for every form. Now fields are only tracked for forms that use `[up-validate]`.
- Features that need to track the insertion or removal of elements now only sync with the DOM once after a render pass.
- Watching a single field no longer tracks dynamically inserted fields.
- Improved the performance of internal form lookups.
### HTML content-type required
⚠️ Unpoly now requires server responses with an HTML content-type, like `text/html` or `application/xhtml+xml`. Trying to render responses with a different type will throw an error, even if the response body contains HTML markup.
Restricting content types is a security precaution. It protects in a hypothetical scenario where an attacker can both upload a file *and* can use an existing XSS vulnerability to cause Unpoly to render that file. It doesn't affect applications that reliably escape user input.
You can configure which responses Unpoly will process by configuring a function in `up.fragment.config.renderableResponse`. To render *any* response regardless of content-type, configure a function that always returns true`:
```js
up.fragment.config.renderableResponse = (response) => true
```
### New guides
The [documentation](https://unpoly.com/api) has been extended with new guides:
- [Enhancing elements with JavaScript](/enhancing-elements) teaches everything you need to know about compilers, destructors and preventing memory leaks.
- [Submitting forms in-place](/submitting-forms) shows how to have Unpoly handle forms from HTML or JavaScript.
- [Notification flashes](/flashes) now contains guidance for [suppressing flashes in cached responses](/flashes#caching).
### Submit buttons can override form attributes
Submit buttons can now supplement or override most Unpoly attributes from the form:
```html
<form method="post" action="/proposal/accept" up-submit>
<button type="submit" up-target="#success">Accept</button>
<button type="submit" up-target="#failure" up-confirm="Really reject?">Reject</button> <!-- mark: up-confirm="Really reject?" -->
</form>
```
Individual submit buttons can now opt for a full page load, by setting an `[up-submit="false"]` attribute:
```html
<form method="post" action="/report/update" up-submit>
<button type="submit" name="command" value="save">Save report</button>
<button type="submit" name="command" value="download" up-submit="false">Download PDF</button> <!-- mark: up-submit="false" -->
</form>
```
See [`[up-submit]`](/up-submit#attributes) for a list of overridable attributes
### Sticky layout elements
When scrolling to reveal a target element, Unpoly will ensure that layout elements with [`[up-fixed=top]`](/up-fixed-top) are not covering the revealed content.
You can now use `[up-fixed]` on elements with [`position: sticky`](https://www.w3schools.com/howto/howto_css_sticky_element.asp). Unpoly will measure sticky element like permanently fixed elements. The current scroll position is not taken into account.
### Support partial tables responses
In the past Unpoly didn't allow a server to [optimize its response](/optimizing-responses) when the result was a single table row (or cell) without an enclosing `<table>`:
```http
Content-type: text/html
<tr>
<td>...</td>
</tr>
```
Unpoly can now parse responses that only contain a `<tr>`, `<td>` or `<th>` element, without an enclosing `<table>` (issue #91).
### Expanding click areas
Unpoly lets you enlarge a link's click area using the `[up-expand]` attribute. This version addresses inconsistent (or impractical) assignment of the `.up-active` [feedback class](/feedback-classes) when an expanded link is clicked.
When either the `[up-expand]` container or the first link is clicked, the `.up-active` class
is now assigned to both elements:
```html
<div up-expand class="up-active"> <!-- mark: class="up-active" -->
<a href="/foo" class="up-active">Foo</a> <!-- mark: class="up-active" -->
<a href="/bar">Bar</a>
</div>
```
When a non-expanded link is clicked, now only that link becomes `.up-active`:
```html
<div up-expand>
<a href="/foo">Foo</a> <!-- chip: not active -->
<a href="/bar" class="up-active">Bar</a> <!-- mark: class="up-active" -->
</div>
```
### Preserving fragments
Two changes were made to [preserving elements](/preserving-elements) using the `[up-keep]` attribute:
- Added an experimental event `up:fragment:kept`. This event is emitted after all [keep conditions](/preserving-elements#conditions) are evaluated and preservation can no longer be prevented. A listener can be sure that the element is going to be kept.
- Fragments with both `[up-poll]` and `[up-keep]` now continue polling when the element is kept (fixes #763)
### Closing overlays
- When an overlay is [closed](/closing-overlays), the overlay now remains in the [layer stack](/up.layer.stack) until all destructors have run. This way destructor functions can still look up elements in their layer.
- ⚠️ The method `up.Layer#isOpen()` has been deprecated. Use `up.Layer#isAlive() instead`.
- ⚠️ The method `up.Layer#isClosed()` has been deprecated. Use `!up.Layer#isAlive() instead`.
- When [closing an overlay](/closing-overlays) that is already closed, Unpoly now throws an `AbortError` instead of doing nothing.
- Fix a crash when an `[up-switch]` input without a containing form is placed in an overlay, and that overlay is closed.
### Manual booting
- ⚠️ To boot manually, the `[up-boot=manual]` must now be set on the `<html>` element instead of on the `<script>` loading Unpoly.
- Unpoly now supports [manual booting](/up-boot-manual) when Unpoly is loaded as a `<script type="module">`.
### Fragment API
- The `up.fragment.get()` function now has a `{ destroying: true }` option. This allows to find destroyed elements that are still playing out their exit animation. Note that all `up.fragment` functions normally ignore elements in an exit animation.
- Added an experimental function `up.fragment.isAlive()`. It returns whether an element is both attached to the DOM and also not in an exit animation.
### Smaller fixes and changes
- Reverted the implementation of `up.util.task()` to again queue macrotasks using `setTimeout()` instead of `postMessage()`. Unpoly 3.11 only recently switched to `postMessage()` because of its tighter scheduling. Unfortunately message order is erratic with `postMessage()` in Safari, making it hard to reason about the sequence of asynchronous callbacks.
- Fix a bug where Unpoly would no longer restore history entries after the page is reloaded (issue #773).
- Added an experimental method `up.Response#isHTML()`. It returns whether the response has a [content-type](/up.Response.prototype.contentType) of `text/html` or `application/xml+html`. It doesn't test if the response body actual contains a valid HTML document.
- Fix a crash when `up.render({ response })` is called while another request is in flight.
- When rendering, and request with the same [`{ failTarget }`](/failed-responses#fail-options) was made while waiting for the network, and the first request responded with an error status and updates , the second request is now aborted.
3.11.0
------
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](#history-handling), [form state switching](/switching-form-state) and the [preservation of `[up-keep]` elements](/preserving-elements). We also [reworked major parts of the documentation](#reworked-documentation) and [stabilized 89 experimental features](#stabilization-of-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`](https://unpoly.com/changes/upgrading).
> [note]
> Our sponsor [makandra](https://makandra.de/en) funded this release ❤️\
> Please take a minute to check out [makandra's services](https://makandra.de/en/services-2) for web development, DevOps or UI/UX.
### Professional support options
We're introducing [optional commercial support](https://unpoly.com/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](https://github.com/unpoly/unpoly/discussions) remains available for free community support**, and the maintainers will also remain active there.
Learn more about support options at [unpoly.com/support](https://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](/scroll-tuning#fixed-layout-elements-obstructing-the-viewport) 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()`](https://developer.mozilla.org/en-US/docs/Web/API/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()`).
#### Complete handling of `#hash` links
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`](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior) 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](/up.link.isFollowable) will now scroll the page without re-rendering.
- Hash links will now reliably scroll far enough to ignore any [obstructing elements](/scroll-tuning#fixed-layout-elements-obstructing-the-viewport) 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](/up.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 (issue #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]`](https://www.w3schools.com/tags/att_form.asp) attribute) is added or removed dynamically.
- When a watched field runs a callback, a purple event badge is now [logged](/up.log) 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](/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:
```html
<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]`](/up-enable-for) and [`[up-disable-for]`](/up-disable-for)
attributes to indicate for which values they should be shown or hidden:
```html
<!-- 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](/switching-form-state#disable).
#### 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:
```html
<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:
```js
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](/switching-form-state#custom-effects).
#### New switching modifiers
The `[up-switch]` attribute itself has been reworked with new modifiying attributes:
- A new `[up-switch-region]` attribute allows to [expand or narrow the region](/switching-form-state#region) where elements are switched.
- `[up-switch]` can now [react to other events](/switching-form-state#reacting-to-different-events), by setting an `[up-watch-event]` attribute.
- `[up-switch]` can now debounce their switching effects with an `[up-watch-delay]` attribute.
#### More `[up-switch]` changes
- Using `[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](/switching-form-state#radio-buttons).
- `[up-switch]` now works on a [container of multiple checkboxes](/switching-form-state#checkboxes) for a single array param, like `category[]`.
- ⚠️ Setting `[up-switch]` on an individual radio button will now throw an error. Watch a container for the entire radio group instead.
- ⚠️ Fields with `[up-switch]` now require a `[name]` attribute.
- ⚠️ Unpoly will no longer un-hide elements targeted by `[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.
### 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](/up-validate#urls). You can do so with the new [`[up-validate-url]`](/up-validate#up-validate-url) and [`[up-validate-method]`](/up-validate#up-validate-params) attributes on individudal fields or on entire forms:
```html
<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:
```html
<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](/up.validate#batching-multiple-urls) 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:
```js
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](/up.validate#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]`](/up-validate#up-validate-params) attribute.
- Fields or forms can add additional headers to the validation request using the [`[up-validate-headers]`](/up-validate#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:
```http
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](/opening-overlays#server).
#### Closing overlays from forms
Forms can now have an `[up-dismiss]` or `[up-accept]` attribute to [close their overlay when submitted](/closing-overlays#on-submit).
This will immediately close the overlay on submission, without making a network request:
```html
<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](/closing-overlays#result-values), encoded as an `up.Params` instance:
```js
up.layer.open({
url: '/form',
onAccepted: ({ value }) => {
console.log(value.get('email')) // result: "foo@bar.de"
}
})
```
See [Closing when a form is submitted](/closing-overlays#on-submit).
#### Detecting the origin layer
The server can now detect if an interaction (e.g. clicking a link or submitting a form) [originated](/origin) 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:
```html
<!-- 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:
```http
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](/script-security).
#### 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](/legacy-scripts) for techniques to remove inline `<script>` elements.
#### Mandatory nonces for `script-dynamic` CSP
A CSP with [`strict-dynamic`](https://content-security-policy.com/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](/script-security#script-element-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:
```js
up.fragment.config.runScripts = (script) => {
return script.src.startsWith('https://myhost.com/')
}
```
#### 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](/script-security#callback-nonces), 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*:
<img src="images/docs/param-sections.png" alt="Parameters organized into sections" width="520">
#### 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:
- [Preserving elements](/preserving-elements)
- [Polling](/polling)
- [Switching form state](/switching-form-state)
- [Reactive server forms](/reactive-server-forms)
#### Guide links
When there is a guide with more context, the documentation for attributes or functions now show a link to that guide:
<img src="images/docs/guide-link.png" alt="Link to guide with more context" width="440">
### 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.
### Navigation bars
[Navigational containers](/navigation-bars) 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:
```html
<!-- 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](/navigation-bars#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](/preserving-elements).
#### Keeping an element until its HTML changes {#same-html}
To preserve an element as long as its [outer HTML](https://developer.mozilla.org/en-US/docs/Web/API/Element/outerHTML) 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