RFC: Signal patterns for Flow components

We’ve taken a closer look at what patterns we want to encourage for using signals to configure components in Flow. Part of this is also to see what new low-level mechanism we should add to the framework to help following these patterns when implementing reusable UI components.

1. Property binding through constructor or bindXyz methods

Span span = new Span(spanTextSignal);
span.bindVisible(spanVisibleSignal);
span.bindClassName("highlight", highlightSpanSignal);
// Fallback for properties without bind methods
ComponentEffect.bind(span, spanMinWidthSignal, Span::setMinWidth);

2. Iteration

someLayout.bindChildren(listSignal, childSignal -> new ChildComponent(childSignal));
// Fallback for layout without bindChildren method
ComponentEffect.bindChildren(otherLayout, listSignal, childSignal -> new ChildComponent(childSignal); 

3. Signal-aware callbacks

grid.setItemSelectableProvider(item -> {
  return !item.isSensitive() || sensitiveSelectionAllowedSignal.value();
});
// Fallback for callbacks without signal support
ComponentEffect.bind(grid, sensitiveSelectionAllowedSignal, (_, _) -> dataView.refreshAll());

4.Branching

ComponentToggle<Integer> toggle = new ComponentToggle<>(widthSignal);
layout.add(toggle);
toggle.addExclusive(Big::new, width -> width > 1000);
toggle.addExclusive(Medium::new, width -> width > 500);
toggle.addFallback(Small::new);

The details for ComponentToggle are discussed in #22031.

General convention for bindings

button.bindText(buttonTextSignal); // Sets up a binding
String text = button.getText(); // Returns the signal value
button.setText("Foo"); // Throws BindingActiveException
button.bindText(buttonTextSignal); // Throws BindingActiveException

button.bindText(null); // Removes the binding
String text = button.getText(); // Returns the value the signal had when removing
button.setText("Bar"); // No longer an exception

We recommend that components provide void bindXyz(Signal<T> signal) methods to complement the regular void setXyz(T value) methods for component features that are expected to be updated on-the-fly. The bindXyz method supplements the setXyz method rather than replacing it. When a binding created using bindXyz is present, invocations of setXyz or bindXyz throw BindingActiveException (extends IllegalStateException). The binding can be cleared by calling bindXyx(null); after which setXyz and bindXyz no longer throws.

A signal binding for a component is active only while the target component is attached so that the signal won’t have an automatically registered listener referencing the detached component and thus preventing it from being garbage collected. Because of this, the getXyz method must read the value directly from the signal if a binding is present rather than reading from the underlying configuration that won’t be updated for a detached component. Otherwise, the getter would surprisingly read stale values under certain conditions that would be far from obvious to debug.

Component features that are not commonly updated on-the-fly should not have a bindXyz method but application developers can still manually set up a signal binding using the static ComponentEffect helper class. A binding created in this way does not make the setter method trigger BindingActiveException and getXyz may return a stale value if invoked when the component is detached.

Shorthand constructors that delegate to setXyx methods should typically also get new signal-based constructor overloads that delegate to the corresponding bindXyz method. As an example, the Span(String text) constructor should be supplemented with a Span(Signal<String> textSignal) constuctor. At the same time, some consideration is needed to avoid a permutational explosion in constructor overloads and compilation errors in cases where a null literal becomes ambiguous.

We do not recommend isXyzBound methods since they bloat the API, from 3 to 4 methods for each property, for a very limited use case, Application code can try setXyz(getXyz()); and check if BindingActiveException is thrown.

Helpers for component implementations

public String getFoo() {
  return getElement().getProperty("foo");
}

public void setFoo(String foo) {
  getElement().setProperty("foo", foo);
}

public void bindFoo(Signal<String> fooSignal) {
  getElement().bindProperty("foo", fooSignal);
}

Many setters in components delegate directly to an element setter: setProperty, setAttribute, or setText. The corresponding getters read directly from the corresponding getter in Element. We should add bindProperty, bindAttribute, and bindText to Element so that components can provide corresponding bindXyz methods with simple one-liner implementations. We should also update the regular setters and getters in Element to provide the expected behavior while a signal binding is present.

Element property change listeners

element.addPropertyChangeListener("foo", listener);
element.bindProperty("foo", signal); // Should notify the listener
signal.value("bar"); // Should notify the listener

Element property change listeners should always be based on the value that would be returned from getProperty, as long as the component is attached. When detached, value changes to a bound signal do not need to fire a property change event but in that case, an event should still be fired when the value is explicitly read.

element.addPropertyChangeListener("foo", "foo-changed", listener);
element.bindProperty("foo", signal); // Throws ISE if signal is not writeable

element2.bindProperty("foo", signal);
element2.addPropertyChangeListener("foo", "foo-changed", listener); // Same

If a signal binding is present, then client-originated changes for a synchronized property updates the signal value. It’s not allowed to combine signal bindings and synchronization if the signal is not writable (e.g. for a computed signal).

Some additional mechanisms will be necessary for two-way binding of input field values through HasValue and Binder. Those mechanisms will be covered in a separate RFC.

Styles and CSS classes

Element has two dedicated method for custom handling of special attributes: getClassList() and getStyles(). While both these can be set through setAttribute, the typical use is to not update the whole value (e.g. "foo bar baz" for the class attribute) but instead just update a specific part of it.

For CSS classes, we add a void bind(String name, Signal<Boolean> signal) method to ClassList that toggles whether a specific class name is present based on a signal value. Manually adding or removing that specific class name will throw a BindingActiveException while the binding is present. Indiscriminate bulk operations such as element.getClassList().clear(); or element.setAttribute("class", "foo"); silently clear all signal bindings.

Correspondingly for inline styles, we add a Style bind(String name, Signal<String> value) method to Style for controlling a specific style. It behaves similarly to the CSS class binding with regards to BindingActiveException and bulk operations. We do not provide signal variants for each of the shorthand helper methods such as setAlignItems at this stage but we might come back and do that in the future.

Other component properties

@Tag(div)
public class MyComponent extends Component {
  private final SignalPropertySupport<String> textProperty =
      SignalPropertySupport.create(this, value -> {
        getElement().executeJs("this.textContent = 'Content: ' + $0", value);
      });

  public String getText() {
    return textProperty.get();
  }

  public void setText(String text) {
    textProperty.set(text);
  }

  public void bindFoo(Signal<String> textSignal) {
    textProperty.bind(textSignal);
  }
}

Some component features don’t delegate directly to state in Element. This might be because the feature is based on high-level APIs, or applies changes using executeJs, or the feature is not reflected in the browser at all.

We provide a SignalPropertySupport helper class that encapsulates the state management needed for making getXyz, setXyz, and bindXyz behave in the same way as for Element properties. The property object uses a callback for applying the change when a value is explicitly set or if the value of a bound signal changes. Note that this mechanism does also take care of applying the change again when the component is re-attached which eliminates one common source of bugs with custom component properties.

Iteration

someLayout.bindChildren(listSignal, childSignal -> new ChildComponent(childSignal));

The general assumption for iteration is that there’s a container component that should have one component child of some specific type for each child signal in a ListSignal (or a computed signal with the same value type). To help implement this pattern, we provide a void bindComponentChildren(Signal<List<ValueSignal<T>> signal, SerializableFunction<T, Component> factory) method along with a similar bindElementChildren method in Element. These methods and bindText are mutually exclusive so that each one throws a BindingActiveException if a binding is present for either feature. While bound, methods that add, remove, or reorder children also throw, as does removeFromParent for any child that is attached through a binding.

We also provide a high-level bindChildren helper in ComponentEffect for use with components that don’t provide appropriate high-level methods with signal support. This helper is described in #21733. Like with using ComponentEffect for component properties, the use of ComponentEffect in this case does not make other methods throw BindingActiveException.