Design input: control flow with signals

One aspect related to building out signal support in Flow is how to show different components based on the value of some signal. As a simple example, let’s assume we have a widthSignal and want to show one out of three different alternative components depending on the available width.

Assuming bindVisible from Thoughts about conventions and naming for binding signals to components?, the straightforward way without any additional help would be to add all three components to the parent layout and manually create separate computed signals for the visibility of each component so that only one is visible.

layout.add(big, medium, small);
big.bindVisible(widthSignal.map(width -> width > 1000));
medium.bindVisible(widthSignal.map(width -> width > 500 && width <= 1000));
small.bindVisible(widthSignal.map(width -> width <= 500));

There are two limitations with this simple approach. First, you have to spend CPU and memory on instances that might never be used. Second, the rules are fragile since each boundary is defined in two separate places and mixing up exclusive and inclusive comparisons might lead to showing two components or no component at all for some specific widths.

There could be a wrapper component that selects the content to show based on a signal and instantiates components on demand. The logic to determine what to show could be exclusive so that it evaluates the condition for each potential content component and uses the first one that matches while ignoring any subsequent matches. It could select and show multiple components simultaneously if some options are not defined as exclusive.

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

There could also be overloads that take a single trigger value rather than a dynamically evaluated condition. This could be used as-is in many cases whereas this case with width ranges would require a separate type to define the option to render.

enum Size { BIG, MEDIUM, SMALL }
Signal<Size> sizeSignal = widthSignal.map(width -> {
  if (width > 1000) return Size.BIG;
  else if (width > 500) return Size.MEDIUM;
  else return Size.SMALL;
});

Selector<Size> selector = new Selector<>(sizeSignal);
layout.add(selector);
selector.add(Big::new, Size.BIG);
selector.add(Medium::new, Size.MEDIUM);
selector.add(Small::new, Size.SMALL);

Does this seems like a sensible approach in general?

Do you see any scenarios that are not covered by the relatively simple example I’ve used?

Is Selector the right name?

I do like the 2nd version with addExclusive and addFallback. Probably wouldn’t call it Selector simply because I think there might be confusion with Select component :sweat_smile:

Maybe Evaluator or SingalEvaluator?

Evaluation is just the used mechanism whereas the actual outcome is that one specific option is selected (or many options if using a non-exclusive variant). Some verbs similar to “select” would be e.g. “choose”, “pick” or “determine”.

One way of making a distinction from components where users make the selection would be with a prefix, e.g ComponentSelector.

Yet another source of inspiration is from programing terminology related to control flow, e.g. “condition”, “branch”, “switch” or “case”.

One more open question is whether there’s any point in supporting non-exclusive options. It seems useful in theory just avoid limiting flexibility but I don’t know any specific use case it. Part of the problem is that you would have little control over the layout without introducing additional complexity since the components would just end up as siblings in e.g. a <div>. Limiting to just a single selected component could potentially even open up the door for going in the direction of Composite to avoid any wrapping element even though there are some open questions e.g. with regards to e.g. preserving slot attributes.

Here’s a simple prototype that only supports exclusive options. This makes the addExclusive name redundant but it felt too short to have just add so I renamed it to addOption.

public class Selector<T> extends Div {
    private static class Option<T> {
        private final Supplier<Component> supplier;
        private final Predicate<T> condition;

        private Component instance;

        public Option(Supplier<Component> supplier, Predicate<T> condition) {
            this.supplier = supplier;
            this.condition = condition;
        }

        private Component getOrCreateInstance() {
            if (instance == null) {
                instance = supplier.get();
            }

            return instance;
        }
    }

    private final List<Option<T>> options = new ArrayList<>();
    private final NumberSignal optionsVersion = new NumberSignal();

    private Option<T> fallback = new Option<>(() -> null, null);

    public Selector(Signal<T> signal) {
        ComponentEffect.effect(this, () -> {
            // Dummy read to set up dependency
            optionsVersion.value();

            T value = signal.value();

            Optional<Option<T>> selected = options.stream()
                    .filter(entry -> entry.condition.test(value)).findFirst();

            // Ignore signal operations from constructors or attach listeners
            Signal.runWithoutTransaction(() -> Signal.untracked(() -> {
                attachOption(selected);
                return null;
            }));
        });
    }

    private void attachOption(Optional<Option<T>> selected) {
        Component instance = selected.orElse(fallback).getOrCreateInstance();

        if (instance == null) {
            removeAll();
        } else if (!instance.isAttached()) {
            removeAll();
            add(instance);
        }
    }

    public Registration addOption(Supplier<Component> supplier,
            Predicate<T> condition) {
        Option<T> option = new Option<>(supplier, condition);

        options.add(option);
        optionsVersion.incrementBy(1);

        return () -> {
            options.remove(option);
            optionsVersion.incrementBy(1);
        };
    }

    public Registration addOption(Supplier<Component> supplier, T value) {
        return addOption(supplier,
                signalValue -> Objects.equals(signalValue, value));
    }

    public void setFallback(Supplier<Component> supplier) {
        fallback = new Option<T>(supplier, null);
        optionsVersion.incrementBy(1);
    }
}

While musing about this, I wondered, if a signal is allowed to produce
a Component, and indeed it seems to work:

ComponentEffect.bind(
    container,
    widthSignal.map(n =>
        if n >= 3 then Div("Big")
        else if n >= 1 then Div("Medium")
        else Div("Smoll")
    ),
    (c, b) => {
        c.removeAll
        c.add(b)
    }
)

I realise, that this is not as efficient as a dedicated helper for
the problem, but this is something that is allowed – at least
as long as things stay on the server-side, correct?

That’s correct. The value of “regular” signals must be JSON serializable since they might in the future end up shared across a cluster or with Hilla clients (though I think there should be a ReferenceSignal for cases that need to opt out). But there are no such limitations on computed signals since they are always local.

I was playing with something similar to your example. The main problem is that it’s not obvious that a straightforward implementation will end up creating and attaching a new component instance when n changes within a range, e.g. from 4 to 5 in your example. This is not only inefficient in general but also causes client-side state like scroll positions and text selection to be discarded.