Repository: vaadin/flow
Issue: app does not start when using V15 alpha7 and flow.serverSideRoute

Steps to reproduce:
 - check out the `ccdm` branch of the https://github.com/vaadin/bookstore-example/ repo (currently uses Vaadin 15.0.0.alpha7)
 - install `@vaadin/router`: `npm install --save @vaadin/router`
 - add a custom `index.ts`
   ```typescript
    import {Router} from '@vaadin/router';
    import {Flow} from '@vaadin/flow-frontend/Flow';
    
    const router = new Router(document.body);
    const {serverSideRoutes} = new Flow({
      imports: () => import('../target/frontend/generated-flow-imports')
    });
    
    router.setRoutes([
      // pass all unmatched paths to server-side
      ...serverSideRoutes
    ]);
   ```
 - start the app with `mvn jetty:run`
 - open the app in the browser
 - see that the page stays blank and there is a constant stream of new requests to `http://localhost:8080/?v-r=uidl&v-uiId=0` in the DevTools' network tab.

Changed files:
flow-server/src/main/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUI.java
flow-server/src/test/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUITest.java

diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUI.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUI.java
index 8e763329a5..95e140a59d 100644
--- a/flow-server/src/main/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUI.java
+++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUI.java
@@ -50,6 +50,11 @@ import com.vaadin.flow.theme.ThemeDefinition;
 public class JavaScriptBootstrapUI extends UI {
     public static final String SERVER_ROUTING = "clientRoutingMode";
 
+    static final String CLIENT_PUSHSTATE_TO =
+            "window.history.pushState(null, '', $0)";
+    static final String CLIENT_NAVIGATE_TO =
+            "window.dispatchEvent(new CustomEvent('vaadin-router-go', {detail: new URL($0, document.baseURI)}))";
+
     Element wrapperElement;
     private NavigationState clientViewNavigationState;
 
@@ -165,21 +170,27 @@ public class JavaScriptBootstrapUI extends UI {
             getInternals().clearLastHandledNavigation();
         }
         return false;
-
     }
 
     private boolean shouldHandleNavigation(Location location) {
-        if (getInternals().hasLastHandledLocation()) {
-            return !location.getPathWithQueryParameters().equals(getInternals()
-                    .getLastHandledLocation().getPathWithQueryParameters());
-        }
-        return true;
+        return !getInternals().hasLastHandledLocation() || !sameLocation(
+                getInternals().getLastHandledLocation(), location);
+    }
+
+    private boolean sameLocation(Location oldLocation, Location newLocation) {
+        return removeLastSlash(newLocation.getPathWithQueryParameters())
+                .equals(removeLastSlash(
+                        oldLocation.getPathWithQueryParameters()));
     }
 
     private String removeFirstSlash(String route) {
         return route.replaceFirst("^/+", "");
     }
 
+    private String removeLastSlash(String route) {
+        return route.replaceFirst("/+$", "");
+    }
+
     private boolean handleNavigation(Location location,
             NavigationState navigationState) {
         NavigationEvent navigationEvent = new NavigationEvent(getRouter(),
@@ -225,12 +236,20 @@ public class JavaScriptBootstrapUI extends UI {
             // server-side routing
             renderViewForRoute(location);
         } else {
-            // client-side routing
-            getPage().executeJs(
-                    "window.dispatchEvent(new CustomEvent('vaadin-router-go',"
-                            + " {detail: new URL($0, document.baseURI)}))",
-                    location.getPathWithQueryParameters()
-            );
+            String execJs;
+            if (getInternals().hasLastHandledLocation()) {
+                // There is an in-progress navigation
+                if (sameLocation(getInternals().getLastHandledLocation(), location)) {
+                    // There are no changes, prevent looping
+                    return;
+                }
+                // Update browser URL but do not fire client-side navigation
+                execJs = CLIENT_PUSHSTATE_TO;
+            } else {
+                // There is navigation, let client-side to handle it
+                execJs = CLIENT_NAVIGATE_TO;
+            }
+            getPage().executeJs(execJs, location.getPathWithQueryParameters());
         }
     }
 
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUITest.java b/flow-server/src/test/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUITest.java
index d45bca3b52..1cd5f9dba8 100644
--- a/flow-server/src/test/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUITest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/component/internal/JavaScriptBootstrapUITest.java
@@ -5,20 +5,24 @@ import java.util.stream.Collectors;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
+import org.mockito.Spy;
 
 import com.vaadin.flow.component.Component;
 import com.vaadin.flow.component.HasComponents;
 import com.vaadin.flow.component.Tag;
 import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.page.Page;
 import com.vaadin.flow.internal.CurrentInstance;
 import com.vaadin.flow.router.BeforeLeaveEvent;
 import com.vaadin.flow.router.BeforeLeaveObserver;
+import com.vaadin.flow.router.Location;
 import com.vaadin.flow.router.Route;
 import com.vaadin.flow.server.MockServletServiceSessionSetup;
 import com.vaadin.flow.server.VaadinRequest;
 
-import static com.vaadin.flow.component.internal.JavaScriptBootstrapUI.SERVER_ROUTING;
+import static com.vaadin.flow.component.internal.JavaScriptBootstrapUI.*;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
@@ -78,6 +82,10 @@ public class JavaScriptBootstrapUITest  {
                 ProductView.class, Collections.emptyList());
         ui = new JavaScriptBootstrapUI();
         ui.getInternals().setSession(mocks.getSession());
+
+        Mockito.when(mocks.getSession().getAttribute(SERVER_ROUTING))
+                .thenReturn(Boolean.FALSE);
+
         CurrentInstance.setCurrent(ui);
     }
 
@@ -191,16 +199,69 @@ public class JavaScriptBootstrapUITest  {
                 ui.getInternals().hasLastHandledLocation());
     }
 
+    @Test
+    public void should_invoke_clientRoute_when_navigationHasNotBeenStarted() {
+        ui = Mockito.spy(ui);
+        Page page = Mockito.mock(Page.class);
+
+        Mockito.when(ui.getPage()).thenReturn(page);
+
+        ArgumentCaptor<String> execJs = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> execArg = ArgumentCaptor.forClass(String.class);
+
+        ui.navigate("whatever");
+        Mockito.verify(page).executeJs(execJs.capture(), execArg.capture());
+
+        assertEquals(CLIENT_NAVIGATE_TO, execJs.getValue());
+        assertEquals("whatever", execArg.getValue());
+    }
+
+    @Test
+    public void should_update_pushState_when_navigationHasBeenAlreadyStarted() {
+        ui = Mockito.spy(ui);
+        Page page = Mockito.mock(Page.class);
+        UIInternals internals = Mockito.mock(UIInternals.class);
+
+        Mockito.when(ui.getPage()).thenReturn(page);
+        Mockito.when(ui.getInternals()).thenReturn(internals);
+
+        Mockito.when(internals.hasLastHandledLocation()).thenReturn(true);
+        Location lastLocation = new Location("clean");
+        Mockito.when(internals.getLastHandledLocation()).thenReturn(lastLocation);
+
+        ArgumentCaptor<String> execJs = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> execArg = ArgumentCaptor.forClass(String.class);
+
+        ui.navigate("clean/1");
+        Mockito.verify(page).executeJs(execJs.capture(), execArg.capture());
+
+        assertEquals(CLIENT_PUSHSTATE_TO, execJs.getValue());
+        assertEquals("clean/1", execArg.getValue());
+    }
+
+    @Test
+    public void should_not_notify_clientRoute_when_navigatingToTheSame() {
+        ui = Mockito.spy(ui);
+        Page page = Mockito.mock(Page.class);
+        UIInternals internals = Mockito.mock(UIInternals.class);
+
+        Mockito.when(ui.getPage()).thenReturn(page);
+        Mockito.when(ui.getInternals()).thenReturn(internals);
+
+        Mockito.when(internals.hasLastHandledLocation()).thenReturn(true);
+        Location lastLocation = new Location("clean");
+        Mockito.when(internals.getLastHandledLocation()).thenReturn(lastLocation);
+
+        ui.navigate("clean/");
+        Mockito.verify(page, Mockito.never()).executeJs(Mockito.anyString(), Mockito.anyString());
+    }
+
     @Test
     public void should_not_navigate_when_client_routing() {
         ui.connectClient("foo", "bar", "/clean");
         assertEquals(Tag.HEADER, ui.wrapperElement.getChild(0).getTag());
         assertEquals(Tag.H2, ui.wrapperElement.getChild(0).getChild(0).getTag());
 
-
-        Mockito.when(mocks.getSession().getAttribute(SERVER_ROUTING))
-                .thenReturn(Boolean.FALSE);
-
         // Dirty view is allowed after clean view
         ui.navigate("dirty");
         assertEquals(Tag.HEADER, ui.wrapperElement.getChild(0).getTag());