Skip to content

Commit

Permalink
fix: Ensure navigation events are delivered when both exported web co…
Browse files Browse the repository at this point in the history
…mponents and a normal UI is used (#18570)

This fixes the issue where if you have a normal UI and exported web components in the same application, navigation events would sometimes be sent to the web components UI and not the normal UI

Part of #18401
  • Loading branch information
Artur- committed Feb 1, 2024
1 parent e11bce7 commit 97b0bc9
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 98 deletions.
31 changes: 20 additions & 11 deletions flow-client/src/main/frontend/Flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ const $wnd = window as any as {
listener: any;
};
} & EventTarget;
const ROOT_NODE_ID = 1; // See StateTree.java

function getClients() {
return Object.keys($wnd.Vaadin.Flow.clients)
.filter((key) => key !== 'TypeScript')
.map((id) => $wnd.Vaadin.Flow.clients[id]);
}

function sendEvent(eventName: string, data: any) {
getClients().forEach((client) => client.sendEventMessage(ROOT_NODE_ID, eventName, data));
}

/**
* Client API for flow UI operations.
Expand Down Expand Up @@ -220,7 +231,7 @@ export class Flow {
};

// Call server side to check whether we can leave the view
flowRoot.$server.leaveNavigation(this.getFlowRoutePath(ctx), this.getFlowRouteQuery(ctx));
sendEvent('ui-leave-navigation', { route: this.getFlowRoutePath(ctx), query: this.getFlowRouteQuery(ctx) });
});
}

Expand Down Expand Up @@ -248,13 +259,13 @@ export class Flow {
};

// Call server side to navigate to the given route
flowRoot.$server.connectClient(
this.getFlowRoutePath(ctx),
this.getFlowRouteQuery(ctx),
this.appShellTitle,
history.state,
this.navigation
);
sendEvent('ui-navigate', {
route: this.getFlowRoutePath(ctx),
query: this.getFlowRouteQuery(ctx),
appShellTitle: this.appShellTitle,
historyState: history.state,
trigger: this.navigation
});
// Default to history navigation trigger.
// Link and client cases are handled by click listener in loadingFinished().
this.navigation = 'history';
Expand Down Expand Up @@ -354,9 +365,7 @@ export class Flow {
return new Promise((resolve) => {
const intervalId = setInterval(() => {
// client `isActive() == true` while initializing or processing
const initializing = Object.keys($wnd.Vaadin.Flow.clients)
.filter((key) => key !== 'TypeScript')
.reduce((prev, id) => prev || $wnd.Vaadin.Flow.clients[id].isActive(), false);
const initializing = getClients().reduce((prev, client) => prev || client.isActive(), false);
if (!initializing) {
clearInterval(intervalId);
resolve();
Expand Down
34 changes: 23 additions & 11 deletions flow-client/src/test/frontend/FlowTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ describe('Flow', () => {
server.remove();
delete $wnd.Vaadin;
delete flowRoot.$;
if (flowRoot.$server) {
if (flowRoot.timers) {
// clear timers started in stubServerRemoteFunction
flowRoot.$server.timers.forEach(clearTimeout);
delete flowRoot.$server;
flowRoot.timers.forEach(clearTimeout);
delete flowRoot.timers;
}
listeners.forEach((recorded: any) => {
$wnd.removeEventListener(recorded.type, recorded.listener);
Expand Down Expand Up @@ -617,7 +617,7 @@ describe('Flow', () => {
});

it("when no Flow client loaded, should transition to CONNECTED when receiving 'offline' and then 'online' events and connection is reestablished", async () => {
server.addHandler("HEAD",/^.*sw.js/, (req) => {
server.addHandler('HEAD', /^.*sw.js/, (req) => {
req.respond(200);
});
new Flow();
Expand All @@ -631,7 +631,7 @@ describe('Flow', () => {
});

it("when no Flow client loaded, should transition to CONNECTION_LOST when receiving 'offline' and then 'online' events and connection is not reestablished", async () => {
server.addHandler("HEAD",/^.*sw.js/, (req) => {
server.addHandler('HEAD', /^.*sw.js/, (req) => {
req.setNetworkError();
});

Expand Down Expand Up @@ -703,10 +703,8 @@ function stubServerRemoteFunction(
) {
let container: any;

// Stub remote function exported in JavaScriptBootstrapUI.
flowRoot.$server = {
timers: [],

flowRoot.timers = [];
const handlers = {
connectClient: (route: string) => {
expect(route).not.to.be.undefined;
if (routeRegex) {
Expand All @@ -730,14 +728,28 @@ function stubServerRemoteFunction(
// container should be visible when not cancelled or not has redirect server-client
expect(cancel || url ? 'none' : '').to.equal(container.style.display);
}, 10);
flowRoot.$server.timers.push(timer);
flowRoot.timers.push(timer);
},
leaveNavigation: () => {
// asynchronously resolve the promise
const timer = setTimeout(() => container.serverConnected(cancel, url), 10);
flowRoot.$server.timers.push(timer);
flowRoot.timers.push(timer);
}
};

flowRoot.timers = [];
server.addHandler('POST', /^.*\?v-r=uidl.*/, (req) => {
const payload = JSON.parse(req.body);
if (payload.rpc && payload.rpc[0].type === 'event') {
const rpc = payload.rpc[0];
if (rpc.event === 'ui-navigate') {
handlers.connectClient(rpc.data.route);
} else if (rpc.event === 'ui-leave-navigation') {
handlers.leaveNavigation();
}
}
req.respond(200, { 'content-type': 'application/json' }, 'for(;;);[{}]');
});
}

function mockInitResponse(appId: string, changes = '[]', pushScript?: string, withCharset?: boolean) {
Expand Down
141 changes: 99 additions & 42 deletions flow-server/src/main/java/com/vaadin/flow/component/UI.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.vaadin.experimental.Feature;
import com.vaadin.experimental.FeatureFlags;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.internal.AllowInert;
import com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer;
Expand All @@ -56,7 +54,6 @@
import com.vaadin.flow.internal.nodefeature.ReconnectDialogConfigurationMap;
import com.vaadin.flow.router.AfterNavigationListener;
import com.vaadin.flow.router.BeforeEnterListener;
import com.vaadin.flow.router.BeforeLeaveEvent;
import com.vaadin.flow.router.BeforeLeaveListener;
import com.vaadin.flow.router.ErrorNavigationEvent;
import com.vaadin.flow.router.ErrorParameter;
Expand Down Expand Up @@ -264,13 +261,21 @@ public void doInit(VaadinRequest request, int uiId, String appId) {

getInternals().setFullAppId(appId);

// Create flow reference for the client outlet element
wrapperElement = new Element(getInternals().getContainerTag());
if (this.isNavigationSupported()) {
// Create flow reference for the client outlet element
wrapperElement = new Element(getInternals().getContainerTag());

// Connect server with client
getElement().getStateProvider().appendVirtualChild(
getElement().getNode(), wrapperElement,
NodeProperties.INJECT_BY_ID, appId);
// Connect server with client
getElement().getStateProvider().appendVirtualChild(
getElement().getNode(), wrapperElement,
NodeProperties.INJECT_BY_ID, appId);

getEventBus().addListener(BrowserLeaveNavigationEvent.class,
this::leaveNavigation);
getEventBus().addListener(BrowserNavigateEvent.class,
this::browserNavigate);

}

// Add any dependencies from the UI class
getInternals().addComponentDependencies(getClass());
Expand Down Expand Up @@ -1685,52 +1690,105 @@ public String getForwardToClientUrl() {
return forwardToClientUrl;
}

@DomEvent(BrowserLeaveNavigationEvent.EVENT_NAME)
public static class BrowserLeaveNavigationEvent extends ComponentEvent<UI> {
public static final String EVENT_NAME = "ui-leave-navigation";
private final String route;
private final String query;

/**
* Creates a new event instance.
*
* @param route
* the route the user is navigating to.
* @param query
* the query string the user is navigating to.
*/
public BrowserLeaveNavigationEvent(UI source, boolean fromClient,
@EventData("route") String route,
@EventData("query") String query) {
super(source, true);
this.route = route;
this.query = query;
}
}

@DomEvent(BrowserNavigateEvent.EVENT_NAME)
public static class BrowserNavigateEvent extends ComponentEvent<UI> {
public static final String EVENT_NAME = "ui-navigate";

private final String route;
private final String query;
private final String appShellTitle;
private final JsonValue historyState;
private final String trigger;

/**
* Creates a new event instance.
*
* @param route
* flow route path that should be attached to the client
* element
* @param query
* flow route query string
* @param appShellTitle
* client side title of the application shell
* @param historyState
* client side history state value
* @param trigger
* navigation trigger
*
*/
public BrowserNavigateEvent(UI source, boolean fromClient,
@EventData("route") String route,
@EventData("query") String query,
@EventData("appShellTitle") String appShellTitle,
@EventData("historyState") JsonValue historyState,
@EventData("trigger") String trigger) {
super(source, true);
this.route = route;
this.query = query;
this.appShellTitle = appShellTitle;
this.historyState = historyState;
this.trigger = trigger;
}

}

/**
* Connect a client with the server side UI. This method is invoked each
* time client router navigates to a server route.
*
* @param flowRoutePath
* flow route path that should be attached to the client element
* @param flowRouteQuery
* flow route query string
* @param appShellTitle
* client side title of the application shell
* @param historyState
* client side history state value
* @param trigger
* navigation trigger
*/
@ClientCallable
@AllowInert
public void connectClient(String flowRoutePath, String flowRouteQuery,
String appShellTitle, JsonValue historyState, String trigger) {

if (appShellTitle != null && !appShellTitle.isEmpty()) {
getInternals().setAppShellTitle(appShellTitle);
* @param event
* the event from the browser
*/
public void browserNavigate(BrowserNavigateEvent event) {

if (event.appShellTitle != null && !event.appShellTitle.isEmpty()) {
getInternals().setAppShellTitle(event.appShellTitle);
}

final String trimmedRoute = PathUtil.trimPath(flowRoutePath);
if (!trimmedRoute.equals(flowRoutePath)) {
final String trimmedRoute = PathUtil.trimPath(event.route);
if (!trimmedRoute.equals(event.route)) {
// See InternalRedirectHandler invoked via Router.
getPage().getHistory().replaceState(null, trimmedRoute);
}
final Location location = new Location(trimmedRoute,
QueryParameters.fromString(flowRouteQuery));
QueryParameters.fromString(event.query));
NavigationTrigger navigationTrigger;
if (trigger.isEmpty()) {
if (event.trigger.isEmpty()) {
navigationTrigger = NavigationTrigger.PAGE_LOAD;
} else if (trigger.equalsIgnoreCase("link")) {
} else if (event.trigger.equalsIgnoreCase("link")) {
navigationTrigger = NavigationTrigger.ROUTER_LINK;
} else if (trigger.equalsIgnoreCase("client")) {
} else if (event.trigger.equalsIgnoreCase("client")) {
navigationTrigger = NavigationTrigger.CLIENT_SIDE;
} else {
navigationTrigger = NavigationTrigger.HISTORY;
}
if (firstNavigation) {
firstNavigation = false;
getPage().getHistory().setHistoryStateChangeHandler(
event -> renderViewForRoute(event.getLocation(),
event.getTrigger()));
e -> renderViewForRoute(e.getLocation(), e.getTrigger()));

if (getInternals().getActiveRouterTargetsChain().isEmpty()) {
// Render the route unless it was rendered eagerly
Expand All @@ -1741,7 +1799,7 @@ public void connectClient(String flowRoutePath, String flowRouteQuery,
.getHistoryStateChangeHandler();
handler.onHistoryStateChange(
new History.HistoryStateChangeEvent(getPage().getHistory(),
historyState, location, navigationTrigger));
event.historyState, location, navigationTrigger));
}

// true if the target is client-view and the push mode is disable
Expand All @@ -1763,13 +1821,12 @@ public void connectClient(String flowRoutePath, String flowRouteQuery,
* This is only called when client route navigates from a server to a client
* view.
*
* @param route
* the route that is navigating to.
* @param event
* the event from the browser
*/
@ClientCallable
public void leaveNavigation(String route, String query) {
navigateToPlaceholder(new Location(PathUtil.trimPath(route),
QueryParameters.fromString(query)));
public void leaveNavigation(BrowserLeaveNavigationEvent event) {
navigateToPlaceholder(new Location(PathUtil.trimPath(event.route),
QueryParameters.fromString(event.query)));

// Inform the client whether the navigation should be postponed
if (isPostponed()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import com.vaadin.flow.component.PollEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.UI.BrowserLeaveNavigationEvent;
import com.vaadin.flow.component.UI.BrowserNavigateEvent;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.shared.JsonConstants;

Expand Down Expand Up @@ -114,6 +116,22 @@ private boolean isPollEventInvocation(JsonObject invocationJson) {
invocationJson.getString(JsonConstants.RPC_EVENT_TYPE));
}

private boolean isNavigationInvocation(JsonObject invocationJson) {
if (!invocationJson.hasKey(JsonConstants.RPC_EVENT_TYPE)) {
return false;
}
if (BrowserNavigateEvent.EVENT_NAME.equals(
invocationJson.getString(JsonConstants.RPC_EVENT_TYPE))) {
return true;
}
if (BrowserLeaveNavigationEvent.EVENT_NAME.equals(
invocationJson.getString(JsonConstants.RPC_EVENT_TYPE))) {
return true;
}
return false;

}

private boolean isPollingEnabledForUI(UI ui) {
return ui.getPollInterval() > 0;
}
Expand Down Expand Up @@ -183,7 +201,8 @@ private boolean isLegitimatePollEventInvocation(UI ui,
* the current invocation or not.
*/
protected boolean allowInert(UI ui, JsonObject invocationJson) {
return isValidPollInvocation(ui, invocationJson);
return isValidPollInvocation(ui, invocationJson)
|| isNavigationInvocation(invocationJson);
}

/**
Expand Down

0 comments on commit 97b0bc9

Please sign in to comment.