+ * If the signal is already registered, signal instance registration is
+ * skipped. if the mapping between the provided {@code clientSignalId} and
+ * {@code signal} is already registered, the mapping is skipped, too.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @param signal
+ * the signal instance, must not be null
+ * @throws NullPointerException
+ * if {@code clientSignalId} or {@code signal} is null
+ */
+ public synchronized void register(UUID clientSignalId,
+ NumberSignal signal) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ Objects.requireNonNull(signal, "Signal must not be null");
+ if (!signals.containsKey(signal.getId())) {
+ signals.put(signal.getId(), signal);
+ }
+ if (!clientSignalToSignalMapping.containsKey(clientSignalId)) {
+ clientSignalToSignalMapping.put(clientSignalId, signal.getId());
+ }
+ LOGGER.debug("Registered client-signal: {} => signal: {}",
+ clientSignalId, signal.getId());
+ }
+
+ /**
+ * Get a signal instance by the provided {@code clientSignalId}.
+ *
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ *
+ * @return the signal instance, or null if no signal is found for the
+ * provided {@code clientSignalId}
+ * @throws NullPointerException
+ * if {@code clientSignalId} is null
+ */
+ public synchronized NumberSignal get(UUID clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ UUID signalId = clientSignalToSignalMapping.get(clientSignalId);
+ if (signalId == null) {
+ LOGGER.debug("No associated signal found for client signal id: {}",
+ clientSignalId);
+ return null;
+ }
+ return signals.get(signalId);
+ }
+
+ /**
+ * Get a signal instance by the provided {@code signalId}.
+ *
+ *
+ * @param signalId
+ * the signal id, must not be null
+ *
+ * @return the signal instance, or null if no signal is found for the
+ * provided {@code signalId}
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized NumberSignal getBySignalId(UUID signalId) {
+ Objects.requireNonNull(signalId, "Signal id must not be null");
+ return signals.get(signalId);
+ }
+
+ /**
+ * Checks if a mapping exists between a registered signal instance and the
+ * provided {@code clientSignalId}.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @return true if the signal instance is registered, false otherwise
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized boolean contains(UUID clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ if (!clientSignalToSignalMapping.containsKey(clientSignalId)) {
+ return false;
+ }
+ var signalId = clientSignalToSignalMapping.get(clientSignalId);
+ if (!signals.containsKey(signalId)) {
+ throw new IllegalStateException(String.format(
+ "A mapping for client Signal exists, but the signal itself is not registered. Client signal id: %s",
+ clientSignalId));
+ }
+ return true;
+ }
+
+ /**
+ * Removes a signal instance by the provided {@code signalId}.
+ *
+ * It also removes all the possible associated client signals, too.
+ *
+ * @param signalId
+ * the signal id, must not be null
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized void unregister(UUID signalId) {
+ Objects.requireNonNull(signalId,
+ "Signal id to remove must not be null");
+ signals.remove(signalId);
+ clientSignalToSignalMapping.values().removeIf(signalId::equals);
+ LOGGER.debug(
+ "Removed signal {}, and the possible mappings between for its associated client signals, too.",
+ signalId);
+ }
+
+ /**
+ * Removes only the mapping between a signal instance and the provided
+ * {@code clientSignalId}.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @throws NullPointerException
+ * if {@code clientSignalId} is null
+ */
+ public synchronized void removeClientSignalToSignalMapping(
+ UUID clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id to remove must not be null");
+ clientSignalToSignalMapping.remove(clientSignalId);
+ LOGGER.debug("Removed client signal to signal mapping: {}",
+ clientSignalId);
+ }
+
+ /**
+ * Checks if the registry is empty.
+ *
+ * @return true if the registry is empty, false otherwise
+ */
+ public synchronized boolean isEmpty() {
+ return signals.isEmpty();
+ }
+
+ /**
+ * Returns the number of registered signal instances.
+ *
+ * @return the number of registered signal instances
+ */
+ public synchronized int size() {
+ return signals.size();
+ }
+
+ /**
+ * Returns the number of registered unique mappings between client signal
+ * ids and the signal instances.
+ *
+ * @return the number of registered client signals
+ */
+ public synchronized int getAllClientSubscriptionsSize() {
+ return clientSignalToSignalMapping.size();
+ }
+
+ /**
+ * Returns the Set of registered client signal ids for the provided
+ * {@code signalId}.
+ *
+ * @param signalId
+ * the signal id, must not be null
+ * @return the Set of registered client signal ids
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized Set getAllClientSignalIdsFor(UUID signalId) {
+ Objects.requireNonNull(signalId, "Signal id must not be null");
+ if (!signals.containsKey(signalId)) {
+ return Set.of();
+ }
+ return clientSignalToSignalMapping.entrySet().stream()
+ .filter(entry -> entry.getValue().equals(signalId))
+ .map(Map.Entry::getKey).collect(Collectors.toUnmodifiableSet());
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java
new file mode 100644
index 0000000000..41e7162dc7
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/StateEvent.java
@@ -0,0 +1,181 @@
+package com.vaadin.hilla.signals.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.BooleanNode;
+import com.fasterxml.jackson.databind.node.DoubleNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * A utility class for representing state events out of an ObjectNode. This
+ * helps to serialize and deserialize state events without getting involved with
+ * the string literals for field names and event types.
+ *
+ * @param
+ * The type of the value of the event.
+ */
+public class StateEvent {
+
+ /**
+ * The field names used in the JSON representation of the state event.
+ */
+ public static final class Field {
+ public static final String ID = "id";
+ public static final String TYPE = "type";
+ public static final String VALUE = "value";
+ }
+
+ /**
+ * Possible types of state events.
+ */
+ public enum EventType {
+ SNAPSHOT, SET,
+ }
+
+ /**
+ * An exception thrown when the event type is null or invalid.
+ */
+ public static class InvalidEventTypeException extends RuntimeException {
+ public InvalidEventTypeException(String message) {
+ super(message);
+ }
+
+ public InvalidEventTypeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ private final UUID id;
+ private final EventType eventType;
+ private final T value;
+
+ /**
+ * Creates a new state event using the given parameters.
+ *
+ * @param id
+ * The unique identifier of the event.
+ * @param eventType
+ * The type of the event.
+ * @param value
+ * The value of the event.
+ */
+ public StateEvent(UUID id, EventType eventType, T value) {
+ this.id = id;
+ this.eventType = eventType;
+ this.value = value;
+ }
+
+ /**
+ * Creates a new state event using the given JSON representation.
+ *
+ * @param json
+ * The JSON representation of the event.
+ */
+ public StateEvent(ObjectNode json) {
+ this.id = UUID.fromString(json.get(Field.ID).asText());
+ this.eventType = extractEventType(json);
+ JsonNode value = json.get(Field.VALUE);
+ if (value.isTextual()) {
+ this.value = (T) value.asText();
+ } else if (value.isBoolean()) {
+ this.value = (T) Boolean.valueOf(value.asBoolean());
+ } else if (value.isNumber()) {
+ this.value = (T) Double.valueOf(value.asDouble());
+ } else {
+ throw new IllegalArgumentException(
+ "Unsupported value type: " + value);
+ }
+ }
+
+ private EventType extractEventType(JsonNode json) {
+ var rawType = json.get(Field.TYPE);
+ if (rawType == null) {
+ var message = String.format(
+ "Missing event type. Type is required, and should be either of: %s",
+ Arrays.toString(EventType.values()));
+ throw new InvalidEventTypeException(message);
+ }
+ try {
+ return EventType.valueOf(rawType.asText().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ var message = String.format(
+ "Invalid event type %s. Type should be either of: %s",
+ rawType.asText(), Arrays.toString(EventType.values()));
+ throw new InvalidEventTypeException(message, e);
+ }
+ }
+
+ private JsonNode getValueAsJson() {
+ if (value instanceof String) {
+ return new TextNode((String) value);
+ } else if (value instanceof Boolean) {
+ return BooleanNode.valueOf((Boolean) value);
+ } else if (value instanceof Number) {
+ return new DoubleNode((Double) value);
+ } else {
+ throw new IllegalArgumentException(
+ "Unsupported value type: " + value);
+ }
+ }
+
+ /**
+ * Returns the JSON representation of the event.
+ *
+ * @return The JSON representation of the event.
+ */
+ public ObjectNode toJson() {
+ ObjectNode json = mapper.createObjectNode();
+ json.put(Field.ID, id.toString());
+ json.put(Field.TYPE, eventType.name().toLowerCase());
+ json.set(Field.VALUE, getValueAsJson());
+ return json;
+ }
+
+ /**
+ * Returns the unique identifier of the event.
+ *
+ * @return The unique identifier of the event.
+ */
+ public UUID getId() {
+ return id;
+ }
+
+ /**
+ * Returns the type of the event.
+ *
+ * @return The type of the event.
+ */
+ public EventType getEventType() {
+ return eventType;
+ }
+
+ /**
+ * Returns the value of the event.
+ *
+ * @return The value of the event.
+ */
+ public T getValue() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (!(o instanceof StateEvent> that))
+ return false;
+ return Objects.equals(getId(), that.getId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getId());
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java
new file mode 100644
index 0000000000..aa00bb3c64
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java
@@ -0,0 +1,76 @@
+package com.vaadin.hilla.signals.handler;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.flow.server.auth.AnonymousAllowed;
+import com.vaadin.hilla.BrowserCallable;
+import com.vaadin.hilla.EndpointInvoker;
+import com.vaadin.hilla.signals.NumberSignal;
+import com.vaadin.hilla.signals.core.SignalsRegistry;
+import reactor.core.publisher.Flux;
+
+import java.util.UUID;
+
+/**
+ * Handler Endpoint for Fullstack Signals' subscription and update events.
+ */
+@AnonymousAllowed
+@BrowserCallable
+public class SignalsHandler {
+
+ private final SignalsRegistry registry;
+ private final EndpointInvoker invoker;
+
+ public SignalsHandler(SignalsRegistry registry, EndpointInvoker invoker) {
+ this.registry = registry;
+ this.invoker = invoker;
+ }
+
+ /**
+ * Subscribes to a signal.
+ *
+ * @param signalProviderEndpointMethod
+ * the endpoint method that provides the signal
+ * @param clientSignalId
+ * the client signal id
+ *
+ * @return a Flux of JSON events
+ */
+ public Flux subscribe(String signalProviderEndpointMethod,
+ UUID clientSignalId) {
+ try {
+ if (registry.contains(clientSignalId)) {
+ return signalAsFlux(clientSignalId);
+ }
+
+ String[] endpointMethodParts = signalProviderEndpointMethod
+ .split("\\.");
+ NumberSignal signal = (NumberSignal) invoker.invoke(
+ endpointMethodParts[0], endpointMethodParts[1], null, null,
+ null);
+ registry.register(clientSignalId, signal);
+ return signalAsFlux(clientSignalId);
+ } catch (Exception e) {
+ return Flux.error(e);
+ }
+ }
+
+ private Flux signalAsFlux(UUID clientSignalId) {
+ return registry.get(clientSignalId).subscribe();
+ }
+
+ /**
+ * Updates a signal with an event.
+ *
+ * @param clientSignalId
+ * the clientSignalId associated with the signal to update
+ * @param event
+ * the event to update with
+ */
+ public void update(UUID clientSignalId, ObjectNode event) {
+ if (!registry.contains(clientSignalId)) {
+ throw new IllegalStateException(String.format(
+ "Signal not found for client signal: %s", clientSignalId));
+ }
+ registry.get(clientSignalId).submit(event);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java
new file mode 100644
index 0000000000..01566a1346
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java
@@ -0,0 +1,4 @@
+@NonNullApi
+package com.vaadin.hilla.signals.handler;
+
+import org.springframework.lang.NonNullApi;
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java
new file mode 100644
index 0000000000..99b7c4c60e
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java
@@ -0,0 +1,4 @@
+@NonNullApi
+package com.vaadin.hilla.signals;
+
+import org.springframework.lang.NonNullApi;
diff --git a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index ce0bbbe0c1..924b966ac9 100644
--- a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -6,3 +6,4 @@ com.vaadin.hilla.startup.EndpointRegistryInitializer
com.vaadin.hilla.startup.RouteUnifyingServiceInitListener
com.vaadin.hilla.route.RouteUtil
com.vaadin.hilla.route.RouteUnifyingConfiguration
+com.vaadin.hilla.signals.config.SignalsConfiguration
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/NumberSignalTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/NumberSignalTest.java
new file mode 100644
index 0000000000..c77af04c0e
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/NumberSignalTest.java
@@ -0,0 +1,118 @@
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.StateEvent;
+import org.junit.Assert;
+import org.junit.Test;
+import reactor.core.publisher.Flux;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class NumberSignalTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @Test
+ public void constructor_withValueArg_usesValueAsDefaultValue() {
+ NumberSignal signal = new NumberSignal(42.0);
+
+ assertEquals(42.0, signal.getValue(), 0.0);
+ }
+
+ @Test
+ public void constructor_withoutValueArg_usesZeroAsDefaultValue() {
+ NumberSignal signal = new NumberSignal();
+
+ assertEquals(0.0, signal.getValue(), 0.0);
+ }
+
+ @Test
+ public void constructor_withoutValueArg_acceptsNull() {
+ NumberSignal signal = new NumberSignal(null);
+
+ assertNull(signal.getValue());
+ }
+
+ @Test
+ public void getId_returns_not_null() {
+ NumberSignal signal1 = new NumberSignal();
+ assertNotNull(signal1.getId());
+
+ NumberSignal signal2 = new NumberSignal(null);
+ assertNotNull(signal2.getId());
+
+ NumberSignal signal3 = new NumberSignal(42.0);
+ assertNotNull(signal3.getId());
+ }
+
+ @Test
+ public void subscribe_returns_flux() {
+ NumberSignal signal = new NumberSignal();
+
+ Flux flux = signal.subscribe();
+
+ assertNotNull(flux);
+ }
+
+ @Test
+ public void subscribe_returns_flux_withJsonEvents() {
+ NumberSignal signal = new NumberSignal();
+
+ Flux flux = signal.subscribe();
+
+ flux.subscribe(Assert::assertNotNull);
+ }
+
+ @Test
+ public void submit_notifies_subscribers() {
+ NumberSignal signal = new NumberSignal();
+
+ Flux flux = signal.subscribe();
+
+ var counter = new AtomicInteger(0);
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ var stateEvent = new StateEvent(eventJson);
+ if (counter.get() == 0) {
+ // notification for the initial value
+ assertEquals(0.0, stateEvent.getValue(), 0.0);
+ } else if (counter.get() == 1) {
+ assertEquals(42.0, stateEvent.getValue(), 0.0);
+ }
+ counter.incrementAndGet();
+ });
+
+ signal.submit(createSetEvent("42"));
+ }
+
+ @Test
+ public void submit_eventWithUnknownCommand_throws() {
+ NumberSignal signal = new NumberSignal();
+
+ var exception = assertThrows(UnsupportedOperationException.class,
+ () -> signal.submit(createUnknownCommandEvent()));
+ assertTrue(exception.getMessage().startsWith("Unsupported JSON: "));
+ }
+
+ private ObjectNode createSetEvent(String value) {
+ var setEvent = new StateEvent<>(UUID.randomUUID(),
+ StateEvent.EventType.SET, Double.parseDouble(value));
+ return setEvent.toJson();
+ }
+
+ private ObjectNode createUnknownCommandEvent() {
+ var unknown = mapper.createObjectNode();
+ unknown.put(StateEvent.Field.ID, UUID.randomUUID().toString());
+ unknown.put("increase", "2");
+ unknown.put(StateEvent.Field.VALUE, "42");
+ return unknown;
+ }
+}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/SignalsRegistryTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/SignalsRegistryTest.java
new file mode 100644
index 0000000000..de4078f61c
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/SignalsRegistryTest.java
@@ -0,0 +1,233 @@
+package com.vaadin.hilla.signals.core;
+
+import com.vaadin.hilla.signals.NumberSignal;
+import org.junit.Test;
+
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class SignalsRegistryTest {
+
+ @Test
+ public void when_inputsAreNull_throws() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.register(null, new NumberSignal()));
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.register(UUID.randomUUID(), null));
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.register(null, null));
+ }
+
+ @Test
+ public void when_signalIsRegistered_clientIdToSignalIdMapping_isAlsoCreated() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(signal, signalsRegistry.getBySignalId(signal.getId()));
+
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(1, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+ }
+
+ @Test
+ public void when_signalIsAlreadyRegistered_signalIsNotRegisteredAgain() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ UUID anotherClientSignalId = UUID.randomUUID();
+ signalsRegistry.register(anotherClientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(2, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(2, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ assertEquals(signal, signalsRegistry.get(anotherClientSignalId));
+ }
+
+ @Test
+ public void get_nullClientSignalIdArg_throws() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.get(null));
+ }
+
+ @Test
+ public void when_noSignalIsFoundForClientSignalId_returnsNull() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ UUID anotherClientSignalId = UUID.randomUUID();
+ assertNull(signalsRegistry.get(anotherClientSignalId));
+ }
+
+ @Test
+ public void getBySignalId_nullSignalIdArg_throws() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.getBySignalId(null));
+ }
+
+ @Test
+ public void when_noSignalIsFoundForSignalId_returnsNull() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ assertNull(signalsRegistry.getBySignalId(UUID.randomUUID()));
+ }
+
+ @Test
+ public void contains_nullClientSignalIdArg_throws() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.contains(null));
+ }
+
+ @Test
+ public void when_signalIsRegistered_contains_returnsTrue() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ assertTrue(signalsRegistry.contains(clientSignalId));
+ }
+
+ @Test
+ public void when_signalIsNotRegistered_contains_returnsFalse() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+ assertFalse(signalsRegistry.contains(UUID.randomUUID()));
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSubscriptionsSize());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ assertFalse(signalsRegistry.contains(UUID.randomUUID()));
+ }
+
+ @Test
+ public void isEmpty_correctly_returns_status() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+ assertFalse(signalsRegistry.isEmpty());
+
+ signalsRegistry.unregister(signal.getId());
+ assertTrue(signalsRegistry.isEmpty());
+ }
+
+ @Test
+ public void unregister_nullIdArg_throws() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertThrows(NullPointerException.class,
+ () -> signalsRegistry.unregister(null));
+ }
+
+ @Test
+ public void when_signalIsUnregistered_clientIdToSignalIdMapping_isAlsoRemoved() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ signalsRegistry.unregister(signal.getId());
+
+ assertTrue(signalsRegistry.isEmpty());
+ assertNull(signalsRegistry.get(clientSignalId));
+ assertEquals(0, signalsRegistry.size());
+ assertEquals(0, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ }
+
+ @Test
+ public void when_clientIdToSignalIdMappingIsRemoved_signalIsNotRemoved() {
+ SignalsRegistry signalsRegistry = new SignalsRegistry();
+ assertTrue(signalsRegistry.isEmpty());
+
+ UUID clientSignalId = UUID.randomUUID();
+ NumberSignal signal = new NumberSignal();
+
+ signalsRegistry.register(clientSignalId, signal);
+
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(1, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ assertEquals(signal, signalsRegistry.get(clientSignalId));
+
+ signalsRegistry.removeClientSignalToSignalMapping(clientSignalId);
+
+ assertFalse(signalsRegistry.isEmpty());
+ assertNull(signalsRegistry.get(clientSignalId));
+ assertNotNull(signalsRegistry.getBySignalId(signal.getId()));
+ assertEquals(1, signalsRegistry.size());
+ assertEquals(0, signalsRegistry.getAllClientSignalIdsFor(signal.getId())
+ .size());
+ }
+}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/StateEventTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/StateEventTest.java
new file mode 100644
index 0000000000..adacb9ae81
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/core/StateEventTest.java
@@ -0,0 +1,120 @@
+package com.vaadin.hilla.signals.core;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.Test;
+
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class StateEventTest {
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ @Test
+ public void constructor_withParameters_shouldCreateStateEvent() {
+ UUID id = UUID.randomUUID();
+ StateEvent.EventType eventType = StateEvent.EventType.SET;
+ String value = "testValue";
+
+ StateEvent event = new StateEvent<>(id, eventType, value);
+
+ assertEquals(id, event.getId());
+ assertEquals(eventType, event.getEventType());
+ assertEquals(value, event.getValue());
+ }
+
+ @Test
+ public void constructor_withJson_shouldCreateStateEvent() {
+ UUID id = UUID.randomUUID();
+ StateEvent.EventType eventType = StateEvent.EventType.SET;
+ String value = "testValue";
+
+ ObjectNode json = mapper.createObjectNode();
+ json.put(StateEvent.Field.ID, id.toString());
+ json.put(StateEvent.Field.TYPE, eventType.name().toLowerCase());
+ json.put(StateEvent.Field.VALUE, value);
+
+ StateEvent event = new StateEvent<>(json);
+
+ assertEquals(id, event.getId());
+ assertEquals(eventType, event.getEventType());
+ assertEquals(value, event.getValue());
+ }
+
+ @Test
+ public void toJson_whenCalled_shouldReturnCorrectJson() {
+ UUID id = UUID.randomUUID();
+ StateEvent.EventType eventType = StateEvent.EventType.SNAPSHOT;
+ String value = "testValue";
+
+ StateEvent event = new StateEvent<>(id, eventType, value);
+ ObjectNode json = event.toJson();
+
+ assertEquals(id.toString(), json.get(StateEvent.Field.ID).asText());
+ assertEquals(eventType.name().toLowerCase(),
+ json.get(StateEvent.Field.TYPE).asText());
+ assertEquals(value, json.get(StateEvent.Field.VALUE).asText());
+ }
+
+ @Test
+ public void constructor_withJsonInvalidEventType_shouldThrowInvalidEventTypeException() {
+ UUID id = UUID.randomUUID();
+ String value = "testValue";
+
+ ObjectNode json = mapper.createObjectNode();
+ json.put(StateEvent.Field.ID, id.toString());
+ json.put(StateEvent.Field.TYPE, "invalidType");
+ json.put(StateEvent.Field.VALUE, value);
+
+ Exception exception = assertThrows(
+ StateEvent.InvalidEventTypeException.class,
+ () -> new StateEvent<>(json));
+
+ String expectedMessage = "Invalid event type invalidType. Type should be either of: [SNAPSHOT, SET]";
+ String actualMessage = exception.getMessage();
+
+ assertTrue(actualMessage.contains(expectedMessage));
+ }
+
+ @Test
+ public void constructor_withJsonMissingEventType_shouldThrowInvalidEventTypeException() {
+ UUID id = UUID.randomUUID();
+ String value = "testValue";
+
+ ObjectNode json = mapper.createObjectNode();
+ json.put(StateEvent.Field.ID, id.toString());
+ json.put(StateEvent.Field.VALUE, value);
+
+ Exception exception = assertThrows(
+ StateEvent.InvalidEventTypeException.class,
+ () -> new StateEvent<>(json));
+
+ String expectedMessage = "Missing event type. Type is required, and should be either of: [SNAPSHOT, SET]";
+ String actualMessage = exception.getMessage();
+
+ assertTrue(actualMessage.contains(expectedMessage));
+ }
+
+ @Test
+ public void constructor_withJsonUnsupportedValueType_shouldThrowIllegalArgumentException() {
+ UUID id = UUID.randomUUID();
+ ObjectNode json = mapper.createObjectNode();
+ json.put(StateEvent.Field.ID, id.toString());
+ json.put(StateEvent.Field.TYPE,
+ StateEvent.EventType.SET.name().toLowerCase());
+ json.set(StateEvent.Field.VALUE, mapper.createArrayNode()); // Unsupported
+ // type
+
+ Exception exception = assertThrows(IllegalArgumentException.class,
+ () -> new StateEvent<>(json));
+
+ String expectedMessage = "Unsupported value type";
+ String actualMessage = exception.getMessage();
+
+ assertTrue(actualMessage.contains(expectedMessage));
+ }
+}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java
new file mode 100644
index 0000000000..df41b79951
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/handler/SignalsHandlerTest.java
@@ -0,0 +1,107 @@
+package com.vaadin.hilla.signals.handler;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.EndpointInvoker;
+import com.vaadin.hilla.signals.NumberSignal;
+import com.vaadin.hilla.signals.core.SignalsRegistry;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import reactor.core.publisher.Flux;
+import reactor.test.StepVerifier;
+
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.when;
+
+public class SignalsHandlerTest {
+
+ private static final UUID CLIENT_SIGNAL_ID_1 = UUID
+ .fromString("90000000-9000-9000-9000-900000000000");
+ private static final UUID CLIENT_SIGNAL_ID_2 = UUID
+ .fromString("80000000-8000-8000-8000-800000000000");
+
+ private final ObjectMapper mapper = new ObjectMapper();
+ private SignalsHandler signalsHandler;
+ private SignalsRegistry signalsRegistry;
+ private EndpointInvoker endpointInvoker;
+
+ @Before
+ public void setUp() {
+ signalsRegistry = new SignalsRegistry();
+ endpointInvoker = Mockito.mock(EndpointInvoker.class);
+ signalsHandler = new SignalsHandler(signalsRegistry, endpointInvoker);
+ }
+
+ @Test
+ public void when_signalAlreadyRegistered_subscribe_returnsSubscriptionOfSameInstance()
+ throws Exception {
+
+ NumberSignal numberSignal = new NumberSignal();
+ var signalId = numberSignal.getId();
+ when(endpointInvoker.invoke("endpoint", "method", null, null, null))
+ .thenReturn(numberSignal);
+
+ var expectedSignalEventJson = new ObjectNode(mapper.getNodeFactory())
+ .put("value", 0.0).put("id", signalId.toString())
+ .put("type", "snapshot");
+
+ // first client subscribe to a signal, it registers the signal:
+ Flux firstFlux = signalsHandler.subscribe("endpoint.method",
+ CLIENT_SIGNAL_ID_1);
+ firstFlux.subscribe(next -> {
+ assertNotNull(next);
+ assertEquals(expectedSignalEventJson, next);
+ }, error -> {
+ throw new RuntimeException(error);
+ });
+
+ // another client subscribes to the same signal:
+ Flux secondFlux = signalsHandler
+ .subscribe("endpoint.method", CLIENT_SIGNAL_ID_2);
+ secondFlux.subscribe(next -> {
+ assertNotNull(next);
+ assertEquals(expectedSignalEventJson, next);
+ }, error -> {
+ throw new RuntimeException(error);
+ });
+
+ assertEquals(signalsRegistry.get(CLIENT_SIGNAL_ID_1).getId(),
+ signalsRegistry.get(CLIENT_SIGNAL_ID_2).getId());
+ }
+
+ @Test
+ public void when_signalIsNotRegistered_update_throwsException() {
+ var setEvent = new ObjectNode(mapper.getNodeFactory()).put("value", 0.0)
+ .put("id", UUID.randomUUID().toString()).put("type", "set");
+ assertThrows(IllegalStateException.class,
+ () -> signalsHandler.update(CLIENT_SIGNAL_ID_1, setEvent));
+ }
+
+ @Test
+ public void when_signalIsRegistered_update_notifiesTheSubscribers()
+ throws Exception {
+ NumberSignal numberSignal = new NumberSignal(10.0);
+ var signalId = numberSignal.getId();
+ when(endpointInvoker.invoke("endpoint", "method", null, null, null))
+ .thenReturn(numberSignal);
+
+ Flux firstFlux = signalsHandler.subscribe("endpoint.method",
+ CLIENT_SIGNAL_ID_1);
+
+ var setEvent = new ObjectNode(mapper.getNodeFactory()).put("value", 42)
+ .put("id", UUID.randomUUID().toString()).put("type", "set");
+ signalsHandler.update(CLIENT_SIGNAL_ID_1, setEvent);
+
+ var expectedUpdatedSignalEventJson = new ObjectNode(
+ mapper.getNodeFactory()).put("value", 42.0)
+ .put("id", signalId.toString()).put("type", "snapshot");
+ StepVerifier.create(firstFlux)
+ .expectNext(expectedUpdatedSignalEventJson).thenCancel()
+ .verify();
+ }
+}
diff --git a/packages/ts/react-crud/src/autogrid-columns.tsx b/packages/ts/react-crud/src/autogrid-columns.tsx
index 3670137dae..b243e70d47 100644
--- a/packages/ts/react-crud/src/autogrid-columns.tsx
+++ b/packages/ts/react-crud/src/autogrid-columns.tsx
@@ -109,7 +109,7 @@ export function getColumnOptions(
const headerFilterRenderer =
customColumnOptions?.filterable === false
? NoHeaderFilter
- : typeColumnOptions.headerFilterRenderer ?? NoHeaderFilter;
+ : (typeColumnOptions.headerFilterRenderer ?? NoHeaderFilter);
// TODO: Remove eslint-disable when all TypeScript version issues are resolved
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return customColumnOptions
diff --git a/packages/ts/react-signals/package.json b/packages/ts/react-signals/package.json
index b3a4d57b20..cf5e84eb47 100644
--- a/packages/ts/react-signals/package.json
+++ b/packages/ts/react-signals/package.json
@@ -21,8 +21,11 @@
"build:esbuild": "tsx ../../../scripts/build.ts",
"build:dts": "tsc --isolatedModules -p tsconfig.build.json",
"build:copy": "cd src && copyfiles **/*.d.ts ..",
- "lint": "eslint src",
- "lint:fix": "eslint src --fix",
+ "lint": "eslint src test",
+ "lint:fix": "eslint src test --fix",
+ "test": "karma start ../../../karma.config.cjs --port 9881",
+ "test:coverage": "npm run test -- --coverage",
+ "test:watch": "npm run test -- --watch",
"typecheck": "tsc --noEmit"
},
"exports": {
@@ -43,7 +46,8 @@
"access": "public"
},
"dependencies": {
- "@preact/signals-react": "^2.0.0"
+ "@preact/signals-react": "^2.0.0",
+ "@vaadin/hilla-frontend": "^24.5.0-alpha5"
},
"peerDependencies": {
"react": "^18",
@@ -51,10 +55,23 @@
},
"devDependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
+ "@testing-library/dom": "^10.2.0",
+ "@testing-library/react": "^16.0.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/chai": "^4.3.6",
+ "@types/chai-as-promised": "^7.1.6",
+ "@types/chai-dom": "^1.11.1",
+ "@types/mocha": "^10.0.2",
"@types/react": "^18.2.23",
"@types/sinon": "^10.0.17",
+ "@types/sinon-chai": "^3.2.10",
"@types/validator": "^13.11.2",
- "react-router-dom": "^6.16.0",
+ "chai-as-promised": "^7.1.1",
+ "chai-dom": "^1.11.0",
+ "karma": "^6.4.3",
+ "karma-viewport": "^1.0.9",
+ "sinon": "^16.0.0",
+ "sinon-chai": "^3.7.0",
"typescript": "5.5.2"
}
}
diff --git a/packages/ts/react-signals/src/EventChannel.ts b/packages/ts/react-signals/src/EventChannel.ts
new file mode 100644
index 0000000000..c2bc68234e
--- /dev/null
+++ b/packages/ts/react-signals/src/EventChannel.ts
@@ -0,0 +1,95 @@
+import type { ConnectClient, Subscription } from '@vaadin/hilla-frontend';
+import { NumberSignal, setInternalValue, type ValueSignal } from './Signals.js';
+import SignalsHandler from './SignalsHandler';
+import { type StateEvent, StateEventType } from './types.js';
+
+/**
+ * The type that describes the needed information to
+ * subscribe and publish to a server-side signal instance.
+ */
+type SignalChannelDescriptor = Readonly<{
+ signalProviderEndpointMethod: string;
+ subscribe(signalProviderEndpointMethod: string, clientSignalId: string): Subscription;
+ publish(clientSignalId: string, event: T): Promise;
+}>;
+
+/**
+ * A generic class that represents a signal channel
+ * that can be used to communicate with a server-side
+ * signal instance.
+ *
+ * The signal channel is responsible for subscribing to
+ * the server-side signal and updating the local signal
+ * based on the received events.
+ *
+ * @typeParam T - The type of the signal value.
+ * @typeParam S - The type of the signal instance.
+ */
+abstract class SignalChannel> {
+ readonly #channelDescriptor: SignalChannelDescriptor;
+ readonly #signalsHandler: SignalsHandler;
+ readonly #id: string;
+
+ readonly #internalSignal: S;
+
+ constructor(signalProviderServiceMethod: string, connectClient: ConnectClient) {
+ this.#id = crypto.randomUUID();
+ this.#signalsHandler = new SignalsHandler(connectClient);
+ this.#channelDescriptor = {
+ signalProviderEndpointMethod: signalProviderServiceMethod,
+ subscribe: (signalProviderEndpointMethod: string, signalId: string) =>
+ this.#signalsHandler.subscribe(signalProviderEndpointMethod, signalId),
+ publish: async (signalId: string, event: StateEvent) => this.#signalsHandler.update(signalId, event),
+ };
+
+ this.#internalSignal = this.createInternalSignal(async (event: StateEvent) => this.publish(event));
+
+ this.#connect();
+ }
+
+ #connect() {
+ this.#channelDescriptor
+ .subscribe(this.#channelDescriptor.signalProviderEndpointMethod, this.#id)
+ .onNext((stateEvent) => {
+ // Update signals based on the new value from the event:
+ this.#updateSignals(stateEvent);
+ });
+ }
+
+ #updateSignals(stateEvent: StateEvent): void {
+ if (stateEvent.type === StateEventType.SNAPSHOT) {
+ setInternalValue(this.#internalSignal, stateEvent.value);
+ }
+ }
+
+ async publish(event: StateEvent): Promise {
+ await this.#channelDescriptor.publish(this.#id, event);
+ return true;
+ }
+
+ /**
+ * Returns the signal instance to be used in components.
+ */
+ get signal(): S {
+ return this.#internalSignal;
+ }
+
+ /**
+ * Returns the id of the signal channel.
+ */
+ get id(): string {
+ return this.#id;
+ }
+
+ abstract createInternalSignal(publish: (event: StateEvent) => Promise, initialValue?: T): S;
+}
+
+/**
+ * A signal channel that is used to communicate with a
+ * server-side signal instance that holds a number value.
+ */
+export class NumberSignalChannel extends SignalChannel {
+ override createInternalSignal(publish: (event: StateEvent) => Promise, initialValue?: number): NumberSignal {
+ return new NumberSignal(publish, initialValue);
+ }
+}
diff --git a/packages/ts/react-signals/src/Signals.ts b/packages/ts/react-signals/src/Signals.ts
new file mode 100644
index 0000000000..54a61831fa
--- /dev/null
+++ b/packages/ts/react-signals/src/Signals.ts
@@ -0,0 +1,88 @@
+import { Signal } from './core.js';
+import { type StateEvent, StateEventType } from './types';
+
+// eslint-disable-next-line import/no-mutable-exports
+export let setInternalValue: (signal: ValueSignal, value: T) => void;
+
+/**
+ * A signal that holds a value. The underlying
+ * value of this signal is stored and updated as a
+ * shared value on the server.
+ *
+ * @internal
+ */
+export abstract class ValueSignal extends Signal {
+ static {
+ setInternalValue = (signal: ValueSignal, value: unknown): void => signal.#setInternalValue(value);
+ }
+
+ readonly #publish: (event: StateEvent) => Promise;
+
+ /**
+ * Creates a new ValueSignal instance.
+ * @param publish - The function that publishes the
+ * value of the signal to the server.
+ * @param value - The initial value of the signal
+ * @defaultValue undefined
+ */
+ constructor(publish: (event: StateEvent) => Promise, value?: T) {
+ super(value);
+ this.#publish = publish;
+ }
+
+ /**
+ * Returns the value of the signal.
+ */
+ override get value(): T {
+ return super.value;
+ }
+
+ /**
+ * Publishes the new value to the server.
+ * Note that this method is not setting
+ * the signal's value.
+ *
+ * @param value - The new value of the signal
+ * to be published to the server.
+ */
+ override set value(value: T) {
+ const id = crypto.randomUUID();
+ this.#publish({ id, type: StateEventType.SET, value }).catch((error) => {
+ throw error;
+ });
+ }
+
+ /**
+ * Sets the value of the signal.
+ * @param value - The new value of the signal.
+ * @internal
+ */
+ #setInternalValue(value: T): void {
+ super.value = value;
+ }
+}
+
+/**
+ * A signal that holds a number value. The underlying
+ * value of this signal is stored and updated as a
+ * shared value on the server.
+ *
+ * After obtaining the NumberSignal instance from
+ * a server-side service that returns one, the value
+ * can be updated using the `value` property,
+ * and it can be read with or without the
+ * `value` property (similar to a normal signal):
+ *
+ * @example
+ * ```tsx
+ * const counter = CounterService.counter();
+ *
+ * return (
+ *
+ *
+ * );
+ * ```
+ */
+export class NumberSignal extends ValueSignal {}
diff --git a/packages/ts/react-signals/src/SignalsHandler.ts b/packages/ts/react-signals/src/SignalsHandler.ts
new file mode 100644
index 0000000000..d54929e5e4
--- /dev/null
+++ b/packages/ts/react-signals/src/SignalsHandler.ts
@@ -0,0 +1,24 @@
+import type { ConnectClient, EndpointRequestInit, Subscription } from '@vaadin/hilla-frontend';
+import type { StateEvent } from './types';
+
+/**
+ * SignalsHandler is a helper class for handling the
+ * communication of the full-stack signal instances
+ * and their server-side counterparts they are
+ * subscribed and publish their updates to.
+ */
+export default class SignalsHandler {
+ readonly #client: ConnectClient;
+
+ constructor(client: ConnectClient) {
+ this.#client = client;
+ }
+
+ subscribe(signalProviderEndpointMethod: string, clientSignalId: string): Subscription {
+ return this.#client.subscribe('SignalsHandler', 'subscribe', { signalProviderEndpointMethod, clientSignalId });
+ }
+
+ async update(clientSignalId: string, event: StateEvent, init?: EndpointRequestInit): Promise {
+ return this.#client.call('SignalsHandler', 'update', { clientSignalId, event }, init);
+ }
+}
diff --git a/packages/ts/react-signals/src/core.ts b/packages/ts/react-signals/src/core.ts
new file mode 100644
index 0000000000..047f8d9520
--- /dev/null
+++ b/packages/ts/react-signals/src/core.ts
@@ -0,0 +1,6 @@
+import { installAutoSignalTracking } from '@preact/signals-react/runtime';
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-call
+installAutoSignalTracking();
+
+export * from '@preact/signals-react';
diff --git a/packages/ts/react-signals/src/index.ts b/packages/ts/react-signals/src/index.ts
index 4cbe9dd400..a0e10b38ce 100644
--- a/packages/ts/react-signals/src/index.ts
+++ b/packages/ts/react-signals/src/index.ts
@@ -1 +1,5 @@
-export * from '@preact/signals-react';
+// eslint-disable-next-line import/export
+export * from './core.js';
+export { NumberSignalChannel } from './EventChannel.js';
+export { NumberSignal, ValueSignal } from './Signals.js';
+export * from './types.js';
diff --git a/packages/ts/react-signals/src/types.ts b/packages/ts/react-signals/src/types.ts
new file mode 100644
index 0000000000..19e4a884ab
--- /dev/null
+++ b/packages/ts/react-signals/src/types.ts
@@ -0,0 +1,16 @@
+/**
+ * Types of events that can be produced or processed by a signal.
+ */
+export enum StateEventType {
+ SET = 'set',
+ SNAPSHOT = 'snapshot',
+}
+
+/**
+ * Event that describes the state of a signal.
+ */
+export type StateEvent = {
+ id: string;
+ type: StateEventType;
+ value: any;
+};
diff --git a/packages/ts/react-signals/test/EventChannel.spec.tsx b/packages/ts/react-signals/test/EventChannel.spec.tsx
new file mode 100644
index 0000000000..83a72a685f
--- /dev/null
+++ b/packages/ts/react-signals/test/EventChannel.spec.tsx
@@ -0,0 +1,98 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import { expect, use } from '@esm-bundle/chai';
+import { render } from '@testing-library/react';
+import { ConnectClient, type Subscription } from '@vaadin/hilla-frontend';
+import sinon from 'sinon';
+import sinonChai from 'sinon-chai';
+import { NumberSignal, NumberSignalChannel, type StateEvent, StateEventType } from '../src/index.js';
+import { nextFrame } from './utils.js';
+
+use(sinonChai);
+
+function simulateReceivedEvent(connectSubscriptionMock: Subscription, event: StateEvent) {
+ const onNextCallback = (connectSubscriptionMock.onNext as sinon.SinonStub).getCall(0).args[0];
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ onNextCallback(event);
+}
+
+describe('@vaadin/hilla-react-signals', () => {
+ describe('NumberSignalChannel', () => {
+ let connectClientMock: sinon.SinonStubbedInstance;
+ let connectSubscriptionMock: Subscription;
+
+ beforeEach(() => {
+ connectClientMock = sinon.createStubInstance(ConnectClient);
+ connectClientMock.call.resolves();
+ connectSubscriptionMock = {
+ cancel: sinon.stub(),
+ context: sinon.stub().returnsThis(),
+ onComplete: sinon.stub().returnsThis(),
+ onError: sinon.stub().returnsThis(),
+ onNext: sinon.stub().returnsThis(),
+ };
+ // Mock the subscribe method
+ connectClientMock.subscribe.returns(connectSubscriptionMock);
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('should create signal instance of type NumberSignal', () => {
+ const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock);
+ expect(numberSignalChannel.signal).to.be.instanceOf(NumberSignal);
+ expect(numberSignalChannel.signal.value).to.be.undefined;
+ });
+
+ it('should subscribe to signal provider endpoint', () => {
+ const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock);
+ expect(connectClientMock.subscribe).to.be.have.been.calledOnce;
+ expect(connectClientMock.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', {
+ clientSignalId: numberSignalChannel.id,
+ signalProviderEndpointMethod: 'testEndpoint',
+ });
+ });
+
+ it('should publish updates to signals handler endpoint', () => {
+ const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock);
+ numberSignalChannel.signal.value = 42;
+
+ expect(connectClientMock.call).to.be.have.been.calledOnce;
+ expect(connectClientMock.call).to.have.been.calledWithMatch(
+ 'SignalsHandler',
+ 'update',
+ {
+ clientSignalId: numberSignalChannel.id,
+ event: { type: StateEventType.SET, value: 42 },
+ },
+ undefined,
+ );
+ });
+
+ it("should update signal's value based on the received event", () => {
+ const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock);
+ expect(numberSignalChannel.signal.value).to.be.undefined;
+
+ // Simulate the event received from the server:
+ const snapshotEvent: StateEvent = { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 };
+ simulateReceivedEvent(connectSubscriptionMock, snapshotEvent);
+
+ // Check if the signal value is updated:
+ expect(numberSignalChannel.signal.value).to.equal(42);
+ });
+
+ it("should render signal's the updated value", async () => {
+ const numberSignalChannel = new NumberSignalChannel('testEndpoint', connectClientMock);
+ const numberSignal = numberSignalChannel.signal;
+ simulateReceivedEvent(connectSubscriptionMock, { id: 'someId', type: StateEventType.SNAPSHOT, value: 42 });
+
+ const result = render(Value is {numberSignal});
+ await nextFrame();
+ expect(result.container.textContent).to.equal('Value is 42');
+
+ simulateReceivedEvent(connectSubscriptionMock, { id: 'someId', type: StateEventType.SNAPSHOT, value: 99 });
+ await nextFrame();
+ expect(result.container.textContent).to.equal('Value is 99');
+ });
+ });
+});
diff --git a/packages/ts/react-signals/test/Signals.spec.tsx b/packages/ts/react-signals/test/Signals.spec.tsx
new file mode 100644
index 0000000000..63ad87b4b8
--- /dev/null
+++ b/packages/ts/react-signals/test/Signals.spec.tsx
@@ -0,0 +1,63 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import { expect, use } from '@esm-bundle/chai';
+import { render } from '@testing-library/react';
+import sinon from 'sinon';
+import sinonChai from 'sinon-chai';
+import type { StateEvent } from '../src';
+import { NumberSignal } from '../src';
+import { nextFrame } from './utils.js';
+
+use(sinonChai);
+
+describe('@vaadin/hilla-react-signals', () => {
+ describe('NumberSignal', () => {
+ let publishSpy: sinon.SinonSpy;
+
+ beforeEach(() => {
+ publishSpy = sinon.spy(async (_: StateEvent): Promise => Promise.resolve(true));
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('should retain default value as initialized', () => {
+ const numberSignal1 = new NumberSignal(publishSpy);
+ expect(numberSignal1.value).to.be.undefined;
+
+ const numberSignal2 = new NumberSignal(publishSpy, undefined);
+ expect(numberSignal2.value).to.be.undefined;
+
+ const numberSignal3 = new NumberSignal(publishSpy, 0);
+ expect(numberSignal3.value).to.equal(0);
+
+ const numberSignal4 = new NumberSignal(publishSpy, 42.424242);
+ expect(numberSignal4.value).to.equal(42.424242);
+
+ const numberSignal5 = new NumberSignal(publishSpy, -42.424242);
+ expect(numberSignal5.value).to.equal(-42.424242);
+ });
+
+ it('should publish the new value to the server when set', () => {
+ const numberSignal = new NumberSignal(publishSpy);
+ numberSignal.value = 42;
+ expect(publishSpy).to.have.been.calledOnce;
+ expect(publishSpy).to.have.been.calledWithMatch({ type: 'set', value: 42 });
+
+ publishSpy.resetHistory();
+
+ const numberSignal2 = new NumberSignal(publishSpy, 0);
+ // eslint-disable-next-line no-plusplus
+ numberSignal2.value++;
+ expect(publishSpy).to.have.been.calledOnce;
+ expect(publishSpy).to.have.been.calledWithMatch({ type: 'set', value: 1 });
+ });
+
+ it('should render value when signal is rendered', async () => {
+ const numberSignal = new NumberSignal(publishSpy, 42);
+ const result = render(Value is {numberSignal});
+ await nextFrame();
+ expect(result.container.textContent).to.equal('Value is 42');
+ });
+ });
+});
diff --git a/packages/ts/react-signals/test/SignalsHandler.spec.ts b/packages/ts/react-signals/test/SignalsHandler.spec.ts
new file mode 100644
index 0000000000..c8dcff3326
--- /dev/null
+++ b/packages/ts/react-signals/test/SignalsHandler.spec.ts
@@ -0,0 +1,53 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import { expect, use } from '@esm-bundle/chai';
+import { ConnectClient } from '@vaadin/hilla-frontend';
+import sinon from 'sinon';
+import sinonChai from 'sinon-chai';
+import { type StateEvent, StateEventType } from '../src/index.js';
+import SignalsHandler from '../src/SignalsHandler.js';
+
+use(sinonChai);
+
+describe('@vaadin/hilla-react-signals', () => {
+ describe('signalsHandler', () => {
+ let connectClientMock: sinon.SinonStubbedInstance;
+ let signalsHandler: SignalsHandler;
+
+ beforeEach(() => {
+ connectClientMock = sinon.createStubInstance(ConnectClient);
+ signalsHandler = new SignalsHandler(connectClientMock);
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('subscribe should call client.subscribe', () => {
+ const signalProviderEndpointMethod = 'testEndpoint';
+ const clientSignalId = 'testSignalId';
+ signalsHandler.subscribe(signalProviderEndpointMethod, clientSignalId);
+
+ expect(connectClientMock.subscribe).to.be.have.been.calledOnce;
+ expect(connectClientMock.subscribe).to.have.been.calledWith('SignalsHandler', 'subscribe', {
+ signalProviderEndpointMethod,
+ clientSignalId,
+ });
+ });
+
+ it('update should call client.call', async () => {
+ const clientSignalId = 'testSignalId';
+ const event: StateEvent = { id: 'testEvent', type: StateEventType.SET, value: 10 };
+ const init = {};
+
+ await signalsHandler.update(clientSignalId, event, init);
+
+ expect(connectClientMock.call).to.be.have.been.calledOnce;
+ expect(connectClientMock.call).to.have.been.calledWith(
+ 'SignalsHandler',
+ 'update',
+ { clientSignalId, event },
+ init,
+ );
+ });
+ });
+});
diff --git a/packages/ts/react-signals/test/utils.ts b/packages/ts/react-signals/test/utils.ts
new file mode 100644
index 0000000000..6ce6718bad
--- /dev/null
+++ b/packages/ts/react-signals/test/utils.ts
@@ -0,0 +1,7 @@
+export async function nextFrame(): Promise {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+}
diff --git a/packages/ts/react-signals/tsconfig.json b/packages/ts/react-signals/tsconfig.json
index c8c8024a39..bfd59eacca 100644
--- a/packages/ts/react-signals/tsconfig.json
+++ b/packages/ts/react-signals/tsconfig.json
@@ -3,5 +3,5 @@
"compilerOptions": {
"jsx": "react-jsx"
},
- "include": ["src"],
+ "include": ["src", "test"]
}