Fix browser BACK button when going to plugin pages
We have to make sure that we only use `pushState()` of the browser
History API for URLs that gr-router will actually handle.
Calling `pushState()` tells the browser that both the previous and the
next URL are handled by the same single page application with a
popstate event handler. But if we call `pushState()` and then
later `window.location.reload()` from the router and a separate page
and document are loaded, then the `BACK` button will stop working.
Bug: Issue 40015337
Release-Notes: Fix browser BACK button when going to plugin pages
Change-Id: I3b2773a1d1080f67ef325b54f92069aced1a1907
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 6919982..f8a1f61 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1810,7 +1810,10 @@
if (this.loggedIn) {
this.openReplyDialog(FocusTarget.ANY);
} else {
- this.getNavigation().setUrl(this.loginUrl);
+ // We are not using `this.getNavigation().setUrl()`, because the login
+ // page is served directly from the backend and is not part of the web
+ // app.
+ window.location.assign(this.loginUrl);
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 94241ea..3d21e07 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -10,7 +10,12 @@
export interface NavigationService {
/**
* This is similar to letting the browser navigate to this URL when the user
- * clicks it, or to just setting `window.location.href` directly.
+ * clicks it, or to just calling `window.location.assign()` directly.
+ *
+ * CAUTION: You should actually use `window.location.assign()` directly for
+ * URLs that are not handled by gr-router. Otherwise we will call
+ * `pushState()` and then `window.location.reload()` from the router, which
+ * will break the browser's back button.
*
* This adds a new entry to the browser location history. Consier using
* `replaceUrl()`, if you want to avoid that.
@@ -23,6 +28,11 @@
* Navigate to this URL, but replace the current URL in the history instead of
* adding a new one (which is what `setUrl()` would do).
*
+ * CAUTION: You should actually use `window.location.replace()` directly for
+ * URLs that are not handled by gr-router. Otherwise we will call
+ * `replaceState()` and then `window.location.reload()` from the router, which
+ * will break the browser's back button.
+ *
* page.redirect() eventually just calls `window.history.replaceState()`.
*/
replaceUrl(url: string): void;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 5e7cc3b..cb96d77 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -53,6 +53,11 @@
path?: string;
}
+export const UNHANDLED_URL_PATTERNS = [
+ /^\/log(in|out)(\/(.+))?$/,
+ /^\/plugins\/(.+)$/,
+];
+
const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
export class Page {
@@ -239,6 +244,18 @@
if (this.base && orig === path && window.location.protocol !== 'file:') {
return;
}
+
+ // See issue 40015337: We have to make sure that we only use
+ // show()/pushState() for URLs that gr-router will actually handle.
+ // Calling pushState() tells the browser that both the previous and the
+ // next URL are handled by the same single page application with a
+ // popstate event handler. But if we call pushState() and then
+ // later `window.location.reload()` from the router and a separate page
+ // and document are loaded, then the BACK button will stop working.
+ if (UNHANDLED_URL_PATTERNS.find(pattern => pattern.test(path))) {
+ return;
+ }
+
e.preventDefault();
this.show(orig);
};
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
index d194bf55..729a15b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -20,14 +20,49 @@
page.stop();
});
- test('click handler', async () => {
- const spy = sinon.spy();
- page.registerRoute(/\/settings/, spy);
- const link = await fixture<HTMLAnchorElement>(
- html`<a href="/settings"></a>`
- );
- link.click();
- assert.isTrue(spy.calledOnce);
+ suite('click handler', () => {
+ const clickListener = (e: Event) => e.preventDefault();
+ let spy: sinon.SinonSpy;
+ let link: HTMLAnchorElement;
+
+ setup(async () => {
+ spy = sinon.spy();
+ link = await fixture<HTMLAnchorElement>(html`<a href="/settings"></a>`);
+
+ document.addEventListener('click', clickListener);
+ });
+
+ teardown(() => {
+ document.removeEventListener('click', clickListener);
+ });
+
+ test('click handled by specific route', async () => {
+ page.registerRoute(/\/settings/, spy);
+ link.href = '/settings';
+ link.click();
+ assert.isTrue(spy.calledOnce);
+ });
+
+ test('click handled by default route', async () => {
+ page.registerRoute(/.*/, spy);
+ link.href = '/something';
+ link.click();
+ assert.isTrue(spy.called);
+ });
+
+ test('click not handled for /plugins/... links', async () => {
+ page.registerRoute(/.*/, spy);
+ link.href = '/plugins/gitiles';
+ link.click();
+ assert.isFalse(spy.called);
+ });
+
+ test('click not handled for /login/... links', async () => {
+ page.registerRoute(/.*/, spy);
+ link.href = '/login';
+ link.click();
+ assert.isFalse(spy.called);
+ });
});
test('register route and exit', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index a3cb0ee..6dad4cf 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -120,12 +120,6 @@
NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
REGISTER: /^\/register(\/.*)?$/,
- // Pattern for login and logout URLs intended to be passed-through. May
- // include a return URL.
- // TODO: Maybe this pattern and its handler can just be removed, because
- // passing through is what the default router would eventually do anyway.
- LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-
// Pattern for a catchall route when no other pattern is matched.
DEFAULT: /.*/,
@@ -171,8 +165,6 @@
// Matches /admin/repos/<repos>,access.
REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
- PLUGINS: /^\/plugins\/(.+)$/,
-
// Matches /admin/plugins with optional filter and offset.
PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
// Matches /admin/groups with optional filter and offset.
@@ -460,7 +452,10 @@
*/
redirectToLogin(returnUrl: string) {
const basePath = getBaseUrl() || '';
- this.setUrl(
+ // We are not using `this.getNavigation().setUrl()`, because the login
+ // page is served directly from the backend and is not part of the web
+ // app.
+ window.location.assign(
'/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
);
}
@@ -583,6 +578,8 @@
* page.show() eventually just calls `window.history.pushState()`.
*/
setUrl(url: string) {
+ // TODO: Use window.location.assign() instead of page.show(), if the URL is
+ // external, i.e. not handled by the router.
this.page.show(url);
}
@@ -593,6 +590,8 @@
* this.page.redirect() eventually just calls `window.history.replaceState()`.
*/
replaceUrl(url: string) {
+ // TODO: Use window.location.replace() instead of page.redirect(), if the
+ // URL is external, i.e. not handled by the router.
this.redirect(url);
}
@@ -809,10 +808,6 @@
this.handleRepoRoute(ctx)
);
- this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
- this.handlePassThroughRoute()
- );
-
this.mapRoute(
RoutePattern.PLUGIN_LIST,
'handlePluginListFilterRoute',
@@ -921,10 +916,6 @@
this.handleRegisterRoute(ctx)
);
- this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
- this.handlePassThroughRoute()
- );
-
this.mapRoute(
RoutePattern.IMPROPERLY_ENCODED_PLUS,
'handleImproperlyEncodedPlusRoute',
@@ -1589,14 +1580,6 @@
}
/**
- * Handler for routes that should pass through the router and not be caught
- * by the catchall _handleDefaultRoute handler.
- */
- handlePassThroughRoute() {
- windowLocationReload();
- }
-
- /**
* URL may sometimes have /+/ encoded to / /.
* Context: Issue 6888, Issue 7100
*/
@@ -1651,10 +1634,15 @@
this.show404();
} else {
// Route can be recognized by server, so we pass it to server.
- this.handlePassThroughRoute();
+ this.windowReload();
}
}
+ // Allows stubbing in tests.
+ windowReload() {
+ windowLocationReload();
+ }
+
private show404() {
// Note: the app's 404 display is tightly-coupled with catching 404
// network responses, so we simulate a 404 response status to display it.
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index fbc0338..6f4e528 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -181,7 +181,6 @@
'handleDocumentationSearchRedirectRoute',
'handleLegacyLinenum',
'handleImproperlyEncodedPlusRoute',
- 'handlePassThroughRoute',
'handleProjectDashboardRoute',
'handleLegacyProjectDashboardRoute',
'handleProjectsOldRoute',
@@ -334,7 +333,7 @@
suite('route handlers', () => {
let redirectStub: sinon.SinonStub;
let setStateStub: sinon.SinonStub;
- let handlePassThroughRoute: sinon.SinonStub;
+ let windowReloadStub: sinon.SinonStub;
let redirectToLoginStub: sinon.SinonStub;
async function checkUrlToState<T extends ViewState>(
@@ -367,18 +366,12 @@
assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
}
- async function checkUrlNotMatched(url: string) {
- handlePassThroughRoute.reset();
- router.page.show(url);
- await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
- }
-
setup(() => {
stubRestApi('addRepoNameToCache');
redirectStub = sinon.stub(router, 'redirect');
redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
setStateStub = sinon.stub(router, 'setState');
- handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+ windowReloadStub = sinon.stub(router, 'windowReload');
router._testOnly_startRouter();
});
@@ -445,7 +438,7 @@
onExit!('', () => {}); // we left page;
router.handleDefaultRoute();
- assert.isTrue(handlePassThroughRoute.calledOnce);
+ assert.isTrue(windowReloadStub.calledOnce);
});
test('IMPROPERLY_ENCODED_PLUS', async () => {
@@ -1039,12 +1032,6 @@
});
});
- test('LOG_IN_OR_OUT pass through', async () => {
- // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
- await checkUrlNotMatched('/login/asdf');
- await checkUrlNotMatched('/logout/asdf');
- });
-
test('PLUGIN_SCREEN', async () => {
// PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
await checkUrlToState('/x/foo/bar', {