dotnet-websharper / core

WebSharper - Full-stack, functional, reactive web apps and microservices in F# and C#
https://websharper.com
Apache License 2.0
593 stars 52 forks source link

WebSharper 7 syntax updates #1318

Open Jand42 opened 1 year ago

Jand42 commented 1 year ago

This is a master ticket for all the EcmaScript features planned to be used for WebSharper 7 output. This ticket does not deal with overall output signature (modules/imports/exports), but type, member and expression translation changes for WebSharper 7.

Overall goals

Types

Classes

WS6 uses a Runtime.Class helper function to create JavaScript constructor functions. WS7 is switching to EcmaScript class.

ES classes will be used for all .NET classes that are not one of the following:

Lazy class expressions

A runtime helper still might be needed to make the class definition lazily executing if one of these are true:

Inheriting from non-WebSharper classes

The System.Object class proxy

WebSharper is designed so that for any plain JavaScript object, equality/comparison/hashing is structural by default so they work on plain JavaScript objects too. Only if any of the argument objects has Equals/Compare/GetHashCode property then it is used. So, to allow for custom equality/comparison/hashing, there is a System.Object proxy that gives a non-structural, reference-based implementation of these methods.

F# unions and records, any exception classes and static classes are not inheriting from this System.Object proxy, neither are classes using a base class that is a JavaScript interop type (Stub/WIG). All other classes inherit from System.Object proxy.

Field initialization order

Field initialization order is relaxed in WebSharper 7, may not follow .NET semantics. This is not a concern for F# as there all fields initializers are implicitly part of the default constructor, you can't have fields initialization on classes without default constructor. But even in C#, as a base class can't access its subclass' instance field values during construction, it is not a big issue. Users should just not rely on side effect order in field initializers, which is already quite an antipattern in .NET C#.

Interfaces

WS6 has no code generated, only metadata support for interfaces. Interface member names are auto-generated from full type name and member name, this can be shortened by the use of Name attributes. Any implementing class will use these JavaScript names for the the translated implementations.

This is still not ideal, in the default case the member names are long (looking like MyNamespace$MyType$MyMember) or a short name can lead to conflicts easier. If for example two different interfaces define a member with [<Name("X")>] then a single class tries to implement both, it's a compilation time error on name conflict.

This can be alleviated by the use of JavaScript Symbols. Name attribute to be expanded to allow defining a Symbol like [<Name("X", symbol = true)>], and naming with Symbols would be the new default for interfaces.

WS7 also generates an is* function for every interface for TypeScript interop and when a type check against the interface needs to be done by checking against a symbol member or all named members. WS6 did this check every place a type check was needed, and checking only the shortest name, making it only an approximation.

F# unions

WS6 translates union values into an object with a $ property for the tag and $1, $2, etc. properties for union fields. This is compact enough for code size and remoting purposes but hides all union case and field names, making it obscure for interop and debugging.

WS7 would handle unions more like they are in .NET: an abstract base class with an unset Tag property and one subclass for each union cases which define their fields as their original name in .NET (or possibly unnamed F# union field like Item1 can be converted to an indexed field [1]). A constructor is added to each union case.

Example:

let U =
    | A of int 
    | B of x:string
    with member this.Value = ...

Translates to:

class U {
    Tag;
    get Value() { ... }
}
class A extends U {
    Tag = 0;
    Item1;
    constructor (Item1) { this.Item1 = Item1; }
}
class B extends U {
    Tag = 1;
    x;
    constructor (x) { this.x = x; }
}

C# interop concerns

F# unions have .NET methods that are not directly accessible from F# code. These are the Tag property and Is... methods.

Generated ToString, Equals, Compare, GetHashCode are currently not planned to be supported directly.

Null and constant cases

F# allows defining a fieldless union case to be translated to null via attribute. If this happens, WebSharper also translates that case value to null, and transforming all instance members of the type to static (same as in .NET).

Additionally WebSharper's Constant attribute translates fieldless union cases to given constant literals. WebSharper translates null

F# records

Access modifiers on types

WS6 does not care about access modifiers on types. WS7 could make not of them in metadata and whenever possible, not exporting these any non-public types.

Members

Methods

Class methods on types translated to EcmaScript classes become EcmaScript class methods, both instance and static. Bundlers like webpack can't break up classes for DCE, but the intention is that WebSharper can do this in all cases.

Properties

Non-indexed properties now translate to Ecmascript getter/setter. Indexed properties still translate to class methods.

Note: it would be technically possible to translate unary indexed properties with number or string index type in a way that they can be used in JavaScript via the same syntax, e.g. x.IndexedProp[i] and x.IndexedProp[i] = value. The way to do this would be that IndexedProp returns a JavaScript Proxy instance which then handles the get/set accessors. But I don't think the syntax is worth the performance overhead.

Constructors

A single non-inlined constructor for a class can be translated to a JavaScript constructor as is. The challenging problem is overloaded constructors, a concept which JavaScript does not have, so it is necessary to combine constructor implementations into a single constructor.

Take this class for example:

type CtorTest (x: int) = 
    member this.X = x

    new (x, y) = CtorTest(x + y) then Console.Log("CtorTest", x, y)

WebSharper 7 would assign names to constructors by default, in this case New for the default and New_1 for the extra constructor, then the single JS constructor would take this name as first argument, so you can construct the type in JavaScript by new CtorTest("New", 1) or new CtorTest("New_1", 1, 2), implemented e.g. like this:

  constructor(i, _1, _2){
    let is_New_1;
    let x;
    let y;
    if(i=="New_1"){
      is_New_1=true;
      x=_1;
      y=_2;
      i="New";
      _1=x+y;
    }
    if(i=="New"){
      const x_1=_1;
      super();
      this.x=x_1;
    }
    if(is_New_1){
      console.log("CtorTest", x, y);
    }
  }

Constructor chaining is analyzed and local vars are used to replicate behavior. Sadly WebSharper can't wrap the separate .NET constructors within JS local functions, because super can be called only from top scope. We might also need to access the original arguments after calling the chained constructors, so they might need local vars to hold the original values as above.

Named constructors

Allow naming constructors for reliable interop, eg:

type CtorTest [<Name "New">] (x: int) = 
    member this.X = x

    [<Name "NewZero">] new (x, y) = CtorTest(x + y)

Then first param of JS constructor should be checked against the given names. It would be good to also have the option to specify [<Name "">] to make a parameterless constructor in .NET to be parameterless in JS too.

Static constructors

JavaScript static blocks in classes execute eagerly when the class definition executes. To not make them eager by default, but also make sure that they have ran even if the class is used from hand-written external JS, it makes sense to still use static block but wrap the whole class initialization with the Runtime.Lazy helper.

F# module functions

F# modules are always static sealed classes in .NET, so WebSharper would translate members of them as straight functions instead of class members.

F# module values

An F# module value is translated by the F# compiler as such:

WebSharper translation should follow this pattern exactly so that F# module values are created whenever a value from an F# code file is accessed the first time.

Recursive module values

Recursive module values also have some extra System.Lazy mapping added by the compiler when creating the static field value, but evaluated for its value by the property on the module class. WebSharper should keep this semantics as is.

Access modifiers on members

WebSharper 6 does not care about access modifiers. However, WebSharper 7 should make private members compile to JavaScript private class members (an ES2022 feature). For protected/internal/file access modifiers, there is no good equivalent, so leave them public.

Remoting

WebSharper 6 creates no single access point for RPC functions, but they are like inlines - whenever an RPC is called from the client, an instance of a RemotingProvider object is created and its Async/Task/Send method is called with passing in the RPC method's handle and arguments.

At the minimum, WebSharper 7 should create a static client-side function for each RPC function. This ensures that even if RPC protocol is changed as planned for better module support, the external signature remains the same.

Expressions

let/const vars

WebSharper 7 will drop emitting var variable declarations in all cases. Only let and const will be used, const for F# lets. (C# has no readonly locals yet.)

arrow functions

WebSharper 7 will generate => inside expressions whenever possible. Functions using this value will still be compiled to function expressions, and recursive local functions to function declaration statements.

optional parameters

Methods that have optional parameters in .NET could use JavaScript default parameter syntax: e.g. function FuncWithDefault(x = 1) { ... }

rest parameters

WebSharper 6 translates .NET [<ParamArray>] parameters to be passed in as Array in JavaScript too. WebSharper 7 could make use of JavaScript ... rest parameters to consume these, making calling code's arguments list to look same as in C#/F# source.