aax
無料Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
Query, recommend, and switch the Pulp JS engine backend (QuickJS, JavaScriptCore, V8). Handles "which JS engine", "switch to V8", "engine for Three.js".
インストール方法を見るインストールする前に、エージェントに与えられる指示の中身を確認できます。
Manage the JavaScript engine backend used by Pulp's scripting layer. Three engines are available:
| Engine | Platform | Strengths | License |
|---|---|---|---|
| QuickJS | All | Portable, small, zero dependencies. Default. | MIT |
| JavaScriptCore | Apple only | System framework, good JIT, zero-dep on macOS/iOS | LGPL-2.1 (system use OK) |
| V8 | Desktop | Best JIT, ideal for heavy JS (Three.js), largest footprint | BSD-3-Clause |
Every engine boots with the same set of web-compat-*.js preludes embedded
into web_compat_preludes_gen.hpp and evaluated in order by
WidgetBridge. The current set covers everything React 18 dev (and most
similar frameworks) feature-detect on construction:
| Surface | Where | Why |
|---|---|---|
Element.nodeType (=1) / nodeName (=tagName) | web-compat-element.js | React reconciler walks every node and bails before first commit without DOM-compatible node identity. |
Element.ELEMENT_NODE / TEXT_NODE / COMMENT_NODE constants | web-compat-element.js | node.ELEMENT_NODE === 1 fast-paths in React |
Widget tag factories (virtual-list, virtuallist, segmented, stepper, etc.) | web-compat-element.js | DOM-lite custom tags construct the same native widgets as @pulp/react; keep this table in sync when a new widget tag is routed through _ensureNative. A tag needs FOUR tables in step, not one: this map, the createX factory (factory_api.cpp), make_widget_for_tag() (widget_bridge.cpp, the __domAppend path), and wire_callbacks() (widget_callbacks.cpp). The factory wires its callbacks inline while the tag path takes them from wire_callbacks(), so missing that one builds a real, clickable widget whose changes reach nothing — on the tag path only, from a script that is identical either way. |
createTextNode → nodeType=3 + nodeName='#text' + data/nodeValue mirrors | web-compat-document.js | DOM Level 1 text-node spec; React's text-update path |
createComment → nodeType=8, createDocumentFragment → nodeType=11 | web-compat-document.js | React portal sentinels + batched commits |
MutationObserver / IntersectionObserver / ResizeObserver / PerformanceObserver no-ops | web-compat-observers.js | typeof X === 'function' feature-detects pass; React skips because no events ever fire |
XMLHttpRequest no-op + spec readyState constants | web-compat-observers.js | React dev-mode error-stack lookup probes XHR |
Element.scrollTop/scrollLeft/scrollWidth/scrollHeight (returns 0) | web-compat-observers.js | React dev focus warnings |
queueMicrotask (Promise-based shim) | web-compat-scheduler.js | React 18 concurrent scheduler |
MessageChannel + MessagePort (microtask-deferred postMessage) | web-compat-scheduler.js | React 18 scheduler prefers MC; falls back to setTimeout if missing (perf cliff, not a blocker) |
URLSearchParams polyfill | web-compat-scheduler.js | React error-source URL parsing |
requestAnimationFrame / cancelAnimationFrame (driven by native __requestFrame__) | web-compat-scheduler.js | Bundled-React frameworks reference the standard names; without this each consumer has to carry its own scheduler shim. |
setTimeout / clearTimeout / setInterval / clearInterval (driven by native __scheduleTimer__ deadline tracker) | web-compat-scheduler.js | React's scheduler yield path + plugin code; setTimeout(fn, 0) drains via microtask, positive delays drain in service_frame_callbacks(). |
performance.now() (driven by native __performanceNow__) | web-compat-scheduler.js | Bundled-React modules read performance.now at module-eval time before the legacy window.performance shim is reachable. |
Mirror block onto window (rAF/cAF/sT/cT/sI/cI/MC/qM/perf) | web-compat-scheduler.js | React 18's scheduler reads window.setTimeout / window.requestAnimationFrame specifically; the global must be reachable through both names. |
web-compat-document.js runs a popup owner that gives any open popup a roving
keyboard cursor: ArrowUp/ArrowDown step, Home/End jump, Enter activates, Escape
dismisses, and the stepped row is marked data-pulp-popup-active="true". Apps
should NOT re-implement this in script; authoring role="menu" with
role="menuitem" rows is enough to inherit it.
Two things about that marker and that owner are easy to get wrong:
PRESENCE vs VALUE of data-pulp-popup-active. The attribute is set on
EVERY row of a popup Pulp owns; the value "true" marks the one row the
cursor is on. A check written as getAttribute(...) truthiness reports every
row active and always finds row 0 — it must compare === "true". The same
distinction bites selectors: [data-pulp-popup-active] asks "does Pulp own a
popup", [data-pulp-popup-active="true"] asks "is a cursor painted", and a
pointer-opened popup paints no cursor until the user asks for one.
A popup may have NO trigger. The owner was originally entered only from
triggerFrom(document.activeElement), so a context menu — summoned at
coordinates, owned by no aria-haspopup element — never got state and ignored
arrow keys. Such a menu is now adopted on the first arrow. When touching this
code, remember state.trigger can be null: document.body.contains(null) is
false, so an unguarded staleness test reads "my trigger vanished" and
dismisses the state on every event, which presents as a cursor that will not
move (it is re-adopted from scratch each key). Adoption is gated on exactly
one unowned, non-empty, non-opted-out role="menu" being open, because an
arrow key that no popup consumes must stay available to the app and to the DAW.
When adding new framework support, check the engine capability
comparison before assuming a polyfill is missing — the entry above is
exhaustive for React 18 dev. Add new files to core/view/CMakeLists.txt's
PULP_JS_PRELUDES list AND to the eval_or_throw block in
core/view/src/widget_bridge.cpp (embed_js.cmake only embeds the
constants; the bridge constructor evaluates them).
web-compat-canvas.js exposes CanvasRenderingContext2D.prototype
with the standard methods plus the gap-list closures and FilterBank-parity
additions that keep common Canvas2D calls from silently no-oping:
| Method | Notes |
|---|---|
measureText(text) | Returns a full HTML5 TextMetrics object — width + actualBoundingBox{Left,Right,Ascent,Descent} + fontBoundingBox{Ascent,Descent}. Routed through canvasMeasureText which calls SkiaCanvas::measure_text_with_font for surface-less metrics. |
drawImage(img, …) | 3 / 5 / 9-arg signatures supported; the 9-arg (sx,sy,sw,sh,dx,dy,dw,dh) form currently ignores the source rect — file a follow-up if a plugin needs sprite-sheet slicing. |
setLineDash([…]) / getLineDash() | Even-length patterns are taken verbatim; odd-length patterns are duplicated per the HTML5 spec. Phase comes from lineDashOffset. |
getImageData(x,y,w,h) | Returns {data: Uint8ClampedArray, width, height}. The bridge currently returns zero-filled pixels (no live surface handle from JS-call context); consumers that need real pixels should round-trip through a render-host integration. |
putImageData(img, dx, dy) | Decodes the typed array to base64 across the bridge and applies via Canvas::write_pixels on backends that implement it (Skia today). |
save() / restore() | Forward to canvasSave / canvasRestore. JS-side caches of last-pushed text/line/global state are invalidated on save so the next draw re-pushes — the bridge captures the matching state on the C++ side via SkCanvas::save. |
translate / scale / rotate / setTransform / resetTransform / transform | Forward to canvasTranslate / canvasScale / canvasRotate / canvasSetTransform. transform is best-effort: pure translation forwards to canvasTranslate; other matrices are silently dropped (the bridge has no concat primitive). setTransform accepts the (a,b,c,d,e,f) form and the single-DOMMatrix form. |
arc(cx,cy,r,a0,a1,ccw) / ellipse | Approximated as cubic-Bezier segments (4-segment unit-circle scaling) so the path participates in fill() / stroke() / clip(). arcTo is a conservative two-segment lineTo approximation — sufficient for rounded marquee corners, not fidelity-critical. |
bezierCurveTo / quadraticCurveTo / rect / roundRect | Forward to canvasCubicTo / canvasQuadTo / repeated canvasLineTo. rect emits an explicit closing lineTo back to the start so the resulting subpath is closed. roundRect honours the uniform-radius case; non-uniform radii[] falls back to radii[0]. |
clip(fillRule) | Calls canvasClip. The fill rule is dropped — Pulp's bridge currently ignores even-odd vs nonzero, matching SkCanvas defaults. |
fillText(text,x,y) / strokeText | fillText syncs global / text state and forwards to canvasFillText with the active fillStyle's colour (or first gradient stop). strokeText falls back to fillText with the strokeStyle colour — Pulp's bridge has no stroke-text command. |
createLinearGradient / createRadialGradient / createConicGradient | Return a CanvasGradient object with _kind, _params, _stops, and an addColorStop(offset, color) method. Gradients are NOT pushed to the bridge until they're assigned to fillStyle / strokeStyle AND a draw fires — _applyFillStyle() / _applyStrokeStyle() flush via the matching linear, radial, two-circle radial, or conic bridge setter. |
fillStyle / strokeStyle | Plain fields. _applyFillStyle() runs before every fill draw and flushes either a string colour (via canvasSetFillColor), a gradient (via canvasSetLinearGradient / canvasSetRadialGradient / canvasSetConicGradient), or a pattern (via canvasSetFillPattern), tracking _activeFillKind so a subsequent string assignment first calls canvasClearGradient. _applyStrokeStyle() routes string colours, linear/radial/conic gradients, and patterns through the stroke bridge setters where the backend supports them; CoreGraphics conic stroke and stroke pattern paths still fall back to a solid colour. |
globalAlpha / globalCompositeOperation / font / textAlign / textBaseline / lineCap / lineJoin | Plain fields. Pushed to the bridge via _syncGlobalState / _syncTextState / _syncLineState lazy helpers — they only emit a canvas* call when the value differs from the last-sent cache, and the cache is invalidated on save() / restore(). |
createPattern | Returns a CanvasPattern handle for non-empty file / data-URL image sources. Fill patterns flush through canvasSetFillPattern; Skia renders tiled image shaders and CoreGraphics uses a CGPattern tile callback for fills. Missing or undecodable images fail gracefully in the backend paint path. |
If any common method (save, setTransform, createLinearGradient,
globalAlpha setter, …) is missing from CanvasRenderingContext2D.prototype,
the very first call to it throws TypeError: ... is not a function. The
exception unwinds the calling function — and in a React render boundary,
the boundary swallows the throw and silently retries on the next commit.
Net effect: only the prefix of canvas calls before the throw makes it
to the bridge, and the rendered output is missing whatever the rest of
the frame would have drawn. The Spectr FilterBank standalone displayed
this exact symptom (a clean clearRect + nothing else, leaving the
parent's dark navy bg showing through where canvas content should have
been). The fix is shim coverage at the JS layer, not at the
CanvasWidget/SkiaCanvas pipeline below.
When adding a new Canvas2D method, audit:
canvas* function in
core/view/src/widget_bridge.cpp? If not, add it.arc() is path-construction, not a stroke._sync*
helper before forwarding to the bridge.test/test_canvas2d_shim.cpp covering the
new method's existence + a representative end-to-end Skia render
(the FilterBank-style raster test pattern).JsEngine::evaluate_script() / ScriptEngine::evaluate_script() evaluate a
whole script (a bundle, a prelude, a runtime-import payload) with
evaluate()'s exact result and error semantics. On QuickJS a script of 2 KB
or more is compiled once per process (JS_EVAL_FLAG_COMPILE_ONLY +
JS_WriteObject), and every later realm evaluating byte-identical source
deserializes the bytecode (JS_ReadObject + JS_EvalFunction) instead of
parsing. WidgetBridge routes preludes, load_script(), runtime-import
payloads and a document's inline <script> blocks through it, so a reopened
plug-in editor or a second instance skips parsing its 1–2 MB UI bundle and
every inline script of a few KB.
PULP_JS_BYTECODE_CACHE=0
disables it.script_bytecode_cache_stats() (compiles / hits / bypassed) lets a test or
an editor-open budget assert reuse by count.evaluate() for expressions whose value you read and for per-frame
traffic. Only whole, repeatable scripts belong in evaluate_script():
the 2 KB floor assumes it never sees per-frame or generated-per-call source,
and a caller that builds a unique string each call (a JSON payload spliced
into code) would churn the 64-entry cache and evict the UI bundle.script_compile under
runtime_import_inline_eval means an inline script differs per open (a
timestamp or nonce baked into the document), not that the cache is off.evaluate_script → evaluate).WidgetBridge::bridge_call_count() is a per-bridge diagnostic counter for
JS-to-native dispatches. It covers direct registered functions, host-object
methods, and promise-backed functions; pure JavaScript evaluation and native
callbacks that invoke JavaScript do not increment it. Reset the counter before
an import or interaction when collecting deterministic work counts. The
counter is instrumentation only and does not change engine selection or
runtime behavior.
Registered functions retain shared counter state until their engine releases
them. Do not replace that capture with a pointer into WidgetBridge: a retained
stateless function or queued Promise can run after the bridge is destroyed.
Counter ownership does not extend the lifetime of the bridge or its widgets.
status — Show current engine configurationCMakeCache.txt in the build directory to find PULP_JS_ENGINE:
grep PULP_JS_ENGINE build/CMakeCache.txt 2>/dev/null || echo "Not configured (default: QuickJS)"
V8_INCLUDE_DIR and V8_LIB_DIR are setrecommend <workload> — Suggest the best engineBased on the workload description:
switch <engine> — Change the JS engineIMPORTANT: Always confirm with the user before switching.
jsc on non-Apple: explain it's not available, suggest alternatives.v8 without V8 libs: explain V8 must be built/installed separately, link to docs.cd <project_root>
cmake -S . -B build -DPULP_JS_ENGINE=<engine>
tools/ci/governed-build.sh cmake --build build
Or via the CLI:
pulp build --js-engine=<engine>
ctest --test-dir build --output-on-failure -E "AudioWorkgroup|GpuSurface"
When reviewing or loading JS code, if you see any of these patterns, proactively suggest an engine:
THREE.Scene, THREE.WebGLRenderer, import * as THREE → suggest V8Use recommend logic above, but never auto-switch — always confirm first.
auto (default): QuickJS everywhere unless the build explicitly
sets a PULP_DEFAULT_ENGINE_* compile option. Backward compatible.
Safe. Do not describe this as "Apple defaults to JSC"; that was an
old header comment, not the implementation.quickjs: Explicit QuickJS. Same as auto today.jsc: JavaScriptCore on Apple. Build fails on non-Apple.v8: V8 on desktop and Android API 29+ via the pinned sealed libv8 (fetched into external/v8-build/). Build fails if not fetched; Android API 26-28 and iOS fail closed for this provider. See "V8 provider library" below.The engine choice is a build-time CMake option. Changing it requires reconfigure + rebuild. The abstraction ensures all JS bridge code works identically across engines — the switch is invisible to UI scripts.
JsEngine exposes supports_interrupt() / request_interrupt() — the one
sanctioned cross-thread entry point on this otherwise single-threaded
interface. It aborts a runaway evaluation (surfacing as a thrown "interrupted"
exception on the engine thread). Gotchas:
QuickJSContext already installs a JS_SetInterruptHandler backed by an
atomic shouldCancel, exposed as the thread-safe Context::cancel().
QuickJsEngine::request_interrupt() just calls context_.cancel(). Installing
a second JS_SetInterruptHandler would clobber CHOC's and break cancel().ScriptInspectorBridge enforces this).evaluate_bounded_json() by walking the live JSValue
with byte, depth, and cycle limits. Keep that serializer in the backend;
materializing an unbounded object graph first defeats the result-size limit.
The bridge gives evaluation two seconds, then a fixed 500 ms grace for the
mandatory realm rebuild, all inside the standalone inspector's three-second
main-thread RPC fence. The server's owned asynchronous worker leaves the same
authenticated controller connection free to deliver Runtime.interrupt and
is included in the module-unload shutdown fence.ScriptInspectorBridge + the inspector Runtime.*
domain) is therefore an honest evaluate / capabilities / interrupt / device
logs console, not a step debugger; Runtime.getCapabilities reports
canBreak/canStep/canInspectLocals = false. A real step debugger is a
future engine-capability milestone (a debugger-enabled backend, or the Chrome
DevTools inspector JSC/V8 expose). See docs/reference/scripted-ui-inspector.md.Pulp does not build V8 from source, and (since 2026-06) does not
use a developer's Homebrew libnode. The provider is a pinned, sealed
prebuilt libv8 from the
danielraffel/v8-builder
fork — the same pin/fetch/Find pattern as Skia:
V8 entry in tools/deps/manifest.json
(determinism.release_assets, per-platform URL + sha256, tag v8-m153-15.3.76.5-26cef0256b0e).python3 tools/scripts/fetch_v8_for_release.py <platform>
downloads + sha256-verifies + unpacks to external/v8-build/<platform>/
(include/ + lib/). Platforms: darwin-arm64, darwin-x64,
linux-x64, linux-arm64, windows-x64, windows-arm64,
android-arm64, ios-simulator-arm64. The iOS asset is an actual jitless
simulator V8.framework; Pulp validates its provenance and headers but does
not select or package it as an iOS/AUv3 runtime.tools/cmake/FindV8.cmake finds external/v8-build/<key>
(or a baked $V8_DIR, or legacy overrides) and exposes the v8::v8
imported target. The configure log prints
-- Pulp V8 provider: <path> (platform key: ...).Selecting V8 — that's all you need:
python3 tools/scripts/fetch_v8_for_release.py darwin-arm64 # once
cmake -S . -B build -DPULP_JS_ENGINE=v8 \
-DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON # no V8_* paths
FindV8 resolves the fetched artifact automatically — no V8_INCLUDE_DIR/
V8_LIB_DIR/V8_LIBRARY_PATH needed. Those still exist as advanced
local-experiment overrides (point at a hand-built V8). V8_DIR points at
a baked V8 (golden VMs: V8_DIR=~/pulp-v8-build).
Three behavior rules:
PULP_JS_ENGINE=auto never pulls in V8 — V8 is strictly opt-in via
=v8. (Previously auto + V8_INCLUDE_DIR silently enabled it.)PULP_JS_ENGINE=v8 on iOS is a configure-time FATAL_ERROR. The pinned
m153 asset is a jitless simulator framework, but Pulp has no device/AUv3 V8
runtime acceptance or packaging contract. Use QuickJS (the default) or JSC.PULP_JS_ENGINE=v8 on Android requires API 29+ for the pinned m153
provider. Pulp's general Android floor remains API 26; use QuickJS below 29.Why the sealed build (the ICU caveat): the v8-builder libv8 exports
only the v8::/cppgc:: API and keeps its bundled ICU/zlib/Abseil
internal, so they don't collide with Skia's bundled-but-flat-named
ICU/HarfBuzz at the final link. Confirm any provider is seal-safe with
nm -gU <lib> | c++filt | grep -cE 'icu_[0-9]+::|absl::' — only
v8::internal:: functions whose signatures mention those types should
appear, never re-exported icu_NN::/absl:: library symbols. V8 is still
confined to js_v8_engine.cpp and never shares a TU with a Skia/Dawn
header.
__Cr)The v8-builder sealed Windows v8.dll (e.g. v8-m153-15.3.76.5-26cef0256b0e) is a special
ABI lane, NOT a drop-in for MSVC cl. It is built (v8-builder
build-v8.py:win_gn_args()) with pointer compression ON, Chromium's
bundled libc++ (__Cr ABI namespace), sandbox OFF, rtti OFF. So
v8.dll.lib exports its std::-bearing API mangled into __Cr@std
(verify: hundreds of ...@__Cr@std@@ symbols). A consumer built with
MSVC cl + MSVC STL will not link — its plain @std@@ symbols never
resolve against the __Cr@std imports. The m153 archive's ABI shape and
required files are inspected and pinned. The following consumer contract was
last link/run proven with m152 (15.2.124.7) on the Win11-24H2 arm64 QEMU
golden (x64 V8 under ARM x64 emulation); the m153 consumer link/run remains a
required platform acceptance step:
js_v8_engine.cpp) with clang-cl, not cl.
tools/cmake/PulpV8Windows.cmake hard-fails configure if the compiler
is MSVC cl.-DV8_COMPRESS_POINTERS — mandatory; without it the inline
v8-internal.h tagged-field offsets mismatch the DLL and silently
corrupt the heap._LIBCPP_ABI_NAMESPACE=__Cr
(routed via clang-cl's /clang:-nostdinc++ /clang:-isystem — bare
-nostdinc++ is silently dropped by clang-cl, which is the trap that
makes a build fall back to MSVC STL). The official Windows LLVM package
ships clang-cl but no libc++, so headers come from an llvm-project
libcxx/include checkout. The __config_site must set
_LIBCPP_HAS_LOCALIZATION 1 or <ostream>/<sstream>/std::endl
silently vanish.__Cr-ABI libc++.lib — v8.dll exports NONE of the
out-of-line libc++ runtime (no std::cout, basic_string/
basic_ostream ctors/dtors, operator new). Build it from
llvm-project runtimes with LLVM_ENABLE_RUNTIMES=libcxx,
LIBCXX_ABI_NAMESPACE=__Cr, LIBCXX_ABI_VERSION=2,
LIBCXX_CXX_ABI=vcruntime, RTTI ON + exceptions ON (libc++ refuses
exceptions-on/rtti-off). Link it + msvcprt.lib (the latter supplies
the __ExceptionPtr* vcruntime glue), and define
_CRT_STDIO_ISO_WIDE_SPECIFIERS=1 to match the lib (else lld-link
/FAILIFMISMATCH).Wiring: select V8 the normal way (PULP_JS_ENGINE=v8 after
fetch_v8_for_release.py windows-x64, which unpacks the sealed
v8.dll.lib under external/v8-build/, so FindV8.cmake resolves it).
On Windows, additionally supply PULP_V8_WIN_LIBCXX_INCLUDE and
PULP_V8_WIN_LIBCXX_LIB (the Chromium-style libc++ headers + the
__Cr-ABI libc++.lib). core/view/CMakeLists.txt links v8::v8 and
then calls pulp_v8_windows_apply_abi(pulp-view-script) (no-op off
Windows). Last runtime proof (m152): choc V8 consumer init +
evaluateExpression("40 + 2") == 42, V8::GetVersion() == 15.2.124.7.
The m153 Windows archive has passed structural ABI inspection but not this
consumer link/run gate. The mac/linux sealed
artifacts use the system libc++ (no __Cr) and need none of this —
the __Cr contract is Windows-only.
Cross-platform rollout + ship/sign/package (Windows MSVC /MT ABI,
macOS nested-dylib re-signing, Android ABI gating, golden-VM bake) are
tracked in planning/2026-06-06-v8-sealed-libv8-provider-migration-plan.md.
macOS arm64 is the proven m153 runtime lane; other platforms are pinned but
pending per-platform verification.
FindV8.cmake → external/v8-build/)The pinned provider is a sealed libv8.dylib from the
v8-builder project: a single
dylib with @rpath/libv8.dylib install_name, no external ICU/zlib/Abseil
links, and a pinned runtime version recorded in tools/deps/manifest.json.
Fetch it once, then select v8 — FindV8.cmake resolves the unpacked
artifact under external/v8-build/<platform>/ and exposes the v8::v8
imported target; no V8_* paths are needed:
python3 tools/scripts/fetch_v8_for_release.py darwin-arm64 # once
cmake -S . -B build -DPULP_JS_ENGINE=v8 \
-DPULP_VALIDATE_V8_PROVIDER_STRICT=ON \
-DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON
When PULP_JS_ENGINE=v8, core/view/CMakeLists.txt includes FindV8.cmake
(fatal if no sealed V8 is resolved — an explicit =v8 request never silently
falls back), links v8::v8, and then fills in the provider-identity compile
defs from the resolved artifact: PULP_V8_PROVIDER_KIND (v8builder),
PULP_V8_PROVIDER_PATH (${V8_RUNTIME_LIBRARY}), and
PULP_V8_EXPECTED_RUNTIME_VERSION (parsed from the V8 dependency's pinned tag
in manifest.json). The build-tree rpath for @rpath/libv8.dylib is handled
by FindV8's imported target. This is the explicit V8 lane only — it does NOT
change the engine default (auto stays QuickJS; iOS is a configure-time
FATAL_ERROR because Pulp has no accepted device/AUv3 V8 runtime or packaging
contract for the simulator-only jitless provider).
Identity surface: JsEngine::runtime_version() (V8's
v8::V8::GetVersion()) / provider_kind() / provider_path() /
expected_runtime_version() (defaults empty for QuickJS/JSC), forwarded
through ScriptEngine. They prove which V8 is actually linked, not just
which headers compiled. examples/threejs-native-demo --print-engine-identity
emits a parseable PULP_ENGINE_IDENTITY_BEGIN…END block; otool -L the demo
to confirm @rpath/libv8.dylib and no libnode.
Why the seal matters (observed A/B, 2026-06-05): Homebrew
libnode.147.dylib links external Homebrew ICU and exports ~567 Abseil
symbols; under the full three.webgpu.js workload its bundled Abseil aborts with
PerTableSeed/raw_hash_set.h:456 (SIGABRT) — the cube never renders, though
basic evaluate() and all pulp-test-js-engine cases still pass. The sealed
v8-builder libv8.dylib (no external ICU, 4 incidental absl symbols) renders
the cube cleanly next to Dawn/Skia. The libnode-147 three.js abort is also
documented in examples/threejs-native-demo/README.md.
Strict CTest: PULP_VALIDATE_V8_PROVIDER_STRICT=ON adds
v8_provider_identity_strict — a no-skip-pass gate (contrast with
capture_test.cmake, which tolerates no-V8/no-Dawn) that asserts the identity
block and renders --demo cube --capture to a non-empty PNG.
Selector parses are memoized. _parseSelector (web-compat-document-
selectors.js) caches its record by selector text (bounded, cleared when
full), so Element.matches()/closest() in a loop over many elements parse
once. The record is shared: never mutate what _parseSelector returns --
copy it first, as StyleSheet does before stripping a pseudo-class.
_parseSelector.__pulpMemoized tells a vendored runtime carrying its own
memo to step aside.
web-compat.js must agree on windowThe runtime embeds the SPLIT preludes (web-compat-document.js and friends,
listed in core/view/cmake/PulpViewJsPreludes.cmake), not the monolithic
web-compat.js. When the two define the same global differently, only the
split one is live. window.innerWidth/innerHeight were once the constants
800/600 in the split module while the legacy bundle read the root size, so a
popover positioned against the window believed the surface was 800x600: a menu
in a 1320x860 editor flipped its submenu left and clamped itself upward. Both
now read getRootSize(). When changing a window/document member, change it
in the split module and keep the legacy bundle in step; a test through a real
WidgetBridge (see test_web_compat_overlay.cpp) is what exercises the live
one.
To confirm-failure a prelude edit, name the generated unit:
--object web_compat_preludes_gen.cpp -- the .js file has no object of its
own, so without it confirm_failure.sh reports INCONCLUSIVE by construction.
The native Three.js bridge needs V8. Use the pinned sealed libv8 — fetch
once, then just select v8:
python3 tools/scripts/fetch_v8_for_release.py darwin-arm64
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DPULP_JS_ENGINE=v8 -DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON
Do not trust an old build directory just because CMake says V8 is enabled. Check the linked dylib points at the pinned artifact:
otool -L build/examples/threejs-native-demo/pulp-threejs-native-demo | grep libv8
# → @rpath/libv8.dylib ; rpath includes external/v8-build/<key>/lib
If it reports a Homebrew libnode.*.dylib, you're on a stale build dir from
before the sealed-libv8 cutover — reconfigure clean. (The former
-DV8_INCLUDE_DIR=/opt/homebrew/opt/node@24/... libnode recipe is gone; see
the V8 provider section above.)
Headless-render gotcha (non-V8):
threejs-native-demoloadsdemo.js.templateviafs::path(__FILE__). ccacheCCACHE_BASEDIR+NOHASHDIR(CI/VM builds) relativizes__FILE__so it resolves into the build tree (missing) → SIGABRT. Stage the template into the build dir or build without ccache. Unrelated to V8.
register_bridge_functionregister_bridge_function(api, "foo", ...) makes the global callable from a
prelude, and nothing else. The @pulp/react type declarations, the mock
registry the package's tests run against, and docs/reference/js-bridge.md are
all GENERATED from core/view/src/widget_bridge_api_manifest.tsv — which is
hand-maintained and is not derived from the C++. A function registered without
its manifest row is invisible to every one of them, and a prelude that calls it
type-checks as an unknown global downstream.
The sequence is: register the function, add its name<TAB>category<TAB>kind<TAB>source
row to the manifest, add its TypeScript signature to the signature map in
tools/scripts/generate_widget_bridge_api.py, then
python3 tools/scripts/generate_widget_bridge_api.py --write # regenerate
python3 tools/scripts/generate_widget_bridge_api.py --check # what CI runs
--check passes on a missing row rather than failing, because a row that is
not there describes nothing to drift from — so a clean check is not evidence
that a newly registered function reached the generated surfaces. Grep the
generated .d.ts for the name instead.
_dataset, and only re-evaluates when tolddata-* attributes land in Element._dataset with the name camel-cased
(data-overlay-trigger → _dataset.overlayTrigger), but nothing re-runs a
prelude's heuristic just because an attribute changed. setAttribute /
removeAttribute in web-compat-element.js carry an explicit per-attribute
hook that calls the re-evaluation, so a new author hint that is not named there
appears to work in a test that sets it before the element mounts, and silently
does nothing when it is set or cleared later.
The mirror-image trap costs more, because it hits every real consumer rather
than a test. _reevaluateOverlay returns immediately while _nativeCreated is
false, and React commits setAttribute BEFORE appendChild, so the hook above
fires against an element with no native widget and does nothing. An author hint
therefore needs BOTH the per-attribute hook (for a later write) and a flush at
mount, which for ARIA attributes is __replayAriaAttributes__ — the same
replay aria-label and role already go through. A hint wired only to the
hook works in a hand-written appendChild-then-setAttribute test and never
fires for React. aria-haspopup, which marks an overlay TRIGGER, is wired to
both.
An author hint may also have a standards spelling already in the document, and
reading only the Pulp-specific one is a silent half-implementation rather than
a missing feature. The overlay pair is the worked example: Pulp read
role/aria-modal to CLAIM an overlay but only data-overlay-trigger to mark
the control that OPENS one, so a document with correct ARIA got the half that
consumes presses and not the half that gives one back — and switching menus
cost two presses in an app whose markup already said everything the policy
needed. When adding a hint, check whether ARIA (or another web standard)
already expresses it.
Element.prototype.focus() / blur() (web-compat-element-events.js) call the
bridge's setFocus(id) / clearFocus(id), which run transfer_input_focus() -
the same blur/gain/publish protocol a pointer press uses. Updating only
document.activeElement and firing a synthetic focus event looks right to
script and to every JS-side test, while the TextEditor never gets a caret and
the host keeps delivering keys elsewhere: the window hosts read the root's
native focus slot, not the DOM. A focus test must therefore assert
TextEditor::has_focus() / focused_input_under_root() and deliver text to
whatever that slot names, never to a widget the test picked
(test/web-compat/test_events_focus.cpp).
Anything that must happen "when an element mounts" has TWO entry points, and a
change to one silently misses the other. Plain DOM script mounts through
Element.appendChild / insertBefore in web-compat-dom-ops.js; @pulp/react
never calls those - its host config materializes widgets through the bridge
directly and only mirrors the DOM shim's parent links. autofocus and the
dialog first-field default are therefore implemented in both places:
__pulpApplyMountFocus__ on the DOM path, and finalizeInitialChildren /
commitMount in packages/pulp-react/src/host-config.ts on the React path
(which is also the only path that sees React's autoFocus prop; it is not a
DOM attribute). React's shim Element does not carry type, so React decides
"text field" from instance props, not from the shim.
CHOC's NativeFunction signature can only carry choc::value::Value arguments — JS function values don't round-trip through it. So even though requestAnimationFrame / setTimeout / setInterval look like they "should" be C++-only bindings, the callbacks themselves have to live in a JS-side registry (__frameCallbacks__, __timerCallbacks__).
What is C++-side: id allocation in WidgetBridge::__scheduleTimer__, deadline tracking in pending_timers_, and the flush driver invoked from service_frame_callbacks(). What is JS-side: the registry table and the setTimeout / requestAnimationFrame global wrappers (in web-compat-scheduler.js) that allocate ids, stash callbacks, and call into the natives.
Don't refactor this into a "pure native" shape — there's no way to do it without copying the entire JS engine's value type into Pulp's bridge layer.
GPUBuffer must COMMIT getMappedRange() writes on unmap()web-compat-document-gpu-mock.js's __createMockGPUBuffer backs each buffer
with a JS _bytes Uint8Array that the buffered-draw serializer
(web-compat-canvas-gpu.js) ships to the native bridge. WebGPU's mapped-write
contract is: getMappedRange() returns an ArrayBuffer the caller writes into,
and unmap() commits those writes to the buffer. An ArrayBuffer cannot alias a
sliced view of _bytes, so getMappedRange() MUST hand back a standalone
range (seeded from _bytes), record it, and unmap() MUST copy each recorded
range back into _bytes. The original code returned _bytes.buffer.slice(...)
(an independent copy) with a no-op unmap(), so every mapped write was silently
dropped. Three.js's WebGPUBackend uploads all geometry via
createBuffer({mappedAtCreation:true}) → new T(getMappedRange()).set(...) →
unmap(), so vertex/index buffers arrived all-zero and meshes collapsed to a
point — only queue.writeBuffer-backed uniforms survived. See the
threejs-bridge skill for the full runtime chronology.
setTimeout(fn, 0) deliberately bypasses __scheduleTimer__ and routes through Promise.resolve().then(...) so it drains on the next pump_message_loop() call. This matches React's scheduler expectations and makes tests deterministic (no host frame loop needed). Positive-delay timeouts go through the native deadline tracker and only fire when service_frame_callbacks() runs.
If a consumer reports "my setTimeout(fn, 1) never fires", check that the host is actually calling service_frame_callbacks() from its frame loop — that's the drain hook for non-zero delays.
display: flex defaults to flex-direction: rowPulp's underlying widgets default to FlexDirection::column (RN convention). The CSS web platform default for display: flex is flex-direction: row — children lay out horizontally. Imported / extracted designs assume the web default, so web-compat-style-decl.js explicitly emits setFlex(id, 'direction', 'row') whenever a CSSStyleDeclaration resolves display: flex and the consumer has NOT also declared flexDirection, flex-direction, or a flexFlow shorthand that includes a direction token.
Order independence is intentional. Both of these end up column:
el.style.flexDirection = 'column'; el.style.display = 'flex';
el.style.display = 'flex'; el.style.flexDirection = 'column';
The setter trap stores into _props BEFORE _applyProperty runs, so the display handler can see a previously-declared direction and skip the row default. A later explicit flexDirection overrides the row default through the normal handler.
flexFlow is content-aware: flexFlow: 'wrap' does NOT block the row default (CSS shorthand semantics — omitted flex-direction defaults to row), but flexFlow: 'column wrap' does. The check uses a \b(row|column)\b regex against _props.flexFlow.
Not changed by this fix: createCol / createRow / createPanel C++ paths preserve their explicit direction; typed React props in pulp-react/prop-applier.ts route directly through bridge setters and don't touch style.
web-compat-document.js's :hover translator snapshots el.style[prop] on
mouseenter and assigns it back on mouseleave. For the ordinary case — an
element that never carried an inline value for the hovered property — the
snapshot is the empty string, so the restore assigns "", not a number.
That makes parseFloat(resolved) || 0 a trap in _applyPaintProp: ""
parses to NaN, NaN || 0 is 0, and the widget is left fully transparent
while its rect, visibility and clip box are untouched — the element looks
deleted rather than un-styled, which sends you hunting in layout instead of
paint. When a paint property cannot parse its resolved value, fall back to
that property's CSS initial value (opacity → 1), never to zero.
The same shape applies to any future numeric paint property routed through
this path. A test for it must NOT pre-assign the inline value, or it
exercises the parseable branch and passes regardless; assert
isNaN(parseFloat(el.style.<prop>)) after the leave as a control.
The empty string is CSSOM's spelling of removal: el.style.background = ""
deletes the declaration. Both halves of Pulp's colour lane used to read it as
"nothing to do" instead — web-compat-style-decl-paint.js skipped the bridge
call because parseCSSColor("") is falsy, and style_visual_api.cpp skipped
the paint because hex.empty(). Two independent no-ops for the same value, so
fixing either alone changes nothing and reads as "the fix did not work".
The symptom is a colour that will not go away. A dropdown deactivates a row by
assigning its captured base background, which is "" for a row that was never
styled — so every row the highlight visited stayed lit, and the highlight read
as an accumulating frontier rather than a moving one. The element's own
bookkeeping stayed correct throughout, which is why an attribute assertion
(data-pulp-popup-active) passed while the pixels were wrong.
View already had clear_background_color() / clear_background_gradient()
and has_background_color(); the bridge simply never called them. Any new
colour-valued bridge entry point needs the same three-way split — empty
clears, parseable sets, unparseable is the only no-op — and the test for it
must assert has_background_color() on the view, with the still-lit element
as a positive control so a stuck colour cannot be confused with one that never
painted. "transparent" is NOT a substitute: css_color.cpp maps it to
rgba(0,0,0,0), which paints nothing but still counts as a declaration.
setTextColor (typography_api.cpp) still drops empty and has no
clear_text_color() primitive, so @pulp/react's documented removal contract
(textColor removed → setTextColor(id,"")) is currently inert on the native
side. Fix that half the same way when it next bites.
web-compat-document.js owns any popup it can reach by ARIA shape -- a
trigger carrying aria-haspopup over a role="listbox"/role="menu" -- and
paints a keyboard cursor on one row. That cursor is NOT the app's selection.
The app paints its own selected row however it likes; the owner's cursor is
navigation position. Both on screen at once reads as two selections, which is
exactly what a user reports as "why is it showing me two".
So the cursor is created at open (an arrow needs somewhere to start) but not
painted until the user asks for one: pointerenter on a row, an arrow key, or
an arrow that opened the popup in the first place. state.activeVisible is
that flag and paint() is gated on it.
The seed comes from selectedIndexIn(), which reads
aria-activedescendant / aria-selected / aria-checked / .checked /
aria-current in that order. An app whose rows advertise none of those gets
index 0, so a listbox whose current value is any row but the first shows the
app's selection on one row and the owner's cursor on another. The fix is on
the app side and is required for assistive technology anyway: a role="listbox"
whose children are bare <button>s has no selection to report. Mark the rows.
Where the first arrow lands then depends on whether the cursor had a home. Seeded from a real selection it steps off it, the way a platform combo box does. Seeded from an edge because nothing was marked, it lands ON that edge -- otherwise the first ArrowDown skips the row the user was aiming at.
confirm_failure.sh needs --object to verdict a .js prelude editPreludes are embedded into a generated build/core/view/web_compat_preludes_gen.cpp,
so a .js file produces no compile line of its own. The script verifies a
recompile by watching for the edited file's object in the build log, so
without help it sees no evidence and returns INCONCLUSIVE. Point it at the
generated translation unit instead and it verdicts normally:
tools/scripts/confirm_failure.sh \
--file core/view/js/web-compat-style-decl.js \
--break "perl -0pi -e 's/var consume = hinted;/var consume = false;/'" \
--object web_compat_preludes_gen.cpp \
--build-dir build --target pulp-test-web-compat-overlay --jobs 6 \
--test ./build/test/pulp-test-web-compat-overlay
The --object flag is documented in the script's own header for exactly this
case. Counting the changed text inside the generated file still works as a
manual positive control, but it is no longer the only route to a verdict.
Note also that core/view/js/web-compat.js is a monolith that is not
embedded. The embedded lane is the split web-compat-*.js set listed above,
so an edit to the monolith is inert — verify against PULP_JS_PRELUDES
before concluding a JS change had no effect.
textBaseline initializes to alphabetic, not topThe Canvas2D initial value for textBaseline is "alphabetic": the y
handed to fillText IS the baseline. Browser-authored canvas code that never
assigns ctx.textBaseline — which is most of it — relies on that, so
defaulting to top treats the same y as the top of the em box and pushes
every such caption down by one ascent. The symptom is subtle: text still
draws, in roughly the right place, just consistently low.
The default lives in three places that must agree, and changing one alone
produces a shim/native split that only shows up in a render:
core/view/js/web-compat-canvas.js (the shim's own this.textBaseline),
core/view/src/widget_bridge/canvas2d_api.cpp (the canvasSetTextBaseline
default argument and its string→int mapping), and
core/view/src/canvas_widget.cpp (the replay's initial TextBaseline).
canvas::TextBaseline enumerators are append-only: the bridge records the
enum's integer value into the command stream, so reordering it would silently
reinterpret every previously recorded top / middle / bottom.
moveTo / lineTo in core/view/js/web-compat-canvas.js do not cross the
bridge per point. They accumulate into this._pendPts, and _fp() ships the
whole run as one canvasPathPolyline call. This exists because the crossing,
not the engine, is the cost on a canvas-heavy UI: a band drag measured
959,968 canvasLineTo calls in 115,880 runs — 81% of them inside runs of 64
points or more, and every run immediately preceded by a canvasMoveTo.
Collapsing a run into one call removes roughly half of all bridge crossings
without changing a single recorded command. Reach for this shape before
reaching for a different JS engine; swapping QuickJS for JSC or V8 does not
make a crossing cheaper.
The buffering has one invariant, and it is easy to break by accident: any
shim method that calls a canvas* bridge global must call this._fp() as
its first statement. Without it the new command is recorded ahead of the
buffered points, so a stroke() paints an empty path, or a fillStyle
applies to the wrong subpath. Nothing about the recorded stream looks wrong
in isolation — the commands are all present, just out of order.
tools/scripts/check_canvas_path_flush.py (ctest canvas-path-flush-lint)
enforces it. It parses the prototype methods out of the shim by brace depth,
so it fails closed if it stops recognizing the file: finding zero
bridge-emitting methods is treated as an error, not a clean result. moveTo,
lineTo, rect and _fp are the only exemptions, because they own the
buffer.
The run survives a moveTo: a later subpath appends its points and records its
start index in this._pendStarts, and _fp() sends
canvasPathPolyline(id, coords, starts), so a row of 63 tick marks is one
crossing instead of 63. _openPendingSubpath flushes first when a run would
pass the bridge's 65536-coordinate cap — over the cap the bridge rejects the
whole batch and the path silently vanishes.
The same "the crossing is the cost" logic is why save()/restore() keep the
shim's _sent* record of native state instead of clearing it: see the
view-bridge skill's scripted-editor call-budget checklist for the replay-side
contract that makes that safe.
ctx.pulpCachedGroup(key, drawFn) records drawFn into a native group between
canvasBeginGroup and canvasEndGroup and later replays it with one
canvasReplayGroup call. While _groupRecording is set, two shim behaviours
change and must stay changed: a full-frame clearRect must not take the
retained-frame canvasClear path (that would replace the frame the group is
being recorded into), and restore() must not pop below _stateFloor, the
save depth at which the group began. The JS state snapshot is taken with
_captureState()/_applyState() rather than save()/restore(), because the
native group brackets itself and a real save/restore would land inside the
group.
Four classes of "silent drop" recur in web-compat-style-decl.js. When
adding a style property, walk all four before declaring done:
Missing case "X": — the property is nowhere in the switch, so
el.style.X = ... writes to _props[X] and never reaches the
bridge. Harness verdict: NOT-IMPL. Example: backdropFilter
already had a bridge setBackdropFilter, but still needed the
JS route.
Coalesced shorthand only — the shorthand routes (e.g.
textDecoration) but the longhands (textDecorationLine /
-Color / -Style) silently no-op. Per-attribute longhands MUST
route to per-attribute bridge setters so a previously-set sibling
isn't clobbered — the same pattern as the per-side border setters.
Don't try to coalesce three independent
property assignments into a single setX(id, line, color, style)
call; the JS shim iterates assignments in source order, so the
first call would always overwrite the next two with defaults.
Keyword-vs-numeric coercion — CSS / RN accept both keyword
forms (fontWeight: 'bold') and numeric (fontWeight: 700).
parseInt('bold') returns NaN, the || 400 fallback then
silently maps bold → normal. Translate before reaching the
bridge. The same translation lives in
packages/pulp-react/src/prop-applier.ts (_normalizeFontWeight)
for parity with React-Native style objects — both paths must
emit the same numeric weight.
Not a CSS property at all — React Native contributes style keys
CSS never had (hitSlop). The shim still has to carry them,
because the RN-style object and el.style.X are the same surface
to a consumer, and the three-class walk above applies unchanged.
The extra cost is that there is no CSS shorthand to inherit from,
so packages/pulp-react/src/prop-applier-paint.ts has to accept
BOTH the RN forms (a number, or {top,right,bottom,left}) and the
CSS-shorthand form a designer will reach for anyway
(hitSlop: '12px 2px'), and normalize to the four-value bridge
call. Emitting the number form only is the silent half-fix: the
object form then writes [object Object] into one edge.
__cssProperties__ array gotcha: properties also need an entry in
the __cssProperties__ array near the bottom of
web-compat-style-decl.js for el.style.X = ... to set the trap.
Properties only reachable via setProperty('x-y', ...) work without
this because setProperty converts kebab to camel and goes through the
same _applyProperty switch — but JSX consumers writing
style.backdropFilter = "blur(10px)" need both the array entry AND the
case block.
Verifier harness ground truth: run
python3 tools/harness/verifier.py --surface=css --json before and
after a CSS-shim change. The JSON delta tells you which entries
reclassified between NOT-IMPL → DIVERGE → PASS, and a drift_count
that drops without manual compat.json edits is the sign that the
catalog status was already correct and only the JS route was missing.
Sticky-state setters (Canvas2D shadow / direction / filter / …):
when wiring a new ctx.X setter that the bridge captures as sticky
state, follow this checklist or you'll land a silent no-op:
CanvasRenderingContext2D —
numeric 0 for shadowBlur, string "none" for filter, etc._sentX cache field initialised to null; the
_syncXState helper only flushes when the value differs._syncXState helper — coerce defensively (unknown strings
→ spec default), gate isFinite() for numeric fields per HTML5
("non-finite assignments are silently ignored"), then call the
bridge fn and update the cache._syncXState() into every consuming draw — fillRect,
stroke, fillText, drawImage, etc. The fillRect set is the
minimum; text + image draws need it too if the state visually
affects them.core/view/src/widget_bridge.cpp
— record a CanvasDrawCmd with the right int_val / extra
/ text field per the enum docstring.cmd_type_to_string in
core/view/src/canvas_widget.cpp — add to the paint switch AND
the trace-logger name table at the top of the file.Canvas base virtual + RecordingCanvas capture — even
when Skia / CG have nothing to do, RecordingCanvas is what the
canvas2d-shim tests assert against.This pattern is now used for shadow*, miter / image-smoothing,
direction, and filter setters. Copy the same shape for the next
canvas2d catalog setter.
var(--mono) etc.)setProperty('--name', value) in web-compat-style-decl.js (and the
mirror in web-compat.js) has THREE tiers, not the original two:
parseCSSLength) → setMotionToken writes
theme.dimensions[name].parseCSSColor) → applyTokenDiff writes
theme.colors[color.name].setStringToken(name, value) writes
theme.strings[name]. This is what catches font families
(--mono: "JetBrains Mono") and any other arbitrary string.Without tier 3, font-family-shaped custom properties were silently
dropped at set time and var(--mono) resolved to 0 (the
getMotionToken empty-token return). getPropertyValue mirrors the
same tier order — string token first, then numeric — so the
round-trip works.
getStringToken and setStringToken are bridge fns registered in
core/view/src/widget_bridge.cpp next to getMotionToken /
setMotionToken.
The React-side mirror lives in packages/pulp-react/src/prop-applier.ts
as _resolveVar(value), with the same lookup tiers (developer-set
__pulpCssVars registry → getStringToken → getMotionToken →
fallback). Call it from every string-valued style prop case that
might receive var(--name) from JSX — see the fontFamily /
color / borderColor* / outlineColor / textDecorationColor /
textShadowColor / shadowColor / background cases for the
canonical wiring.
JsEngine base, not per-backendJsEngine::register_function, register_host_object, and
register_promise_function are now non-virtual on the base class. They
each call claim_native_symbol(name) (which throws on duplicates) and
then delegate to the backend's register_*_impl hook. Backends only
implement the _impl half.
Why this matters: a duplicate-name registration is a silent bug that either shadows the previous binding (QuickJS-shaped behavior) or crashes at first call (JSC/V8-shaped behavior), and the symptom path depends on which engine the user picked at build time. Catching it at registration time, in the base class, makes the failure backend-independent and immediate.
When adding a new registration surface (Promise variants, typed-array
bridges, host-object subforms, etc.), follow the same pattern:
public non-virtual entry point on JsEngine → claim the name → call a
new _impl hook each backend implements. Do not add a duplicate-name
table inside an individual backend; that path drifts.
pulp-test-widget-bridge-api-contracts scans for accidental literal
re-registrations across WidgetBridge (function, host-object, and
promise-function names) and is the cheapest tripwire if a split-up
registration module forgets the contract.
When an element event is delivered through the shared __dispatch__ path, its
per-event registration callback should only keep the native channel alive. Do
not dispatch the same DOM event there too: one wheel tick will otherwise reach
every listener twice even though engine-specific tests may still look healthy.
This includes append-time auto-registration for React root delegation:
__pulpRegisterAutoDomEvents__ installs no-op click/pointer callbacks solely to
arm native delivery. __dispatch__ owns the only Element dispatch, while the
native pointer registrar emits the matching mouse event as a separate channel.
Direct @pulp/react handlers return the internal
__pulpEventPropagation marker from their synthetic-event wrapper. Pointer
payloads carry a per-native-dispatch token so __dispatchCallbackOnly__ can
suppress later native ancestor callbacks. Keep the token scoped and reentrant,
and key cancellation by both token and event name: cancelling pointerdown
must not silently cancel its independent compatibility mousedown. Level 1
(stopPropagation) still allows remaining same-target listeners; level 2
(stopImmediatePropagation) does not.
A wheel that resolved inside an open overlay is contained there: the bridge
adds __pulpWheelBoundary: "<overlay element id>" to the wheel payload,
_makeEvent copies it to ev._pulpBoundaryId, and _dispatchEvent drops
every path element above that element from BOTH phases, except __root__ (the
React-DOM delegate, which dispatches by fiber tree and would otherwise lose the
tick inside the overlay too). The native half stops at the overlay root in
deliver_mouse_wheel. Testing the JS half needs its own fail-before: a native
on(id,'wheel') ancestor stops at the native boundary, so only an
addEventListener('wheel') ancestor proves the DOM bubble is cut.
_reevaluateOverlay (in web-compat-style-decl.js) decides whether an element
claims the native overlay slot. It reads three kinds of signal, and they are
not interchangeable:
| Signal | Kind | Claims with |
|---|---|---|
data-overlay="true" | statement | outside-click consumed |
role=menu|listbox|tree|grid|dialog|alertdialog, aria-modal="true" | statement | outside-click consumed |
position:absolute + z-index >= 10 | inference | click-through |
A fourth signal does not claim at all — it qualifies a claim:
| Signal | Kind | Effect |
|---|---|---|
data-overlay-parent="<id>" on the overlay | statement | the claim NESTS on that overlay instead of dismissing it |
aria-owns="<overlay id>" on its parent | statement | same, stated from the other end |
The inference stays click-through on purpose — a false positive that consumes
swallows a real click, whereas one that clicks through merely closes something
that should not have claimed. The statements consume, because an author who
wrote role="menu" meant a menu, and a menu that lets the press through keeps
operating whatever sits under it.
Two traps:
aria-haspopup says "I OPEN an
overlay" and marks set_overlay_trigger; role/aria-modal say "I AM one".
For a while only the trigger half was read, so a correctly-authored
role="menu" panel fell through to the CSS-shape inference and claimed
click-through — visible to the user as a menu that closes but also draws on
whatever was behind it._reevaluateOverlay runs on
position/zIndex writes and on specific setAttribute/removeAttribute
names in web-compat-element.js. An attribute that is not in that list marks
nothing on a static panel, because no unrelated style write ever arrives to
drive the heuristic. Adding a signal means adding its attribute name to BOTH
branches there, not only to the heuristic.role="menu" is equally true of a menu and of its submenu, and a submenu
positioned to escape its menu's box — position: fixed, a portal, a returned
fragment — is emitted as a SIBLING of that menu. Native stacking recognises a
submenu by descent, so a sibling reads as a rival: the menu underneath is
dismissed the moment the submenu opens and every row on both of them goes
with it. Nothing in the markup that already claims can supply the missing
fact, so _resolveOverlayParent reads it from data-overlay-parent on the
submenu or aria-owns on the menu and passes it as claimOverlay's third
argument. Deliberately never inferred — an inferred parent would let any
panel nest on whatever happened to be open, which is what the descent rule
exists to prevent — and a name that resolves to no live, currently-open
overlay under the same root is ignored natively, leaving the ordinary claim.aria-owns changes a claim that is not on the element it was written on.
It lands on the menu and names the submenu, so re-evaluating only the element
that received the attribute leaves the submenu still claiming as a rival.
__pulpReevaluateOwnedOverlays__ in web-compat-element.js walks the token
list and re-evaluates each named element, from setAttribute, from
removeAttribute (reading the OLD value, since the attribute is already
gone), and from the pre-mount replay for a menu that mounts after the panel it
owns.A consumer that marks its overlays in its own private vocabulary
(data-<product>-overlay) is invisible to all three rows above. The attribute
name is the contract; nothing infers intent from a product-specific prefix.
__pulpPopupDefaultHandle__ (in web-compat-document.js) is the default
keyboard/dismiss state machine for any aria-haspopup trigger. It can only
answer ArrowUp/ArrowDown/Enter/Escape for a menu it has taken ownership of, and
it paints the row highlight from exactly one place — paint(), the sole writer
of data-pulp-popup-active and of the highlight background. If ownership never
happens, both the navigation and the highlight are silently absent, and they
fail together: one cause, two symptoms.
Two traps around that ownership:
A keydown path alone is not ownership. The owner's keydown branch needs
triggerFrom(document.activeElement), and document.activeElement is written
only by Element.prototype.focus. A native mouse click never runs one, so on
the real user path that branch is dead. Ownership on that path has to come
from the pointer/click the user actually made. A test that calls
trigger.focus() in setup hand-satisfies the one condition the mouse path
cannot, so it passes over a defect a user still sees — if a popup test focuses
the trigger, it is not covering the mouse-opened case.
The menu does not exist yet when the click arrives. The app's own click
handler creates the popup, and a host that defers dispatch plus a reconciler
that commits a tick later mean a single synchronous activate() inspects a
DOM with no popup and gives up. Re-offer across a few frames instead of
deciding once.
The owner clicks things itself — a trigger to commit or close, an option to mirror a keyboard open onto the app's handler. Once element clicks are offered to the owner, those come back in as if a user had pressed the control, and a close reads as an open. Wrap owner-issued clicks in a counter and return at the very top of the handler while it is non-zero: above the stale-state sweep, not merely inside each branch. The sweep runs first, sees a popup the app has already removed, and dismisses the state the in-flight branch still needs to hand focus back to its trigger.
A press on a menu's OWN trigger is a toggle, not a switch. The native
policy (route_press_to_active_overlay) lets a press on an overlay trigger
through so switching to a DIFFERENT dropdown costs one press. Applied to the
trigger that opened the menu, that pass-through was a reopen: the dismissal
made this owner clickSelf the trigger shut, then the same press's click
reached the app's toggle and opened it again, so a dropdown button could
never close its own menu. The overlay now knows its anchor
(View::set_overlay_anchor): a claim that follows a press on a trigger
adopts it natively, and adopt() names it as claimOverlay's fourth
argument so a KEYBOARD-opened menu, which had no press to learn from, knows it
too. A press on the anchor is dismissed and consumed (trigger_closed).
Enter/Space on a focused <button> trigger open a closed menu and, while no
row is revealed, close an open one — scoped to real buttons because a browser
gives role="button" no key activation and its author handles the key in
script, so activating it here as well toggles twice.
Restoring a row's background is its own trap: parseCSSColor("") returns null
and _applyPaintProp silently drops it, so assigning the empty string leaves
the highlight on every visited row, while "transparent" parses to #00000000
and paints a transparent fill over a background that came from a non-script
source. Remove the property and clear the native background instead
(getBackground / clearBackground on the style bridge).
Note that __pulpActivateMaterializedElement__ invokes the React callback
directly and never enters Element.prototype._dispatchEvent, so a fixture that
opens a menu through it gets a menu no popup owner has claimed. Drive the
production line (__dispatch__(el._id, "click", …)) when the fixture is meant
to stand in for a user's mouse.
web-compat-document.js's semantic popup default opens a role="listbox" /
role="menu" with its cursor on the option the author marked as selected, and
only falls back to the edge the caller asked for ("first" for ArrowDown,
"last" for ArrowUp) when nothing is marked. selectedIndexIn() reads, in
descending order of authority: aria-activedescendant on the trigger or the
popup, aria-selected="true", aria-checked="true", the option's checked
property, then aria-current. Assigning the checked property writes no
attribute in this shim, so it is a genuinely separate signal from
aria-checked.
Two consequences for authors of scripted UI:
aria-selected on its options — which is also
the ARIA requirement for role="option" inside a listbox.ui_components.cpp's ComboBox::open
seeds hover_index_ = selected_. The scripted path and the native path now
agree, so a design that moves between them keeps the same first highlight.false, not a defaultclaimOverlay(id, consume) reaches native as
args.size() > 1 && args.get<bool>(1, false) (event_api.cpp). A caller that
passes only the id therefore opts OUT of consuming the dismissing press, with
no diagnostic anywhere — the call looks complete and the behaviour silently
differs from a caller that passed true. web-compat-style-decl.js shipped
that way for months while web-compat-document.js and all three
prop-applier-events.ts sites passed true, so the same author intent behaved
differently depending on which authoring surface expressed it, and a comment
claiming the two paths matched made the fork read as intentional.
Two rules follow. When you add a bridge argument, grep every call site rather
than trusting a default to be safe — args.size() > N style readers cannot
distinguish "omitted" from "explicitly false". And when the shim claims to
mirror another authoring surface, verify that against the surface, not the
comment: the comment is the thing that goes stale.
The style-decl overlay claim now splits deliberately. data-overlay="true" is
an explicit author statement, identical in intent to <View overlay>, so it
consumes. The CSS-shape heuristic (position: absolute + z-index >= 10) is
an inference, so it stays click-through: a false positive that consumed would
swallow a real click outright, while a false positive that clicks through only
closes something that should not have claimed. The claimed mode is tracked in
_autoOverlayConsume so adding the hint to an element that already claimed by
shape re-claims instead of stranding the old mode — claim_overlay() is
idempotent, so the re-claim is safe.
| Engine | Public ESM API | Status |
|---|---|---|
V8 (pinned sealed libv8 on desktop/Android) | import.meta, dynamic import(), setModuleLoaderDelegate-equivalent | Full ESM. Used by examples/threejs-native-demo/main.cpp for macOS Three.js. |
| JSC (system framework on iOS) | NO public ESM module loader on iOS | ❌ JSScript.h + JSModuleLoaderDelegate are private. Shipping them in an .appex risks App Store rejection. |
| JSC (system framework on macOS) | JSScript exposed via framework headers | 🟡 Available, but typically unused since macOS uses V8. |
QuickJS (vendored under external/quickjs/) | Full ESM via JS_NewModule* | ✅ Used as a fallback when V8 is unavailable + jitless V8 isn't an option. |
| Hermes | Limited (no full ES module support) | ❌ Not viable for Three.js. |
iOS Three.js workaround: Bundle the ESM source into a self-contained IIFE that registers exports on globalThis.THREE, then JSC evaluate() it. The bundler is at tools/scripts/bundle_threejs_for_jsc.mjs; the iOS NSBundle loader at core/view/src/threejs_resources_apple.mm; the .appex build-time wiring at tools/cmake/PulpAuv3.cmake. Pattern is general — any ESM library can be bundled this way for the JSC iOS lane.
Bundler implementation note: The bundler delegates to esbuild (pinned in tools/scripts/package.json at 0.25.10). The earlier regex-only pass could not resolve sibling import { ... } from "./three.core.js" statements that Three.js's webgpu entry depends on, which caused JSC parse errors at runtime ("expecting ("). esbuild handles ESM resolution + http: import stripping out of the box. The bundler auto-installs esbuild on first run via npm install --prefix tools/scripts/ when node_modules/esbuild is missing — no manual setup needed.
See .agents/skills/{ios,threejs-bridge}/SKILL.md for the runtime contract.
Several silent-failure traps were caught during the iOS-D.3c Three.js bring-up. Each now has an explicit diagnostic surface in the engine layer.
core/view/src/js_quickjs_engine.cpp)QuickJS does not call JS-side addEventListener("unhandledrejection", ...) handlers. Without a host hook, a rejected promise with no .catch is silently dropped. The worst case is the new Promise(async (resolve, reject) => { ... }) anti-pattern: a sync throw inside the async executor rejects the inner async function's promise (not the outer), and the outer Promise sits in pending forever.
install_quickjs_rejection_tracker() calls JS_SetHostPromiseRejectionTracker in QuickJsEngine()'s constructor and logs unhandled rejections + stack via runtime::log_error("PULP_QJS_UNHANDLED_REJECTION: …") / PULP_QJS_UNHANDLED_REJECTION_STACK: …. This single hook surfaced the real Three.js init error (TypeError on self not defined) in one shot after hours of fruitless other debugging — turn it on, grep for PULP_QJS_UNHANDLED_REJECTION in the log.
core/view/src/js_jsc_engine.mm)[JSContext evaluateScript:] can throw NSException for malformed scripts (bare import statements, syntax errors that the parser can't recover from). Without @catch(NSException*), the exception unwinds through the Objective-C runtime and surfaces in C++ as the useless string "unknown exception". Now the engine catches NSException + falls back to id, formats the reason + name, and rethrows as std::runtime_error with the actual JSC error message. Grep for PULP_JSC_EVAL_NSEXCEPTION / PULP_JSC_EVAL_OBJC in logs to find scripts that JSC's parser rejected.
self as a window alias in the web-compat document shim (core/view/js/web-compat-document.js)Per browser spec, self, window, and globalThis all reference the same object in a page context. Three.js's Animation class (and many other libraries) keys its requestAnimationFrame lookup on self:
this._context = typeof self !== "undefined" ? self : null;
If self is undefined, libraries set their context to null and crash with TypeError on the next rAF call. globalThis.self = window; is the spec-conforming fix and is now installed alongside the other window-global aliases (localStorage, sessionStorage). Holds for any standards-compliant library that follows the browser globals convention.
eval_or_throw logs the JS error before wrapping (core/view/src/widget_bridge.cpp)When WidgetBridge evaluates user JS via eval_or_throw, the catch chain re-throws as std::runtime_error("failed to evaluate <name>: <err>"). A cross-translation-unit std::exception typeinfo mismatch can cause the caller's catch(const std::exception&) to fall through to catch(...), which then loses the original message. The fix: runtime::log_error("PULP_EVAL_THROW: name={} js_len={} ..._error={}", ...) is called before re-throwing in all three catch branches, so the JS error reaches the log regardless of whether the downstream catch matches. Grep PULP_EVAL_THROW to find the actual error text when "unknown exception" surfaces upstream.
The web-compat-*.js mocks (web-compat-document-gpu-mock.js etc.) are loaded identically under QuickJS, JSC, and V8 — so a logic bug in a mock behaves the same on every engine, but the surrounding native path may not. A WebGPU-mock writeBuffer bug (treating a TypedArray dataOffset/size as bytes instead of element counts) can pass the macOS V8 headless smoke because that lane does a one-shot Skia pixel readback, while the live iOS AUv3 path depends on the presentable swapchain. Two lessons: (1) when you change a web-compat-*.js mock, add a focused assertion of the exact code path you touched (the smoke's whole-array writeBuffer masked the explicit five-arg element-count form); (2) "green on the V8 headless lane" is necessary but not sufficient for the JSC/iOS live present path — that path must be verified on a device/simulator screenshot, never inferred from the readback test.
web-compat-style-decl-paint.js hands a gradient to the native side as ONE
string and the stops are parsed there, by a colour parser that does not know
oklab() / oklch() / lab() / lch() / color(). An unrecognised function
name falls back to white, so a translucent bloom paints as an opaque white
blob rather than failing visibly.
This is not an exotic case. Chrome serializes every color-mix(in oklab, …) to
oklab(…), which is the idiom generated panels use for glows, blooms and
shadows — so the modern-colour path is the common path, not the edge. Measured
on one stop: it rendered (254,254,254) where the design asked for #58f0ff at
48%, with G - R = 0 at every blur radius (a flat grey/white tell, since the
requested colour is strongly cyan).
parseCSSColor on the JS side already resolves all of these correctly, so
resolve to hex before the string crosses over. Do NOT grow a second colour
parser on the native side: two parsers for one syntax must then be kept in step
forever, and the failure mode when they drift is a silently wrong colour rather
than an error.
Two traps worth keeping when writing the rewrite pattern:
calc() inside a stop
position from being swallowed by the colour match.The same boundary explains why box-shadow needed a colour-first branch:
CSS allows the colour on either side of the offsets and Chrome serializes it
first (oklab(0.97 0.008 -0.014 / 0.18) 0px 1px 0px 0px inset). Resolve
function colours to hex before any offset split — every offset match divides on
whitespace, and oklab(…) carries both spaces and a slash, which tears the
colour apart in either ordering.
Anchor the offsets regex. Unanchored, it matches inside a colour-first
shadow: given #f7f3ff2e 0px 1px 3px 0px it starts at the offsets and takes the
trailing 0px as the colour, yielding a shadow that parses "successfully" with
garbage in it — strictly worse than not matching, because the colour-first branch
then never runs.
Do not test this with a round-trip assertion. Reading el.style.boxShadow
back returns whatever string was assigned whether or not it ever parsed, so a
round-trip passes against a shadow that never reached the renderer. Export the
parse helper and assert its decomposition directly.
CSSStyleDeclaration._applyProperty skips a write whose raw string equals the
one already applied for that property. This is not a micro-optimisation: a
stylesheet re-application pass rewrites every matched declaration on every
pass, so an interaction that changes no fonts still re-sent every font property
every frame, each one paying var() resolution, per-property parsing and a
bridge call to set a value the widget already held.
Four things about the cache are load-bearing, and all four are easy to break:
var() resolution — and var()
values are never deduped. They resolve against live theme tokens, so the
same raw string can legitimately produce a different applied value with no
write to observe.visibility and opacity both drive setOpacity; shorthands expand over
their longhands), so caching properties independently would let a stale entry
suppress a write the widget needs. _DEDUP_GROUP / _DEDUP_MEMBERS in
web-compat-style-dedup-table.js partition the properties into groups that
share state; applying one member drops the cached entries of the others.
That table is generated, not hand-written — tools/scripts/style_dedup_table.py
reads the _apply*Prop handlers, records which bridge function and which
literal sub-key each case reaches (setFlex(id, "margin_top", v) — the
sub-key, not the function, is the real granularity), and unions the properties
that collide. A computed sub-key conflicts with every sub-key of its function.
Only writes count: a get* bridge call reads state, and treating one as a
slot would merge every property that resolves a var() into a single group
and erase the optimisation while still rendering correctly.
A hand-maintained alias table would have to stay exhaustively correct forever
and fails visually and silently when it does not; a ctest re-derives this
one and fails if it has drifted from the handlers. A property the extractor
cannot classify is absent from the table, and an absent property is never
cached and drops the whole element's cache when it applies — so being
missing costs speed, never correctness.[style][dedup][benchmark] case; it
A/Bs the guard inside one binary and prints the apply ratio.el.style must call
__invalidateStyleCache__(el). The cache describes a widget, not an
Element, so it is wrong the moment the widget is recreated under a surviving
Element (_ensureNative, _reparentNative, the appendChild /
removeChild / replaceChild mount paths, __forgetWidgetCallbacks__) or
another path writes a slot it claims (media-attribute replay, the hidden
setter, dialog show / close). If you add a bridge setter call outside
the web-compat-style-decl-* handlers, you are adding one of these. The
failure mode is a later identical style write being skipped as redundant
against a widget that no longer holds that value — which renders wrong with
every test still green, because el.style.foo reads back the assigned string
whether or not it ever reached the widget. Assert against real View state.precompile_scripts(sources, cancel) compiles whole scripts into the
process-wide bytecode cache in a private QuickJS runtime on the calling thread,
without running them; evaluate_script() in any later realm then reads the
bytecode (script_bytecode_read) instead of compiling (script_compile).
Bytecode is runtime-independent (atoms serialize as strings), which is what
makes a background compile reusable. Gotchas: the calling thread needs a stack
of a few MB — QuickJS's parser recurses on the native stack and the engine
allows it 1 MB of JS stack, so a default 512 KB macOS secondary thread can
crash before QuickJS notices (view::prewarm_scripted_ui uses an 8 MB
thread); the cache tracks in-flight sources, so an evaluation that asks for a
source another thread is compiling waits (bounded, 5 s) instead of compiling it
twice — script_bytecode_cache_stats().waits counts those; a precompile that
finds the source already cached is not a hit (hits count evaluations only).
Only the QuickJS backend has a bytecode cache; with a JSC or V8 default the
prewarm is a no-op. Bytecode stays in memory, never on disk (QuickJS does not
validate untrusted bytecode).
To test that evaluate_script() waits for a precompile that is still running,
do not give precompile_scripts() a head start and hope it is still compiling:
on a loaded runner a small script finishes first, the evaluation reads a cached
entry, and waits stays 0. Install detail::set_precompile_claim_hook_for_tests()
instead. It runs on the precompile's thread after the source is claimed and
before it is compiled, so the hook can hold the compile in flight until
script_bytecode_cache_stats().waits shows the evaluation waiting. Bound every
wait in such a test (the hook's and the main thread's) so a regression fails
instead of hanging, and clear the hook afterwards.
まだレビューはありません。使ってみた感想をお寄せください。
概要と使いどころ
Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.
日本語の概要は準備中です。原文の説明を表示しています。
Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.
日本語の概要は準備中です。原文の説明を表示しています。
Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.
日本語の概要は準備中です。原文の説明を表示しています。
Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.
日本語の概要は準備中です。原文の説明を表示しています。
The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.
日本語の概要は準備中です。原文の説明を表示しています。