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:
<Ultralight/CAPI/CAPI_DOMDocument.h>and itsCAPI_DOM*siblings for DOM manipulation<Ultralight/CAPI/CAPI_DOMTriggers.h>for persistent page listeners across navigations<Ultralight/CAPI/CAPI_DOMData.h>for declarative data bindings
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.
#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:
ULStringhandles for core operations (such asulViewLoadHTML()) and JavaScript evaluation.- NUL-terminated UTF-8
const char*pointers for most JavaScript, DOM, and data-binding calls (such as selectors and property names). - A UTF-8
const char*pointer with an explicit byte length for functions taking free text (such asulDOMElementSetTextContent()).
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().
#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.
#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_datapointer 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 passNULLto 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:
#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 withulConfigSetJavaScriptDiagnostics()andulConfigSetDOMDiagnostics(). 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:
- Destroy and
IsAlivefunctions for JavaScript, DOM, data-binding, and layout handles (such asulDestroyJSValue()andulDOMElementIsAlive()). - Most reference functions for those same families.
- Task posting functions, including
ulRendererPostTask(),ulAppPostTask(),ulJSContextPostTask(), andulDOMDataContextPostTask().
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:
- Functions use C linkage. Every function uses the platform's default C calling convention (never
__stdcall). - Handles are opaque pointers. All library object handles pass across the API as opaque pointers.
- Pass small value structs by value. Match their C struct layout exactly for types such as
ULRect,ULColor, andULRenderTarget. - Initialize
struct_sizeon descriptor structs. When using AppCore descriptor structs, setstruct_sizeto the byte size of your mirrored struct.
Callbacks and Handlers
When passing callbacks to C, manage their state and errors carefully:
- Store language closures in
user_data. Pass your language's closure handle (such as aGCHandlein C# or a boxed closure in Rust) inuser_data. Free it insidedestroy_user_data, which the library calls exactly once, potentially on another thread. - Platform handlers do not take
user_data. Keep their function pointers valid for the lifetime of the Renderer (in C#, prevent your delegate instances from being garbage collected). For details, see Platform Handlers in C. - Never unwind into the library. Catch all exceptions, panics, and
longjmpcalls at the callback boundary. In a DOM callback, report any caught error withulDOMReportCallbackException()so the library logs it and continues dispatching events.
Finalizers and Object Identity
Clean up handles and check object identity using these methods:
- Most handles are safe to destroy in finalizers. Calling destroy and
IsAlivefunctions for JavaScript, DOM, data-binding, and layout handles is safe from any thread. - Core handles must be destroyed on the Renderer's thread. Dispose
ULView,ULRenderer, andULSessionhandles explicitly on that thread, or post the destroy call withulRendererPostTask(). - Compare identity with
IsSamefunctions. If your binding caches wrapper objects by handle, compare them using the family'sIsSamefunction rather than comparing raw pointer addresses.
Strings and Editions
Account for text encoding, edition differences, and version checks in your code:
- Strings use UTF-8 encoding. Functions that accept a pointer and byte length work with non-NUL-terminated slices (such as Rust
&str). - Use UTF-8 getters to avoid string allocations. Functions like
ulJSValueGetUTF8(),ulDOMElementGetAttributeUTF8(), andulDOMElementGetTextContentUTF8()write into your provided buffer and return the required size when the buffer is too small. - Feature macros differ across editions. The
UL_HAS()feature macros remove declarations from the C headers at compile time, so one edition's library binary may lack functions declared in another. Resolve optional functions dynamically at load time, or generate separate bindings per edition. - Check library versions at runtime. Call
ulVersionMajor()and its companion functions to inspect the library version at runtime (the C API provides no compile-time version macros).