From 6aa0338c0f52b9c1c437ad6e2154a57f5508da01 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 5 Aug 2026 04:08:13 +0200 Subject: [PATCH] wasm improvements --- additional/dom-env.js | 121 ++++++++++++++- additional/dom-webgpu.js | 41 ++++- implementations/Crafter.Graphics-Dom.cpp | 163 ++++++++++++++++---- implementations/Crafter.Graphics-Router.cpp | 123 ++++++++++++++- implementations/Crafter.Graphics-Window.cpp | 28 ++++ interfaces/Crafter.Graphics-Dom.cppm | 102 ++++++++++-- interfaces/Crafter.Graphics-Router.cppm | 54 ++++++- interfaces/Crafter.Graphics-Window.cppm | 15 ++ project.cpp | 26 +++- 9 files changed, 603 insertions(+), 70 deletions(-) diff --git a/additional/dom-env.js b/additional/dom-env.js index 6e9d8ce..eda0273 100644 --- a/additional/dom-env.js +++ b/additional/dom-env.js @@ -109,10 +109,60 @@ function setStyle(cookie, stylePtr, styleLen) { const el = __jsmemory.get(cookie); if (el) el.style.cssText = __readUtf8(stylePtr, styleLen); } +// NOTE: setProperty is a CSS custom-property setter (el.style.setProperty), +// NOT setAttribute. The name is historical and misleading; the attribute +// functions below are the ones you want for href/src/disabled/aria-*. function setProperty(cookie, propPtr, propLen, valPtr, valLen) { const el = __jsmemory.get(cookie); if (el) el.style.setProperty(__readUtf8(propPtr, propLen), __readUtf8(valPtr, valLen)); } +// Real attribute access. Without these the only way to change an href, src, +// disabled or aria-* was to re-render the parent's innerHTML, which destroys +// every descendant (and their event listeners) to change one string. +function setAttribute(cookie, namePtr, nameLen, valPtr, valLen) { + const el = __jsmemory.get(cookie); + if (el) el.setAttribute(__readUtf8(namePtr, nameLen), __readUtf8(valPtr, valLen)); +} +function removeAttribute(cookie, namePtr, nameLen) { + const el = __jsmemory.get(cookie); + if (el) el.removeAttribute(__readUtf8(namePtr, nameLen)); +} +// Returns 0 for "absent" so the caller can tell it apart from an attribute +// that is present with an empty value (`disabled=""`, `alt=""`) — which +// matters because for boolean attributes presence alone is the signal. +function getAttribute(cookie, namePtr, nameLen) { + const el = __jsmemory.get(cookie); + if (!el) return 0; + const v = el.getAttribute(__readUtf8(namePtr, nameLen)); + if (v === null) return 0; + return __writeUtf8(v); +} +function hasAttribute(cookie, namePtr, nameLen) { + const el = __jsmemory.get(cookie); + if (!el) return false; + return el.hasAttribute(__readUtf8(namePtr, nameLen)); +} +// Checkbox / radio state. el.value on a checkbox returns "on" regardless of +// whether it is ticked, so GetValue cannot express this. +function getChecked(cookie) { + const el = __jsmemory.get(cookie); + return !!(el && el.checked); +} +function setChecked(cookie, checked) { + const el = __jsmemory.get(cookie); + if (el) el.checked = !!checked; +} +// Move keyboard focus. AddFocusListener could observe focus but nothing +// could set it, which makes an error summary ("jump to the first invalid +// field") impossible to implement accessibly. +function focusElement(cookie) { + const el = __jsmemory.get(cookie); + if (el && el.focus) el.focus(); +} +function blurElement(cookie) { + const el = __jsmemory.get(cookie); + if (el && el.blur) el.blur(); +} function addClass(cookie, namePtr, nameLen) { const el = __jsmemory.get(cookie); if (el) el.classList.add(__readUtf8(namePtr, nameLen)); @@ -161,12 +211,25 @@ function __dpr() { return window.crafter_dpr || window.devicePixelRatio || 1; } +// `preventDefault` is opt-in per listener (the C++ Add*Listener overloads +// take it as a trailing argument). Two reasons it has to exist here rather +// than being something C++ can do after the fact: +// * the wasm callback runs synchronously inside the dispatch, but the +// decision has to be made before the browser acts on the event, and +// there is no handle to the event object on the C++ side; +// * intercepting a click on a real — the whole basis of +// client-side routing over crawlable links — is impossible without it. +// Cancelling happens BEFORE the wasm call so a trap or exception inside the +// handler can't leave the default action to fire anyway. +// `{ passive: false }` is required or the browser silently ignores +// preventDefault on wheel (and warns); harmless on the other kinds. function __makeMouseListenerPair(kind, eventName, exportName) { return { - add(cookie, id) { + add(cookie, id, preventDefault) { const el = __jsmemory.get(cookie); if (!el) return; const handler = (event) => { + if (preventDefault) event.preventDefault(); const s = __dpr(); __wasm()[exportName](id, event.clientX * s, event.clientY * s, @@ -175,7 +238,7 @@ function __makeMouseListenerPair(kind, eventName, exportName) { event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); }; __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler); - el.addEventListener(eventName, handler); + el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined); }, remove(cookie, id) { const el = __jsmemory.get(cookie); @@ -188,17 +251,18 @@ function __makeMouseListenerPair(kind, eventName, exportName) { } function __makeKeyListenerPair(kind, eventName, exportName) { return { - add(cookie, id) { + add(cookie, id, preventDefault) { const el = __jsmemory.get(cookie); if (!el) return; const handler = (event) => { + if (preventDefault) event.preventDefault(); const keyPtr = __writeUtf8(event.key || ""); __wasm()[exportName](id, keyPtr, event.keyCode, event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); __wasm().WasmFree(keyPtr); }; __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler); - el.addEventListener(eventName, handler); + el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined); }, remove(cookie, id) { const el = __jsmemory.get(cookie); @@ -339,9 +403,10 @@ const __scrollPair = { } }; const __wheelPair = { - add(cookie, id) { + add(cookie, id, preventDefault) { const el = __jsmemory.get(cookie); if (!el) return; const handler = (event) => { + if (preventDefault) event.preventDefault(); const s = __dpr(); __wasm().ExecuteWheelHandler(id, event.deltaX, event.deltaY, event.deltaZ, event.deltaMode, @@ -350,7 +415,10 @@ const __wheelPair = { event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); }; __listenerHandlers.set(`${cookie}-${id}-wheel`, handler); - el.addEventListener("wheel", handler); + // passive:false is mandatory here — Chrome treats wheel as passive by + // default and would otherwise drop the preventDefault with a console + // warning rather than an error. + el.addEventListener("wheel", handler, preventDefault ? { passive: false } : undefined); }, remove(cookie, id) { const el = __jsmemory.get(cookie); @@ -619,9 +687,45 @@ function removePopStateListener(id) { if (h) window.removeEventListener("popstate", h); __listenerHandlers.delete(`popstate-${id}`); } +// replaceState mirrors pushState but rewrites the current history entry +// instead of adding one. Needed for anything that changes the URL without +// being a distinct "back" destination — filter/sort state, canonicalising a +// sloppy incoming URL, or recording scroll position on the entry you are +// about to leave. +function replaceState(dataPtr, dataLen, titlePtr, titleLen, urlPtr, urlLen) { + const dataStr = __readUtf8(dataPtr, dataLen); + const titleStr = __readUtf8(titlePtr, titleLen); + const urlStr = __readUtf8(urlPtr, urlLen); + let parsed; + try { parsed = JSON.parse(dataStr); } catch { parsed = null; } + window.history.replaceState(parsed, titleStr, urlStr); +} function getPathName() { return __writeUtf8(window.location.pathname); } +// location.search / .hash, including their leading "?" / "#" so an empty +// query and "?" are distinguishable, and so the result can be concatenated +// straight back onto a path. pathname alone left query strings completely +// unreachable from wasm, which made ?page= / ?sort= impossible to read. +function getSearch() { + return __writeUtf8(window.location.search); +} +function getHash() { + return __writeUtf8(window.location.hash); +} +function getHref() { + return __writeUtf8(window.location.href); +} +// Full-page navigation, including cross-origin. pushState deliberately +// cannot leave the origin, so this is the only way for a wasm app to hand +// the user off to an external URL (an OAuth consent screen, a hosted +// payment page). `replace` omits the current page from history so Back +// doesn't return to a stale checkout. +function navigate(urlPtr, urlLen, replace) { + const url = __readUtf8(urlPtr, urlLen); + if (replace) window.location.replace(url); + else window.location.assign(url); +} // ─── Gamepad polling helper ─────────────────────────────────────────── // @@ -659,6 +763,8 @@ Object.assign(window.crafter_webbuild_env, { // DOM lookup / creation / mutation freeJs, getElementById, createElement, getBody, setInnerHTML, setStyle, setProperty, + setAttribute, removeAttribute, getAttribute, hasAttribute, + getChecked, setChecked, focusElement, blurElement, addClass, removeClass, toggleClass, hasClass, deleteElement, getValue, setValue, @@ -697,7 +803,8 @@ Object.assign(window.crafter_webbuild_env, { clipboardSetText, clipboardGetText, // History - pushState, addPopStateListener, removePopStateListener, getPathName, + pushState, replaceState, addPopStateListener, removePopStateListener, + getPathName, getSearch, getHash, getHref, navigate, // Gamepad gamepadPollConnected, gamepadPollDisconnected, diff --git a/additional/dom-webgpu.js b/additional/dom-webgpu.js index 4ba8518..5bee542 100644 --- a/additional/dom-webgpu.js +++ b/additional/dom-webgpu.js @@ -94,6 +94,16 @@ document.body.appendChild(canvas); // when set, the canvas is reparented into that element and sized to it. let mountEl = null; +// Upper bound on the canvas backing store, in pixels. Infinity until an RT +// dispatch derives the real budget from the device's buffer limits (see +// wgpuDispatchRT): the wavefront ray buffers scale with W·H·raysPerPixel, +// and a 256 MiB maxBufferSize (Firefox's baseline) overflows at ~0.5 Mpx +// for 4 rays/pixel — far below a HiDPI 16:9 mount — failing not at the +// createBuffer call but as an uncapturable device OOM at submit. The +// mounted canvas is CSS-pinned to width:100%/height:100%, so a clamped +// backing store upscales on screen instead of shrinking. +let maxCanvasPixels = Infinity; + function syncCanvasSize() { // Canvas pixel size = CSS size × devicePixelRatio so the GPU draws // at physical pixel resolution on HiDPI displays — otherwise the @@ -117,8 +127,13 @@ function syncCanvasSize() { cssW = window.innerWidth; cssH = window.innerHeight; } - const w = Math.max(1, Math.round(cssW * dpr)); - const h = Math.max(1, Math.round(cssH * dpr)); + let w = Math.max(1, Math.round(cssW * dpr)); + let h = Math.max(1, Math.round(cssH * dpr)); + if (w * h > maxCanvasPixels) { + const s = Math.sqrt(maxCanvasPixels / (w * h)); + w = Math.max(1, Math.floor(w * s)); + h = Math.max(1, Math.floor(h * s)); + } if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; return { w, h }; @@ -3511,6 +3526,28 @@ env.wgpuDispatchRT = (pipelineHandle, pushPtr, pushBytes, } const W = state.width, H = state.height; const depth = Math.max(1, maxDepth | 0); + // Clamp the render resolution to what the wavefront buffers can hold. + // The payload store is the largest per-ray cost (2 regions × + // WF_PAYLOAD_BYTES) and must fit both maxBufferSize and one storage + // binding; the indirect TRACE/SHADE dispatch additionally bounds rays + // at maxComputeWorkgroupsPerDimension×64. When the canvas is over + // budget, tighten maxCanvasPixels and sit this frame out — the next + // wgpuFrameBegin's ensureSized() shrinks the backing store (CSS keeps + // the on-screen size) and rendering resumes within budget. + const rpp = Math.max(1, raysPerPixel | 0); + const rayBudget = Math.min( + Math.floor(Math.min(device.limits.maxBufferSize, + device.limits.maxStorageBufferBindingSize) + / (2 * WF_PAYLOAD_BYTES)), + device.limits.maxComputeWorkgroupsPerDimension * 64); + const pixelBudget = Math.max(1, Math.floor(rayBudget / rpp)); + if (pixelBudget < maxCanvasPixels) maxCanvasPixels = pixelBudget; + if (W * H > maxCanvasPixels) { + console.warn(`[crafter-wgpu] ${W}x${H} at ${rpp} rays/px overflows the ` + + `wavefront budget (maxBufferSize ${device.limits.maxBufferSize}); ` + + `reducing render resolution to ~${maxCanvasPixels}px`); + return; + } const wf = ensureWavefrontBuffers(W, H, raysPerPixel); const cap = wf.cap; // per-bounce ray capacity = raysPerPixel·W·H diff --git a/implementations/Crafter.Graphics-Dom.cpp b/implementations/Crafter.Graphics-Dom.cpp index 4b21a9e..b9bf5fd 100644 --- a/implementations/Crafter.Graphics-Dom.cpp +++ b/implementations/Crafter.Graphics-Dom.cpp @@ -41,6 +41,24 @@ namespace Crafter::DomBindings { void SetProperty(std::int32_t ptr, const char* property, std::int32_t propertyLength, const char* value, std::int32_t valueLength); + __attribute__((import_module("env"), import_name("setAttribute"))) + void SetAttribute(std::int32_t ptr, + const char* name, std::int32_t nameLength, + const char* value, std::int32_t valueLength); + __attribute__((import_module("env"), import_name("removeAttribute"))) + void RemoveAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength); + __attribute__((import_module("env"), import_name("getAttribute"))) + const char* GetAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength); + __attribute__((import_module("env"), import_name("hasAttribute"))) + bool HasAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength); + __attribute__((import_module("env"), import_name("getChecked"))) + bool GetChecked(std::int32_t ptr); + __attribute__((import_module("env"), import_name("setChecked"))) + void SetChecked(std::int32_t ptr, bool checked); + __attribute__((import_module("env"), import_name("focusElement"))) + void FocusElement(std::int32_t ptr); + __attribute__((import_module("env"), import_name("blurElement"))) + void BlurElement(std::int32_t ptr); __attribute__((import_module("env"), import_name("addClass"))) void AddClass(std::int32_t ptr, const char* className, std::int32_t classNameLength); __attribute__((import_module("env"), import_name("removeClass"))) @@ -57,37 +75,54 @@ namespace Crafter::DomBindings { void SetValue(std::int32_t ptr, const char* value, std::int32_t valueLength); // Per-event-kind listener register / unregister imports. + // + // Two flavours, because only some DOM events are cancelable: + // CG_DOM_LISTENER_IMPORT_CANCELABLE — mouse, key and wheel kinds. The + // add takes a trailing `preventDefault` the JS side applies before + // dispatching into wasm (see additional/dom-env.js). + // CG_DOM_LISTENER_IMPORT — everything else. focus/blur/change/input/ + // resize/scroll are not cancelable, so a flag there would be a + // silent no-op and is better left off the API. `submit` is the odd + // one out: its JS handler ALWAYS calls preventDefault (a native form + // POST would navigate away from the wasm app), so it has no flag and + // no way to opt out. #define CG_DOM_LISTENER_IMPORT(addName, removeName) \ __attribute__((import_module("env"), import_name(#addName))) \ void addName(std::int32_t ptr, std::int32_t id); \ __attribute__((import_module("env"), import_name(#removeName))) \ void removeName(std::int32_t ptr, std::int32_t id); + #define CG_DOM_LISTENER_IMPORT_CANCELABLE(addName, removeName) \ + __attribute__((import_module("env"), import_name(#addName))) \ + void addName(std::int32_t ptr, std::int32_t id, bool preventDefault); \ + __attribute__((import_module("env"), import_name(#removeName))) \ + void removeName(std::int32_t ptr, std::int32_t id); - CG_DOM_LISTENER_IMPORT(addClickListener, removeClickListener) - CG_DOM_LISTENER_IMPORT(addMouseOverListener, removeMouseOverListener) - CG_DOM_LISTENER_IMPORT(addMouseOutListener, removeMouseOutListener) - CG_DOM_LISTENER_IMPORT(addMouseMoveListener, removeMouseMoveListener) - CG_DOM_LISTENER_IMPORT(addMouseDownListener, removeMouseDownListener) - CG_DOM_LISTENER_IMPORT(addMouseUpListener, removeMouseUpListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addClickListener, removeClickListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseOverListener, removeMouseOverListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseOutListener, removeMouseOutListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseMoveListener, removeMouseMoveListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseDownListener, removeMouseDownListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseUpListener, removeMouseUpListener) CG_DOM_LISTENER_IMPORT(addFocusListener, removeFocusListener) CG_DOM_LISTENER_IMPORT(addBlurListener, removeBlurListener) - CG_DOM_LISTENER_IMPORT(addKeyDownListener, removeKeyDownListener) - CG_DOM_LISTENER_IMPORT(addKeyUpListener, removeKeyUpListener) - CG_DOM_LISTENER_IMPORT(addKeyPressListener, removeKeyPressListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyDownListener, removeKeyDownListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyUpListener, removeKeyUpListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyPressListener, removeKeyPressListener) CG_DOM_LISTENER_IMPORT(addChangeListener, removeChangeListener) CG_DOM_LISTENER_IMPORT(addSubmitListener, removeSubmitListener) CG_DOM_LISTENER_IMPORT(addInputListener, removeInputListener) CG_DOM_LISTENER_IMPORT(addResizeListener, removeResizeListener) CG_DOM_LISTENER_IMPORT(addScrollListener, removeScrollListener) - CG_DOM_LISTENER_IMPORT(addContextMenuListener, removeContextMenuListener) - CG_DOM_LISTENER_IMPORT(addDragStartListener, removeDragStartListener) - CG_DOM_LISTENER_IMPORT(addDragEndListener, removeDragEndListener) - CG_DOM_LISTENER_IMPORT(addDropListener, removeDropListener) - CG_DOM_LISTENER_IMPORT(addDragOverListener, removeDragOverListener) - CG_DOM_LISTENER_IMPORT(addDragEnterListener, removeDragEnterListener) - CG_DOM_LISTENER_IMPORT(addDragLeaveListener, removeDragLeaveListener) - CG_DOM_LISTENER_IMPORT(addWheelListener, removeWheelListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addContextMenuListener, removeContextMenuListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragStartListener, removeDragStartListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragEndListener, removeDragEndListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDropListener, removeDropListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragOverListener, removeDragOverListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragEnterListener, removeDragEnterListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragLeaveListener, removeDragLeaveListener) + CG_DOM_LISTENER_IMPORT_CANCELABLE(addWheelListener, removeWheelListener) #undef CG_DOM_LISTENER_IMPORT + #undef CG_DOM_LISTENER_IMPORT_CANCELABLE // Per-event-kind callback maps. Counters are per-kind so two // different event kinds can share an id without aliasing — the JS @@ -272,6 +307,23 @@ namespace { trackingList.push_back(id); return id; } + // As AddImpl, for the cancelable kinds whose JS add takes the extra + // preventDefault flag. Separate overload rather than a defaulted + // parameter so passing the flag to a non-cancelable kind doesn't compile. + template + std::int32_t AddCancelableImpl(Crafter::DomBindings::HandlerTable& table, + std::int32_t ptr, + std::vector& trackingList, + JsAdd jsAdd, + std::function callback, + bool preventDefault) { + if (ptr == 0) return 0; + std::int32_t id = table.maxId++; + table.map.insert({id, std::move(callback)}); + jsAdd(ptr, id, preventDefault); + trackingList.push_back(id); + return id; + } template std::int32_t AddVoidImpl(Crafter::DomBindings::HandlerTable& table, std::int32_t ptr, @@ -412,6 +464,42 @@ namespace Crafter::Dom { if (ptr) Crafter::DomBindings::SetValue(ptr, value.data(), value.size()); } + void HtmlElementPtr::SetAttribute(const std::string_view name, + const std::string_view value) { + if (ptr) Crafter::DomBindings::SetAttribute(ptr, name.data(), name.size(), + value.data(), value.size()); + } + void HtmlElementPtr::RemoveAttribute(const std::string_view name) { + if (ptr) Crafter::DomBindings::RemoveAttribute(ptr, name.data(), name.size()); + } + std::optional HtmlElementPtr::GetAttribute(const std::string_view name) { + if (!ptr) return std::nullopt; + const char* raw = Crafter::DomBindings::GetAttribute(ptr, name.data(), name.size()); + // 0 means the attribute is absent, which is distinct from present-but- + // empty — for boolean attributes presence alone is the signal. + if (!raw) return std::nullopt; + std::string out(raw); + std::free(const_cast(raw)); + return out; + } + bool HtmlElementPtr::HasAttribute(const std::string_view name) { + if (!ptr) return false; + return Crafter::DomBindings::HasAttribute(ptr, name.data(), name.size()); + } + bool HtmlElementPtr::GetChecked() { + if (!ptr) return false; + return Crafter::DomBindings::GetChecked(ptr); + } + void HtmlElementPtr::SetChecked(bool checked) { + if (ptr) Crafter::DomBindings::SetChecked(ptr, checked); + } + void HtmlElementPtr::Focus() { + if (ptr) Crafter::DomBindings::FocusElement(ptr); + } + void HtmlElementPtr::Blur() { + if (ptr) Crafter::DomBindings::BlurElement(ptr); + } + // Listener wrappers. Each Add/Remove pair just plugs the right // table, kind index, and js import into the helper templates. The // 23 (+1 for popstate, lived in :Router) listener kinds previously @@ -423,6 +511,13 @@ namespace Crafter::Dom { handlerIds_[(std::size_t)Crafter::DomBindings::Kind::KindEnum], \ Crafter::DomBindings::JsAdd, std::move(cb)); \ } + #define CG_DOM_ADD_CANCELABLE(MethodName, JsAdd, TableName, EvType, KindEnum) \ + std::int32_t HtmlElementPtr::MethodName(std::function cb, \ + bool preventDefault) { \ + return AddCancelableImpl(Crafter::DomBindings::TableName, ptr, \ + handlerIds_[(std::size_t)Crafter::DomBindings::Kind::KindEnum], \ + Crafter::DomBindings::JsAdd, std::move(cb), preventDefault); \ + } #define CG_DOM_REMOVE(MethodName, JsRemove, TableName, KindEnum) \ void HtmlElementPtr::MethodName(std::int32_t id) { \ RemoveImpl(Crafter::DomBindings::TableName, ptr, \ @@ -430,27 +525,27 @@ namespace Crafter::Dom { Crafter::DomBindings::JsRemove, id); \ } - CG_DOM_ADD (AddClickListener, addClickListener, clickT, MouseEvent, Click) + CG_DOM_ADD_CANCELABLE(AddClickListener, addClickListener, clickT, MouseEvent, Click) CG_DOM_REMOVE(RemoveClickListener, removeClickListener, clickT, Click) - CG_DOM_ADD (AddMouseOverListener, addMouseOverListener, mouseOverT, MouseEvent, MouseOver) + CG_DOM_ADD_CANCELABLE(AddMouseOverListener, addMouseOverListener, mouseOverT, MouseEvent, MouseOver) CG_DOM_REMOVE(RemoveMouseOverListener, removeMouseOverListener, mouseOverT, MouseOver) - CG_DOM_ADD (AddMouseOutListener, addMouseOutListener, mouseOutT, MouseEvent, MouseOut) + CG_DOM_ADD_CANCELABLE(AddMouseOutListener, addMouseOutListener, mouseOutT, MouseEvent, MouseOut) CG_DOM_REMOVE(RemoveMouseOutListener, removeMouseOutListener, mouseOutT, MouseOut) - CG_DOM_ADD (AddMouseMoveListener, addMouseMoveListener, mouseMoveT, MouseEvent, MouseMove) + CG_DOM_ADD_CANCELABLE(AddMouseMoveListener, addMouseMoveListener, mouseMoveT, MouseEvent, MouseMove) CG_DOM_REMOVE(RemoveMouseMoveListener, removeMouseMoveListener, mouseMoveT, MouseMove) - CG_DOM_ADD (AddMouseDownListener, addMouseDownListener, mouseDownT, MouseEvent, MouseDown) + CG_DOM_ADD_CANCELABLE(AddMouseDownListener, addMouseDownListener, mouseDownT, MouseEvent, MouseDown) CG_DOM_REMOVE(RemoveMouseDownListener, removeMouseDownListener, mouseDownT, MouseDown) - CG_DOM_ADD (AddMouseUpListener, addMouseUpListener, mouseUpT, MouseEvent, MouseUp) + CG_DOM_ADD_CANCELABLE(AddMouseUpListener, addMouseUpListener, mouseUpT, MouseEvent, MouseUp) CG_DOM_REMOVE(RemoveMouseUpListener, removeMouseUpListener, mouseUpT, MouseUp) CG_DOM_ADD (AddFocusListener, addFocusListener, focusT, FocusEvent, Focus) CG_DOM_REMOVE(RemoveFocusListener, removeFocusListener, focusT, Focus) CG_DOM_ADD (AddBlurListener, addBlurListener, blurT, FocusEvent, Blur) CG_DOM_REMOVE(RemoveBlurListener, removeBlurListener, blurT, Blur) - CG_DOM_ADD (AddKeyDownListener, addKeyDownListener, keyDownT, KeyboardEvent, KeyDown) + CG_DOM_ADD_CANCELABLE(AddKeyDownListener, addKeyDownListener, keyDownT, KeyboardEvent, KeyDown) CG_DOM_REMOVE(RemoveKeyDownListener, removeKeyDownListener, keyDownT, KeyDown) - CG_DOM_ADD (AddKeyUpListener, addKeyUpListener, keyUpT, KeyboardEvent, KeyUp) + CG_DOM_ADD_CANCELABLE(AddKeyUpListener, addKeyUpListener, keyUpT, KeyboardEvent, KeyUp) CG_DOM_REMOVE(RemoveKeyUpListener, removeKeyUpListener, keyUpT, KeyUp) - CG_DOM_ADD (AddKeyPressListener, addKeyPressListener, keyPressT, KeyboardEvent, KeyPress) + CG_DOM_ADD_CANCELABLE(AddKeyPressListener, addKeyPressListener, keyPressT, KeyboardEvent, KeyPress) CG_DOM_REMOVE(RemoveKeyPressListener, removeKeyPressListener, keyPressT, KeyPress) CG_DOM_ADD (AddChangeListener, addChangeListener, changeT, ChangeEvent, Change) CG_DOM_REMOVE(RemoveChangeListener, removeChangeListener, changeT, Change) @@ -460,21 +555,21 @@ namespace Crafter::Dom { CG_DOM_REMOVE(RemoveResizeListener, removeResizeListener, resizeT, Resize) CG_DOM_ADD (AddScrollListener, addScrollListener, scrollT, ScrollEvent, Scroll) CG_DOM_REMOVE(RemoveScrollListener, removeScrollListener, scrollT, Scroll) - CG_DOM_ADD (AddContextMenuListener, addContextMenuListener, contextMenuT, MouseEvent, ContextMenu) + CG_DOM_ADD_CANCELABLE(AddContextMenuListener, addContextMenuListener, contextMenuT, MouseEvent, ContextMenu) CG_DOM_REMOVE(RemoveContextMenuListener,removeContextMenuListener,contextMenuT, ContextMenu) - CG_DOM_ADD (AddDragStartListener, addDragStartListener, dragStartT, MouseEvent, DragStart) + CG_DOM_ADD_CANCELABLE(AddDragStartListener, addDragStartListener, dragStartT, MouseEvent, DragStart) CG_DOM_REMOVE(RemoveDragStartListener, removeDragStartListener, dragStartT, DragStart) - CG_DOM_ADD (AddDragEndListener, addDragEndListener, dragEndT, MouseEvent, DragEnd) + CG_DOM_ADD_CANCELABLE(AddDragEndListener, addDragEndListener, dragEndT, MouseEvent, DragEnd) CG_DOM_REMOVE(RemoveDragEndListener, removeDragEndListener, dragEndT, DragEnd) - CG_DOM_ADD (AddDropListener, addDropListener, dropT, MouseEvent, Drop) + CG_DOM_ADD_CANCELABLE(AddDropListener, addDropListener, dropT, MouseEvent, Drop) CG_DOM_REMOVE(RemoveDropListener, removeDropListener, dropT, Drop) - CG_DOM_ADD (AddDragOverListener, addDragOverListener, dragOverT, MouseEvent, DragOver) + CG_DOM_ADD_CANCELABLE(AddDragOverListener, addDragOverListener, dragOverT, MouseEvent, DragOver) CG_DOM_REMOVE(RemoveDragOverListener, removeDragOverListener, dragOverT, DragOver) - CG_DOM_ADD (AddDragEnterListener, addDragEnterListener, dragEnterT, MouseEvent, DragEnter) + CG_DOM_ADD_CANCELABLE(AddDragEnterListener, addDragEnterListener, dragEnterT, MouseEvent, DragEnter) CG_DOM_REMOVE(RemoveDragEnterListener, removeDragEnterListener, dragEnterT, DragEnter) - CG_DOM_ADD (AddDragLeaveListener, addDragLeaveListener, dragLeaveT, MouseEvent, DragLeave) + CG_DOM_ADD_CANCELABLE(AddDragLeaveListener, addDragLeaveListener, dragLeaveT, MouseEvent, DragLeave) CG_DOM_REMOVE(RemoveDragLeaveListener, removeDragLeaveListener, dragLeaveT, DragLeave) - CG_DOM_ADD (AddWheelListener, addWheelListener, wheelT, WheelEvent, Wheel) + CG_DOM_ADD_CANCELABLE(AddWheelListener, addWheelListener, wheelT, WheelEvent, Wheel) CG_DOM_REMOVE(RemoveWheelListener, removeWheelListener, wheelT, Wheel) #undef CG_DOM_ADD #undef CG_DOM_REMOVE diff --git a/implementations/Crafter.Graphics-Router.cpp b/implementations/Crafter.Graphics-Router.cpp index 7c23f92..686b975 100644 --- a/implementations/Crafter.Graphics-Router.cpp +++ b/implementations/Crafter.Graphics-Router.cpp @@ -20,8 +20,20 @@ namespace Crafter::DomBindings { void AddPopStateListener(std::int32_t id); __attribute__((import_module("env"), import_name("removePopStateListener"))) void RemovePopStateListener(std::int32_t id); + __attribute__((import_module("env"), import_name("replaceState"))) + void ReplaceState(const char* data, std::int32_t dataLength, + const char* title, std::int32_t titleLength, + const char* url, std::int32_t urlLength); __attribute__((import_module("env"), import_name("getPathName"))) const char* GetPathName(); + __attribute__((import_module("env"), import_name("getSearch"))) + const char* GetSearch(); + __attribute__((import_module("env"), import_name("getHash"))) + const char* GetHash(); + __attribute__((import_module("env"), import_name("getHref"))) + const char* GetHref(); + __attribute__((import_module("env"), import_name("navigate"))) + void Navigate(const char* url, std::int32_t urlLength, bool replace); // Defined in Crafter.Graphics-Dom.cpp. std::int32_t PopStateRegister(std::function cb); @@ -48,11 +60,114 @@ namespace Crafter::Router { Crafter::DomBindings::PopStateUnregister(id); } + void ReplaceState(std::string_view data, std::string_view title, std::string_view url) { + Crafter::DomBindings::ReplaceState( + data.data(), static_cast(data.size()), + title.data(), static_cast(title.size()), + url.data(), static_cast(url.size())); + } + + // The four location accessors share the same shape: the JS side + // WasmAllocs a NUL-terminated UTF-8 copy, we adopt it into a + // std::string and free the raw buffer. A null return means the JS + // allocation failed. + namespace { + std::string AdoptJsString(const char* raw) { + if (!raw) return {}; + std::string out(raw); + std::free(const_cast(raw)); + return out; + } + } + std::string GetPath() { - const char* raw = Crafter::DomBindings::GetPathName(); - if (!raw) return {}; - std::string out(raw); - std::free(const_cast(raw)); + return AdoptJsString(Crafter::DomBindings::GetPathName()); + } + + std::string GetSearch() { + return AdoptJsString(Crafter::DomBindings::GetSearch()); + } + + std::string GetHash() { + return AdoptJsString(Crafter::DomBindings::GetHash()); + } + + std::string GetHref() { + return AdoptJsString(Crafter::DomBindings::GetHref()); + } + + void Navigate(std::string_view url, bool replace) { + Crafter::DomBindings::Navigate( + url.data(), static_cast(url.size()), replace); + } + + // ─── Query-string parsing ───────────────────────────────────────── + // + // Pure string work, no JS round-trip. Kept here rather than left to + // callers because every consumer would otherwise hand-roll percent + // decoding, and getting `+` vs `%20` wrong is the usual bug. + + namespace { + int HexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + } + + std::string PercentDecode(std::string_view in) { + std::string out; + out.reserve(in.size()); + for (std::size_t i = 0; i < in.size(); ++i) { + const char c = in[i]; + if (c == '+') { + // application/x-www-form-urlencoded encodes space as '+'. + out.push_back(' '); + } else if (c == '%' && i + 2 < in.size()) { + const int hi = HexVal(in[i + 1]); + const int lo = HexVal(in[i + 2]); + if (hi >= 0 && lo >= 0) { + out.push_back(static_cast(hi * 16 + lo)); + i += 2; + } else { + // Malformed escape: pass the '%' through rather than + // dropping input. Lets a stray '%' in a search box + // survive a round trip. + out.push_back(c); + } + } else { + out.push_back(c); + } + } return out; } + + std::vector> ParseQuery(std::string_view query) { + std::vector> out; + if (!query.empty() && query.front() == '?') query.remove_prefix(1); + while (!query.empty()) { + const std::size_t amp = query.find('&'); + std::string_view pair = query.substr(0, amp); + query = (amp == std::string_view::npos) ? std::string_view{} + : query.substr(amp + 1); + if (pair.empty()) continue; // tolerate "a=1&&b=2" and a trailing '&' + const std::size_t eq = pair.find('='); + if (eq == std::string_view::npos) { + // Valueless key ("?debug") — present, empty value. + out.emplace_back(PercentDecode(pair), std::string{}); + } else { + out.emplace_back(PercentDecode(pair.substr(0, eq)), + PercentDecode(pair.substr(eq + 1))); + } + } + return out; + } + + std::optional QueryGet(std::string_view query, std::string_view key) { + for (auto& [k, v] : ParseQuery(query)) { + if (k == key) return v; + } + return std::nullopt; + } } diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index a8c22e5..8ae726a 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -715,6 +715,17 @@ void Window::StartSync() { #endif } +void Window::StayAlive() { + // Native has no equivalent of the browser's "return from main but keep + // the instance alive" trick — the process simply ends. The event loop + // IS what keeps a native window alive, so the only correct native + // behaviour is StartSync's loop. Delegating keeps a single source that + // compiles identically on both targets: an app can call StayAlive + // unconditionally and get the cheap path in the browser without a + // #ifdef at the call site. + StartSync(); +} + void Window::StartUpdate() { lastFrameBegin = std::chrono::high_resolution_clock::now(); updating = true; @@ -1571,6 +1582,23 @@ void Window::StartSync() { std::_Exit(0); } +void Window::StayAlive() { + // As StartSync, minus the rAF loop. + // + // The _Exit(0) is the part that keeps the instance alive: it skips + // __wasm_call_dtors so every statically allocated object (event + // listeners, HtmlElementPtr handles, the Window itself) survives, and + // runtime.js catches the resulting __wasi_proc_exit via a sentinel. + // The frame loop is a separate concern, and a page that only renders + // DOM has no use for it — a rAF tick that runs forever to do nothing + // costs battery on every open tab. + // + // Use this for event-driven DOM pages; use StartSync when something + // actually animates or draws. As with StartSync, no caller code after + // this point ever runs. + std::_Exit(0); +} + void Window::StartUpdate() { lastFrameBegin = std::chrono::high_resolution_clock::now(); updating = true; diff --git a/interfaces/Crafter.Graphics-Dom.cppm b/interfaces/Crafter.Graphics-Dom.cppm index 05742ac..9f30b35 100644 --- a/interfaces/Crafter.Graphics-Dom.cppm +++ b/interfaces/Crafter.Graphics-Dom.cppm @@ -61,7 +61,12 @@ export namespace Crafter::Dom { // DOM ops ───────────────────────────────────────────────────── void SetInnerHTML(const std::string_view html); void SetStyle(const std::string_view style); + + // WARNING: SetProperty sets a *CSS* property (el.style.setProperty). + // It is NOT setAttribute — the name is historical. For href / src / + // disabled / aria-* / data-*, use SetAttribute below. void SetProperty(const std::string_view property, const std::string_view value); + void AddClass(const std::string_view className); void RemoveClass(const std::string_view className); void ToggleClass(const std::string_view className); @@ -69,28 +74,80 @@ export namespace Crafter::Dom { std::string GetValue(); void SetValue(const std::string_view value); + // Attributes ────────────────────────────────────────────────── + // + // Real setAttribute/getAttribute access. Before these existed the + // only way to change an href or toggle `disabled` was to re-render + // the parent's innerHTML, which destroys every descendant and every + // listener attached to them just to change one string. + void SetAttribute(const std::string_view name, const std::string_view value); + void RemoveAttribute(const std::string_view name); + + // nullopt = attribute absent. Distinct from an empty string, which + // means present-with-no-value (`alt=""`, `disabled=""`) — for + // boolean attributes presence alone is the signal. + std::optional GetAttribute(const std::string_view name); + bool HasAttribute(const std::string_view name); + + // Checkbox / radio state. GetValue cannot express this: `el.value` + // on a checkbox is "on" whether or not it is ticked. + bool GetChecked(); + void SetChecked(bool checked); + + // Move keyboard focus to / away from this element. AddFocusListener + // could already observe focus, but nothing could set it, which makes + // an accessible "jump to first invalid field" impossible. + void Focus(); + void Blur(); + // Listener API — each Add* returns an opaque id that can be // passed to the matching Remove*. The destructor automatically // removes every handler still registered, so manual removal is // optional. Returns 0 only if registration failed at the JS // boundary (the element was already collected). The 23 event // types are 1:1 with CppDOM's surface. - std::int32_t AddClickListener(std::function callback); + // + // `preventDefault` (mouse / key / wheel kinds only — the others are + // not cancelable events, so the flag would be a silent no-op and is + // deliberately absent): + // Cancels the browser's default action. The JS bridge applies it + // BEFORE dispatching into wasm, so a trap in the callback cannot let + // the default action through anyway. + // + // This is what makes client-side routing over real, crawlable + // `` links possible: take the click, cancel the + // navigation, then Router::PushState. Without it the handler runs + // *and* the browser performs a full page load. It is also mandatory + // on dragover/drop — a drop target does not function at all unless + // the default is cancelled. + // + // Wheel additionally registers with `{ passive: false }` when set, + // or the browser drops the cancellation with a console warning. + // + // AddSubmitListener has no flag: its handler always preventDefaults, + // because a native form POST would navigate away from the wasm app. + std::int32_t AddClickListener(std::function callback, + bool preventDefault = false); void RemoveClickListener(std::int32_t id); - std::int32_t AddMouseOverListener(std::function callback); + std::int32_t AddMouseOverListener(std::function callback, + bool preventDefault = false); void RemoveMouseOverListener(std::int32_t id); - std::int32_t AddMouseOutListener(std::function callback); + std::int32_t AddMouseOutListener(std::function callback, + bool preventDefault = false); void RemoveMouseOutListener(std::int32_t id); - std::int32_t AddMouseMoveListener(std::function callback); + std::int32_t AddMouseMoveListener(std::function callback, + bool preventDefault = false); void RemoveMouseMoveListener(std::int32_t id); - std::int32_t AddMouseDownListener(std::function callback); + std::int32_t AddMouseDownListener(std::function callback, + bool preventDefault = false); void RemoveMouseDownListener(std::int32_t id); - std::int32_t AddMouseUpListener(std::function callback); + std::int32_t AddMouseUpListener(std::function callback, + bool preventDefault = false); void RemoveMouseUpListener(std::int32_t id); std::int32_t AddFocusListener(std::function callback); @@ -99,13 +156,16 @@ export namespace Crafter::Dom { std::int32_t AddBlurListener(std::function callback); void RemoveBlurListener(std::int32_t id); - std::int32_t AddKeyDownListener(std::function callback); + std::int32_t AddKeyDownListener(std::function callback, + bool preventDefault = false); void RemoveKeyDownListener(std::int32_t id); - std::int32_t AddKeyUpListener(std::function callback); + std::int32_t AddKeyUpListener(std::function callback, + bool preventDefault = false); void RemoveKeyUpListener(std::int32_t id); - std::int32_t AddKeyPressListener(std::function callback); + std::int32_t AddKeyPressListener(std::function callback, + bool preventDefault = false); void RemoveKeyPressListener(std::int32_t id); std::int32_t AddChangeListener(std::function callback); @@ -123,28 +183,36 @@ export namespace Crafter::Dom { std::int32_t AddScrollListener(std::function callback); void RemoveScrollListener(std::int32_t id); - std::int32_t AddContextMenuListener(std::function callback); + std::int32_t AddContextMenuListener(std::function callback, + bool preventDefault = false); void RemoveContextMenuListener(std::int32_t id); - std::int32_t AddDragStartListener(std::function callback); + std::int32_t AddDragStartListener(std::function callback, + bool preventDefault = false); void RemoveDragStartListener(std::int32_t id); - std::int32_t AddDragEndListener(std::function callback); + std::int32_t AddDragEndListener(std::function callback, + bool preventDefault = false); void RemoveDragEndListener(std::int32_t id); - std::int32_t AddDropListener(std::function callback); + std::int32_t AddDropListener(std::function callback, + bool preventDefault = false); void RemoveDropListener(std::int32_t id); - std::int32_t AddDragOverListener(std::function callback); + std::int32_t AddDragOverListener(std::function callback, + bool preventDefault = false); void RemoveDragOverListener(std::int32_t id); - std::int32_t AddDragEnterListener(std::function callback); + std::int32_t AddDragEnterListener(std::function callback, + bool preventDefault = false); void RemoveDragEnterListener(std::int32_t id); - std::int32_t AddDragLeaveListener(std::function callback); + std::int32_t AddDragLeaveListener(std::function callback, + bool preventDefault = false); void RemoveDragLeaveListener(std::int32_t id); - std::int32_t AddWheelListener(std::function callback); + std::int32_t AddWheelListener(std::function callback, + bool preventDefault = false); void RemoveWheelListener(std::int32_t id); protected: diff --git a/interfaces/Crafter.Graphics-Router.cppm b/interfaces/Crafter.Graphics-Router.cppm index 363e8bf..79d02f7 100644 --- a/interfaces/Crafter.Graphics-Router.cppm +++ b/interfaces/Crafter.Graphics-Router.cppm @@ -17,18 +17,62 @@ export namespace Crafter::Router { // Push a new history entry. `data` is a JSON string serialized by // the caller — the browser stores it on the entry but the popstate // listener in V1 receives no payload (matches CppDOM's surface). - // `url` is browser-relative, e.g. "/blog/post-1". + // `url` is browser-relative, e.g. "/blog/post-1". Cannot leave the + // origin; use Navigate for that. void PushState(std::string_view data, std::string_view title, std::string_view url); + // As PushState, but rewrites the current entry instead of adding one. + // For URL changes that shouldn't become their own Back destination: + // filter/sort state, canonicalising a sloppy incoming URL. + void ReplaceState(std::string_view data, std::string_view title, std::string_view url); + // Subscribe to the browser's `popstate` event (back/forward button, // programmatic history.go). Returns an opaque id usable with // `RemovePopStateListener`. Multiple subscribers OK. + // + // The callback still receives no payload — re-read the location with + // the accessors below rather than relying on the pushState `data`. std::int32_t AddPopStateListener(std::function callback); void RemovePopStateListener(std::int32_t id); - // Current `window.location.pathname` as a freshly-allocated string. - // Allocates per call — cache the result in the caller if used in - // hot paths. - std::string GetPath(); + // Location accessors. Each allocates a fresh string per call — cache + // the result if used in a hot path. + // + // GetSearch / GetHash include their leading '?' / '#', so an absent + // query and a bare "?" stay distinguishable and the result can be + // concatenated straight back onto a path. + std::string GetPath(); // window.location.pathname + std::string GetSearch(); // window.location.search, e.g. "?page=2&sort=new" + std::string GetHash(); // window.location.hash, e.g. "#reviews" + std::string GetHref(); // window.location.href, absolute + + // Full-page navigation, cross-origin allowed. PushState deliberately + // cannot leave the origin, so this is the only way to hand the user off + // to an external URL (a hosted payment page, an OAuth consent screen). + // `replace == true` drops the current page from history, so Back won't + // return to a stale page. + void Navigate(std::string_view url, bool replace = false); + + // ─── Query-string helpers ───────────────────────────────────────── + // + // Pure string functions — no JS round-trip, safe to call on any + // string, not just the live location. Provided here so callers don't + // each hand-roll percent decoding; `+` vs `%20` is the usual bug. + + // Split a query string into decoded key/value pairs, in source order. + // Accepts an optional leading '?'. Duplicate keys are preserved as + // separate entries (?tag=a&tag=b yields two). A valueless key + // ("?debug") yields an empty value. Empty segments are skipped, so + // "a=1&&b=2" and a trailing '&' are tolerated. + std::vector> ParseQuery(std::string_view query); + + // First value for `key`, or nullopt if absent. Distinguishes "missing" + // from "present but empty" — which ?debug and ?q= need. + std::optional QueryGet(std::string_view query, std::string_view key); + + // Percent-decode a single component, treating '+' as a space per + // application/x-www-form-urlencoded. A malformed escape is passed + // through literally rather than dropped. + std::string PercentDecode(std::string_view in); } #endif // CRAFTER_GRAPHICS_WINDOW_DOM diff --git a/interfaces/Crafter.Graphics-Window.cppm b/interfaces/Crafter.Graphics-Window.cppm index ab170b7..37e97f4 100644 --- a/interfaces/Crafter.Graphics-Window.cppm +++ b/interfaces/Crafter.Graphics-Window.cppm @@ -121,7 +121,22 @@ export namespace Crafter { ~Window(); #endif + // Enter the platform event loop and never return. In DOM mode this + // hands the loop to requestAnimationFrame and _Exit(0)s so the wasm + // instance survives without running static destructors. No caller + // code after this point ever runs, on any target. void StartSync(); + + // As StartSync, but does NOT start the animation-frame loop in DOM + // mode — for event-driven pages that only touch the DOM and never + // draw. A rAF tick running forever to do nothing costs battery in + // every open tab. Still never returns. + // + // On native this is identical to StartSync: the event loop is what + // keeps a window alive there, so there is no cheaper option. Call it + // unconditionally and get the browser saving without an #ifdef. + void StayAlive(); + void StartUpdate(); void StopUpdate(); void SetTitle(const std::string_view title); diff --git a/project.cpp b/project.cpp index 791f7a3..7342e2f 100644 --- a/project.cpp +++ b/project.cpp @@ -192,7 +192,31 @@ extern "C" Configuration CrafterBuildProject(std::span a // JS glue shipped alongside the .wasm so the loader has the // env-import surface the Window/Dom bindings expect. cfg.files.emplace_back(fs::path("additional/dom-env.js")); - cfg.files.emplace_back(fs::path("additional/dom-webgpu.js")); + + // dom-webgpu.js is opt-out via --no-webgpu, for apps that only use + // the Dom/Router partitions and never touch Device/RTPass/UIRenderer. + // + // Three reasons a DOM-only app wants it gone, in increasing order of + // severity: + // 1. It is ~182 KB (47 KB gzip) of dead weight. + // 2. Its init is an async IIFE that runtime.js awaits before + // _start(), and that init does `await navigator.gpu + // .requestAdapter()` — so every page load blocks wasm startup on + // a GPU adapter request, including on machines with no GPU. + // 3. Worst: when navigator.gpu is absent it REPLACES + // document.body.innerHTML with an error message and throws, so + // _start() never runs. For a server-rendered page that means the + // real content is destroyed and replaced with a WebGPU warning on + // any browser without WebGPU support. + // + // Safety: with --gc-sections an app that never calls into the WebGPU + // partitions emits no wgpu* env imports, so omitting the bridge links + // and instantiates cleanly. An app that DOES use WebGPU and passes + // --no-webgpu anyway fails at instantiate with a missing-import error + // naming the specific wgpu* symbol. + if (!opts.Has("--no-webgpu")) { + cfg.files.emplace_back(fs::path("additional/dom-webgpu.js")); + } } else { std::array impls = { "implementations/Crafter.Graphics-Clipboard",