Skip to content

Conversation

@Artur-
Copy link
Member

@Artur- Artur- commented Jan 29, 2026

…ter)

This enables mapping a single field from a record or bean to a separate writable signal that supports two-way bindings. The mapped signal propagates changes back to the parent signal.

Added:

  • SignalSetter<P,C> interface for immutable update patterns
  • SignalModifier<P,C> interface for mutable in-place modification
  • MappedWritableSignal class using parent.update() for immutable values
  • MappedModifySignal class using parent.modify() for mutable values
  • WritableSignal.map(getter, setter) default method
  • ValueSignal.map(getter, modifier) overload for mutable patterns

Example usage with records:
WritableSignal todoSignal = new ValueSignal<>(new Todo("Task", false)); WritableSignal doneSignal = todoSignal.map(Todo::done, Todo::withDone); checkbox.bindValue(doneSignal); // Two-way binding to done property

Example usage with mutable beans:
ValueSignal todoSignal = new ValueSignal<>(new Todo()); WritableSignal doneSignal = todoSignal.map(Todo::isDone, Todo::setDone); checkbox.bindValue(doneSignal); // Two-way binding using in-place modification

Slack thread: https://vaadin.slack.com/archives/C6RAXJATF/p1769700370488139?thread_ts=1769696326.293389&cid=C6RAXJATF

https://claude.ai/code/session_01TxPx9BEUzohqox27CLezT5

Description

Please list all relevant dependencies in this section and provide summary of the change, motivation and context.

Fixes # (issue)

Type of change

  • Bugfix
  • Feature

Checklist

  • I have read the contribution guide: https://vaadin.com/docs/latest/guide/contributing/overview/
  • I have added a description following the guideline.
  • The issue is created in the corresponding repository and I have referenced it.
  • I have added tests to ensure my change is effective and works as intended.
  • New and existing tests are passing locally with my change.
  • I have performed self-review and corrected misspellings.

Additional for Feature type of change

  • Enhancement / new feature was discussed in a corresponding GitHub issue and Acceptance Criteria were created.

…ter)

This enables mapping a single field from a record or bean to a separate
writable signal that supports two-way bindings. The mapped signal
propagates changes back to the parent signal.

Added:
- SignalSetter<P,C> interface for immutable update patterns
- SignalModifier<P,C> interface for mutable in-place modification
- MappedWritableSignal class using parent.update() for immutable values
- MappedModifySignal class using parent.modify() for mutable values
- WritableSignal.map(getter, setter) default method
- ValueSignal.map(getter, modifier) overload for mutable patterns

Example usage with records:
  WritableSignal<Todo> todoSignal = new ValueSignal<>(new Todo("Task", false));
  WritableSignal<Boolean> doneSignal = todoSignal.map(Todo::done, Todo::withDone);
  checkbox.bindValue(doneSignal); // Two-way binding to done property

Example usage with mutable beans:
  ValueSignal<Todo> todoSignal = new ValueSignal<>(new Todo());
  WritableSignal<Boolean> doneSignal = todoSignal.map(Todo::isDone, Todo::setDone);
  checkbox.bindValue(doneSignal); // Two-way binding using in-place modification

Slack thread: https://vaadin.slack.com/archives/C6RAXJATF/p1769700370488139?thread_ts=1769696326.293389&cid=C6RAXJATF

https://claude.ai/code/session_01TxPx9BEUzohqox27CLezT5
@CLAassistant
Copy link

CLAassistant commented Jan 29, 2026

CLA assistant check
All committers have signed the CLA.

Wrap lines exceeding 80 characters to comply with Vaadin code style.

https://claude.ai/code/session_01TxPx9BEUzohqox27CLezT5
The original implementation of two-way computed signals introduced an
ambiguous overload: WritableSignal.map(getter, setter) and
ValueSignal.map(getter, modifier) had the same arity but different
functional interface parameter types. Java's type inference could not
always disambiguate between SignalSetter (returns parent type) and
SignalModifier (returns void) when using method references or lambdas.

Renaming ValueSignal's method from map to mapMutable resolves this
ambiguity and makes the distinction clear in the API: map() creates
immutable value mappings, mapMutable() creates in-place modification
mappings.

Fixes #23370

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
* @see WritableSignal#map(SignalMapper, SignalSetter)
*/
@FunctionalInterface
public interface SignalSetter<P, C> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not 100% sure about this name but I don't have any good counter proposal right now. The problem is that the callback doesn't itself "set" the value but instead creates a value that the caller will use for setting. So from that point of view, this type is more of a "converter", "creator", or "reducer".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point from @Legioth. The name SignalSetter is misleading since the function doesn't set anything - it creates a new parent value given the old parent and new child value.

Some naming alternatives to consider:

Name Rationale
SignalReducer Follows the Redux pattern: (state, value) -> newState
SignalValueReducer More explicit variant
SignalCombiner Combines parent + child into new parent
SignalWithMapper Reflects the withX() method pattern commonly used
SignalValueCreator Creates a new value from parent + child

I think SignalReducer fits well because the signature (P parent, C child) -> P is essentially a reducer function - it takes the current state and a value, and produces a new state. This is a familiar pattern from functional programming.

Would you like me to rename SignalSetter to one of these alternatives?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Signal" prefix might be redundant in this context. I'd suggest ValueMerger since it creates a new outer value by merging the new inner value with the old outer value.

Copy link
Member

@Legioth Legioth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't looked at the tests yet


@Override
public SignalOperation<C> value(C newChildValue) {
AtomicReference<C> oldChildValue = new AtomicReference<>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than all the complexity in each method for capturing references to some old value from inside some callback and manually resolving operations, how about just adding support for a map operation on SignalOperation instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added SignalOperation.map() method (SignalOperation.java:119-131):

Transforms the result value using a mapper function
Preserves error propagation
Added CancelableOperation.map() override (CancelableOperation.java:68-92):

Returns a CancelableOperation (not just SignalOperation)
Cancelling the mapped operation also cancels the parent
isCancelled() delegates to parent
Simplified MappedWritableSignal (MappedWritableSignal.java:77-121):

Before (complex with AtomicReference and helpers):

public SignalOperation<C> value(C newChildValue) {
    AtomicReference<C> oldChildValue = new AtomicReference<>();
    CancelableOperation<P> parentOp = parent.update(parentValue -> {
        oldChildValue.set(getter.map(parentValue));
        return setter.set(parentValue, newChildValue);
    });
    return mapOperation(parentOp, oldChildValue);
}

After (clean and simple):

public SignalOperation<C> value(C newChildValue) {
    return parent.update(
            parentValue -> setter.set(parentValue, newChildValue))
            .map(oldParent -> getter.map(oldParent));
}

The replace method still uses AtomicReference because it has conditional success/failure logic that can't be expressed through just map().

AtomicReference<SignalOperation.ResultOrError<Void>> resultRef =
new AtomicReference<>();

CancelableOperation<P> parentOp = parent.update(parentValue -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this could be much simpler like this:

var originalParentValue = parent.peek();
var oldChildValue = getter.map(originalParentValue);
if (!Object.equals(oldChildValue, newValue)) {
  return new SignalOperation<>(new Error<>("Unexpected value"));
}

return parent.replace(originalParentValue, setter.set(originalParentValue, newValue));

There might be some ABA cases that are rejected in this way even though they could in theory be fine but I don't think that's an issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.signals.local;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be in an impl package since the method creating these instances is defined to return WritableSignal.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Add a map() method to SignalOperation and CancelableOperation that transforms
the result value using a mapper function. This allows cleaner composition of
operations.

Use this new method to simplify MappedWritableSignal's value() and update()
implementations, removing the need for manual AtomicReference tracking and
custom operation mapping helpers.

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
…nt.replace

Use a simpler approach that peeks the current parent value and delegates to
parent.replace() instead of using update() with AtomicReference tracking.

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
Move MappedModifySignal from local to impl package since the mapMutable()
method that creates these instances returns WritableSignal, making the
concrete class an implementation detail.

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
Artur- pushed a commit that referenced this pull request Jan 30, 2026
The original implementation of two-way computed signals introduced an
ambiguous overload: WritableSignal.map(getter, setter) and
ValueSignal.map(getter, modifier) had the same arity but different
functional interface parameter types. Java's type inference could not
always disambiguate between SignalSetter (returns parent type) and
SignalModifier (returns void) when using method references or lambdas.

Renaming ValueSignal's method from map to mapMutable resolves this
ambiguity and makes the distinction clear in the API: map() creates
immutable value mappings, mapMutable() creates in-place modification
mappings.

Fixes #23370

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
Artur- and others added 2 commits January 30, 2026 10:58
Update @see reference and example code in SignalModifier to use the
renamed mapMutable method instead of the old map method name.

https://claude.ai/code/session_019LonZZA33kKDmk7jCgN7cT
@Artur- Artur- force-pushed the claude/slack-implement-list-signal-qxPj1 branch from 1c6cf78 to 5c7df2c Compare January 30, 2026 09:02
@Artur- Artur- marked this pull request as ready for review January 30, 2026 09:02
@github-actions
Copy link

github-actions bot commented Jan 30, 2026

Test Results

1 348 files  + 2  1 348 suites  +2   1h 16m 57s ⏱️ - 1m 22s
9 513 tests +26  9 445 ✅ +26  68 💤 ±0  0 ❌ ±0 
9 983 runs  +29  9 907 ✅ +28  76 💤 +1  0 ❌ ±0 

Results for commit 4944f86. ± Comparison against base commit aa1ddca.

♻️ This comment has been updated with latest results.

@Override
public SignalOperation<C> value(C newChildValue) {
return parent
.update(parentValue -> setter.set(parentValue, newChildValue))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already discussed this offline but the outcome is non-trivial so it might be good to keep in a comment for future reference.

If we apply the change to the new item, then the user gets the impression that they happened to click immediately after it was changed and then they immediately realize that they can easily undo that accidental change directly from the same UI without having to find the old item in the UI to see if their change ended up there

return parent.isCancelled();
}
};
result().thenAccept(resultOrError -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated code in SignalOperation. Could be extracted to a protected helper method.

}
}

record Pair<A, B>(A first, B second) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusing to have two different test types that have essentially the same structure. Could all tests use the same test type instead?

}

@Test
void map_withNullParentValue_handlesGracefully() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pointless test?

}

@Test
void map_stringField_works() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pointless test

}
}

static class MutablePair<A, B> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant and confusing with two separate test classes?

assertFalse(doneSignal.value());

todo.setDone(true);
todoSignal.modify(t -> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test makes more sense if the modification is done inside modify instead of before it since that's the recommended way of doing a mutation

}

@Test
void map_stringField_works() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems redundant

* @see WritableSignal#map(SignalMapper, SignalSetter)
*/
@FunctionalInterface
public interface SignalSetter<P, C> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Signal" prefix might be redundant in this context. I'd suggest ValueMerger since it creates a new outer value by merging the new inner value with the old outer value.

Extract duplicated result forwarding logic from SignalOperation.map() and
CancelableOperation.map() into a protected forwardMappedResult() helper method.
Add comment explaining why update() is used in MappedWritableSignal.value()
for better concurrent modification behavior.
Rename SignalSetter to ValueMerger with merge() method to better describe
its purpose of merging inner values into outer values. Consolidate test
types by using single Todo/MutableTodo classes with priority field instead
of separate Pair types. Remove redundant tests and fix modify() usage in
MappedModifySignalTest.
Use boolean inversion for update() tests instead of adding an int field.
Keep Todo/MutableTodo simple with just text and done fields.
@sonarqubecloud
Copy link

sonarqubecloud bot commented Feb 2, 2026

@Legioth Legioth merged commit eea90b5 into main Feb 2, 2026
31 checks passed
@Legioth Legioth deleted the claude/slack-implement-list-signal-qxPj1 branch February 2, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants