Skip to content

wasm runtime: __classDispatch doesn't pad missing args → BigInt(undefined) throw for any TS optional parameter #1035

Description

@proggeramlug

Summary

When a TS class method has an optional parameter and is called from Perry-compiled code without that parameter, __classDispatch in wasm_runtime.js passes the missing slot directly to the WASM function. The WASM ABI expects an i64 (BigInt) for each declared parameter; JavaScript's auto-coercion does BigInt(undefined), which throws:

TypeError: Cannot convert undefined to a BigInt
    at __classDispatch (.../index.html:4540:40)
    at mem_call (.../index.html:2183:22)

This makes every class with optional-param methods unusable on --target web.

Environment

  • Perry: 0.5.1006
  • Target: web (--target web)
  • macOS 26.4 (Darwin 25.4.0)
  • Repro vehicle: @honeide/editor's CompositeEngine.parse(buffer, changedRanges?) called with one arg.

Minimal repro

// entry.ts
class Tokenizer {
  parse(buffer: string, changedRanges?: number[]): number {
    return buffer.length + (changedRanges?.length ?? 0);
  }
}

const t = new Tokenizer();
console.log(t.parse('hello'));   // boom on --target web
perry compile entry.ts --target web --output out.html
open out.html
# DevTools:
# Boot error: TypeError: Cannot convert undefined to a BigInt
#   at __classDispatch (...:4540:40)

Adding the second arg (t.parse('hello', [])) avoids the crash. On macOS/iOS/native this works fine.

Root cause

crates/perry-codegen-wasm/src/wasm_runtime.js __classDispatch (around line 4540 in emitted output):

if (objVal && typeof objVal === 'object' && objVal.__class__) {
  let cls = objVal.__class__;
  while (cls) {
    const methods = classMethodTable[cls];
    if (methods && mname in methods) {
      const fn = wasmInstance?.exports.__indirect_function_table?.get(methods[mname]);
      if (fn) return __bitsToJsValue(fn(__jsValueToBits(objVal), ...rawArgs.map(v => __jsValueToBits(v))));
    }
    cls = classParentTable[cls] || null;
  }
}

rawArgs.map(v => __jsValueToBits(v)) produces exactly rawArgs.length BigInt args. When fn expects more (because the TS signature has optional params that compile to required WASM params), the missing slots come through as JS undefined. The WASM call site coerces each arg with BigInt(...)BigInt(undefined) throws.

__jsValueToBits(undefined) itself returns TAG_UNDEFINED (a BigInt) — so if __classDispatch padded the args array first, the call would succeed.

Suggested fix

Pad rawArgs to match fn.length - 1 (subtract the receiver) before mapping:

if (fn) {
  const need = Math.max(0, (fn.length | 0) - 1);
  const padded = rawArgs.slice();
  while (padded.length < need) padded.push(undefined);
  return __bitsToJsValue(fn(__jsValueToBits(objVal), ...padded.map(v => __jsValueToBits(v))));
}

Verified working against the editor's CompositeEngine.parse(buffer) call site via runtime patch.

Worth also auditing the freestanding-function call path (mem_call dispatch table → WASM) for the same issue, since the same ABI mismatch likely affects top-level functions with optional params.

Workaround

Source-patch the emitted HTML's __classDispatch after compile. Concrete example: https://github.com/HoneIDE/editor/blob/main/examples/web/build.ts (search for dispatchPatchTarget).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions