docs

DOM Access in C

Read, modify, and listen to the DOM tree of a page from C.

On this page

This page covers what differs when you use the DOM API from C— read About the DOM API and the C++ DOM pages first for underlying concepts, and see C API Conventions for shared rules.

📘 Preview API

The C DOM API is a preview— names and behavior may still change after 2.0.

CAPI_DOMDocument.h doesn't declare the ulDOMElement* functions or include CAPI_DOMElement.h, so include both— see C API Conventions for the other DOM headers.

Differences from C++

The C API replaces C++ DOM objects, properties, and operator overloads with opaque handles and C functions.

C++ C What Changes
dom::Document document(view) ulViewGetDOMDocument() Call inside the DOM-ready callback to receive an owned handle you must destroy.
Property proxies (el.textContent = "Saved") ulDOMElementSetTextContent() and getters Setters take a pointer and byte length, while getters return an owned ULString.
Typed views (el.AsInput(), event.AsMouse()) ulDOMElement* and ulDOMEvent* functions Calling a function on an incompatible element or event type returns a fallback value (such as 0, -1, false, or NULL).
std::optional (getAttribute(), selectedIndex) NULL and sentinel return values Missing attributes return NULL, and missing indices return -1.
el.dataset["topic"] ulDOMElementSetAttribute() Custom dataset properties are set and read through explicit data-* attribute names.
Range-based for over querySelectorAll() ULDOMElementList snapshot Queries return a snapshot list that you iterate by index and destroy.
el.style.left = dom::StyleValue::Px(x) ulDOMElementSetStylePropertyNumeric() and ulDOMElementSetStylePropertyColor() Set properties by name and unit. For per-frame updates, intern the name once with ulDOMInternStyleProperty() and set by ID (valid only in the calling process).
dom::Checked and dom::Result ULDOMError* out-parameter Page errors write to an optional out-parameter (see C API Conventions).
dom::AddEventListenerOptions and dom::AbortController ULDOMEventFlags bit flags Bit flags replace options structs. You remove listeners manually with ulDOMEventListenerRemove().
dom::Triggers object ULDOMTriggers handle Attach the handle to a View to maintain listeners across page navigations.

Getting the Document

To access a page's DOM, retrieve its document handle inside your DOM-ready callback.

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

static void OnDOMReady(void* user_data, ULView caller,
                       unsigned long long frame_id, bool is_main_frame,
                       ULString url) {
  (void)user_data;
  (void)frame_id;
  (void)url;
  if (!is_main_frame)
    return;

  ULDOMDocument document = ulViewGetDOMDocument(caller);
  ULDOMElement status = ulDOMDocumentGetElementById(document, "status");

  const char* text = "Connected";
  ulDOMElementSetTextContent(status, text, strlen(text));
  ulDOMElementClassListAdd(status, "online", NULL);

  ulDestroyDOMElement(status);
  ulDestroyDOMDocument(document);
}

void WatchPages(ULView view) {
  ulViewSetDOMReadyCallback(view, OnDOMReady, NULL, NULL);
}

The DOM-ready callback runs for every frame in the View. Check is_main_frame so you process only the root document.

Each page navigation creates a new document. Previous handles stop working once the page navigates away (see C API Conventions).

Frame Documents

To inspect the document inside an <iframe> or <frame>, call ulDOMElementGetContentDocument() on the frame element. The returned handle is an owned document that stops working when the frame navigates or is removed.

Passing Text

Functions that take text and an explicit byte length accept NULL with a length of 0 to represent an empty string— calling ulDOMElementSetTextContent(element, NULL, 0) clears the element. Passing NULL with a non-zero length fails as if the handle's page were gone.

Destroying Handles

Every handle a DOM function returns is yours to destroy (see C API Conventions).

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

void MarkSeen(ULDOMDocument document) {
  ULDOMElementList items =
      ulDOMDocumentQuerySelectorAll(document, "#log li", NULL);
  size_t count = ulDOMElementListGetLength(items);

  for (size_t i = 0; i < count; i++) {
    ULDOMElement item = ulDOMElementListGetElement(items, i);
    ulDOMElementClassListAdd(item, "seen", NULL);
    ulDestroyDOMElement(item);
  }

  ulDestroyDOMElementList(items);
}

ulDOMDocumentQuerySelectorAll() returns a snapshot list handle. Each call to ulDOMElementListGetElement() creates a fresh element handle that you own, so destroy each item before destroying the list with ulDestroyDOMElementList().

🚧 Callback Handles Are Borrowed

The ULDOMEvent handle, a delegated callback's matched_target, and a triggers hook's ULDOMDocument are borrowed— they remain valid only while the callback runs. Never destroy them or store them across calls. To keep one longer, call ulCreateDOMElementRef() or ulCreateDOMDocumentRef(). Calling ulDOMEventGetTarget() inside the callback returns an owned handle, so you must destroy it.

📘 Reusing Error Structs

When reusing a ULDOMError struct across calls, destroy any non-NULL message with ulDestroyString() and zero the struct again. A new DOM error overwrites both fields without freeing the previous message, leaking the string.

Listening for Events

To listen for events on an element, register a callback with a state pointer and a destroy hook.

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

typedef struct {
  int clicks;
} SaveState;

static ULDOMEventListener save_listener = NULL;

static void OnSaveClick(void* user_data, ULDOMEvent event) {
  SaveState* state = user_data;
  state->clicks++;

  ULDOMElement button = ulDOMEventGetTarget(event);
  ulDOMElementSetTextContent(button, "Saved", 5);
  ulDestroyDOMElement(button);
}

void WireSave(ULDOMDocument document) {
  ULDOMElement save = ulDOMDocumentGetElementById(document, "save");
  SaveState* state = calloc(1, sizeof(SaveState));

  // The library calls free(state) once it's done with the listener.
  save_listener = ulDOMElementAddEventListener(
      save, "click", kULDOMEventFlags_None, OnSaveClick, state, free);
  ulDestroyDOMElement(save);
}

void UnwireSave(void) {
  ulDOMEventListenerRemove(save_listener);
  ulDestroyDOMEventListener(save_listener);
  save_listener = NULL;
}

Destroying a listener handle doesn't stop the listener. To stop receiving events, call ulDOMEventListenerRemove() before destroying the handle (see C API Conventions).

Ownership of user_data transfers directly to the listener, so never free it yourself. The destroy_user_data hook runs once on the Renderer's thread in one of two situations:

Other options and listener types include:

For details on event phases and delegation, see Handling DOM Events.

When Callbacks Run

Callbacks run synchronously on the Renderer's thread, inside the call that dispatched the event. Input events run inside ulViewFireMouseEvent() or ulViewFireKeyEvent(). Custom events run inside ulDOMElementDispatchEvent(). Events the page dispatches on its own run during ulUpdate().

Scroll and resize events dispatch when the View paints during ulRender()— never inside ulViewFireScrollEvent().

Wiring Every Page

To wire event listeners and hooks that apply to every page a View loads, attach a ULDOMTriggers set (see DOM Triggers and Navigation).

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

static ULDOMTriggers ui = NULL;

static void OnAnySave(void* user_data, ULDOMEvent event,
                      ULDOMElement button) {
  (void)user_data;
  (void)event;
  // Borrowed: don't destroy button.
  ulDOMElementSetTextContent(button, "Saved", 5);
}

static void OnReady(void* user_data, ULDOMDocument document) {
  (void)user_data;
  ULDOMElement status = ulDOMDocumentGetElementById(document, "status");
  ulDOMElementSetTextContent(status, "Ready", 5);
  ulDestroyDOMElement(status);
}

bool SetupUI(ULView view) {
  ui = ulCreateDOMTriggers();
  ulDOMTriggersOn(ui, "#save", "click", kULDOMEventFlags_None, OnAnySave,
                  NULL, NULL);
  ulDOMTriggersOnDOMReady(ui, OnReady, NULL, NULL);

  // Attach before loading content so the first page gets the set too.
  return ulViewAttachDOMTriggers(view, ui, kULDOMTriggersAttachFlags_None,
                                 NULL, 0);
}

void ShutdownUI(void) {
  // The last owning handle: the set detaches from every View.
  ulDestroyDOMTriggers(ui);
  ui = NULL;
}

Keep a few things in mind when working with trigger sets:

Threading Rules

You must call DOM functions on the Renderer's thread (see C API Conventions).

In addition to the standard reference, destroy, and alive checks, a few DOM operations are safe to call from any thread:

To inspect or modify the DOM from another thread, post a task to the Renderer's thread with ulRendererPostTask() (see Renderer in C).