# remote-dom-vm: efficiently render to DOM from a worker Run all your UI code from inside a web worker, rather than on the main thread. This project provides a minimal instruction set for efficiently driving the DOM in a write-only fashion from Rust WASM code in a web worker. That is its scope. The “web worker” and “Rust WASM” bits are technically negotiable, but if you want to use it outside of a worker or in a different language then you’ll have to implement those parts yourself. “Write-only” means that you can perform write operations like Document.createElement, Element.setAttribute and Node.appendChild, but not read operations like Element.getAttribute, ParentNode.children or Element.getBoundingClientRect. ## The architecture The main thread runs a small VM, written in JavaScript, which takes byte code instructions and executes them. The worker runs Rust code that accepts and queues DOM mutations into byte code, then can flush them to the main thread to be executed by the TypeScript VM. Logically, communication only operates in a single direction: from worker to main thread, write-only. This is more efficient than bidirectional communication. DOM nodes are accessed by index, with nodes assigned incremental indexes. (It’s possible that in the future the worker will assign IDs; but for now, auto-incrementing is adequate.) Since DOM operations are only performed by this VM when queued instructions are flushed, this gets you efficient batching with no hazard of triggering layout multiple times in a frame. Indeed, at this time you can’t query anything about the DOM or layout. ## Background: DOM interop from WASM At the time of writing, WASM code can’t interact with the DOM directly. Instead, it has to go through JavaScript FFI, where the FFI performs the operation and gives the Rust what amounts to a pointer. Here’s an approximation of how it happens: JavaScript code (written here as TypeScript): ◊code.ts` type Ref = number; let refs: {[ref: Ref]: Node} = {}; let next_ref: Ref = 0; exports.get_document = function (): Ref { const document_ref = next_ref++; refs[document_ref] = document; return document_ref; }; exports.create_element = function (document_ref: number, element_name: string) { const document = refs[document_ref]; const result = document.createElement(element_name); }; ` And the Rust code that uses it: ◊code.rs` let document_ref = get_document(); let element_ref = create_element(document_ref, "div"); ` It is intended that eventually WASM be able to work with browser GC objects directly; when that is done, this JS layer will be able to disappear. (Rust’s wasm-bindgen has been designed with such future-compatibility in mind.) I have decided to instead view this FFI layer as a feature, and take the separation further, performing not synchronous FFI, but asynchronous cross-thread FFI. This also explains part of why a bytecode VM is used, rather than using objects for structure, as WorkerDOM does (though even with it, you will notice that it uses a dictionary to compress its objects, which suggests to me that they found that so doing improved memory usage and/or performance). In the end, even once WASM can work with GC objects directly, if you use structured objects, you’ll still be needing to do some forms of type conversion, so it’s fastest to just do all the work in an `ArrayBuffer` to avoid needing more than one format shift. ## Why? The reasons in the first 41 slides of apply. This is an experiment in various things: • efficient batching of DOM operations; • seeing how much can be shifted off the main thread; • seeing if even event handling can be shifted so. The less you run on the main thread, the greater your chances of avoiding all jank. There are two logical extremes here: ① run no code; and ② run all code on workers. This experiment runs with the second of those logical extremes, seeing how close we can get to it. A possible goal is effective full-powered rendering to popup windows from the same code, which frameworks don’t tend to consider. A stretch goal is seeing if we can run all of this not in a worker on the concerned document, but on the service worker; if it can be done, this could introduce interesting possibilities in totally synced multiple-tab support, and improved memory efficiency with respect to local object caches. Not sure whether it’ll pan out; my service worker is rusty. ## Event handling Event handling will be most interesting, to see whether it is actually possible to manage without maintaining at least the structure of the DOM on the worker side, because I’m pairing it with doing event dispatch entirely manually, so that we just register one event handler for each type on the window or document, and traverse our own *component* tree to dispatch. (The most interesting thing there will be preventDefault(); that must be done synchronously, so we’ll need to define on the main thread a pure function to decide whether that should be called. I think it should generally be reasonable, but it will probably require breaking out of the component model occasionally. Why am I writing all this here? This stuff belongs in a section of its own, not in the “Why?” section.) ## Performing other operations (especially getters) from the worker That is, things like `Window.getComputedStyle`, `Element.getBoundingClientRect` and `Element.scrollTop` for the inevitable occasions when you need them. Also other mutating methods that aren’t particularly useful without such getters, like `Element.scrollTo` which is only useful for zero and infinity unless you can get the coordinates of something inside it. Answer: I don’t know. I’m going to start without them and see what happens when I need them. I have a few ideas: I may relax the “write-only” nature of the instruction set; or introduce some kind of FFI layer in the byte code; or declare it out of scope, just providing a `get_node(Ref)` function on the VM in the main thread; or genuinely refuse them and see where that constraint leaves me. ## Similar or related projects ### [WorkerDOM ] I had the idea for this project fairly firmly mapped out in my mind, then I went and searched the web to see if someone else had implemented something like it. I found WorkerDOM, published about nine months prior, which implements the full DOM API inside a Web Worker. Most of WorkerDOM’s rationale applies to remote-dom-vm as well; the talk in which it was announced, [*WorkerDOM: JavaScript Concurrency and the DOM* ], is great. WorkerDOM maintains its own representation of the structure of the real DOM inside the worker. DOM methods then fall into three categories: 1. Methods that can be implemented based upon this record of the DOM structure, by copying the algorithm browsers use (this is almost everything); 2. Methods that need to be run asynchronously instead: mostly for methods that interact with layout, such as getBoundingClientRect; asynchronous alternatives are defined which call the main thread to perform the operation and yield the answer. 3. Methods that are unimplementable with no alternative: synchronous methods on events, like event.preventDefault(). You can’t do those asynchronously, so you’ll need a bit of main thread code there. By comparison, remote-dom-vm is deliberately write-only, and does not maintain any knowledge of DOM structure, exposing only a few mutator method calls and no getters. You’re expected to maintain any knowledge of the structure that you need yourself. This makes it *much* more flexible than remote-dom-vm, which roughly just defines an assembly code. Notably, remote-dom-vm has no interest at this time in providing *read* access to *anything*. Even if it ever does, it’s not going to be done as a DOM shim. ### [Glimmer ] Glimmer manages to be a rather fast DOM rendering engine by using a VM architecture. Its instruction set is a good deal more complicated than remote-dom-vm’s, as it represents a lot more semantics; remote-dom-vm’s instruction set, by comparison, is limited to just a few DOM things. Glimmer is CISC to remote-dom-vm’s RISC, if you like. ## Author [Chris Morgan ] is the primary author and maintainer of remote-dom-vm. ## License Copyright © 2019– Chris Morgan This project is distributed under the terms of three different licenses, at your choice: • [Blue Oak Model License 1.0.0 ] • [MIT License ] • [Apache License, Version 2.0 ] If you do not have particular cause to select the MIT or the Apache-2.0 license, Chris Morgan recommends that you select BlueOak-1.0.0, which is better and simpler than both MIT and Apache-2.0, which are only offered due to their greater recognition and their conventional use in the Rust ecosystem. (BlueOak-1.0.0 was only published in March 2019.) When using this code, ensure you comply with the terms of at least one of these licenses.