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"; + } +}