diff --git a/flow-client/src/main/java/com/vaadin/client/ApplicationConnection.java b/flow-client/src/main/java/com/vaadin/client/ApplicationConnection.java
index b88f58a816a..98eb76f680b 100644
--- a/flow-client/src/main/java/com/vaadin/client/ApplicationConnection.java
+++ b/flow-client/src/main/java/com/vaadin/client/ApplicationConnection.java
@@ -90,8 +90,6 @@ public ApplicationConnection(
Console.debug(
"Vaadin application servlet version: " + servletVersion);
}
-
- ConnectionIndicator.setState(ConnectionIndicator.LOADING);
}
/**
@@ -210,12 +208,10 @@ private native void publishJavascriptMethods(String applicationId,
var ur = ap.@ApplicationConnection::registry.@com.vaadin.client.Registry::getURIResolver()();
return ur.@com.vaadin.client.URIResolver::resolveVaadinUri(Ljava/lang/String;)(uriToResolve);
});
-
client.sendEventMessage = $entry(function(nodeId, eventType, eventData) {
var sc = ap.@ApplicationConnection::registry.@com.vaadin.client.Registry::getServerConnector()();
sc.@com.vaadin.client.communication.ServerConnector::sendEventMessage(ILjava/lang/String;Lelemental/json/JsonObject;)(nodeId,eventType,eventData);
});
-
client.initializing = false;
client.exportedWebComponents = exportedWebComponents;
$wnd.Vaadin.Flow.clients[applicationId] = client;
diff --git a/flow-client/src/main/java/com/vaadin/client/ConnectionIndicator.java b/flow-client/src/main/java/com/vaadin/client/ConnectionIndicator.java
index b378460382f..b0e3293e5d4 100644
--- a/flow-client/src/main/java/com/vaadin/client/ConnectionIndicator.java
+++ b/flow-client/src/main/java/com/vaadin/client/ConnectionIndicator.java
@@ -100,4 +100,48 @@ public static native void setProperty(String property, Object value)
$wnd.Vaadin.connectionIndicator[property] = value;
}
}-*/;
+
+ /**
+ * Notifies the client-side connection state indicator that a loading
+ * operation has started.
+ *
+ * This method triggers the {@link ConnectionIndicator#LOADING} state
+ * transition on the client side.
+ */
+ public static native void loadingStarted()
+ /*-{
+ if ($wnd.Vaadin.connectionState) {
+ $wnd.Vaadin.connectionState.loadingStarted();
+ }
+ }-*/;
+
+ /**
+ * Notifies the client-side connection state indicator that a loading
+ * operation has completed successfully.
+ *
+ * When all requests finish, this method triggers the
+ * {@link ConnectionIndicator#CONNECTED} state transition on the client
+ * side.
+ */
+ public static native void loadingFinished()
+ /*-{
+ if ($wnd.Vaadin.connectionState) {
+ $wnd.Vaadin.connectionState.loadingFinished();
+ }
+ }-*/;
+
+ /**
+ * Notifies the client-side connection state indicator that a loading
+ * operation has encountered an error or failed.
+ *
+ * If no requests are remaining, triggers the
+ * {@link ConnectionIndicator#CONNECTION_LOST} state transition on the
+ * client side.
+ */
+ public static native void loadingFailed()
+ /*-{
+ if ($wnd.Vaadin.connectionState) {
+ $wnd.Vaadin.connectionState.loadingFailed();
+ }
+ }-*/;
}
diff --git a/flow-client/src/main/java/com/vaadin/client/DefaultRegistry.java b/flow-client/src/main/java/com/vaadin/client/DefaultRegistry.java
index fe8a8ff4f64..6da4d51576d 100644
--- a/flow-client/src/main/java/com/vaadin/client/DefaultRegistry.java
+++ b/flow-client/src/main/java/com/vaadin/client/DefaultRegistry.java
@@ -20,6 +20,7 @@
import com.vaadin.client.communication.ConnectionStateHandler;
import com.vaadin.client.communication.DefaultConnectionStateHandler;
import com.vaadin.client.communication.Heartbeat;
+import com.vaadin.client.communication.LoadingIndicatorStateHandler;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.communication.MessageSender;
import com.vaadin.client.communication.Poller;
@@ -87,6 +88,8 @@ public DefaultRegistry(ApplicationConnection connection,
set(PushConfiguration.class, new PushConfiguration(this));
set(ReconnectConfiguration.class, new ReconnectConfiguration(this));
set(Poller.class, new Poller(this));
+ set(LoadingIndicatorStateHandler.class,
+ new LoadingIndicatorStateHandler(this));
}
}
diff --git a/flow-client/src/main/java/com/vaadin/client/Registry.java b/flow-client/src/main/java/com/vaadin/client/Registry.java
index 879d9fb064c..083a673b3fa 100644
--- a/flow-client/src/main/java/com/vaadin/client/Registry.java
+++ b/flow-client/src/main/java/com/vaadin/client/Registry.java
@@ -19,6 +19,7 @@
import com.vaadin.client.communication.ConnectionStateHandler;
import com.vaadin.client.communication.Heartbeat;
+import com.vaadin.client.communication.LoadingIndicatorStateHandler;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.communication.MessageSender;
import com.vaadin.client.communication.Poller;
@@ -319,6 +320,15 @@ public Poller getPoller() {
return get(Poller.class);
}
+ /**
+ * Gets the {@link LoadingIndicatorStateHandler} singleton.
+ *
+ * @return the {@link LoadingIndicatorStateHandler} singleton instance
+ */
+ public LoadingIndicatorStateHandler getLoadingIndicatorStateHandler() {
+ return get(LoadingIndicatorStateHandler.class);
+ }
+
/**
* Deletes and recreates resettable instances of registry singletons.
*/
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/DefaultConnectionStateHandler.java b/flow-client/src/main/java/com/vaadin/client/communication/DefaultConnectionStateHandler.java
index e502a02d2e1..6bea7d513e1 100644
--- a/flow-client/src/main/java/com/vaadin/client/communication/DefaultConnectionStateHandler.java
+++ b/flow-client/src/main/java/com/vaadin/client/communication/DefaultConnectionStateHandler.java
@@ -460,7 +460,16 @@ private void resolveTemporaryError(Type type) {
scheduledReconnect.cancel();
scheduledReconnect = null;
}
- ConnectionIndicator.setState(ConnectionIndicator.CONNECTED);
+ if (Type.HEARTBEAT.equals(type)) {
+ // Heartbeat never has loading indication, it is safe to assume
+ // that no other requests are in progress and set the `CONNECTED`
+ // state directly.
+ ConnectionIndicator.setState(ConnectionIndicator.CONNECTED);
+ } else {
+ // Let the loading indicator state handler check and remove
+ // the prior loading state indication if necessary.
+ registry.getLoadingIndicatorStateHandler().stopLoading();
+ }
Console.debug("Re-established connection to server");
}
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/LoadingIndicatorStateHandler.java b/flow-client/src/main/java/com/vaadin/client/communication/LoadingIndicatorStateHandler.java
new file mode 100644
index 00000000000..d301676c1e1
--- /dev/null
+++ b/flow-client/src/main/java/com/vaadin/client/communication/LoadingIndicatorStateHandler.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.client.communication;
+
+import com.google.gwt.core.client.Scheduler;
+
+import com.vaadin.client.ConnectionIndicator;
+import com.vaadin.client.Registry;
+import com.vaadin.client.flow.collection.JsCollections;
+import com.vaadin.client.flow.collection.JsSet;
+import com.vaadin.flow.shared.JsonConstants;
+
+/**
+ * Manages the state of loading indicator based on active RPC requests, event
+ * types, and lifecycle events.
+ *
+ * This class ensures appropriate visual feedback (e.g., loading bar) is shown
+ * or hidden according to the current network conditions and request status. It
+ * is responsible for muting the loading indication when RPC requests are
+ * triggered by high-frequency UI events (mousemove and such) to avoid excessive
+ * visual noise in these cases.
+ */
+public class LoadingIndicatorStateHandler {
+ private final Registry registry;
+
+ private boolean loading = false;
+
+ private boolean showLoading = false;
+
+ // High-frequency events, whose related RPC requests are not expected
+ // to trigger loading indication.
+ private static final JsSet SILENT_EVENT_TYPES = JsCollections.set();
+ {
+ JsCollections.array("keydown", "keypress", "keyup", "mousemove",
+ "pointermove", "pointerrawupdate", "touchmove", "beforeinput",
+ "input", "scroll", "wheel", "drag", "dragover")
+ .forEach(SILENT_EVENT_TYPES::add);
+ }
+
+ /**
+ * Creates a new instance connected to the given registry.
+ *
+ * @param registry
+ * the global registry
+ */
+ public LoadingIndicatorStateHandler(Registry registry) {
+ this.registry = registry;
+ }
+
+ /**
+ * Updates the connection state to {@link ConnectionIndicator#LOADING} when
+ * a non-silent request starts.
+ */
+ public void startLoading() {
+ if (!showLoading) {
+ // The next request is muted, do not show loading.
+ return;
+ }
+
+ update();
+ }
+
+ /**
+ * Updates the connection state to {@link ConnectionIndicator#CONNECTED}
+ * when active requests finish.
+ */
+ public void stopLoading() {
+ if (registry.getRequestResponseTracker().hasActiveRequest()) {
+ // Some request is in progress, skip the current stop.
+ return;
+ }
+
+ // Reset the loading state
+ showLoading = false;
+
+ // Debounce the update to avoid hiding loading when a follow-up
+ // request is started or scheduled right away.
+ Scheduler.get().scheduleDeferred(this::update);
+ }
+
+ /**
+ * Processes an RPC message to determine if a loading indicator should be
+ * displayed.
+ *
+ * @param rpcType
+ * the type of RPC request being processed
+ * @param eventType
+ * for event RPC requests, the name of the event, otherwise
+ * {@code null}
+ */
+ public void processMessage(String rpcType, String eventType) {
+ // Require at least one non-silent message to indicate loading for
+ // the next request.
+ boolean silent = JsonConstants.RPC_TYPE_EVENT.equals(rpcType)
+ && eventType != null && SILENT_EVENT_TYPES.has(eventType);
+ if (!silent) {
+ showLoading = true;
+ }
+ }
+
+ /**
+ * Applies the loading state change after a dirty check.
+ */
+ private void update() {
+ if (showLoading == loading) {
+ return;
+ }
+
+ loading = showLoading;
+ // Setting the loading state directly using
+ // `ConnectionIndicator.setState()` interferes with other loading
+ // parties
+ // (Flow router, Hilla requests), therefore `.loadingStarted()` /
+ // `.loadingFinished()` are preferred.
+ if (loading) {
+ ConnectionIndicator.loadingStarted();
+ } else {
+ ConnectionIndicator.loadingFinished();
+ }
+ }
+}
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/MessageHandler.java b/flow-client/src/main/java/com/vaadin/client/communication/MessageHandler.java
index ea79c6b8d79..cdb16e17c42 100644
--- a/flow-client/src/main/java/com/vaadin/client/communication/MessageHandler.java
+++ b/flow-client/src/main/java/com/vaadin/client/communication/MessageHandler.java
@@ -592,6 +592,7 @@ private void endRequestIfResponse(ValueMap json) {
// End the request if the received message was a
// response, not sent asynchronously
registry.getRequestResponseTracker().endRequest();
+ registry.getLoadingIndicatorStateHandler().stopLoading();
}
}
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/MessageSender.java b/flow-client/src/main/java/com/vaadin/client/communication/MessageSender.java
index 9c695c72002..0a800fbd672 100644
--- a/flow-client/src/main/java/com/vaadin/client/communication/MessageSender.java
+++ b/flow-client/src/main/java/com/vaadin/client/communication/MessageSender.java
@@ -21,7 +21,6 @@
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.Timer;
-import com.vaadin.client.ConnectionIndicator;
import com.vaadin.client.Console;
import com.vaadin.client.Registry;
import com.vaadin.flow.shared.ApplicationConstants;
@@ -137,7 +136,6 @@ private void doSendInvocationsToServer() {
+ pushPendingMessage.toJson());
JsonObject payload = pushPendingMessage;
pushPendingMessage = null;
- registry.getRequestResponseTracker().startRequest();
sendPayload(payload);
return;
} else if (hasQueuedMessages()) {
@@ -156,7 +154,6 @@ private void doSendInvocationsToServer() {
return;
}
- boolean showLoadingIndicator = serverRpcQueue.showLoadingIndicator();
JsonArray reqJson = serverRpcQueue.toJson();
serverRpcQueue.clear();
@@ -177,9 +174,7 @@ private void doSendInvocationsToServer() {
resetTimer();
extraJson.put(ApplicationConstants.RESYNCHRONIZE_ID, true);
}
- if (showLoadingIndicator) {
- ConnectionIndicator.setState(ConnectionIndicator.LOADING);
- }
+ registry.getLoadingIndicatorStateHandler().startLoading();
send(reqJson, extraJson);
}
@@ -193,7 +188,6 @@ private void doSendInvocationsToServer() {
*/
protected void send(final JsonArray reqInvocations,
final JsonObject extraJson) {
- registry.getRequestResponseTracker().startRequest();
send(preparePayload(reqInvocations, extraJson));
}
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/RequestResponseTracker.java b/flow-client/src/main/java/com/vaadin/client/communication/RequestResponseTracker.java
index 180e3246497..1fa0805e8ff 100644
--- a/flow-client/src/main/java/com/vaadin/client/communication/RequestResponseTracker.java
+++ b/flow-client/src/main/java/com/vaadin/client/communication/RequestResponseTracker.java
@@ -19,7 +19,6 @@
import com.google.web.bindery.event.shared.EventBus;
import com.google.web.bindery.event.shared.HandlerRegistration;
-import com.vaadin.client.ConnectionIndicator;
import com.vaadin.client.Registry;
import com.vaadin.client.communication.MessageSender.ResynchronizationState;
import com.vaadin.client.gwt.com.google.web.bindery.event.shared.SimpleEventBus;
@@ -120,13 +119,6 @@ public void endRequest() {
registry.getMessageSender().sendInvocationsToServer();
}
- // Always reset loading indicator when request ends.
- // Client-side component will handle timing for each request
- // independently.
- // This ensures rapid successive requests get individual timing instead
- // of accumulating time across requests.
- ConnectionIndicator.setState(ConnectionIndicator.CONNECTED);
-
fireEvent(new ResponseHandlingEndedEvent());
}
diff --git a/flow-client/src/main/java/com/vaadin/client/communication/ServerConnector.java b/flow-client/src/main/java/com/vaadin/client/communication/ServerConnector.java
index c0a887f97c9..0e92bf79681 100644
--- a/flow-client/src/main/java/com/vaadin/client/communication/ServerConnector.java
+++ b/flow-client/src/main/java/com/vaadin/client/communication/ServerConnector.java
@@ -244,6 +244,9 @@ public void sendReturnChannelMessage(int stateNodeId, int channelId,
}
private void sendMessage(JsonObject message) {
+ registry.getLoadingIndicatorStateHandler().processMessage(
+ message.getString(JsonConstants.RPC_TYPE),
+ message.getString(JsonConstants.RPC_EVENT_TYPE));
ServerRpcQueue rpcQueue = registry.getServerRpcQueue();
rpcQueue.add(message);
rpcQueue.flush();
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/ClientEngineTestBase.java b/flow-client/src/test-gwt/java/com/vaadin/client/ClientEngineTestBase.java
index 486bfb62267..7990ba36362 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/ClientEngineTestBase.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/ClientEngineTestBase.java
@@ -8,6 +8,40 @@
* from being run as a regular JVM unit test.
*/
public abstract class ClientEngineTestBase extends GWTTestCase {
+ protected static native void createDummyConnectionState()
+ /*-{
+ if (!$wnd.Vaadin) {
+ $wnd.Vaadin = {};
+ }
+ if (!$wnd.Vaadin.connectionState) {
+ $wnd.Vaadin.connectionState = {
+ state: 'connected',
+ requestCount: 0,
+ setState: function(state) {
+ this.state = state;
+ },
+ loadingStarted: function() {
+ this.state = 'loading';
+ this.requestCount++;
+ },
+ loadingFinished: function() {
+ if (this.requestCount == 0) { return; }
+ this.requestCount--;
+ if (this.requestCount == 0) { this.state = 'connected'; }
+ },
+ loadingFailed: function() {
+ if (this.requestCount == 0) { return; }
+ this.requestCount--;
+ if (this.requestCount == 0) { this.state = 'connection-lost'; }
+ }
+ };
+ } else {
+ // reset to initial state
+ $wnd.Vaadin.connectionState.setState('connected');
+ $wnd.Vaadin.connectionState.requestCount = 0;
+ }
+ }-*/;
+
@Override
protected void gwtSetUp() throws Exception {
installPolyfills();
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/GwtApplicationConnectionTest.java b/flow-client/src/test-gwt/java/com/vaadin/client/GwtApplicationConnectionTest.java
index dfb6b11689e..f64bf2efb59 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/GwtApplicationConnectionTest.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/GwtApplicationConnectionTest.java
@@ -30,6 +30,7 @@ public class GwtApplicationConnectionTest extends ClientEngineTestBase {
public void test_should_not_addNavigationEvents_forWebComponents() {
mockFlowBootstrapScript(true);
+ createDummyConnectionState();
JsonObject windowEvents = Json.createObject();
addEventsObserver(Browser.getWindow(), windowEvents);
@@ -86,11 +87,6 @@ private native void mockFlowBootstrapScript(boolean webComponentMode) /*-{
}
}
},
- connectionState: {
- setState: function(state) {
- // NOP
- }
- }
};
}-*/;
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/GwtMessageHandlerTest.java b/flow-client/src/test-gwt/java/com/vaadin/client/GwtMessageHandlerTest.java
index 3e08c86073d..e128ed79bf5 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/GwtMessageHandlerTest.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/GwtMessageHandlerTest.java
@@ -22,6 +22,7 @@
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.user.client.Timer;
+import com.vaadin.client.communication.LoadingIndicatorStateHandler;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.communication.MessageSender;
import com.vaadin.client.communication.RequestResponseTracker;
@@ -194,6 +195,7 @@ protected void gwtSetUp() throws Exception {
set(ExecuteJavaScriptProcessor.class,
new TestExecuteJavaScriptProcessor(this));
set(UILifecycle.class, new TestUILifecycle());
+ set(LoadingIndicatorStateHandler.class, new LoadingIndicatorStateHandler(this));
}
};
handler = new TestMessageHandler(registry);
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/GwtSuite.java b/flow-client/src/test-gwt/java/com/vaadin/client/GwtSuite.java
index e28d5bb3f0a..eb601132421 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/GwtSuite.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/GwtSuite.java
@@ -7,6 +7,7 @@
import com.vaadin.client.communication.GwtAtmospherePushConnectionTest;
import com.vaadin.client.communication.GwtDefaultConnectionStateHandlerTest;
+import com.vaadin.client.communication.GwtLoadingIndicatorStateHandlerTest;
import com.vaadin.client.flow.GwtBasicElementBinderTest;
import com.vaadin.client.flow.GwtErrotHandlerTest;
import com.vaadin.client.flow.GwtEventHandlerTest;
@@ -52,6 +53,7 @@ public static Test suite() {
suite.addTestSuite(GwtMessageHandlerTest.class);
suite.addTestSuite(GwtMultipleBindingTest.class);
suite.addTestSuite(GwtDefaultConnectionStateHandlerTest.class);
+ suite.addTestSuite(GwtLoadingIndicatorStateHandlerTest.class);
suite.addTestSuite(GwtErrotHandlerTest.class);
suite.addTestSuite(GwtAtmospherePushConnectionTest.class);
suite.addTestSuite(GwtClientJsonCodecTest.class);
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtDefaultConnectionStateHandlerTest.java b/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtDefaultConnectionStateHandlerTest.java
index 4522615ba37..4ddc576762e 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtDefaultConnectionStateHandlerTest.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtDefaultConnectionStateHandlerTest.java
@@ -40,6 +40,7 @@ protected void gwtSetUp() throws Exception {
new RequestResponseTracker(this));
set(ConnectionStateHandler.class,
handler = new DefaultConnectionStateHandler(this));
+ set(LoadingIndicatorStateHandler.class, new LoadingIndicatorStateHandler(this));
}
};
}
@@ -107,13 +108,4 @@ private static native Event createEvent(String type)
return new Event(type);
}-*/;
- private static native void createDummyConnectionState()
- /*-{
- if (!$wnd.Vaadin) {
- $wnd.Vaadin = {};
- }
- if (!$wnd.Vaadin.connectionState) {
- $wnd.Vaadin.connectionState = { state: 'connected' };
- }
- }-*/;
}
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtLoadingIndicatorStateHandlerTest.java b/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtLoadingIndicatorStateHandlerTest.java
new file mode 100644
index 00000000000..c17b13d2073
--- /dev/null
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/communication/GwtLoadingIndicatorStateHandlerTest.java
@@ -0,0 +1,227 @@
+package com.vaadin.client.communication;
+
+import com.google.gwt.core.client.impl.SchedulerImpl;
+import com.vaadin.client.ApplicationConfiguration;
+import com.vaadin.client.ClientEngineTestBase;
+import com.vaadin.client.ConnectionIndicator;
+import com.vaadin.client.CustomScheduler;
+import com.vaadin.client.DependencyLoader;
+import com.vaadin.client.Registry;
+import com.vaadin.client.ResourceLoader;
+import com.vaadin.client.UILifecycle;
+import com.vaadin.client.URIResolver;
+import com.vaadin.client.ValueMap;
+import com.vaadin.client.flow.StateNode;
+import com.vaadin.client.flow.StateTree;
+import com.vaadin.client.flow.binding.Binder;
+import com.vaadin.client.flow.collection.JsCollections;
+import com.vaadin.client.flow.collection.JsMap;
+import com.vaadin.flow.internal.nodefeature.NodeFeatures;
+import com.vaadin.flow.internal.nodefeature.ReconnectDialogConfigurationMap;
+import com.vaadin.flow.shared.JsonConstants;
+import elemental.client.Browser;
+import elemental.dom.Element;
+import elemental.json.Json;
+import elemental.json.JsonObject;
+
+public class GwtLoadingIndicatorStateHandlerTest extends ClientEngineTestBase {
+
+ private LoadingIndicatorStateHandler handler;
+ private TestMessageHandler messageHandler;
+ private StateTree stateTree;
+ private StateNode stateNode;
+
+ private static class TestMessageSender extends MessageSender {
+ private final Registry registry;
+
+ public TestMessageSender(Registry registry) {
+ super(registry);
+ this.registry = registry;
+ }
+
+ @Override
+ public void send(final JsonObject payload) {
+ if (!registry.getRequestResponseTracker().hasActiveRequest()) {
+ registry.getRequestResponseTracker().startRequest();
+ }
+ }
+ }
+
+ private static class TestMessageHandler extends MessageHandler {
+ public TestMessageHandler(Registry registry) {
+ super(registry);
+ }
+
+ public void simulateResponse() {
+ handleJSON(createJSONResponse(getLastSeenServerSyncId() + 1));
+ }
+ }
+
+ @Override
+ protected void gwtSetUp() throws Exception {
+ super.gwtSetUp();
+ // Minimum setup for simulating RPC request and response
+ createDummyConnectionState();
+ setUpAtmosphere();
+ initScheduler(new CustomScheduler()); // enforces sync debounce
+
+ new Registry() {
+ {
+ UILifecycle uiLifecycle = new UILifecycle();
+ uiLifecycle.setState(UILifecycle.UIState.RUNNING);
+ set(UILifecycle.class, uiLifecycle);
+ set(ApplicationConfiguration.class, new ApplicationConfiguration() {{
+ setServiceUrl("");
+ setContextRootUrl("/");
+ }});
+ set(StateTree.class, stateTree = new StateTree(this) {{
+ getRootNode().getMap(NodeFeatures.RECONNECT_DIALOG_CONFIGURATION)
+ .getProperty(ReconnectDialogConfigurationMap.RECONNECT_ATTEMPTS_KEY).setValue((double)3);
+ // keep the timer from interfering with the test:
+ getRootNode().getMap(NodeFeatures.RECONNECT_DIALOG_CONFIGURATION)
+ .getProperty(ReconnectDialogConfigurationMap.RECONNECT_INTERVAL_KEY).setValue((double)10000000);
+ }});
+ set(RequestResponseTracker.class,
+ new RequestResponseTracker(this));
+ set(ConnectionStateHandler.class,
+ new DefaultConnectionStateHandler(this));
+ set(LoadingIndicatorStateHandler.class, handler = new LoadingIndicatorStateHandler(this));
+ set(DependencyLoader.class, new DependencyLoader(this));
+ set(ResourceLoader.class, new ResourceLoader(this, false));
+ set(ServerConnector.class, new ServerConnector(this));
+ set(ServerRpcQueue.class, new ServerRpcQueue(this));
+ set(URIResolver.class, new URIResolver(this));
+ set(MessageSender.class, new TestMessageSender(this));
+ set(MessageHandler.class, messageHandler = new TestMessageHandler(this));
+ set(PushConfiguration.class, new PushConfiguration(this) {
+ @Override
+ public boolean isPushEnabled() {
+ return false;
+ }
+
+ @Override
+ public JsMap getParameters() {
+ return JsCollections.map();
+ }
+ });
+ set(AtmospherePushConnection.class, new AtmospherePushConnection(this));
+ }
+ };
+
+ // Create a valid element node to reference in event RPC messages
+ stateNode = new StateNode(0, stateTree);
+ stateTree.registerNode(stateNode);
+ stateNode.getMap(NodeFeatures.ELEMENT_DATA);
+ final Element mainElement = Browser.getDocument().createElement("main");
+ Binder.bind(stateNode, mainElement);
+ }
+
+ public void test_default_loadingMuted() {
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+
+ handler.startLoading();
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+
+ handler.stopLoading();
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+
+ public void test_navigationFlow_loadingVisible() {
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+
+ handler.processMessage(JsonConstants.RPC_TYPE_NAVIGATION, null);
+ handler.startLoading();
+
+ assertEquals(1, getRequestCount());
+ assertEquals(ConnectionIndicator.LOADING, ConnectionIndicator.getState());
+
+ handler.stopLoading();
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+
+ public void test_regularUiEventFlow_loadingVisible() {
+ final String[] regularEvents = new String[] { "click", "change", "submit" };
+ for (String event : regularEvents) {
+ handler.processMessage(JsonConstants.RPC_TYPE_EVENT, "click");
+ handler.startLoading();
+
+ assertEquals(ConnectionIndicator.LOADING, ConnectionIndicator.getState());
+
+ handler.stopLoading();
+
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+ }
+
+ public void test_mutedUiEventFlow_loadingMuted() {
+ final String[] mutedEvents = new String[] { "mousemove", "touchmove",
+ "drag", "keydown", "keyup", "keypress", "wheel", "scroll", "input" };
+ for (String event : mutedEvents) {
+ handler.processMessage(JsonConstants.RPC_TYPE_EVENT, event);
+ handler.startLoading();
+
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+
+ handler.stopLoading();
+
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+ }
+
+ public void test_clickEventRpc_loadingVisible() {
+ stateTree.sendEventToServer(stateNode, "click", Json.createObject());
+
+ assertEquals(1, getRequestCount());
+ assertEquals(ConnectionIndicator.LOADING, ConnectionIndicator.getState());
+
+ messageHandler.simulateResponse();
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+
+ public void test_mousemoveEventRpc_loadingMuted() {
+ stateTree.sendEventToServer(stateNode, "mousemove", Json.createObject());
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+
+ messageHandler.simulateResponse();
+
+ assertEquals(0, getRequestCount());
+ assertEquals(ConnectionIndicator.CONNECTED, ConnectionIndicator.getState());
+ }
+
+ private static native int getRequestCount()
+ /*-{
+ return $wnd.Vaadin.connectionState.requestCount;
+ }-*/;
+
+ private native void setUpAtmosphere()/*-{
+ $wnd.vaadinPush={};
+ $wnd.vaadinPush.atmosphere ={};
+ $wnd.vaadinPush.atmosphere.subscribe = function(config){
+ $wnd.subscribeUrl = config.url;
+ };
+ $wnd.vaadinPush.atmosphere.unsubscribeUrl = function(uri){
+ $wnd.unsubscribeUri = uri;
+ };
+ }-*/;
+
+ private native void initScheduler(SchedulerImpl scheduler)
+ /*-{
+ @com.google.gwt.core.client.impl.SchedulerImpl::INSTANCE = scheduler;
+ }-*/;
+
+ private static native ValueMap createJSONResponse(int serverId)
+ /*-{
+ return { "serverId": serverId };
+ }-*/;
+}
diff --git a/flow-client/src/test-gwt/java/com/vaadin/client/flow/dom/GwtDomApiTest.java b/flow-client/src/test-gwt/java/com/vaadin/client/flow/dom/GwtDomApiTest.java
index 349d7afb61b..9a2ce358c7a 100644
--- a/flow-client/src/test-gwt/java/com/vaadin/client/flow/dom/GwtDomApiTest.java
+++ b/flow-client/src/test-gwt/java/com/vaadin/client/flow/dom/GwtDomApiTest.java
@@ -9,6 +9,7 @@
import com.vaadin.client.UILifecycle;
import com.vaadin.client.UILifecycle.UIState;
import com.vaadin.client.ValueMap;
+import com.vaadin.client.communication.LoadingIndicatorStateHandler;
import com.vaadin.client.communication.MessageHandler;
import com.vaadin.client.communication.MessageSender;
import com.vaadin.client.communication.RequestResponseTracker;
@@ -34,6 +35,7 @@ protected void gwtSetUp() throws Exception {
set(ServerRpcQueue.class, new ServerRpcQueue(this));
set(DependencyLoader.class, new DependencyLoader(this));
set(ResourceLoader.class, new ResourceLoader(this, false));
+ set(LoadingIndicatorStateHandler.class, new LoadingIndicatorStateHandler(this));
}
};
diff --git a/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationFastTargetView.java b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationFastTargetView.java
new file mode 100644
index 00000000000..bbcee4fe9a8
--- /dev/null
+++ b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationFastTargetView.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.navigate;
+
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.NativeButton;
+import com.vaadin.flow.component.html.NativeLabel;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+@Route(value = "loading-indicator-navigation-fast-target")
+@PageTitle("Loading Indicator Navigation Fast Target")
+public class LoadingIndicatorNavigationFastTargetView extends Div {
+
+ public static final String TARGET_LABEL_ID = "target-label";
+ public static final String DATA_LABEL_ID = "data-label";
+
+ private final NativeLabel dataLabel;
+
+ public LoadingIndicatorNavigationFastTargetView() {
+ final NativeLabel label = new NativeLabel(
+ "Navigation fast target reached");
+ label.setId(TARGET_LABEL_ID);
+ add(label);
+
+ add(" ");
+
+ dataLabel = new NativeLabel("data loaded");
+ label.setId(DATA_LABEL_ID);
+ // Skip adding now - to be added later in the `loadData()` method
+
+ final NativeButton loadData = new NativeButton("Auto-load data");
+ loadData.addClickListener(event -> loadData());
+ add(loadData);
+ add(" ");
+
+ // Simulate a follow-up data request
+ loadData.getElement().callJsFunction("click");
+ }
+
+ public void loadData() {
+ add(dataLabel);
+ }
+}
diff --git a/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationSlowTargetView.java b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationSlowTargetView.java
new file mode 100644
index 00000000000..838ca311bf8
--- /dev/null
+++ b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationSlowTargetView.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.navigate;
+
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.NativeButton;
+import com.vaadin.flow.component.html.NativeLabel;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+@Route(value = "loading-indicator-navigation-slow-target")
+@PageTitle("Loading Indicator Navigation Slow Target")
+public class LoadingIndicatorNavigationSlowTargetView extends Div {
+
+ public static final String TARGET_LABEL_ID = "target-label";
+ public static final String DATA_LABEL_ID = "data-label";
+
+ private final NativeLabel dataLabel;
+
+ public LoadingIndicatorNavigationSlowTargetView() {
+ final NativeLabel label = new NativeLabel(
+ "Navigation slow target reached");
+ label.setId(TARGET_LABEL_ID);
+ add(label);
+
+ add(" ");
+
+ dataLabel = new NativeLabel("data loaded");
+ label.setId(DATA_LABEL_ID);
+ // Skip adding now - to be added later in the `loadData()` method
+
+ final NativeButton loadData = new NativeButton("Auto-load data");
+ loadData.addClickListener(event -> loadData());
+ add(loadData);
+ add(" ");
+
+ // Simulate a follow-up data request
+ loadData.getElement().callJsFunction("click");
+ }
+
+ public void loadData() {
+ try {
+ // Server-side slowdown to simulate slow view data loading
+ Thread.sleep(1000); // NOSONAR intentional: test scenario
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ add(dataLabel);
+ }
+
+}
diff --git a/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationView.java b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationView.java
new file mode 100644
index 00000000000..31c0cd46018
--- /dev/null
+++ b/flow-tests/test-ccdm-flow-navigation/src/main/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationView.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.navigate;
+
+import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.NativeButton;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+@Route(value = "loading-indicator-navigation")
+@PageTitle("Loading Indicator Navigation Tests")
+public class LoadingIndicatorNavigationView extends Div {
+
+ public static final String SLOW_NAVIGATE_BUTTON_ID = "slow-navigate-button";
+ public static final String FAST_NAVIGATE_BUTTON_ID = "fast-navigate-button";
+ public static final String SOURCE_LABEL_ID = "source-label";
+
+ public LoadingIndicatorNavigationView() {
+ Paragraph sourceLabel = new Paragraph("Source view");
+ sourceLabel.setId(SOURCE_LABEL_ID);
+ add(sourceLabel);
+
+ // Button that does server-side slow work before navigating
+ NativeButton slowNavigateButton = new NativeButton("Navigate to slow view",
+ event -> {
+ UI.getCurrent().navigate(
+ LoadingIndicatorNavigationSlowTargetView.class);
+ });
+ slowNavigateButton.setId(SLOW_NAVIGATE_BUTTON_ID);
+ add(slowNavigateButton);
+
+ // Fast navigation button (no delay) for baseline testing
+ NativeButton fastNavigateButton = new NativeButton("Navigate to fast view",
+ event -> UI.getCurrent()
+ .navigate(LoadingIndicatorNavigationFastTargetView.class));
+ fastNavigateButton.setId(FAST_NAVIGATE_BUTTON_ID);
+ add(fastNavigateButton);
+ }
+}
diff --git a/flow-tests/test-ccdm-flow-navigation/src/test/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationIT.java b/flow-tests/test-ccdm-flow-navigation/src/test/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationIT.java
new file mode 100644
index 00000000000..2ca560cd3a9
--- /dev/null
+++ b/flow-tests/test-ccdm-flow-navigation/src/test/java/com/vaadin/flow/navigate/LoadingIndicatorNavigationIT.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.navigate;
+
+import net.jcip.annotations.NotThreadSafe;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.openqa.selenium.By;
+
+import com.vaadin.flow.testutil.ChromeBrowserTest;
+
+@NotThreadSafe
+public class LoadingIndicatorNavigationIT extends ChromeBrowserTest {
+
+ @Before
+ @Override
+ public void setup() throws Exception {
+ super.setup();
+ getDriver().get(getRootURL() + "/loading-indicator-navigation");
+ waitForDevServer();
+ }
+
+ @Test
+ public void slowNavigationShowsLoadingThroughout() {
+ expectConnectionState("connected");
+
+ // Navigate to slow target view
+ findElement(
+ By.id(LoadingIndicatorNavigationView.SLOW_NAVIGATE_BUTTON_ID))
+ .click();
+
+ expectConnectionState("loading");
+
+ // Wait for navigation to complete
+ waitUntil(driver -> isElementPresent(By
+ .id(LoadingIndicatorNavigationSlowTargetView.TARGET_LABEL_ID)));
+
+ // State should be "loading" from the follow-up data loading request
+ expectConnectionState("loading");
+ }
+
+ @Test
+ public void fastNavigationHandledCorrectly() {
+ expectConnectionState("connected");
+
+ findElement(
+ By.id(LoadingIndicatorNavigationView.FAST_NAVIGATE_BUTTON_ID))
+ .click();
+
+ // Wait for navigation and data loading follow-up request
+ waitUntil(driver -> isElementPresent(By
+ .id(LoadingIndicatorNavigationFastTargetView.DATA_LABEL_ID)));
+
+ // State should be "connected" after fast navigation
+ expectConnectionState("connected");
+ }
+
+ /**
+ * Checks that the connection state matches the expected value by polling
+ * the JavaScript window.Vaadin.connectionState.state property.
+ */
+ private void expectConnectionState(String state) {
+ Assert.assertEquals(state, executeScript(
+ "return window.Vaadin.connectionState.state;"));
+ }
+
+ @Override
+ protected String getRootURL() {
+ return super.getRootURL() + "/context-path";
+ }
+}