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.04k stars 1.55k forks source link

[dart:js_interop] Introduce API for defining getters and setters. #56136

Open Zekfad opened 1 month ago

Zekfad commented 1 month ago

Currently JS interop missing a good way to define getters and setters that would be available from JS side.

We have no Object.defineProperty function built-in. Even if we had such, currently it's very hacky and requires some workarounds (see this comment https://github.com/dart-lang/sdk/issues/54381#issuecomment-2208745412 ), which wont always work.

My actual use case is to implement feature detection code from following article: https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests#feature_detection

That is:

const supportsRequestStreams = (() => {
  let duplexAccessed = false;

  const hasContentType = new Request('', {
    body: new ReadableStream(),
    method: 'POST',
    get duplex() {
      duplexAccessed = true;
      return 'half';
    },
  }).headers.has('Content-Type');

  return duplexAccessed && !hasContentType;
})();

As you can see this requires using a getter to check that property is accessed. While exactly this use can can be achieved without doing much of beforementioned workaround, there are still some issues.

Using a setter would requires somehow externally passing reference to the object. Meaning there's no good way to do something like this (example from MDN)

const language = {
  set current(name) {
    this.log.push(name);
  },
  log: [],
};

language.current = 'EN';
language.current = 'FA';

console.log(language.log);
// Expected output: Array ["EN", "FA"]

Mostly this issue is not about exposing Dart API as JS interface, but rather passing data to existing JS APIs.

I think to fix this:

dart-github-bot commented 1 month ago

Summary: The dart:js_interop library lacks a proper way to define getters and setters for Dart objects that are exposed to JavaScript. This makes it difficult to interact with JavaScript APIs that rely on these features, such as feature detection code.

Zekfad commented 1 month ago

Here is my current defineProperty:

js_object_define_property.dart ```dart import 'dart:js_interop'; // Workaround for #54381 @JS('Function') external JSFunction _createDynamicJSFunctionUnsafe$1(JSString argument, JSString body); final _captureThis = () { try { return _createDynamicJSFunctionUnsafe$1( 'f'.toJS, 'return function (...args) { return f.call(this, this, ...args); };'.toJS, ); } catch(e) { return null; } }(); JSFunction _tryCaptureThis$0(R Function(JSObject? context) f) { final jsF = f.toJS; if (_captureThis == null) { return (() => f(null)).toJS; } return _captureThis!.callAsFunction(_captureThis, jsF)! as JSFunction; } JSFunction _tryCaptureThis$1(R Function(JSObject? context, Arg1 arg1) f) { final jsF = f.toJS; if (_captureThis == null) { return ((Arg1 arg1) => f(null, arg1)).toJS; } return _captureThis!.callAsFunction(_captureThis, jsF)! as JSFunction; } // Prototype code @JS('Object.defineProperty') external void defineProperty(JSObject object, JSStringOrJSSymbol property, JSPropertyDescriptor descriptor); /// [JSString] and [JSSymbol] union type. extension type JSStringOrJSSymbol._(JSAny _) implements JSAny { /// Wrap [JSString] to [JSStringOrJSSymbol] union. factory JSStringOrJSSymbol.fromJSString(JSString body) = _JSStringOrJSSymbolJSString; /// Wrap [JSSymbol] to [JSStringOrJSSymbol] union. factory JSStringOrJSSymbol.fromJSSymbol(JSSymbol body) = _JSStringOrJSSymbolJSSymbol; } extension type _JSStringOrJSSymbolJSString(JSString _) implements JSString, JSStringOrJSSymbol {} extension type _JSStringOrJSSymbolJSSymbol(JSSymbol _) implements JSSymbol, JSStringOrJSSymbol {} /// Conversions from [JSString] to [JSStringOrJSSymbol]. extension JSStringToJSStringOrJSSymbol on JSString { JSStringOrJSSymbol get toJSStringOrJSSymbol => _JSStringOrJSSymbolJSString(this); } /// Conversions from [JSSymbol] to [JSStringOrJSSymbol]. extension JSSymbolToJSStringOrJSSymbol on JSSymbol { JSStringOrJSSymbol get toJSStringOrJSSymbol => _JSStringOrJSSymbolJSSymbol(this); } /// Property descriptors present in objects come in two main flavors: /// data descriptors and accessor descriptors. /// A data descriptor is a property with a value that may or /// may not be writable. An accessor descriptor is a property described by /// a getter-setter pair of functions. /// A descriptor must be one of these two flavors; it cannot be both. extension type JSPropertyDescriptor._(JSObject _) implements JSObject { /// When this is set to `false`: /// * The type of this property cannot be changed between data property /// and accessor property. /// * The property may not be deleted. /// * Other attributes of this property's descriptor cannot be changed /// (however, if it's a data descriptor with `writable: true`, /// the value can be changed, and `writable` can be changed to false). @JS() external final bool configurable; // Defaults to false. /// `true` if and only if this property shows up during enumeration /// of the properties on the corresponding object. @JS() external final bool enumerable; // Defaults to false. } /// A data descriptor includes key and value pairs that contain a property's /// value, regardless of whether that value is writable, configurable, /// or enumerable. extension type JSDataDescriptor._(JSPropertyDescriptor _) implements JSPropertyDescriptor { /// Create new JavaScript property data descriptor. factory JSDataDescriptor({ bool configurable = false, bool enumerable = false, bool writable = false, T? value, }) => JSDataDescriptor._new( configurable: configurable, enumerable: enumerable, writable: writable, value: value, ); @JS('') external factory JSDataDescriptor._new({ bool configurable, bool enumerable, bool writable, T? value, }); /// `true` if the value associated with the property may be changed with /// an assignment operator. @JS() external final bool writable; // Defaults to false. /// The value associated with the property. Can be any valid JavaScript value /// (number, object, function, etc.). @JS() external final T? value; // Defaults to undefined. } /// Accessor descriptors contain functions that execute when a property /// is set, changed, or accessed. extension type JSAccessorDescriptor._(JSPropertyDescriptor _) implements JSPropertyDescriptor { /// Create new JavaScript property accessor descriptor. factory JSAccessorDescriptor({ bool configurable = false, bool enumerable = false, T Function(JSObject? context)? get, Null Function(JSObject? context, JSAny? value)? set, }) { final descriptor = JSAccessorDescriptor._new( configurable: configurable, enumerable: enumerable, ); if (get != null) descriptor.get = _tryCaptureThis$0(get); if (set != null) // ignore: prefer_void_to_null descriptor.set = _tryCaptureThis$1(set); return descriptor; } @JS('') external factory JSAccessorDescriptor._new({ bool configurable, bool enumerable, JSFunction? get, JSFunction? set, }); /// A function which serves as a getter for the property, or `undefined` if /// there is no getter. When the property is accessed, this function /// is called without arguments and with `this` set to the object through /// which the property is accessed (this may not be the object on which /// the property is defined due to inheritance). The return value /// will be used as the value of the property. @JS() external JSFunction? get; // Defaults to undefined. /// A function which serves as a setter for the property, or `undefined` if /// there is no setter. When the property is assigned, this function /// is called with one argument (the value being assigned to the property) /// and with this set to the object through which the property is assigned. @JS() external JSFunction? set; // Defaults to undefined. } ```

With that feature detection looks like this:

  final supportsRequestStreams = (){
    var duplexAccessed = false;
    final options = RequestOptions(
      method: 'POST',
      body: RequestBody.fromReadableStream(ReadableStream()),
    );
    defineProperty(
      options,
      'duplex'.toJS.toJSStringOrJSSymbol,
      JSAccessorDescriptor(
        get: (context) {
          duplexAccessed = true;
          return RequestDuplex.half.value.toJS;
        },
      ),
    );
    final hasContentType = Request('', options).headers.has('Content-Type');
    return duplexAccessed && !hasContentType;
  }();
srujzs commented 1 month ago

I think toJSCaptureThis is essential either way, but this is useful to see one more motivation.

I'm hesitant to say we should add Object.defineProperty in dart:js_interop_unsafe. Beyond the fact that it's an "unsafe" API, it necessitates a separate definition for the descriptor and I'm not sure it will be used often enough to warrant its own definition. There's been general brainstorming about moving such APIs to package:web in a section that's specific to JS instead of the DOM - maybe that might be the place for this in the long-term.

Purely FYI - somewhat related to defining JS getters/setters is createJSInteropWrapper that wraps a Dart object in a JS wrapper with Dart getters and setters being called in JS getters and setters using Object.defineProperty. I can't tell if that is useful here or not.

Zekfad commented 1 month ago

I believe Object.defineProperty is also useful for creating immutable properties which can be useful for exposed objects. Another solution for this exact issue is to add a new annotation, such that getters/setters defined on JS extension type would remain getters/setters in JS side. This will make it necessary to bind this, as "this" will point to JS value with JS interop type.