dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.08k stars 1.56k forks source link

Add capability to call `JSFunction` directly without `call()` #54844

Open leonsenft opened 7 months ago

leonsenft commented 7 months ago

I'm writing Dart bindings for JS types that essentially are function objects with some extra properties tacked on dynamically.

// TypeScript definition

export type Signal<T> = () => T;

export interface WritableSignal<T> extends Signal<T> {
  set(value: T): void;
}

I need to be able to both call an instance of Signal, and call methods on WritableSignal. My current implementation supports these requirements using call() to make the Signal instance callable from Dart:

// Dart bindings

import 'package:js/js.dart';

@JS()
@staticInterop
class Signal<T> {}

extension SignalInterface<T> on Signal<T> {
  external T call();
}

@JS()
@staticInterop
class WritableSignal<T> implements Signal<T> {}

extension WritableSignalInterface<T> on WritableSignal<T> {
  @JS()
  external void set(T value);
}

This works, but is suboptimal as it generates signal.call() in the resulting JavaScript output. Ideally I'd like to bind directly to signal(), but I don't believe this capability exists with JS interop today. I propose either

(a) allowing us to define a static interop class that implements/extends some JS function type,

@JS()
@staticInterop
// Not currently possible because JSFunction is an extension type.
class Signal<T> implements JSFunction {}

(b) or add an API–perhaps to dart:js_interop_unsafe along the lines of callAsFunction(signal) that translates to signal().

@srujzs

leonsenft commented 7 months ago

I'm realizing the solution looks like it might be (a) if I define the Signal itself as an extension type as well?

Which I'm currently blocked on doing because extension types require @JS() from dart:js_interop which preclude me from using arbitrary generic types.

srujzs commented 7 months ago

If we had a JSUnsafeDartObject or something similar that is purely a static interface for an arbitrary Dart value, then I can imagine writing this as:

import 'dart:js_interop';

@JS()
extension type Signal(JSFunction _) implements JSFunction {
  external JSUnsafeDartObject call();
}

but this still doesn't allow arbitrary generic types. How important is that ability? Can something like this work:

import 'dart:js_interop';

@JS()
extension type Signal<T>(JSFunction _) implements JSFunction {
  @JS('call')
  external JSUnsafeDartObject _call();

  T call() => _call().toDart as T;
}

? Presumably, dart2js can inline functions as needed and the generated code should look the same as if you used the generic in the external (which would also implicitly have a cast to T).

(b) or add an API–perhaps to dart:js_interop_unsafe along the lines of callAsFunction(signal) that translates to signal().

There actually is a callAsFunction, but that lowers to JS' call. :) But yes, even though we can implement JSFunction for an interop extension type, you still don't have a mechanism to call it such that the resulting JS syntax looks like () and not .call(). That'll need to be added.

sigmundch commented 7 months ago

Also, you can probably annotate the T call() => _call.toDart as T; method with a pragma to elide the cost of the cast if that's a cost concern.

leonsenft commented 5 months ago

With the introduction of extension types I'm now able to define:

import 'dart:js_interop';

extension type Signal<T>(JSFunction _) implements JSFunction {
  // callAsFunction(): 
  T call() => _trustAs(callAsFunction());
}

@pragma('dart2js:as:trust')
T _trustAs<T>(Object? value) => value as T;

This solves my type issues, however, the implementation of callAsFunction() still emits function.call() in the generated JS code rather than simply function(). Since this type is a JSFunction, it should be possible to simply emit the function call directly and drop the call, right?

Edit: I realized @srujzs already pointed out this exact issue in https://github.com/dart-lang/sdk/issues/54844#issuecomment-1932802349

sigmundch commented 5 months ago

Regarding a mechanism to do x.call() => x(), this is likely something we can do in a lowering by adding a helper method.

This would be similar to how we convert callMethod (which uses .apply and creates an array) to _callMethod1 (which passes arguments directly to the method). It would also be similar to how DDC today converts x.call() into x() in it's backend when it knows that x is an interop value.