WICG / webcomponents

Web Components specifications
Other
4.34k stars 371 forks source link

[dom-parts] Attribute Grouping #1011

Open rniwa opened 1 year ago

rniwa commented 1 year ago

There is an open question as to how to multiple (partial) attribute parts can work together to set the value.

Current proposal

Option 1.

In this approach, AttributePart gets a new static function which creates a list of AttributeParts which work together to set a value when the values are to be committed:

const [firstName, lastName] = AttributePart.create(element, 'title', null, [null, ' ', null]);
// Syntax to be improved. Here, a new AttributePart is created between each string.

Option 2.

In this approach, we group multiple AttributeParts together by creating an explicit group:

const firstName = new AttributePart();
const lastName = new AttributePart();
const group = AttributePartGroup(element, 'title');
group.append(firstName, ' ', lastName);

Option 3.

Unlike option 2, this creates PartialAttributeParts from AttributePart, meaning that AttributePart in option 3 plays the role of AttributePartGroup in option 2:

const firstNamePartial = new PartialAttributePart();
const lastNamePartial = new PartialAttributePart();
const part = AttributePart(element, 'title');
part.values = [firstNamePartial, ' ', lastNamePartial];
bathos commented 1 year ago

I’m surprised “attribute value substring” is considered a worthwhile “part” primitive given it doesn’t correspond to any DOM Node concept. Is this definitely going to be part of the API?

tbondwilkinson commented 1 year ago

What do you think about a fourth option where we have an AttributePart with a .value that is not a string, and instead is an Object that can be updated but is then always serialized to the full value for the AttributePart.

This would let the AttributePart still be 1:1 with the Attr node, but allow for splitting up attributes.

const firstName = new StringPart();
const lastName = new StringPart();
const attributeTemplate = attribute`${lastName} ${firstName}`;

const part = new AttributePart(element, 'title');
part.value = attributeTemplate;

Note that StringPart and the attribute template are userland constructs, they don't need to be native things. We can define an API for dynamic value objects... though I'll note then we get into trouble because they look a lot like signals and we should be careful to add something that is like signals but not signals.

justinfagnani commented 11 months ago

We've gone through a couple of these APIs in lit-html, which has nearly an identical concept to AttributePart, and from that I think there are a few considerations to judge the options by:

1. Syntax and expression-to-part association

Given a syntax that creates attribute parts, is there a 1-1 association between expressions and parts or not?

ie, given a template like:

<template>
  <x-foo fullname="{{ lastName }}, {{ firstName}}">
</template>

Does this create one AttributePart or two?

The argument in favor of a 1-1 association is that updating entire template instances becomes easy - you typically have an array of values, and with an equal length and ordered array of parts, you just do:

parts.forEach((part, i) => part.setValue(values[i]));

This consideration still applies without a native syntax, because userland systems will have a syntax and often a similar list of values to assign to parts.

2. API for setting all attribute partial values at once

Is there an API, either specific to a group of AttributeParts, or more general for a whole template instance, for setting all the values for an attribute at once.

This might be a multi-valued AttributePart, like Option 3:

part.setValue([firstName, lastName]);

An attribute group, like Option 2:

group.setValues([firstName, lastName]);

Or a more generic PartGroup:

```ts
// This might contain attribute and other parts
partGroup.update([x, firstName, lastName, y]);

Another option is that AttributeParts are updated individually, but all parts have to be committed to update the DOM, and AttributeParts that share an attribute also share a dirty/committed state such that you can update them at once:

firstNamePart.setValue(firstName);
lastNamePart.setValue(lastName);
firstNamePart.commit(); // sets the attribute
lastNamePart.commit(); // no-op: the attribute is already committed

This allows generic handling of all part types, but requires two loops to do so:

function update(values) {
  parts.forEach((part, i) => parts.setValue(values[i]));
  parts.forEach((part) => part.commit());
}

3. API for setting individual partial values

The API for setting just one partial comes up when you have any extension or update system that operates at the individual expression level, such as lit-html's directives or many other libraries Signals systems.

One question is whether those updaters can operate on a general Part interface, or whether they have to special-case multi-valued parts like Attribute Part.

Another question is how individual updates interact with batching. Sometimes an update comes in as part of an external batch. lit-html updates all parts during a render in a batch, so attribute expressions wrapped in a directive shouldn't cause the attribute to commit early before the batch, but an update outside of one of those batches should update immediately. Signal systems often have batched updates as well.

The problem is a bit constrained. We would like:

The set/commit approach works, but puts an awkward requirement on extensions/directives that to update a part they have to call two methods:

// part here represents an individual expression of possibly many for a single attribute
part.setValue(value);
part.commit();

One way around this is a second argument to setValue() when batching:

part1.setValue(value); // commits to DOM immediately
part2.setValue(value, false); // requires a later commit
part2.commit();

If AttributeParts are multi-valued, there there is no API for setting an individual partial, and an extension would have to specially handle attribute parts, and know the index of the partial they're updating:

updateAttributeExpression(part, value, index) {
  const currentValues = part.values;
  const newValues = [...currentValues.slice(0, index), value, ...currentValues.slice(index + 1)
  part.setValues(newValues);
}

fwiw, in lit-html we started with a two-pass set/commit system, but ended up with a multi-valued AttributePart due to a slight performance improvement. I don't like the ergonomics of either of those APIs myself, and would prefer one where there's a 1-1 expression-to-part association and a simple setValue(value) API for the non-batching case. I think a second argument to setValue() to defer when in a batch is maybe the nicer API.

EisenbergEffect commented 11 months ago

From our conversation in the last F2F, I think we landed on an API that was something like this (a combination of option 1 and 3):

// create
const emailAttributePart = AttributePart.create(link, "href", ["mailto:"]);

// update
emailAttributePart.values = ["john@doe.org"];

The idea being that the AttributePart would have an array of static strings when created and that setting the values property to an array of values would cause the static strings to be interpolated with the values, very similarly to how tagged templates work.

If I'm incorrect on this, maybe someone can chime in, and we can iron out the details here.

justinfagnani commented 11 months ago

Thanks @EisenbergEffect! We didn't get to the specific API shape, but that seems like the obvious one from what we described.