Changelog (2.0.0-beta.2)
Changes since 2.0.0-beta.1.
On this page
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.
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::diagnosticsturns 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: pixelatedand canvasimageSmoothingEnablednow 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, ordom::data::Context, the bridge object detaches from every View. Keep these objects alive for as long as pages should use them. - Destroying a
dom::EventListenerno longer removes the listener. CallRemove()on it, or add it with adom::AbortControllersignal. - 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_datacallback fails, the library now callsdestroy_user_datafor you. Don't freeuser_datayourself after a failed call.
Logging and diagnostics:
- Bridge diagnostics are off unless you set
Config::diagnostics.developer_mode = true. LogLevel(and the CULLogLevel) now starts withFataland ends withDebug, so the numeric values ofError,Warning, andInfowent 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].Debugmessages are not sent to yourLoggerunless you lowerConfig::diagnostics.min_log_level. - AppCore's error dialog now appears only for
Fatalmessages, not for everyError.
Custom surfaces:
- The CPU renderer now repaints only the area that changed. A custom
Surfacemust 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()andulJSValueGetType()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 ofperformance.now(). SetSettings::sync_animations_to_present = falseto keep the old timing.
Data bindings:
Context::PostTask()with a callable that takes nodom::Documentnow runs once, not once per View.ul-attr:no longer setsclass,style, oron*attributes. Useul-class-*andul-style-*.ul-on:submitnow always prevents the form from submitting.{{path}}is no longer filled in inside<script>,<style>, or<textarea>. Useul-valuefor 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, andrules_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,
ctxis a rawULJSContext. On the right,contextis ajs::Context: create one from a View (js::Context context(view)), or useinfo.context()inside a binding. - An operation on an empty handle now fails with
error.is_empty(), notis_page_gone(). Value::type()returns the newjs::Typeenum instead ofULJSType.- On C++23,
js::Resultis no longerstd::expected. Usejs::Unexpectedinstead ofstd::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(), andmatches(), now return their value directly, as they do in JavaScript. To get thedom::Result<T>back, passdom::Checkedas the last argument:el.querySelector(sel, dom::Checked). dom::Eventcan't be copied. Takeconst 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. OnChangeandOnActionhandlers must take the field's exact type. For example, afloatparameter for anintfield 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 returnsbool.ULCursor,ULMessageSource, andULMessageLevelmoved toCAPI_View.h. Code that includes<Ultralight/CAPI.h>is unaffected.
Platform interfaces:
Surfacehas a new required method,Scroll(). Most implementations can callSurface::ShiftPixels()on their pixel buffer. In the C API, thescrollcallback is optional.FontFilehas a new required method,face_index(). This only matters if you subclassFontFiledirectly.Renderer::RenderOnly()is deprecated. UseRender(), and hide a View withView::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-edgesand canvasimageSmoothingEnabled/imageSmoothingQuality - Time animations,
requestAnimationFrame(), and smooth scrolling to the display's refresh - Add
Renderer::RefreshDisplay(display_id, target_timestamp),SetDisplayRefreshRate(), andSetDisplayUsesCustomClock()for apps that drive their own frame timing - Load the requested face of a
.ttcfont 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()), andAPI::DumpSchema() - Data bindings:
Binding::IsAlive(),Context::DumpSchema(), and theul-on:<event>.preventmodifier
AppCore:
- Add
Window::ShowWhenReady() - Add
App::monitor_count()andApp::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) andgen-typescript.pyintools/scripts/.gen-typescript.pyreplacesgenerate_dts.py - Update the API documentation in most public headers
Notable Fixes
- Fix
position: fixedand sticky elements shaking at fractional display scales - Fix overlay scrollbars showing through elements stacked above them
- Fix
window.close(),View::CancelDownload(), andOnUpdateHistory(), which did nothing in beta.1 - Fix right-click not firing
contextmenu, Ctrl- and Shift-click modifiers, andwheelDeltavalues - Fix in-memory Sessions saving
localStorageto disk - Fix a second
ULViewhandle 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)