**Last updated: October 2nd, 2026**

> 📘 See the full changelog for 2.0.0
>
> These are the changes since `2.0.0-beta.1` (released August 28th, 2026). For all changes since v1.4, see the [2.0.0 changelog](/docs/2.0/changelog).

## Highlights

- **Linux x64 support.** The beta SDK now ships for Linux x64, alongside Windows x64 and macOS arm64. It needs glibc 2.31 or newer and an X11 desktop (Wayland desktops work through XWayland, native Wayland support coming soon). Apps need GCC 11 or later, or a C++20 clang.
- **Less work per frame.** Each frame repaints only what changed (better damage-tracking support in the compositor). On the CPU renderer, scrolling now moves existing pixels instead of repainting the page (much better scroll performance).
- **Smoother animation.** Each frame, the animation clock (CSS animations, rAF, scroll, etc.) is now timed to the physical display's future refresh timestamp for smoother animations and better latency.
- **Final bridge APIs.** The JavaScript bridge, DOM API, and data bindings API have changed (more consistent API naming, ownership rules, and error handling). Most apps that use them need changes; see "Upgrading from beta.1".
- **Developer mode.** `Config::diagnostics` turns on bridge warnings and sets the minimum log level in one place.
- **No white flash at startup.** `Window::ShowWhenReady()` shows a window once its pages are ready.
- **Pixel-art scaling.** CSS `image-rendering: pixelated` and canvas `imageSmoothingEnabled` now work on both renderers.

## Upgrading from beta.1

Recompile everything against the beta.2 headers. Then check the two lists below.

### Changes the compiler won't catch

Some of the API *behavior* has changed, make sure to be aware of the following changes:

**Object lifetimes:**

- A View no longer keeps your JS/DOM/data-binding bridge objects alive. When you destroy the last `js::API`, `dom::Triggers`, or `dom::data::Context`, the bridge object detaches from every View. Keep these objects alive for as long as pages should use them.
- Destroying a `dom::EventListener` no longer removes the listener. Call `Remove()` on it, or add it with a `dom::AbortController` signal.
- C API: these functions now return handles you must destroy:
  - `ulDOMEventGetTarget()` and the other event target getters;
  - `ulDOMDataBindingGetContext()`;
  - `ulJSAPIGetDiagnosticsAPI()`.
- C API: when a call that takes a `destroy_user_data` callback fails, the library now calls `destroy_user_data` for you. Don't free `user_data` yourself after a failed call.

**Logging and diagnostics:**

- Bridge diagnostics are off unless you set `Config::diagnostics.developer_mode = true`.
- `LogLevel` (and the C `ULLogLevel`) now starts with `Fatal` and ends with `Debug`, so the numeric values of `Error`, `Warning`, and `Info` went up by one. Update any code that stores or compares these numbers.
- Every log message now starts with a category tag such as `[render]` or `[net]`. `Debug` messages are not sent to your `Logger` unless you lower `Config::diagnostics.min_log_level`.
- AppCore's error dialog now appears only for `Fatal` messages, not for every `Error`.

**Custom surfaces:**

- The CPU renderer now repaints only the area that changed. A custom `Surface` must keep its pixels from one frame to the next.
- `Surface::set_dirty_bounds()` now adds to the dirty area instead of replacing it.
- The library now destroys Surfaces through `SurfaceFactory::DestroySurface()`. Before, it deleted them directly.

**JavaScript:**

- `Value::To<int>()` (and other integer conversions) now return an error for a non-integer or out-of-range number instead of truncating it.
- `JSValueGetType()` and `ulJSValueGetType()` return a new BigInt type for a BigInt, where they used to return the object type.

**AppCore:**

- Hidden windows are no longer painted, so pages in a hidden window don't finish loading until the window is shown. Use `Window::ShowWhenReady()` to show a window once its pages are ready.
- `requestAnimationFrame()` timestamps now point to future timestamps (when the frame will reach the physical screen), slightly ahead of `performance.now()`. Set `Settings::sync_animations_to_present = false` to keep the old timing.

**Data bindings:**

- `Context::PostTask()` with a callable that takes no `dom::Document` now runs once, not once per View.
- `ul-attr:` no longer sets `class`, `style`, or `on*` attributes. Use `ul-class-*` and `ul-style-*`.
- `ul-on:submit` now always prevents the form from submitting.
- `{{path}}` is no longer filled in inside `<script>`, `<style>`, or `<textarea>`. Use `ul-value` for a textarea.
- C API: a string length of 0 now means an empty string. Before, the library called `strlen()`.

### Changes the compiler will catch

When you rebuild the compiler should flag each of these changes. In the tables below, the left column is beta.1 code and the right column is the equivalent in beta.2.

**View:**

| beta.1                                      | beta.2                                     |
| ------------------------------------------- | ------------------------------------------ |
| `view->GetDocument()`                       | `view->GetDOMDocument()`                   |
| `view->AttachAPI(api)`                      | `view->AttachJSAPI(api)`                   |
| `view->DetachAPI(api)`                      | `view->DetachJSAPI(api)`                   |
| `view->SetAPIInjectionFilter(...)`          | `view->SetJSAPIInjectionFilter(...)`       |
| `view->AttachDOMListeners(listeners)`       | `view->AttachDOMTriggers(triggers)`        |
| `view->DetachDOMListeners(listeners)`       | `view->DetachDOMTriggers(triggers)`        |
| `view->SetDOMListenersInjectionFilter(...)` | `view->SetDOMTriggersInjectionFilter(...)` |

- Injection filter callbacks now receive one request struct instead of five arguments. The struct has the same values as before: `view`, the API or triggers handle, `origin`, `is_main_frame`, and `rules_allow`.

**JavaScript (`ultralight::js`):**

| beta.1                                               | beta.2                                                          |
| ---------------------------------------------------- | --------------------------------------------------------------- |
| `api.AttachTo(view, flags, rules)`                   | `api.AttachTo(view, { .flags = flags, .origin_rules = rules })` |
| `js::Value::Undefined(ctx)`                          | `context.Make(js::undefined)`                                   |
| `js::Value::Null(ctx)`                               | `context.Make(js::null)`                                        |
| `js::Value::Boolean(ctx, true)`                      | `context.Make(true)`                                            |
| `js::Value::Number(ctx, 1.5)`                        | `context.Make(1.5)`                                             |
| `js::Value::String(ctx, "text")`                     | `context.Make("text")`                                          |
| `js::Value::Object(ctx)`                             | `context.MakeObject()`                                          |
| `js::ArrayBuffer::Create(ctx, buffer)`               | `context.MakeArrayBuffer(buffer)`                               |
| `js::TypedArray<T>::Create(ctx, length)`             | `context.MakeTypedArray<T>(length)`                             |
| `js::TypedArray<T>::View(buffer, offset, length)`    | `js::TypedArray<T>::Create(buffer, offset, length)`             |
| `co_await js::OnWorker(fn)`                          | `co_await js::RunOnWorker(fn)`                                  |
| `co_await js::Async<T>(promise)`                     | `co_await js::Await<T>(promise)`                                |
| `co_await js::CallAsync<T>(fn, this_value, args...)` | `co_await js::Await<T>(fn.InvokeOn(this_value, args...))`       |
| `error.is_context_dead()`                            | `error.is_page_gone()`                                          |
| `api.Describe()`                                     | `api.schema()`                                                  |
| `js::HolderTraits<H>`                                | `ultralight::HolderTraits<H>`                                   |
| `#include <Ultralight/js/CoreInterop.h>`             | `#include <Ultralight/JS.h>`                                    |

- On the left, `ctx` is a raw `ULJSContext`. On the right, `context` is a `js::Context`: create one from a View (`js::Context context(view)`), or use `info.context()` inside a binding.
- An operation on an empty handle now fails with `error.is_empty()`, not `is_page_gone()`.
- `Value::type()` returns the new `js::Type` enum instead of `ULJSType`.
- On C++23, `js::Result` is no longer `std::expected`. Use `js::Unexpected` instead of `std::unexpected`.

**DOM (`ultralight::dom`):**

| beta.1                                  | beta.2                                               |
| --------------------------------------- | ---------------------------------------------------- |
| `dom::Listeners`                        | `dom::Triggers`                                      |
| `#include <Ultralight/dom/Listeners.h>` | `#include <Ultralight/dom/Triggers.h>`               |
| `dom::ErrorCode`                        | `dom::ErrorType`                                     |
| `error.code()`                          | `error.type()`                                       |
| `el.addEventListener(type, fn, true)`   | `el.addEventListener(type, fn, { .capture = true })` |
| `doc.window()`                          | `doc.defaultView()`                                  |
| `listener.Detach()`                     | `listener.Release()`                                 |

These members changed type:

| Member                           | beta.1 type           | beta.2 type                                         |
| -------------------------------- | --------------------- | --------------------------------------------------- |
| `selectedIndex`                  | `int` (-1 means none) | `std::optional<size_t>` (`std::nullopt` means none) |
| `selectionStart`, `selectionEnd` | `long long`           | `std::optional<size_t>`                             |
| `scrollTop`, `scrollLeft`        | `int`                 | `double`                                            |
| `Window::scrollX()`, `scrollY()` | `int`                 | `double`                                            |

- Calls that can fail, such as `querySelector()`, `appendChild()`, and `matches()`, now return their value directly, as they do in JavaScript. To get the `dom::Result<T>` back, pass `dom::Checked` as the last argument: `el.querySelector(sel, dom::Checked)`.
- `dom::Event` can't be copied. Take `const dom::Event&` in your own functions.

**Data bindings (`ultralight::dom::data`):**

| beta.1                                         | beta.2                                                  |
| ---------------------------------------------- | ------------------------------------------------------- |
| `ctx.AttachTo(*view);`                         | `if (!ctx.AttachTo(view)) { /* handle the failure */ }` |
| `ctx.DetachFrom(*view);`                       | `ctx.DetachFrom(view);`                                 |
| `binding.EmitAction<"name">()`                 | `binding.PostAction<"name">()`                          |
| `ctx.EmitAction("binding.action", payload)`    | `ctx.PostAction("binding.action", payload)`             |
| `ctx.DescribeSchemas()`                        | `ctx.schema()`                                          |
| `#include <Ultralight/dom/data/Descriptors.h>` | `#include <Ultralight/dom/data/Context.h>`              |

- Field names in `Schema()` must be identifiers: `"max_hp"` works and `"max-hp"` doesn't.
- `OnChange` and `OnAction` handlers must take the field's exact type. For example, a `float` parameter for an `int` field no longer compiles.
- A field with a validator must be `dd::Editable`.

**C API:**

| beta.1                                              | beta.2                                            |
| --------------------------------------------------- | ------------------------------------------------- |
| `ULDOMListeners`                                    | `ULDOMTriggers`                                   |
| `ulCreateDOMListeners()`, `ulDOMListenersOn()`, ... | `ulCreateDOMTriggers()`, `ulDOMTriggersOn()`, ... |
| `ulViewAttachDOMListeners()`                        | `ulViewAttachDOMTriggers()`                       |
| `ulViewAttachDOMDataContextWithRules()`             | `ulViewAttachDOMDataContext()`                    |
| `ulDOMDataContextEmitAction()`                      | `ulDOMDataContextPostAction()`                    |
| `ulDOMDataContextDescribeSchemas()`                 | `ulDOMDataContextGetSchema()`                     |
| `ulJSAPIDescribe()`                                 | `ulJSAPIGetSchema()`                              |
| `ulDOMElementGetParent()`                           | `ulDOMElementGetParentElement()`                  |
| `ulDOMElementGetFirstChild()`                       | `ulDOMElementGetFirstElementChild()`              |
| `ulDOMElementGetLastChild()`                        | `ulDOMElementGetLastElementChild()`               |
| `ulDOMElementGetNextSibling()`                      | `ulDOMElementGetNextElementSibling()`             |
| `ulDOMElementGetPreviousSibling()`                  | `ulDOMElementGetPreviousElementSibling()`         |
| `ulDOMElementGetChildCount()`                       | `ulDOMElementGetChildElementCount()`              |
| `ULDestroyJSUserDataCallback`                       | `ULUserDataDestroyCallback`                       |
| `ULDOMUserDataDestroyCallback`                      | `ULUserDataDestroyCallback`                       |
| `ULDOMDataDestroyCallback`                          | `ULUserDataDestroyCallback`                       |

- `ulViewAttachDOMDataContext()` now takes `(view, context, flags, origin_rules, num_origin_rules)` and returns `bool`.
- `ULCursor`, `ULMessageSource`, and `ULMessageLevel` moved to `CAPI_View.h`. Code that includes `<Ultralight/CAPI.h>` is unaffected.

**Platform interfaces:**

- `Surface` has a new required method, `Scroll()`. Most implementations can call `Surface::ShiftPixels()` on their pixel buffer. In the C API, the `scroll` callback is optional.
- `FontFile` has a new required method, `face_index()`. This only matters if you subclass `FontFile` directly.
- `Renderer::RenderOnly()` is deprecated. Use `Render()`, and hide a View with `View::set_visible(false)`.
- If you copied code from the SDK's `platform/` folder (GPU drivers, clipboards, audio outputs), replace it with the beta.2 copies. The beta.2 copies need the beta.2 library, and the shaders changed.

## What's New

**Rendering:**

- Repaint only what changed each frame, and skip frames with no changes, on both renderers
- Scroll on the CPU renderer by moving pixels instead of repainting
- Redraw only the changed area on the GPU when a custom driver sets `GPUDeviceCaps::supports_partial_redraw` (all reference drivers set it)
- Add CSS `image-rendering: pixelated` / `crisp-edges` and canvas `imageSmoothingEnabled` / `imageSmoothingQuality`
- Time animations, `requestAnimationFrame()`, and smooth scrolling to the display's refresh
- Add `Renderer::RefreshDisplay(display_id, target_timestamp)`, `SetDisplayRefreshRate()`, and `SetDisplayUsesCustomClock()` for apps that drive their own frame timing
- Load the requested face of a `.ttc` font collection instead of the first face

**APIs:**

- Add `Config::diagnostics`: developer mode, JavaScript and DOM warning levels, and the minimum log level
- Add `Surface::dirty_rect()` / `dirty_rect_count()` so a custom surface can copy only the regions that changed
- Add `ViewConfig::allow_universal_access_from_file_urls` (on by default, as in beta.1)
- DOM: `dom::AbortController`, listener options (`once`, `passive`, `signal`), `dom::NodeList`, text and comment nodes, `append()` / `before()` / `remove()`, form validation, element offsets and scrolling, and camelCase style names (`el.style.fontSize`)
- JavaScript: `js::Or()` for reading a value with a fallback, error locations (`stack()`, `line()`, `column()`), and `API::DumpSchema()`
- Data bindings: `Binding::IsAlive()`, `Context::DumpSchema()`, and the `ul-on:<event>.prevent` modifier

**AppCore:**

- Add `Window::ShowWhenReady()`
- Add `App::monitor_count()` and `App::monitor()`
- On Linux: OpenGL rendering, CPU-only windows with no GPU (`Settings::force_cpu_renderer`), PulseAudio media output, clipboard, and IME

**SDK:**

- Ship the data-binding mockup tools (`gen-mock-data.py`, `ul-mock.js`) and `gen-typescript.py` in `tools/scripts/`. `gen-typescript.py` replaces `generate_dts.py`
- Update the API documentation in most public headers

## Notable Fixes

- Fix `position: fixed` and sticky elements shaking at fractional display scales
- Fix overlay scrollbars showing through elements stacked above them
- Fix `window.close()`, `View::CancelDownload()`, and `OnUpdateHistory()`, which did nothing in beta.1
- Fix right-click not firing `contextmenu`, Ctrl- and Shift-click modifiers, and `wheelDelta` values
- Fix in-memory Sessions saving `localStorage` to disk
- Fix a second `ULView` handle for the same View turning off the first handle's callbacks
- Fix `ulCreateRenderer()` returning a handle when it failed (it now returns NULL)
- Fix `ultralight_copy_runtime_files()` putting resources in the wrong folder of a macOS app bundle
- Fix the public headers failing to compile with GCC or after X11 headers

## Known Issues

- On Windows, `App::monitor_count()` always returns 1 (monitor enumeration works on macOS and Linux)
