Skip to content

Opt-in document::eval, gated on both sides of the boundary - #23

Merged
lannbot merged 1 commit into
mainfrom
feat/document-eval-opt-in
Sep 5, 2026
Merged

Opt-in document::eval, gated on both sides of the boundary#23
lannbot merged 1 commit into
mainfrom
feat/document-eval-opt-in

Conversation

@lannbot

@lannbot lannbot commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Adds document::eval support to the renderer as a capability that is off unless granted on both sides:

  • Guest: new eval Cargo feature on polyengine-dioxus. Without it the component does not import polymorph:dioxus/eval at all (wit-component prunes unreferenced world imports; just example now asserts this, just fixtures asserts the converse for the new eval-probe fixture). With it, WitDocument is provided as root context and document::eval works.
  • Host: MountOptions.eval?: boolean (default false). Only then is polymorph:dioxus/eval@0.6.0 supplied. A component that imports eval against a non-opted host fails at instantiation with a PlanError naming the import — tested.

For polyvisor: the visor mounts with eval: true; apps never do, and an app that was built with the feature cannot instantiate.

WIT (interface eval): resource evaluation { constructor(js); send(json); recv: async -> result<string, error>; join: async -> result<string, error> }, error = invalid-js | communication | finished. Semantics mirror dioxus-web/desktop (async-function body with dioxus.send/dioxus.recv, JSON both ways, undefined -> null). Package version unchanged: the mutation schema is untouched.

Guest owner lifetime: Eval is Copy; the reference renderers free the evaluator on JS GC. Here it is freed when the script completes unobserved (fire-and-forget) or when poll_join delivers the result. Details in src/document.rs's module doc.

Workaround, flagged: host/src/eval.ts defers recv/join settlement by one macrotask. Under the pinned polyengine, an async host import called from an export's initial activation whose promise settles in the next microtask checkpoint is never delivered back to the guest (driver exits EXIT-done before the settlement pump arms). handle-event handlers awaiting an eval hit this every time; use_future evals dodge it by accident. Repro matrix and citation are in the comment. Upstream issue to follow separately.

Tests: host/tests/eval_test.ts (unit, no component), host/tests/eval_component_test.ts (real fixture: fire-and-forget, recv/send/join round trip, both error paths via click handlers, negative mount). Full just check/fixtures/example counter/ssg-example counter/serve-test counter/test pass locally.

Automerge armed (merge commit).

`polymorph:dioxus` gains an `eval` interface — the back end of dioxus's
`document::eval` — that nothing gets by default. A browser cannot sandbox
arbitrary JS well enough for a host running untrusted components, so the
capability is granted twice or not at all:

- Guest: the renderer references the interface only under its new `eval`
  Cargo feature. wit-component encodes only the interfaces the core module
  actually imports, so a component built without the feature does not
  import `polymorph:dioxus/eval` at all (the `example` recipe asserts the
  absence; `fixtures` asserts the presence for the eval fixture).
  Without it `document::eval` still resolves to `NoOpDocument`.
- Host: `mountApp` supplies the import only for `MountOptions.eval`. A
  component that imports it against a host that did not opt in fails at
  instantiation (`PlanError` naming the import), which is the safe
  direction. polyvisor sets the flag for its visor and never for an app.

Semantics follow dioxus-web/dioxus-desktop: the script is the body of an
async function with a `dioxus` object (`send`/`recv`), values cross as
JSON text, `undefined` returns as `null`, and the constructor's
synchronous prefix is bracketed by the dispatch gate like `dom.set-focus`.

Guest side, `Eval` is `Copy` over a generational box whose owner the
reference renderers release on JS GC. Here the owner is released when the
script completes unobserved (fire-and-forget, the common case) or when
`poll_join` hands the result over — scheduled through `spawn_local`
because `Eval::join` holds a borrow on the box at that moment.

Host side carries one workaround: `recv`/`join` settle no earlier than a
macrotask after they would otherwise. An async import called from an
export's initial activation whose promise settles in the following
microtask checkpoint is never delivered back to the guest under the
pinned polyengine (the export's driver exits before the settlement pump
arms); `handle-event` handlers awaiting an eval hit this directly. The
deferral comes out when polyengine closes the gap.
@lannbot
lannbot enabled auto-merge September 5, 2026 03:25
@lannbot
lannbot merged commit 0723405 into main Sep 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants