Proposal: ESX as core JS feature

edit actually never mind as this misses interpolations as child nodes so that the boolean without a specific type INTERPOLATION hence an Interpolation class won't represent all cases ... sorry for the confusion.

@bergus OK, I think this is the most elegant and easy to reason about representation of ESX:

  • it never requires usage of typeof
  • it defines interpolations as either attributes or children
  • it could have unique IDs all over the place in specs, but the Babel transformer for the time being will simply ignore, or better never set, IDs for static nested components or elements / fragments
  • it uses type to describe the shape we're dealing with, these can be symbols too, it doesn't matter (JSON can understand these and use numbers instead on serialization)
  • it allows interpolations non spec'd ... it doesn't have to be spread, but I am still convinced for attributes spread should be the only non named attribute allowed, while spreading within a named attribute should throw, as well as having just {123} in the wild should ... still TBD but this proposal won't stop decisions there
  • all details are still available so that any library can decide best strategy to consume each and every token of ESX
interface Token {
  __proto__: Token.prototype
  type: Token.ATTRIBUTE |
        Token.COMPONENT |
        Token.ELEMENT |
        Token.FRAGMENT |
        Token.INTERPOLATION |
        Token.STATIC
}

// non constructable
interface Template extends Token {
  id: null | object | symbol
  children: (
    Component | Element | Fragment | Interpolation | Static
  )[]
}

// constrcutables?
interface Attribute extends Token {
  type: Token.ATTRIBUTE
  dynamic: boolean
  name: string
  value: unknown
}

interface Component extends Template {
  type: Token.COMPONENT
  value: function // object or any ref in the scope, really
  attributes: (Attribute | Interpolation)[]
}

interface Element extends Template {
  type: Token.ELEMENT
  name: string
  attributes: (Attribute | Interpolation)[]
}

interface Fragment extends Template {
  type: Token.FRAGMENT
}

interface Interpolation extends Token {
  type: Token.INTERPOLATION
  value: unknown
}

interface Static extends Token {
  type: Token.STATIC
  value: string
}

The key-change here is around Attribute and its dynamic field, which already distinguishes between static, dynamic, and keeps interpolation for spread around, so nothing is missed.

The Static interface might seem redundant on the surface, but it makes operations around children typeof or instanceof free which is not only great for performance, but I believe it improves DX too.

Static can never be an attribute, there's an interface for attributes indeed, but it could be part of the children list. All statics means one-off population/render and bye bye ... very desirable.

This version would address all your points but also will address all my points, and it doesn't stagnate this proposal as all IDs are fine, id as symbol are fine, types as symbol are fine, and so are bitwise operations but all of this is left to transformer implementations or the eventual standard definition but it won't block me from updating/rewriting the vurrent ESX transformer, and it shouldn't stop anyone else from defining best type/primitive to use to define 'em all.

I hope this middle-ground compromise works well, and I think it can cover every single case. If that's not the case though, I'd be happy to re-iterate on this one more time ... but I think at this point I might end up out of ideas.

edit for transformer / history sake, this is how the class would look with this proposal in related repositories:

class ESXToken {
  static ATTRIBUTE =      1;
  static COMPONENT =      2;
  static ELEMENT =        3;
  static FRAGMENT =       4;
  static INTERPOLATION =  5;
  static STATIC =         6;

  // internal/transformer implementation details
  static a = (dynamic, name, value) => ({
    __proto__: ESXToken.prototype,
    type: 1, dynamic, name, value
  });
  static c = (id, value, attributes, children) => ({
    __proto__: ESXToken.prototype,
    type: 2, id, value, attributes, children
  });
  static e = (id, name, attributes, children) => ({
    __proto__: ESXToken.prototype,
    type: 3, id, name, attributes, children
  });
  static f = (id, children) => ({
    __proto__: ESXToken.prototype,
    type: 4, id, children
  });
  static i = value => ({
    __proto__: ESXToken.prototype,
    type: 5, value
  });
  static s = value => ({
    __proto__: ESXToken.prototype,
    type: 6, value
  });
}

P.S. I've sponsored the current ESX transformer and that costed both time and money so I am not updating neither the gist nor the transformer until we reach somehow an agreement that can move this proposal forward. If that won't happen, I might still try to adjust the transformer and update the gist to represent my latest attempt just to prove everything can work and does work as expected already, but FYI please don't take the current gist or transformer for granted, as it's clear things are moving pretty fast (although my latest attempt is close to what we have already as ESX transformer).

I am updating all posts specifying this after posting this comment so that new-comers will also be informed about the current state of both gist proposal and transformer. Thanks for your patience and understanding.

P.S. a better polyfill suggestion might be this one, in case there is an agreement all issues have been addressed:

export class ESXToken {
  static ATTRIBUTE =      1;
  static COMPONENT =      2;
  static ELEMENT =        3;
  static FRAGMENT =       4;
  static INTERPOLATION =  5;
  static STATIC =         6;
}

export class ESXAttribute extends ESXToken {
  type = ESXToken.ATTRIBUTE;
  constructor(dynamic, name, value) {
    this.dynamic = dynamic;
    this.name = name;
    this.value = value;
  }
}

class ESXDetail extends ESXToken {
  constructor(value) {
    this.value = value;
  }
}

export class ESXInterpolation extends ESXDetail {
  type = ESXToken.INTERPOLATION;
}

export class ESXStatic extends ESXDetail {
  type = ESXToken.STATIC;
}

class ESXTemplate extends ESXToken {
  constructor(id, children) {
    this.id = id;
    this.children = children;
  }
}

export class ESXFragment extends ESXTemplate {
  type = ESXToken.FRAGMENT;
}

export class ESXComponent extends ESXTemplate {
  type = ESXToken.COMPONENT;
  constructor(id, value, attributes, children) {
    super(id, children);
    this.value = value;
    this.attributes = attributes;
  }
}

export class ESXElement extends ESXFragment {
  type = ESXToken.ELEMENT;
  constructor(id, name, attributes, children) {
    super(id, children);
    this.name = name;
    this.attributes = attributes;
  }
}

FYI I've changed the ESXToken class and also the transformer to satisfy latest discussions.
The current MR is here: v0.2 with perf and structural changes by WebReflection ยท Pull Request #3 ยท ungap/babel-plugin-transform-esx ยท GitHub

The current TS like description is the following:

edit I now have 100% test covered polyfill for the proposal and the only misaligned part is that statics are numbers instead of Symbols, and the name field for the ESXElement case is still value. To make relevant classes homogeneous I've used value instead, but if there's any will to move this forward as ECMAScript implementation I'll be more than happy to add some extra logic to have the name field for elements too and/or improve the polyfill in general.

However, I must admit I am pretty pleased with how the original idea became actually simpler, yet more structured, still satisfying all requirements around discussions and non-blocking possible improvements (e.g. interpolation also as non spread operations).

As summary, the current ESXToken polyfill and transformer look pretty aligned and "done" at this point, so thanks so far to all participants.

edit2

Showing 3 changed files with 50 additions and 112 deletions.

the latest transformer makes SSR an absolute no-brainer to deal with :partying_face: this split is amazing

1 Like

@WebReflection I have a nit to pick regarding your table, I think that you can spread properties using tagged templates: h`<x ...${{aha: "oho"}}>`.



@mhofman Early errors are always better, the shorter the feedback loop, the more you can stay in the zone and the quicker you can fix your mistakes. The industry moving towards TypeScript is not a coincidence, squiggle-oriented programming rules.

In a world where JSX isn't a thing, esx`<div>` would have had the advantage of tooling backwards compatibility.

But JSX exists, it is widely supported by the ecosystem, and the ESX syntax is backwards compatible with it.

From an implementation stand point, a naive JS parser that generates the inline ESX data structures at load time would increase the initial parse time vs having a template tag. However, AFAIK, JS parsers are tiered with a pre-parser that doesn't create an AST, so the overhead of the inline syntax should be minimal.

So ultimately (and for the host of reasons listed by @WebReflection) I think that inline ESX is superior to a builtin template tag.

That requires a tag that checks last 4 chars of a chunk of the template to see if it's ... and, after that, it crawls all chunks before that to be sure that's actually a spread within an element or component, which is the error prone approach I've mentioned as there is no context or notion of the fact spread is happening as element spread, instead of a content spread like it is in the <pre> ...${anything}</pre> case.

edit let's keep in mind the ... just scratches the surface of the amount of issues using strings around would cause ... invalid attributes, or even elements name, are another check: do we really want to have so much slowdown through template literal tags so that we end up representing trees with a tool that was not designed to represent XML like trees?

this parser is optimistic and it provides zero feedback on wrong syntax.

this one is also problematic.

A slower but more convoluted solution previously adopted is still error prone.

On top of that, let's compare the potential future of JS to define trees, if we go with the template literal proposed alternative:

// template literal tag with scope resolution
esx`<${Component} ...${props}> ...${text}</${Component}>`;
esx`<${Component} ...${props} />`;

// VS this proposal
<Component {...props}> ...{text}</Component>;
<Component {...props} />;

There are visual hints in current IDEs that makes reading this proposal 20x easier than dealing with the template literal tag alternative, which is why I still believe ESX as template literal tag is a dead-end.

Thanks for sharing some love around ESX, appreciated!

Is spread syntax even needed in children position? <pre>${yop}<pre> can dispatch at run time on the type of yop, and treat it as a fragment if it is an Array (or even iterable). Edit: I think I see you point, in children position, you want the triple dots to be interpreted as a static string, and thus you have to parse all string fragments up to that point and pieces to know what to do with them.

Otherwise, I agree that visual clutter is another major drawback with the template tag solution, and the reason why I never adopted or even explored such a solution.

That's not a spread ... it was the whole point of comparison: with template literal tags you can't define an explicit spread intent ... it's all the same, with dots before ... in ESX the spread on text breaks as indeed children as spread are not, and should not, be supported, for the static children position as index previously mentioned and, like you said, the ability to understand/accept array as children.

The outcome of my example is that the component receives props and it has text content ...whatever ... let me rewrite the example:

const text = 'test';
const props = {a: 'ny'};

function Component({a}, ...children) {
  return `<div a="${a}">${children.join('')}</div>`;
}

// both solutions return these two strings:
// <div a="ny"> ...test</div>
// <div a="ny"></div>

// template literal tag with scope resolution
esx`<${Component} ...${props}> ...${text}</${Component}>`;
esx`<${Component} ...${props} />`;

// VS this proposal
<Component {...props}> ...{text}</Component>;
<Component {...props} />;

Having ... outside the ESX means it's static string content as children ... having ... anywhere in esx as template literal tag means, visually, confusion to me.

Yes, I edited my message, too late :-)

1 Like

to complete the comparison, let's see what IDEs would show:

Screenshot from 2022-11-30 14-32-29

to be fair, the fact you were already confused about spreading children with template literals esx would suggest indeed that template literal tags are not the road to take, imho (and the reason JSX won in terms of DX/adoption compared to "standard template literal based" alternatives).

I've been out of the game for months, so not everything is pristine in my mind... When I come to think of it. You don't need spread syntax in attribute position either.

<x staticName={dynValue} {spreadThisPls}> could work too, by dispatching on the presence of = before the splice point if inside an element.

Another thing that comes to mind is the fact that, as proposed, a new template tree is allocated every time the ESX expression is evaluated.

We could use our own advice re. static vs dynamic bits, and hoist the template allocation, then only allocate a single ESXInstance object with a

{
  template: ESXElement | ESXFragment | ESXComponent,
  values: any[], 
  metadata: any = null
}

structure.

It would be best IMO if the template tree was made of immutable values (see records and tuples) since it derives from the immutable source code, and that would offer strong guarantees to consumers. I wouldn't want this to be blocked on another proposal though, I just think it makes sense conceptually, if records and tuples land before this.

Hoisting the static template would also make it easier to the consuming code to find the dynamic bits without having to reach deep within the ESXNode tree (assuming the ESX tree is compiled by a library into, say, a

interface Compiled{  
  DOM, 
  setters: Array<(x:any)=>void>
}

and kept in in a (Template => Compiled) map the first time it is seen. The immutable template can be used as key, no need for an explicit ID. From then on, subsequent invocations can clone the DOM and iterate through the setters/values). I'd expect this to perform better. It would be gentler on the allocator/GC, and would have fewer property lookups.

that makes the proposal break with current industry de-facto standard but it's true ... although ...

that still requires look behind to rebuild and understand it's attributes we're after, not just content, so it needs to crawl previous chunks and guestimate what's the intent ... as previously mentioned after editing, ...spread within ESX is just one of the many issues around templates based solutions.

1 Like

everything in this comment looks based on older proposal ... the latter one, and its transformer, covers everything except it doesn't output frozen references, but that won't work anyway for repeated updates with different values in those interpolations. The latest proposal fixes all previous points, so please be sure you have looked at it, otherwise we might discuss stuff already discussed and changed.

1 Like

also ... anything with DOM in it is a red-flag to me, as ESX has strictly nothing to do with the DOM, so pointing at it won't make it portable for SSR or any other environment where DOM doesn't exist.

Current proposal works already even in Espruino devices, and I believe as syntax and nothing else that should be a desirable goal. Make it DOM related and it doesn't belong to TC39 anymore.

Yes, let's forget the DOM for a minute.

Proposal: ESX as core JS feature - #45 by WebReflection

^^^ This is the latest proposal, right?

What I propose is to:

  • drop ESXNode.id and ESXInterpolation.value.
  • Hoist the ESXNode tree and use the root node as ID (or even sub nodes, for element passed as children to components)
  • Only allocate a {template: ESXNode, values: unkown[], metadata?} object when an ESX expression is evaluated.

The client code can then walk the tree, and consume the values array in order when it finds ESXInterpolation nodes.

As an optimization, user code can compile the template into a different representation (I picked DOM because it is a well known example), with setters that match the interpolations. On a further run, the setters can consume the dynamic values in order.

Edit: clarification in the last paragraph

correct

the id is there because as previously discussed a representation of ESX cannot be frozen. I mean, it could be unique (like the id already is) but its attributes or children values cannot be frozen (or your values as I understand these).

In previous discussions the id should be unique per each static children too, to which I disagree as it has no practical usage, but current proposal doesn't stop that from happening, real-world consumers like udomsay library wouldn't care a single bit about those though.

It wouldn't be enough. Attributes and children must be understood and a static template as component has no meaning in your representation, if I understand correctly. <Component /> is a valid unique outer template and it means nothing, because Component(props, ...children) is what matters, as it can return anything, if anything is even returned. A component can be outer template or sub-tree static children too, having it either ways tells the consumer nothing in practice. Current ESX still provides an ID but it's discarded in real world usage.

The reason I've sponsored and updated the ESX Babel transformer is to stop guessing its usage and actually use it, like I am doing already with the SSR example and planning to do with udomsay.

So far the latest proposal made me drop many lines of code and provides every single needed detail.

It'd be great if people would play around it instead of imagining how it works, because there is already an implementation of the current proposal, so we should (hopefully) start from there, not from scratch each time.

Current udomsay library uses a different version of ESX but it already showed the idea works, all details are there, anod literally nothing else is needed.

Can we make it better for GC and perf? I hope so ... but can we also try to use it in practice as there is already an implementation that covers everything already discussed in here? That'd be awesome!

That's happening already.

That is a consumer implementation detail, not magic behing ESX. ESX is meant to land as syntax, not as library ... if we go for the latter I am pretty sure it'll never land.

thinking about my latest reply, maybe we should discuss in a different way: which use case the current proposal wouldn't cover? To me it covers every possible use case JSX covers already, except it has a way to cache unique outer templates, JSX doesn't, it reuse the same empty array for attributes or children, when not available (the GC and heap part), it provides an always crawable list of information either as attributes or children, no typeof checks or instanceof operations needed, and everything else is up to the consumer, like it is already, and worked, with the current JSX standard, except nobody needs to define callbacks around the code, as the representation doesn't need any callback.

In short, current state is that ESX already covers everything missing in JSX, and JSX has been proven to work for any sort of client/server/native/mobile application, so we should kinda start from this fact and improve when, or if, is really necessary, without diverging too much, or I can see already memes floating around the proposal. That's at least my thinking ... we don't need to re-invent the wheel, we have an opportunity to make it "rounder" than it is, even if everyone is driving with squared wheels to date and that works regardless of GC or heap constraints we all do care about.

I hope this makes sense.

worth mentioning ... my current proposal took inspiration from MutationObserver records based API:

  • addedNodes and removedNodes are always crawable (attributes and children in here) ... are these always the same unique frozen array when empty to avoid GC pressure? If I was the implementer, my answer would be yes (current transformer indeed does that, it's always the same array, no GC pressure added)
  • all other properties represent the actual record state ... these could be null or actually point to a value, independently from the kind of record/change these represent (curent proposal has that for id or properties and it could be extended as long as the type makes it possible to understand what kind of record we're dealing with)

The previous proposal had homogeneous, always same, record structure, and accordingly with the type other properties/fields made sense to be accessed ... and this is kinda reflected with the current proposal too.

The type of a token is what matters, and tokens should be expected in attributes, children, and can distinguish between all cases around templates (static, interpolation, attribute, element, fragment, component) so if we stick with this approach, I think the current transformer already satisfy all possible consumers, but of course it can be improved if improvements are suggested.

This was just to underline current idea/API is nothing new for standard bodies, it's based on previous work that worked out there, and it's happy to change the type field as symbol or string, where latter is used to describe operations in the MutationObserver case.