docs

C API Conventions

Shared rules for memory ownership, threading, callbacks, and error handling across C headers.

On this page

The Ultralight C API exposes the full API surface to C applications and provides the foundation for writing Language Bindings in other languages.

The C API follows a common set of conventions for memory ownership, callbacks, lifetimes, and threading, as documented below.

Differences from C++

The C API mirrors most of the Ultralight C++ API, with a few key differences— each rule shown in the following table is covered in further detail below.

C++ C What Changes
RefPtr and handle classes released at scope exit ulCreateX() and ulDestroyX() You destroy the handles you own
Copying a dom::Element or js::Value ulCreateXRef() Copying a pointer creates no new reference
Listener classes and lambdas with captures Function pointer, user_data, and destroy_user_data Pass captured state in user_data
dom::Result and js::Result false or NULL with an error out-parameter Errors pass through out-parameters instead of exceptions
if (el) and IsEmpty() ulXIsAlive() and a NULL check Check NULL separately from whether the page is gone
== on element, node, and layout handles ulDOMElementIsSame() and siblings Two lookups of the same element return different pointers
C++ exceptions in callbacks Caught at the callback boundary Nothing may unwind out of a callback

Including Headers

Two umbrella headers provide access to most of the library.

<Ultralight/CAPI.h> includes the core renderer, Views, sessions, configuration, platform handlers, and JavaScript headers. <AppCore/CAPI.h> covers AppCore features— including windows, panels, and layout— and automatically includes <Ultralight/CAPI.h>.

DOM and data-binding headers remain opt-in and aren't pulled in by either umbrella header. You must include them explicitly:

The C API requires C17 or later. For compiler setup and linker configuration, see Linking to the Library.

Passing Strings

Functions that accept a ULString take an explicit string handle that you create and destroy.

C
#include <Ultralight/CAPI.h>

void LoadGreeting(ULView view) {
  ULString html = ulCreateString("<h1>Howdy!</h1>");
  ulViewLoadHTML(view, html);
  ulDestroyString(html);
}

Functions accept text in one of three forms, depending on their signature:

A function that takes a pointer and a byte length reads exactly length bytes— the text doesn't need a terminating NUL and can contain embedded NUL characters. A length of 0 represents an empty string. To pass a standard NUL-terminated string to these functions, pass strlen(text) as the length.

Managing Memory and Ownership

You destroy only the handles you create or explicitly own. A function returns an owned handle if its name starts with Create or its documentation states that you must call ulDestroyX() when finished. All other returned handles are borrowed— you must never call a destroy function on a borrowed handle.

Owned and Borrowed Return Values

A Get name alone doesn't tell you whether a returned handle is owned or borrowed— ownership depends on the header family.

API Family What Getters Return Example
Core (ULRenderer, ULView, ULSession, ULConfig) Borrowed handles ulViewGetURL()
JavaScript, DOM, and layout Owned handles ulJSObjectGetProperty(), ulViewGetDOMDocument(), ulWindowGetLayout(), ulPanelGetView()
Data bindings Owned strings and binding handles, but borrowed ULDOMDataTable views and walk records (ULDOMDataType is never destroyed) ulDOMDataContextGetSchema() (an owned string)

Handles passed as callback arguments are always borrowed across every family. They stay valid only while the callback is running.

Keeping a Borrowed Value

To keep a borrowed string past the next call, copy it with ulCreateStringFromCopy().

C
#include <Ultralight/CAPI.h>

static ULString saved_url = NULL;

void SaveURL(ULView view) {
  // Borrowed: the next ulViewGetURL() call overwrites it.
  ULString url = ulViewGetURL(view);

  if (saved_url)
    ulDestroyString(saved_url);
  saved_url = ulCreateStringFromCopy(url);
}

Core getters like ulViewGetURL() and ulViewGetTitle() return a borrowed string owned by the View. Each call overwrites an internal buffer, which invalidates any previous pointer returned by ulStringGetData(). Copying the string gives you an owned handle that remains valid until you destroy it.

To keep a borrowed handle in the JavaScript, DOM, or layout families, call the corresponding reference function (such as ulCreateJSValueRef(), ulCreateDOMElementRef(), or ulCreatePanelRef()). These functions return an owned reference that you must release with ulDestroyX() when finished.

What Destroying a Handle Does

Destroying a handle typically releases only your reference without affecting the underlying object on the page or in the window.

Handle Effect of Calling Destroy
Last owning ULJSAPI, ULDOMTriggers, or ULDOMDataContext Detaches it from every View
ULDOMEventListener Releases your handle while the listener continues firing on the page (call ulDOMEventListenerRemove() to stop it)

Using Callbacks and User Data

Each callback setter manages a single slot on the target object. Setting a new callback replaces the existing one, and passing NULL removes it.

To share a single allocation across multiple callbacks, assign the destroy hook to only one setter.

C
#include <Ultralight/CAPI.h>
#include <stdlib.h>

typedef struct {
  int title_changes;
  int url_changes;
} PageStats;

static void OnChangeTitle(void* user_data, ULView caller, ULString title) {
  (void)caller;
  (void)title;
  ((PageStats*)user_data)->title_changes++;
}

static void OnChangeURL(void* user_data, ULView caller, ULString url) {
  (void)caller;
  (void)url;
  ((PageStats*)user_data)->url_changes++;
}

void WatchPage(ULView view) {
  PageStats* stats = calloc(1, sizeof(PageStats));

  // The title slot owns stats and frees it; the URL slot only borrows it.
  ulViewSetChangeTitleCallback(view, OnChangeTitle, stats, free);
  ulViewSetChangeURLCallback(view, OnChangeURL, stats, NULL);
}

Ownership of user_data transfers directly to the callback slot. The corresponding destroy_user_data hook runs exactly once when the callback is replaced, cleared, or when the owning object is destroyed. If a registration call fails, the library still calls destroy_user_data immediately so cleanup never depends on whether registration succeeded.

A destroy hook never runs while its callback is actively executing. If a callback replaces itself, the previous user_data is freed after the callback returns.

Destroy hooks can be invoked on any thread, so write them to be thread-safe. Inside a destroy callback, do not call any library functions other than destroying handles.

🚧 Assign Destroy Hooks to a Single Slot

Each callback setter owns its user_data pointer separately. If you register the same pointer with a destroy hook on multiple setters, the library frees it multiple times. Provide the destroy hook to one setter and pass NULL to the rest, or allocate separate state for each setter.

Handle Lifetimes Across Navigations

JavaScript, DOM, and layout handles track identity rather than lifetime. Holding a handle never prevents a page, window, or layout node from being destroyed.

Checking Handle State

A handle is in one of three states: valid, empty (NULL in C), or gone. A DOM or JavaScript handle enters the gone state when its page navigates away, its frame is removed, or its View is destroyed. A layout handle enters the gone state when its node is removed or its window closes. The concepts behind these states are covered in DOM Handles and Errors and Working with JavaScript Values.

Functions called on NULL or on a handle whose page is gone fail safely without crashing, and destroying these handles remains safe. When a page navigates away, its handles never become valid again, so you must obtain fresh handles for each new page (for example, in the DOM-ready callback).

Calling an IsAlive function such as ulDOMElementIsAlive() returns false for both NULL handles and handles whose page is gone. To distinguish between the two states, check whether the pointer is NULL first.

Comparing Handle Identity

Do not compare element, node, or layout handles with ==. Two lookups of the same element or layout node can return different pointer addresses. To test whether two handles refer to the same underlying item, call ulDOMElementIsSame(), ulDOMNodeIsSame(), or ulLayoutNodeIsSame().

Passing NULL Handles

Passing NULL to JavaScript, DOM, data-binding, or layout functions is harmless. The library treats NULL like a handle whose page is gone— calls return default values or fail quietly, and log warnings only at the Strict diagnostics level.

Core and AppCore functions do not accept NULL. Core functions log a fatal error if you pass NULL for a ULRenderer, ULView, ULSession, ULConfig, or ULViewConfig. AppCore functions crash if you pass NULL for a ULApp or ULWindow.

Core destroy functions are an exception— calls like ulDestroyView() accept NULL and do nothing.

Handling Errors

A function that fails returns false or NULL, while errors raised during execution write to an out-parameter.

Here is how to check both failure paths when querying a DOM element:

C
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>

void ShowScore(ULView view) {
  ULDOMDocument document = ulViewGetDOMDocument(view);
  ULDOMError error = {0};
  ULDOMElement score = ulDOMDocumentQuerySelector(document, "#score", &error);

  if (error.code != kULDOMErrorCode_None) {
    // The page rejected the call (eg, a malformed selector).
    if (error.message)
      ulDestroyString(error.message);
  } else if (score) {
    ulDOMElementSetTextContent(score, "100", 3);
  } else {
    // No match, or the page is gone (ulDOMDocumentIsAlive() tells which).
  }

  ulDestroyDOMElement(score);
  ulDestroyDOMDocument(document);
}

Error Out-Parameters

Functions in the JavaScript and DOM headers report execution errors through out-parameters. The JavaScript API uses ULJSValue* exception, which stores an owned value handle that you must destroy. The DOM API uses ULDOMError* error, which populates a code and an owned message string (which you must destroy if non-NULL). If you do not need the error details, pass NULL for the out-parameter.

The library writes to these out-parameters only when an active operation fails during execution. This includes page exceptions, malformed CSS selectors, and JavaScript errors raised by the library.

Passing a NULL handle, calling a function on a handle whose page is gone, or running a query that finds no matches leaves the out-parameter untouched. Call ulXIsAlive() to tell these cases apart from execution errors.

Zero-initialize ULDOMError before each call so you can check whether error.code changed from kULDOMErrorCode_None.

📘 Diagnostic Logging

Operations on missing pages or empty handles fail quietly by default. Enabling developer mode with ulConfigSetDeveloperMode() causes JavaScript and DOM functions to log warnings whenever code calls a handle whose page is gone. You can set finer logging thresholds with ulConfigSetJavaScriptDiagnostics() and ulConfigSetDOMDiagnostics(). For details, see Developer Mode and Diagnostics.

Threading Rules

Every header family belongs to a specific owner thread. You must call its functions on that thread unless the documentation explicitly marks a function safe to call from any thread.

Header Family Owner Thread
Core (ULRenderer, ULView, ULSession) The Renderer's thread (the thread that creates the Renderer and calls ulUpdate())
JavaScript, DOM, and DOM triggers The Renderer's thread
AppCore app, windows, and layout The main thread (which is also the Renderer's thread under AppCore)
Data bindings The context's home thread (the thread that created it)

Calling Functions from Any Thread

A subset of functions can be called from any thread:

These are the most common cross-thread functions, but not an exhaustive list. Each family guide and the API reference mark any others.

Deferred Work and the Update Loop

Work originating off the Renderer's thread runs during ulUpdate(). This includes freeing handles destroyed from background threads, processing posted tasks, and running certain destroy hooks.

AppCore calls ulUpdate() automatically inside its run loop. If you run your own loop, make sure it continues calling ulUpdate() regularly— if your loop stops calling it, deferred cleanup and posted tasks wait indefinitely. For details on passing work to the Renderer's thread, see Renderer in C.

Writing Language Bindings

Bindings for languages such as Java, C#, and Rust build directly on top of the C API.

Types and Calling Conventions

Your binding must match how C exposes functions, handles, and structs:

Callbacks and Handlers

When passing callbacks to C, manage their state and errors carefully:

Finalizers and Object Identity

Clean up handles and check object identity using these methods:

Strings and Editions

Account for text encoding, edition differences, and version checks in your code: