Skip to content

Pass objects through the bridge

fakeivchenko edited this page Oct 3, 2026 · 2 revisions

A binding and an event carry text by default. With a codec, they carry records and other objects: the page passes a JavaScript object, and Java receives it as the type that it asks for, and back. In this guide, a task list keeps its tasks in Java as records.

A task list whose tasks live in Java, on Linux and on Windows 11

Add a codec

With the lwjwae Gradle plugin, one line in build.gradle.kts adds a codec at the version that the plugin knows:

lwjwae {
    jackson()   // or gson(), or jsonb()
}

The application finds the codec on the classpath by itself. Without the plugin, add dev.ivchenko.lwjwae.codec:lwjwae-codec-jackson to runtimeOnly, or pass a codec to ApplicationParameters.builder().codec(...).

Pass objects both ways

  1. Describe what crosses the bridge as a record:

    public record Task(String title, boolean done) {}
  2. Bind a function that takes a Task and returns the list, and listen to an event that carries a number:

    public class TypedBridge {
      private static final List<Task> TASKS = new CopyOnWriteArrayList<>();
    
      public static void main(String[] args) {
        Application.launch(
            WindowParameters.of("Tasks", 560, 440),
            window -> {
              window.bind("addTask", Task.class, TypedBridge::add);
              window.listen("toggle", Integer.class, index -> TypedBridge.toggle(window, index));
              window.loadResource("typed/index.html");
            });
      }
    
      private static List<Task> add(Task task) {
        TASKS.add(task);
        return TASKS;
      }
    
      private static void toggle(Window window, int index) {
        Task task = TASKS.get(index);
        TASKS.set(index, new Task(task.title(), !task.done()));
        window.emit("tasks", TASKS);
      }
    }

    bind(name, Class, handler) decodes the argument into the class, and encodes what the handler returns. listen(name, Class, listener) decodes the payload of an event. emit(name, Object) encodes an object into the payload.

  3. On the page, pass objects and receive them:

    const list = document.querySelector("#tasks");
    
    function show(tasks) {
      list.replaceChildren(...tasks.map((task, index) => {
        const item = document.createElement("li");
        const box = Object.assign(document.createElement("input"), { type: "checkbox", checked: task.done });
        box.addEventListener("change", () => lwjwae.emit("toggle", index));
        const title = Object.assign(document.createElement("span"), { textContent: task.title });
        title.classList.toggle("done", task.done);
        item.append(box, title);
        return item;
      }));
    }
    
    document.querySelector("#add").addEventListener("submit", async (event) => {
      event.preventDefault();
      const title = document.querySelector("#title");
      show(await window.addTask({ title: title.value, done: false }));
      title.value = "";
    });
    
    lwjwae.listen("tasks", (event) => show(event.payload));

    window.addTask resolves to the list as an array of objects, and event.payload of an event that Java emitted with an object is the object, not its text.

In a native image

A codec reads and creates the records through reflection, which a native image allows only for the classes that its metadata names. Mark the outermost type that crosses the bridge with @BridgeType:

@BridgeType
public record Task(String title, boolean done) {}

The lwjwae-processor annotation processor writes the metadata for it and for every type that it holds: the components of a record, the fields of a class, the type arguments of a List<Task>. The Gradle plugin adds the processor from lwjwae 0.8.0, so there is nothing else to do.

Without the plugin, or with an older lwjwae, add the processor to annotationProcessor yourself, or list every type that crosses the bridge, and the types that they hold, in src/main/resources/META-INF/native-image/GROUP/NAME/reachability-metadata.json:

{
  "reflection": [
    {
      "type": "com.example.guides.Task",
      "allDeclaredConstructors": true,
      "allDeclaredMethods": true,
      "allDeclaredFields": true
    }
  ]
}

Good to know

  • A handler runs on a virtual thread, so it may block, as add would to write a file.
  • A binding on the Application instead of a Window reaches every window, and its handler can take the window that called; see Open several windows.
  • Without a codec, the typed methods fail with a message that says so, and the text methods keep working.

Clone this wiki locally