diff --git a/examples/virtual-dom/platform/Html/Internal.roc b/examples/virtual-dom/platform/Html/Internal.roc
index 161dc34c97..d3baca3530 100644
--- a/examples/virtual-dom/platform/Html/Internal.roc
+++ b/examples/virtual-dom/platform/Html/Internal.roc
@@ -1,5 +1,6 @@
interface Html.Internal
exposes [
+ App,
Node,
Attribute,
CyclicStructureAccessor,
@@ -9,6 +10,7 @@ interface Html.Internal
insertHandler,
replaceHandler,
dispatchEvent,
+ rocScript,
appendRenderedStatic,
nodeSize,
]
@@ -219,3 +221,42 @@ dispatchEvent = \lookup, handlerId, eventData, state ->
Ok (Custom handler) ->
handler state eventData
+
+HtmlId : Str
+
+App state : {
+ static : Node [],
+ initDynamic : Str -> state,
+ renderDynamic : state -> Dict HtmlId (Node state),
+}
+
+rocScript : Str, List HtmlId, Str -> Result (Node []) [InvalidUtf8]*
+rocScript = \initData, dynamicRootIds, wasmUrl ->
+ toJs = \data ->
+ data
+ |> Encode.toBytes Json.toUtf8
+ |> Str.fromUtf8
+ encInitData = toJs initData
+ encDynamicRootIds = toJs dynamicRootIds
+ encWasmUrl = toJs wasmUrl
+
+ when { encInitData, encDynamicRootIds, encWasmUrl } is
+ { encInitData: Ok jsInitData, encDynamicRootIds: Ok jsDynamicRootIds, encWasmUrl: Ok jsWasmUrl } ->
+ elem : Node []
+ elem = (element "script") [] [
+ Text
+ """
+ \(virtualDomJavaScript)
+ (function() {
+ const initData = \(jsInitData);
+ const dynamicRootIds = \(jsDynamicRootIds);
+ const wasmUrl = \(jsWasmUrl);
+ window.roc.init(initData, dynamicRootIds, wasmUrl);
+ })();
+ """,
+ ]
+
+ Ok elem
+
+ _ ->
+ Err InvalidUtf8
diff --git a/examples/virtual-dom/platform/Html/VdomJs.roc b/examples/virtual-dom/platform/Html/VdomJs.roc
new file mode 100644
index 0000000000..7dc02eeecd
--- /dev/null
+++ b/examples/virtual-dom/platform/Html/VdomJs.roc
@@ -0,0 +1,279 @@
+interface Html.VdomJs
+ exposes [virtualDomJavaScript]
+ imports []
+
+virtualDomJavaScript : Str
+virtualDomJavaScript =
+ """
+ /** @typedef {(e: Event) => void} JsEventDispatcher */
+
+ /** @typedef {[string, JsEventDispatcher]} Listener */
+
+ /**
+ * @typedef {Object} RocWasmExports
+ * @property {(size: number, alignment: number) => number} roc_alloc
+ * @property {(jsonListAddr: number, jsonListLength: number, handlerId: number) => void} roc_dispatch_event
+ * @property {() => number} main
+ */
+
+ /**
+ * @typedef {{ObjectField: [string, CyclicStructureAccessor]} | {ArrayIndex: [number, CyclicStructureAccessor]} | {SerializableValue: []}} CyclicStructureAccessor
+ */
+
+ /** @type {Array} */
+ const nodes = [];
+
+ /** @type {Array} */
+ const listeners = [];
+
+ const utf8Decoder = new TextDecoder();
+ const utf8Encoder = new TextEncoder();
+
+ /**
+ * @param {string} wasmFilename
+ */
+ export const init = async (wasmFilename) => {
+ const effects = {
+ /**
+ * @param {number} tagAddr
+ */
+ createElement: (tagAddr) => {
+ const tagName = decodeRocStr(tagAddr);
+ const node = document.createElement(tagName);
+ return insertNode(node);
+ },
+
+ /**
+ * @param {number} contentAddr
+ */
+ createTextNode: (contentAddr) => {
+ const content = decodeRocStr(contentAddr);
+ const node = document.createTextNode(content);
+ return insertNode(node);
+ },
+
+ /**
+ * @param {number} parentId
+ * @param {number} childId
+ */
+ appendChild: (parentId, childId) => {
+ const parent = nodes[parentId];
+ const child = nodes[childId];
+ parent.appendChild(child);
+ },
+
+ /**
+ * @param {number} id
+ */
+ removeNode: (id) => {
+ var _a;
+ const node = nodes[id];
+ nodes[id] = null;
+ node.parentElement.removeChild(node);
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} typeAddr
+ * @param {number} valueAddr
+ */
+ setAttribute: (nodeId, typeAddr, valueAddr) => {
+ const node = nodes[nodeId];
+ const name = decodeRocStr(typeAddr);
+ const value = decodeRocStr(valueAddr);
+ node.setAttribute(name, value);
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} typeAddr
+ */
+ removeAttribute: (nodeId, typeAddr) => {
+ const node = nodes[nodeId];
+ const name = decodeRocStr(typeAddr);
+ node.removeAttribute(name);
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} propNameAddr
+ * @param {number} jsonAddr
+ */
+ setProperty: (nodeId, propNameAddr, jsonAddr) => {
+ const node = nodes[nodeId];
+ const propName = decodeRocStr(propNameAddr);
+ const json = decodeRocListUtf8(jsonAddr);
+ const value = JSON.parse(json);
+ node[propName] = value;
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} propNameAddr
+ */
+ removeProperty: (nodeId, propNameAddr) => {
+ const node = nodes[nodeId];
+ const propName = decodeRocStr(propNameAddr);
+ node[propName] = null;
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} eventTypeAddr
+ * @param {number} accessorsJsonAddr
+ * @param {number} handlerId
+ */
+ setListener: (nodeId, eventTypeAddr, accessorsJsonAddr, handlerId) => {
+ const element = nodes[nodeId];
+ const eventType = decodeRocStr(eventTypeAddr);
+ const accessorsJson = decodeRocStr(accessorsJsonAddr);
+ const accessors = JSON.parse(accessorsJson);
+
+ // Dispatch a DOM event to the specified handler function in Roc
+ const dispatchEvent = (ev) => {
+ const { roc_alloc, roc_dispatch_event } = app.exports;
+ const outerListRcAddr = roc_alloc(4 + accessors.length * 12, 4);
+ memory32[outerListRcAddr >> 2] = 1;
+ const outerListBaseAddr = outerListRcAddr + 4;
+ let outerListIndex32 = outerListBaseAddr >> 2;
+ accessors.forEach((accessor) => {
+ const json = accessCyclicStructure(accessor, ev);
+ const length16 = json.length;
+
+ // Due to UTF-8 encoding overhead, a few code points go from 2 bytes in UTF-16 to 3 bytes in UTF-8!
+ const capacity8 = length16 * 3; // Extremely "worst-case", but simple, and the allocation is short-lived.
+ const rcAddr = roc_alloc(4 + capacity8, 4);
+ memory32[rcAddr >> 2] = 1;
+ const baseAddr = rcAddr + 4;
+
+ // Write JSON to the heap allocation of the inner `List U8`
+ const allocation = memory8.subarray(baseAddr, baseAddr + capacity8);
+ const { written } = utf8Encoder.encodeInto(json, allocation);
+ const length = written || 0; // TypeScript claims that `written` can be undefined, though I don't see this in the spec.
+
+ // Write the fields of the inner `List U8` into the heap allocation of the outer List
+ memory32[outerListIndex32++] = baseAddr;
+ memory32[outerListIndex32++] = length;
+ memory32[outerListIndex32++] = capacity8;
+ });
+
+ roc_dispatch_event(outerListBaseAddr, accessors.length, handlerId);
+ };
+
+ // Make things easier to debug
+ dispatchEvent.name = `dispatchEvent${handlerId}`;
+ element.setAttribute("data-roc-event-handler-id", `${handlerId}`);
+
+ listeners[handlerId] = [eventType, dispatchEvent];
+ element.addEventListener(eventType, dispatchEvent);
+ },
+
+ /**
+ * @param {number} nodeId
+ * @param {number} handlerId
+ */
+ removeListener: (nodeId, handlerId) => {
+ const element = nodes[nodeId];
+ const [eventType, dispatchEvent] = findListener(element, handlerId);
+ listeners[handlerId] = null;
+ element.removeAttribute("data-roc-event-handler-id");
+ element.removeEventListener(eventType, dispatchEvent);
+ },
+ };
+
+ /**
+ * decode a Roc `Str` to a JavaScript string
+ * @param {number} strAddr8
+ */
+ const decodeRocStr = (strAddr8) => {
+ const lastByte = memory8[strAddr8 + 12];
+ const isSmall = lastByte >= 0x80;
+ if (isSmall) {
+ const len = lastByte & 0x7f;
+ const bytes = memory8.slice(strAddr8, strAddr8 + len);
+ return utf8Decoder.decode(bytes);
+ } else {
+ return decodeRocListUtf8(strAddr8);
+ }
+ };
+
+ /**
+ * decode a Roc List of UTF-8 bytes to a JavaScript string
+ * @param {number} listAddr8
+ */
+ const decodeRocListUtf8 = (listAddr8) => {
+ const listIndex32 = listAddr8 >> 2;
+ const bytesAddr8 = memory32[listIndex32];
+ const len = memory32[listIndex32 + 1];
+ const bytes = memory8.slice(bytesAddr8, bytesAddr8 + len);
+ return utf8Decoder.decode(bytes);
+ };
+
+ const wasmImports = { effects };
+ const promise = fetch(wasmFilename);
+ const instanceAndModule = await WebAssembly.instantiateStreaming(
+ promise,
+ wasmImports
+ );
+ const app = instanceAndModule.instance;
+ const memory = app.exports.memory;
+ const memory8 = new Uint8Array(memory.buffer);
+ const memory32 = new Uint32Array(memory.buffer);
+
+ const { main } = app.exports;
+ const exitCode = main();
+ if (exitCode) {
+ throw new Error(`Roc exited with error code ${exitCode}`);
+ }
+ };
+
+ /**
+ * @param {Node} node
+ */
+ const insertNode = (node) => {
+ let i = 0;
+ for (; i < nodes.length; i++) {
+ if (!nodes[i]) break;
+ }
+ nodes[i] = node;
+ return i;
+ };
+
+ /**
+ * @param {CyclicStructureAccessor} accessor
+ * @param {any} structure
+ */
+ const accessCyclicStructure = (accessor, structure) => {
+ while (true) {
+ if ("SerializableValue" in accessor) {
+ return JSON.stringify(structure);
+ } else if ("ObjectField" in accessor) {
+ const [field, childAccessor] = accessor.ObjectField;
+ structure = structure[field];
+ accessor = childAccessor;
+ } else if ("ArrayIndex" in accessor) {
+ const [index, childAccessor] = accessor.ArrayIndex;
+ structure = structure[index];
+ accessor = childAccessor;
+ }
+ throw new Error("Invalid CyclicStructureAccessor");
+ }
+ };
+
+ /**
+ * @param {Element} element
+ * @param {number} handlerId
+ */
+ const findListener = (element, handlerId) => {
+ const listener = listeners[handlerId];
+ if (listener) {
+ return listener;
+ } else {
+ throw new Error(
+ `Event listener #${handlerId} not found. This is a bug in virtual-dom, not your app!` +
+ "It should have been on this node:\n" +
+ element.outerHTML
+ );
+ }
+ };
+ """