Skip to content

Translate wasm-bindgen imports - #657

Merged
kixelated merged 7 commits into
mainfrom
quest/m3/ts-wasm-bindgen/imports
Nov 7, 2026
Merged

kixelated merged 7 commits into
mainfrom
quest/m3/ts-wasm-bindgen/imports

Conversation

@kixelated

@kixelated kixelated commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes and deletes quest/m3/ts-wasm-bindgen/imports.md. Its links in the epic's Required list and in closures.md are removed too.

Problem

web-codecs, and any crate that calls the browser, reaches JS through wasm-bindgen, js-sys, and web-sys. Translation refused all of it. A translated crate had no way to call JS, even though the target already runs inside JS.

Approach

  • Reading the imports. Each import is read from the __wasm_bindgen_unstable program statics that #[wasm_bindgen] leaves in the crate. These are the same statics the CLI reads.
    • The decoder in ts/src/lower/bindgen/decode.rs is vendored from the CLI's at 0.2.130, with fields gated by schema. It decodes schemas 0.2.128 (wasm-bindgen 0.2.129, pinned by js-sys 0.3.106) and 0.2.130 (pinned by js-sys 0.3.107).
    • Any other schema is refused, and the error names it.
    • An import shim maps to the function that wraps it, and that function's body becomes the JS call the CLI would write. That covers functions, methods (on the prototype or structural), getters and setters, indexing, constructors, static methods, symbol-named methods, variadic calls, and vendor prefixes.
  • Values. A JsValue, and every type an extern "C" block declares, is rs.JsValue, which is TS unknown.
    • The glue around these types becomes the value itself: the obj field, the aggregate, Clone, the macro's From/AsRef/Deref impls, and JsCast's unchecked casts. Drop does nothing.
    • wasm-bindgen's LazyCell is rs.LazyCell.
  • Runtime. ts/runtime/rs/js.ts holds the boundary conversions, re-exported from rs.ts:
    • numbers as the WebAssembly ABI coerces them, BigInts, char, strings, typed arrays, and arrays;
    • Option through undefined, and string enums;
    • catch into a Result;
    • instanceof, vendor prefixes, and js-sys's intrinsics. debug_string is a verbatim port of the CLI's.
  • Refused, each at its span:
    • custom JS (module, raw_module, inline_js);
    • suspending and experimental_generic_mono imports;
    • Closure;
    • intrinsics that only exist in WASM (memory());
    • #[wasm_bindgen] exports.
  • Tests. The new bindgen fixture (its own workspace) has 15 harness cases over imports of each kind: its own, js-sys's, and web-sys's (TextEncoder, TextDecoder, console).
    • The native side runs through wasm-bindgen --target bundler glue, with the CLI at 0.2.129 from the dev shell. Each WASM instance gets a fresh glue instance, because the glue caches its memory views.
    • The fixture is translated twice: under its lockfile, and again under wasm-bindgen-0.2.130.lock. Both translations are compared with the same native run. Both schemas showed 0 differences.
    • refused.toml snapshots the refusals.

Impact

  • rs.ts gains JsValue and the js* helpers from rs/js.ts.
  • Translated crates can now call wasm-bindgen imports. Those calls were refused before.
  • Translated output changes for one existing pattern: a call whose result was held across a loop entry now runs before the loop instead of inside it. This changes stock's hashlink and rangemap snapshots, and those changes are fixes (see below).
  • The dev shell gains wasm-bindgen-cli_0_2_129.
  • ts/README.md documents the mapping and the supported schemas under "Well-known crates", and the fixture under "Tests".

Alternatives

  • Reading imports from source, or from the macro's expansion: rejected. Imports made by macro_rules! are on web-codecs' path, and the encoded program is what the CLI itself trusts.
  • Running the CLI and shipping its JS glue: rejected. The glue talks to WASM memory, and translation has no WASM.

Choices made without asking

  • Globals are reached through globalThis. Lexical let/class bindings at the top of a script are not seen; the CLI's ES-module glue sees them.
  • A vendor prefix is resolved at each call. The CLI resolves it once, when the glue loads, so the result differs only if the global changes in between.
  • A thread_local_v2 static is an rs.LazyCell that is read once, as native code caches it.
  • differential.ts and wasm.ts gain a "thrown" outcome. When glued, a JS error that neither side catches is compared by its message.
  • A bare-specifier module = "pkg" encodes as a raw module, so it is reported as "its JS module pkg".
  • An export is refused at the exported function's span, found by following the closure that its generated shim calls.
  • General fixes this work needed:
    • Fx::node now flushes effectful pending temps before a loop header, so a call is no longer moved into the loop. This fixed real miscompilations in stock's hashlink (DropFilteredValues) and rangemap.
    • loop_header checks reachability before it asks about dominance.
    • The printer parenthesizes new whose callee contains a call.
  • Impl functions of the crate's own represented types are named in their Rust module, because they get no class.
  • A struct counts as an imported type only when the #[wasm_bindgen] attribute (a macro from wasm_bindgen_macro, under any path) wrote its obj field. A crate's own #[repr(transparent)] struct { obj: JsValue } stays a struct and keeps its Drop.
  • A missing global throws ReferenceError("X is not defined"), which is V8's text; the differential runs on node. Other engines word it differently.
  • A catch import catches result conversions that the CLI's glue does (strings, char, vectors, Options, 128-bit integers). It does not catch the WebAssembly ABI's coercion of a plain number or of an Option<u8/i8/u16/i16>, which happens after the glue returns. An Option<u8/i8/u16/i16> also takes 0xFFFFFF as None, as the ABI sentinel does.
  • Number results coerce with + first, so a BigInt throws the ABI's ToNumber error text.
  • Refused for now:
    • A mutable non-byte slice inside an Option, since only a top-level argument is written back.
    • 0.2.130's Nullable, since nixpkgs has no 0.2.130 CLI to compare its glue against.
  • The uncaught throw of a non-optional static that no JS defines is not in the differential. Native renews the instance after a throw, but the translation's module state, including that static's LazyCell, persists across inputs.

Follow-ups

Suggested, not planned:

  • Support custom JS modules (module, inline_js) by emitting the JS beside the output and importing it. Recommend drop until a consumer needs one; web-codecs doesn't.
  • Translate experimental_generic_mono by writing the JS per instance, as the CLI does. Recommend drop for now: it is experimental, and web-codecs doesn't use it.
  • Support 0.2.130's Nullable and Option<Nullable<T>> once nixpkgs packages wasm-bindgen-cli 0.2.130, compared against its glue. Recommend plan when a consumer's js-sys reaches 0.3.107.
  • Schemas newer than 0.2.130 need a gated field in decode.rs each time js-sys bumps. Recommend plan once a consumer bumps past 0.2.130, rather than a recurring quest.
  • Closures and promises are already covered by closures.md.

Models

  • Size: XL
  • Implementer: Claude Code, claude-opus-5-5, high effort

(Written by Claude Opus 5.5 with high effort)

🤖 Generated with Claude Code

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, xhigh effort
  • Session: 01a1b7ea-c984-75e0-89a6-3ba7380578a3, round 4
  • Head: 57655c6
  • Base: origin/main at 41ea814
  • Score: 10/10, The merge adds no new branch-specific implementation, and the snapshot update preserves the Rust loops’ behavior; no builds or tests were run.
  • Findings: none

@pullfrog

pullfrog Bot commented Nov 7, 2026

Copy link
Copy Markdown

Pullfrog  | Review this ➔

kixelated and others added 6 commits November 7, 2026 13:23
Read each import from the program statics `#[wasm_bindgen]` leaves in the
crate, by schema 0.2.128 or 0.2.130, and write the JS call the CLI would.
`JsValue` and imported types are the JS value itself; the boundary
conversions, `catch`, statics, string enums, casts, and js-sys's intrinsics
map to the runtime's `rs/js.ts`. Custom JS modules, `suspending`,
`experimental_generic_mono`, closures, WASM-only intrinsics, and exports
are refused at their spans.

The `bindgen` fixture compares a harness of each kind of import against
native Rust run through the CLI's glue, at both schemas.

Also flushes effectful pending temps before a loop header, which fixes a
call that was sunk into a loop in stock's hashlink and rangemap output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gen/imports

# Conflicts:
#	ts/src/lower/library.rs
#	ts/src/lower/mod.rs
- A namespace's variadic function is applied on its namespace.
- A variadic call spreads its last argument by its iterator.
- A `catch` import catches what the JS glue's conversion throws, not the
  WebAssembly ABI's.
- An optional `bool` reads by its truthiness.
- A global no JS defines throws a `ReferenceError`.
- Number results coerce through `ToNumber`, so a BigInt throws the ABI's error.
- Only a struct the `#[wasm_bindgen]` attribute wrote is a JS value.
- An optional mutable slice is refused, and so is 0.2.130's `Nullable`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at the ABI

A variadic call now reads what it is a method of once, then its function,
and then spreads its arguments, as a spread call does. An `Option` of a
`u8`, `i8`, `u16`, or `i16` crosses the ABI's `i32` as it is: a `catch`
import doesn't catch its coercion, and `0xFFFFFF` is `None`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 7, 2026 22:40
@coderabbitai

coderabbitai Bot commented Nov 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 37 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2a4489c0-b249-4307-bbcb-c3def23fea2b
Commits

Reviewing files that changed from the base of the PR and between 41ea814 and 57655c6.

Files ignored due to path filters: 2

  • ts/fixtures/bindgen/Cargo.lock is excluded by !**/*.lock
  • ts/fixtures/bindgen/wasm-bindgen-0.2.130.lock is excluded by !**/*.lock
Files selected for processing: 56

  • flake.nix
  • quest/m3/ts-wasm-bindgen/README.md
  • quest/m3/ts-wasm-bindgen/closures.md
  • quest/m3/ts-wasm-bindgen/imports.md
  • ts/README.md
  • ts/fixtures/bindgen/Cargo.toml
  • ts/fixtures/bindgen/caller.ts
  • ts/fixtures/bindgen/expected/core/bool.ts
  • ts/fixtures/bindgen/expected/core/option.ts
  • ts/fixtures/bindgen/expected/core/result.ts
  • ts/fixtures/bindgen/expected/harness.Input.ts
  • ts/fixtures/bindgen/expected/harness.Owned.ts
  • ts/fixtures/bindgen/expected/harness.ts
  • ts/fixtures/bindgen/expected/imports.Kind.ts
  • ts/fixtures/bindgen/expected/imports.ts
  • ts/fixtures/bindgen/expected/js_sys.ArrayIter.ts
  • ts/fixtures/bindgen/expected/js_sys.ts
  • ts/fixtures/bindgen/expected/lib.ts
  • ts/fixtures/bindgen/expected/wasm_bindgen.JsThreadLocal.ts
  • ts/fixtures/bindgen/expected/wasm_bindgen.ts
  • ts/fixtures/bindgen/expected/wasm_bindgen/cast.ts
  • ts/fixtures/bindgen/expected/web_sys/features/gen_TextDecoderOptions.ts
  • ts/fixtures/bindgen/js/custom.js
  • ts/fixtures/bindgen/refused.toml
  • ts/fixtures/bindgen/refused.txt
  • ts/fixtures/bindgen/src/harness.rs
  • ts/fixtures/bindgen/src/imports.rs
  • ts/fixtures/bindgen/src/lib.rs
  • ts/fixtures/bindgen/src/refused.rs
  • ts/fixtures/bindgen/ts.toml
  • ts/fixtures/stock/expected/hashlink/linked_hash_map.DropFilteredValues.ts
  • ts/fixtures/stock/expected/rangemap/inclusive_map.RangeInclusiveMap.ts
  • ts/fixtures/stock/expected/rangemap/map.RangeMap.ts
  • ts/justfile
  • ts/runtime/rs.ts
  • ts/runtime/rs/js.ts
  • ts/runtime/tsconfig.json
  • ts/src/census.rs
  • ts/src/lower/bindgen/convert.rs
  • ts/src/lower/bindgen/decode.rs
  • ts/src/lower/bindgen/lower.rs
  • ts/src/lower/bindgen/mod.rs
  • ts/src/lower/body.rs
  • ts/src/lower/consts.rs
  • ts/src/lower/library.rs
  • ts/src/lower/mir/memory.rs
  • ts/src/lower/mir/mod.rs
  • ts/src/lower/mir/place.rs
  • ts/src/lower/mir/rvalue.rs
  • ts/src/lower/mod.rs
  • ts/src/lower/names.rs
  • ts/src/print.rs
  • ts/src/program.rs
  • ts/tests/differential.ts
  • ts/tests/fixtures.rs
  • ts/tests/wasm.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@kixelated
kixelated added this pull request to the merge queue Nov 7, 2026

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed head 57655c6 against base 41ea814. The current head is seven commits ahead of that base; there was no prior submitted review to compare against.

Findings:

  • P2: Preserve every Rust wrapper that shares an import shim. Identical declarations in separate Rust modules can have the same wasm-bindgen shim name, but the new map retains only one wrapper.
  • P2: Preserve sparse-array formatting in JsValue Debug. The new Array.map implementation skips holes that wasm-bindgen's index-based implementation formats as undefined.

Direction: Address the two inline findings and add differential cases for duplicate imports and sparse arrays before merging. The encoded-program approach and whole-wrapper replacement otherwise fit the intended direction.

Scope and limits: GitHub-only static review of the decoder, import discovery and lowering, boundary/runtime conversions, related MIR/printer changes, and fixture/differential harness. Checked the relevant behavior against wasm-bindgen 0.2.129 source. No builds or tests were run, and this is not an approval or a claim that runtime behavior has been fully validated.

(Written by review (OpenAI))

}
DefKind::Fn if tcx.is_foreign_item(did) && in_placeholder(tcx, did) => {
if let Some(wrapper) = enclosing_fn(tcx, did) {
shims.insert(tcx.item_name(did).to_string(), wrapper);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep all wrappers for a shared shim name

wasm-bindgen hashes a function's signature, JS namespace, module and cfg attributes, but not its enclosing Rust module. Two modules can therefore both declare the same #[wasm_bindgen(js_namespace = Math)] fn abs(x: f64) -> f64 and produce the same shim name. This insert overwrites the first wrapper, and the later shims.remove(&import.shim) registers only the surviving DefId. When translation reaches the other wrapper it falls through into the Wasm ABI glue rather than becoming the JS import, so an otherwise supported declaration is refused. Store all wrapper DefIds for each shim and apply the decoded import to each; add a differential fixture that calls identical declarations from two Rust modules. The upstream naming logic is in wasm-bindgen 0.2.129 crates/macro-support/src/parser.rs:1149-1182.

Comment thread ts/runtime/rs/js.ts
const name = (val as { name: unknown }).name;
return typeof name === "string" && name.length > 0 ? `Function(${name})` : "Function";
}
if (Array.isArray(val)) return `[${val.map(jsDebugString).join(", ")}]`;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Visit holes when formatting a JS array

Array.prototype.map skips missing elements, whereas wasm-bindgen's debugString explicitly reads every index. For a normal sparse array such as js_sys::Array::new_with_length(2), the native helper produces [undefined, undefined], but this branch produces [, ]. This changes the observable Debug text of a JsValue even though both values are supported. Port the index-based traversal (also avoiding calls to an array's overridden map) and add a sparse-array differential case. The matching upstream implementation is wasm-bindgen 0.2.129 crates/cli-support/src/js/mod.rs:7029-7039.

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.

1 participant