Remove page.js in favor of our own re-written version of it

Release-Notes: skip
Change-Id: I61aed543a197b0b004af24c48c2ccf673c9ab839
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index e2afbf5..114aa3a 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1274,38 +1274,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[marked]]
 marked
 
@@ -1363,7 +1331,7 @@
 [[page]]
 page
 
-* page
+* polygerrit-gr-page
 
 [[page_license]]
 ----
@@ -1393,38 +1361,6 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
 [[resemblejs]]
 resemblejs
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 8ccbcab..f8ca85b 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4178,38 +4178,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[marked]]
 marked
 
@@ -4267,7 +4235,7 @@
 [[page]]
 page
 
-* page
+* polygerrit-gr-page
 
 [[page_license]]
 ----
@@ -4297,38 +4265,6 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
 [[resemblejs]]
 resemblejs
 
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 d4627e2..371e5b5 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
@@ -1259,7 +1259,7 @@
         @click=${(e: MouseEvent) => {
           // We don't want to handle clicks on the star or the <a> link.
           // Calling `stopPropagation()` from the click handler of <a> is not an
-          // option, because then the click does not reach the top-level page.js
+          // option, because then the click does not reach the top-level gr-page
           // click handler and would result is a full page reload.
           if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
           this.copyLinksDropdown?.toggleDropdown();
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
new file mode 100644
index 0000000..3a49c32
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -0,0 +1,372 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * This file was originally a copy of https://github.com/visionmedia/page.js.
+ * It was converted to TypeScript and stripped off lots of code that we don't
+ * need in Gerrit. Thus we reproduce the original LICENSE in js_licenses.txt.
+ */
+
+/**
+ * This is what registered routes have to provide, see `registerRoute()` and
+ * `registerExitRoute()`.
+ * `context` provides information about the matched parameters in the URL.
+ * Then you can decide to handle the route exclusively (not calling `next()`),
+ * or to pass it on to other registered routes. Normally you would not call
+ * `next()`, because your regex matching the URL was specific enough.
+ */
+export type PageCallback = (
+  context: PageContext,
+  next: PageNextCallback
+) => void;
+
+/** See comment on `PageCallback` above. */
+export type PageNextCallback = () => void;
+
+/** Options for starting the router. */
+export interface PageOptions {
+  /**
+   * Should a `popstate` listener be installed? The default is `true`. Can be
+   * turned off for testing.
+   */
+  popstate: boolean;
+  /**
+   * Should the router inspect the current URL and dispatch it when the router
+   * is started? Default is `true`, but can be turned off for testing.
+   */
+  dispatch: boolean;
+
+  /**
+   * The base path of the application. For Gerrit this must be set to
+   * getBaseUrl().
+   */
+  base: string;
+}
+
+/**
+ * The browser `History` API allows `pushState()` to contain an arbitrary state
+ * object. Our router only sets `path` on the state and inspects it when
+ * handling `popstate` events. This interface is internal only.
+ */
+interface PageState {
+  path?: string;
+}
+
+const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
+
+export class Page {
+  /**
+   * When a new URL is dispatched all these routes are called one after another.
+   * If a route decides that it wants to handle a URL, then it does not call
+   * next().
+   */
+  private entryRoutes: PageCallback[] = [];
+
+  /**
+   * Before a new URL is dispatched exit routes for the previous URL are called.
+   * They can clean up some state for example. But they could also prevent the
+   * user from navigating away (from within the app), if they don't call next().
+   */
+  private exitRoutes: PageCallback[] = [];
+
+  /**
+   * The path that is currently being dispatched. This is used, so that we can
+   * check whether a context is still valid, i.e. ctx.path === currentPath.
+   */
+  private currentPath = '';
+
+  /**
+   * The base path of the application. For Gerrit this must be set to
+   * getBaseUrl(). For example https://gerrit.wikimedia.org/ uses r/ as its
+   * base path.
+   */
+  private base = '';
+
+  /**
+   * Is set at the beginning of start() and stop(), so that you cannot start
+   * the routing twice.
+   */
+  private running = false;
+
+  /**
+   * Keeping around the previous context for being able to call exit routes
+   * after creating a new context.
+   */
+  private prevPageContext?: PageContext;
+
+  /**
+   * We don't want to handle popstate events before the document is loaded.
+   */
+  private documentLoaded = false;
+
+  start(options: PageOptions = {dispatch: true, popstate: true, base: ''}) {
+    if (this.running) return;
+    this.running = true;
+    this.base = options.base;
+
+    window.document.addEventListener(clickEvent, this.clickHandler);
+    window.addEventListener('load', this.loadHandler);
+    if (options.popstate) {
+      window.addEventListener('popstate', this.popStateHandler);
+    }
+    if (document.readyState === 'complete') this.documentLoaded = true;
+
+    if (options.dispatch) {
+      const loc = window.location;
+      this.replace(loc.pathname + loc.search + loc.hash);
+    }
+  }
+
+  stop() {
+    if (!this.running) return;
+    this.currentPath = '';
+    this.running = false;
+
+    window.document.removeEventListener(clickEvent, this.clickHandler);
+    window.removeEventListener('popstate', this.popStateHandler);
+    window.removeEventListener('load', this.loadHandler);
+  }
+
+  show(path: string, push = true) {
+    const ctx = new PageContext(path, {}, this.base);
+    const prev = this.prevPageContext;
+    this.prevPageContext = ctx;
+    this.currentPath = ctx.path;
+    this.dispatch(ctx, prev);
+    if (push && !ctx.preventPush) ctx.pushState();
+  }
+
+  redirect(to: string) {
+    setTimeout(() => this.replace(to), 0);
+  }
+
+  replace(path: string, state: PageState = {}, dispatch = true) {
+    const ctx = new PageContext(path, state, this.base);
+    const prev = this.prevPageContext;
+    this.prevPageContext = ctx;
+    this.currentPath = ctx.path;
+    ctx.replaceState(); // replace before dispatching, which may redirect
+    if (dispatch) this.dispatch(ctx, prev);
+  }
+
+  dispatch(ctx: PageContext, prev?: PageContext) {
+    let j = 0;
+    const nextExit = () => {
+      const fn = this.exitRoutes[j++];
+      // First call the exit routes of the previous context. Then proceed
+      // to the entry routes for the new context.
+      if (!fn) {
+        nextEnter();
+        return;
+      }
+      fn(prev!, nextExit);
+    };
+
+    let i = 0;
+    const nextEnter = () => {
+      const fn = this.entryRoutes[i++];
+
+      // Concurrency protection. The context is not valid anymore.
+      // Stop calling any further route handlers.
+      if (ctx.path !== this.currentPath) {
+        ctx.preventPush = true;
+        return;
+      }
+
+      // You must register a route that handles everything (.*) and does not
+      // call next().
+      if (!fn) throw new Error('No route has handled the URL.');
+
+      fn(ctx, nextEnter);
+    };
+
+    if (prev) {
+      nextExit();
+    } else {
+      nextEnter();
+    }
+  }
+
+  registerRoute(re: RegExp, fn: PageCallback) {
+    this.entryRoutes.push(createRoute(re, fn));
+  }
+
+  registerExitRoute(re: RegExp, fn: PageCallback) {
+    this.exitRoutes.push(createRoute(re, fn));
+  }
+
+  loadHandler = () => {
+    setTimeout(() => (this.documentLoaded = true), 0);
+  };
+
+  clickHandler = (e: MouseEvent | TouchEvent) => {
+    if ((e as MouseEvent).button !== 0) return;
+    if (e.metaKey || e.ctrlKey || e.shiftKey) return;
+    if (e.defaultPrevented) return;
+
+    let el = e.target as HTMLAnchorElement;
+    const eventPath = e.composedPath();
+    if (eventPath) {
+      for (let i = 0; i < eventPath.length; i++) {
+        const pathEl = eventPath[i] as HTMLAnchorElement;
+        if (!pathEl.nodeName) continue;
+        if (pathEl.nodeName.toUpperCase() !== 'A') continue;
+        if (!pathEl.href) continue;
+
+        el = pathEl;
+        break;
+      }
+    }
+
+    while (el && 'A' !== el.nodeName.toUpperCase())
+      el = el.parentNode as HTMLAnchorElement;
+    if (!el || 'A' !== el.nodeName.toUpperCase()) return;
+
+    if (el.hasAttribute('download') || el.getAttribute('rel') === 'external')
+      return;
+    const link = el.getAttribute('href');
+    if (samePath(el) && (el.hash || '#' === link)) return;
+    if (link && link.indexOf('mailto:') > -1) return;
+    if (el.target) return;
+    if (!sameOrigin(el.href)) return;
+
+    let path = el.pathname + el.search + (el.hash ?? '');
+    path = path[0] !== '/' ? '/' + path : path;
+
+    const orig = path;
+    if (path.indexOf(this.base) === 0) {
+      path = path.substr(this.base.length);
+    }
+    if (this.base && orig === path && window.location.protocol !== 'file:') {
+      return;
+    }
+    e.preventDefault();
+    this.show(orig);
+  };
+
+  popStateHandler = () => (e: PopStateEvent) => {
+    if (!this.documentLoaded) return;
+    if (e.state) {
+      const path = e.state.path;
+      this.replace(path, e.state);
+    } else {
+      const loc = window.location;
+      this.show(loc.pathname + loc.search + loc.hash, /* push */ false);
+    }
+  };
+}
+
+function sameOrigin(href: string) {
+  if (!href) return false;
+  const url = new URL(href, window.location.toString());
+  const loc = window.location;
+  return (
+    loc.protocol === url.protocol &&
+    loc.hostname === url.hostname &&
+    loc.port === url.port
+  );
+}
+
+function samePath(url: HTMLAnchorElement) {
+  const loc = window.location;
+  return url.pathname === loc.pathname && url.search === loc.search;
+}
+
+function escapeRegExp(s: string) {
+  return s.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1');
+}
+
+function decodeURIComponentString(val: string | undefined | null) {
+  if (!val) return '';
+  return decodeURIComponent(val.replace(/\+/g, ' '));
+}
+
+export class PageContext {
+  /**
+   * Includes everything: base, path, query and hash.
+   */
+  canonicalPath = '';
+
+  /**
+   * Does not include base path.
+   * Does not include hash.
+   * Includes query string.
+   */
+  path = '';
+
+  /** Does not include hash. */
+  querystring = '';
+
+  hash = '';
+
+  /**
+   * Regular expression matches of capturing groups. The first entry params[0]
+   * corresponds to the first capturing group. The entire matched string is not
+   * returned in this array.
+   */
+  params: string[] = [];
+
+  /**
+   * Prevents `show()` from eventually calling `pushState()`. For example if
+   * the current context is not "valid" anymore, i.e. the URL has changed in the
+   * meantime.
+   *
+   * This is router internal state. Do not use it from routes.
+   */
+  preventPush = false;
+
+  private title = '';
+
+  constructor(
+    path: string,
+    private readonly state: PageState = {},
+    pageBase = ''
+  ) {
+    this.title = window.document.title;
+
+    if ('/' === path[0] && 0 !== path.indexOf(pageBase)) path = pageBase + path;
+    this.canonicalPath = path;
+    const re = new RegExp('^' + escapeRegExp(pageBase));
+    this.path = path.replace(re, '') || '/';
+    this.state.path = path;
+
+    const i = path.indexOf('?');
+    this.querystring =
+      i !== -1 ? decodeURIComponentString(path.slice(i + 1)) : '';
+
+    // Does the path include a hash? If yes, then remove it from path and
+    // querystring.
+    if (this.path.indexOf('#') === -1) return;
+    const parts = this.path.split('#');
+    this.path = parts[0];
+    this.hash = decodeURIComponentString(parts[1]) || '';
+    this.querystring = this.querystring.split('#')[0];
+  }
+
+  pushState() {
+    window.history.pushState(this.state, this.title, this.canonicalPath);
+  }
+
+  replaceState() {
+    window.history.replaceState(this.state, this.title, this.canonicalPath);
+  }
+}
+
+function createRoute(re: RegExp, fn: Function) {
+  return (ctx: PageContext, next: Function) => {
+    const qsIndex = ctx.path.indexOf('?');
+    const pathname = qsIndex !== -1 ? ctx.path.slice(0, qsIndex) : ctx.path;
+    const matches = re.exec(decodeURIComponent(pathname));
+    if (matches) {
+      ctx.params = matches
+        .slice(1)
+        .map(match => decodeURIComponentString(match));
+      fn(ctx, next);
+    } else {
+      next();
+    }
+  };
+}
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
new file mode 100644
index 0000000..6691bd8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {html, assert, fixture, waitUntil} from '@open-wc/testing';
+import './gr-router';
+import {Page, PageContext} from './gr-page';
+
+suite('gr-page tests', () => {
+  let page: Page;
+
+  setup(() => {
+    page = new Page();
+    page.start({dispatch: false, popstate: false, base: ''});
+  });
+
+  teardown(() => {
+    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);
+  });
+
+  test('register route and exit', () => {
+    const handleA = sinon.spy();
+    const handleAExit = sinon.stub();
+    page.registerRoute(/\/A/, handleA);
+    page.registerExitRoute(/\/A/, handleAExit);
+
+    page.show('/A');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleAExit.callCount, 0);
+
+    page.show('/B');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleAExit.callCount, 1);
+  });
+
+  test('register, show, replace', () => {
+    const handleA = sinon.spy();
+    const handleB = sinon.stub();
+    page.registerRoute(/\/A/, handleA);
+    page.registerRoute(/\/B/, handleB);
+
+    page.show('/A');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 0);
+
+    page.show('/B');
+    assert.equal(handleA.callCount, 1);
+    assert.equal(handleB.callCount, 1);
+
+    page.replace('/A');
+    assert.equal(handleA.callCount, 2);
+    assert.equal(handleB.callCount, 1);
+
+    page.replace('/B');
+    assert.equal(handleA.callCount, 2);
+    assert.equal(handleB.callCount, 2);
+  });
+
+  test('register pattern, check context', async () => {
+    let context: PageContext;
+    const handler = (ctx: PageContext) => (context = ctx);
+    page.registerRoute(/\/asdf\/(.*)\/qwer\/(.*)\//, handler);
+    page.stop();
+    page.start({dispatch: false, popstate: false, base: '/base'});
+
+    page.show('/base/asdf/1234/qwer/abcd/');
+
+    await waitUntil(() => !!context);
+    assert.equal(context!.canonicalPath, '/base/asdf/1234/qwer/abcd/');
+    assert.equal(context!.path, '/asdf/1234/qwer/abcd/');
+    assert.equal(context!.querystring, '');
+    assert.equal(context!.hash, '');
+    assert.equal(context!.params[0], '1234');
+    assert.equal(context!.params[1], 'abcd');
+
+    page.show('/asdf//qwer////?a=b#go');
+
+    await waitUntil(() => !!context);
+    assert.equal(context!.canonicalPath, '/base/asdf//qwer////?a=b#go');
+    assert.equal(context!.path, '/asdf//qwer////?a=b');
+    assert.equal(context!.querystring, 'a=b');
+    assert.equal(context!.hash, 'go');
+    assert.equal(context!.params[0], '');
+    assert.equal(context!.params[1], '//');
+  });
+});
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 264560b..d0b545f 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -3,12 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  Options,
-  page,
-  PageContext,
-  PageNextCallback,
-} from '../../../utils/page-wrapper-utils';
+import {Page, PageOptions, PageContext, PageNextCallback} from './gr-page';
 import {NavigationService} from '../gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
@@ -108,7 +103,7 @@
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
 const RoutePattern = {
-  ROOT: '/',
+  ROOT: /^\/$/,
 
   DASHBOARD: /^\/dashboard\/(.+)$/,
   CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
@@ -302,7 +297,7 @@
 
   private view?: GerritView;
 
-  readonly page = page.create();
+  readonly page = new Page();
 
   constructor(
     private readonly reporting: ReportingService,
@@ -340,12 +335,7 @@
         }
 
         if (browserUrl.toString() !== stateUrl.toString()) {
-          this.page.replace(
-            stateUrl.toString(),
-            null,
-            /* init: */ false,
-            /* dispatch: */ false
-          );
+          this.page.replace(stateUrl.toString(), {}, /* dispatch: */ false);
         }
       }),
       this.routerModel.routerView$.subscribe(view => (this.view = view)),
@@ -429,13 +419,13 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    this.page(
+    this.setUrl(
       '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
     );
   }
 
   /**
-   * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+   * Hashes parsed by gr-page exclude "inner" hashes, so a URL like "/a#b#c"
    * is parsed to have a hash of "b" rather than "b#c". Instead, this method
    * parses hashes correctly. Will return an empty string if there is no hash.
    *
@@ -464,18 +454,18 @@
    * @return A promise yielding the original route ctx
    * (if it resolves).
    */
-  redirectIfNotLoggedIn(ctx: PageContext) {
+  redirectIfNotLoggedIn(path: string) {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
-        this.redirectToLogin(ctx.canonicalPath);
+        this.redirectToLogin(path);
         return Promise.reject(new Error());
       }
     });
   }
 
-  /**  Page.js middleware that warms the REST API's logged-in cache line. */
+  /**  gr-page middleware that warms the REST API's logged-in cache line. */
   private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
     this.restApiService.getLoggedIn().then(() => {
       next();
@@ -485,11 +475,10 @@
   /**
    * Map a route to a method on the router.
    *
-   * @param pattern The page.js pattern for the route.
+   * @param pattern The regex pattern for the route.
    * @param handlerName The method name for the handler. If the
    * route is matched, the handler will be executed with `this` referring
-   * to the component. Its return value will be discarded so that it does
-   * not interfere with page.js.
+   * to the component. Its return value will be discarded.
    * TODO: Get rid of this parameter. This is really not something that the
    * router wants to be concerned with. The reporting service and the view
    * models should figure that out between themselves.
@@ -499,24 +488,23 @@
    * redirect specifies the matched URL to be used after successful auth.
    */
   mapRoute(
-    pattern: string | RegExp,
+    pattern: RegExp,
     handlerName: string,
     handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
-    this.page(
-      pattern,
-      (ctx, next) => this.loadUserMiddleware(ctx, next),
-      ctx => {
-        this.reporting.locationChanged(handlerName);
-        const promise = authRedirect
-          ? this.redirectIfNotLoggedIn(ctx)
-          : Promise.resolve();
-        promise.then(() => {
-          handler(ctx);
-        });
-      }
+    this.page.registerRoute(pattern, (ctx, next) =>
+      this.loadUserMiddleware(ctx, next)
     );
+    this.page.registerRoute(pattern, ctx => {
+      this.reporting.locationChanged(handlerName);
+      const promise = authRedirect
+        ? this.redirectIfNotLoggedIn(ctx.canonicalPath)
+        : Promise.resolve();
+      promise.then(() => {
+        handler(ctx);
+      });
+    });
   }
 
   /**
@@ -583,16 +571,13 @@
   }
 
   _testOnly_startRouter() {
-    this.startRouter({dispatch: false, popstate: false});
+    this.startRouter({dispatch: false, popstate: false, base: getBaseUrl()});
   }
 
-  startRouter(opts: Options = {}) {
-    const base = getBaseUrl();
-    if (base) {
-      this.page.base(base);
-    }
-
-    this.page.exit('*', (_, next) => {
+  startRouter(
+    opts: PageOptions = {dispatch: true, popstate: true, base: getBaseUrl()}
+  ) {
+    this.page.registerExitRoute(/(.*)/, (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
       }
@@ -603,7 +588,7 @@
 
     // Remove the tracking param 'usp' (User Source Parameter) from the URL,
     // just to have users look at cleaner URLs.
-    this.page((ctx, next) => {
+    this.page.registerRoute(/(.*)/, (ctx, next) => {
       if (window.URLSearchParams) {
         const pathname = toPathname(ctx.canonicalPath);
         const searchParams = toSearchParams(ctx.canonicalPath);
@@ -619,7 +604,7 @@
     });
 
     // Middleware
-    this.page((ctx, next) => {
+    this.page.registerRoute(/(.*)/, (ctx, next) => {
       document.body.scrollTop = 0;
 
       if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
@@ -937,7 +922,7 @@
     // For backward compatibility with GWT links.
     if (hash) {
       // In certain login flows the server may redirect to a hash without
-      // a leading slash, which page.js doesn't handle correctly.
+      // a leading slash, which gr-page doesn't handle correctly.
       if (hash[0] !== '/') {
         hash = '/' + hash;
       }
@@ -1087,7 +1072,7 @@
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.GROUPS,
-      offset: ctx.params[2] ?? '0',
+      offset: ctx.params[2] || '0',
       filter: ctx.params[1] ?? null,
       openCreateModal:
         !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
@@ -1184,7 +1169,7 @@
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] ?? '0',
+      offset: ctx.params[2] || '0',
       filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
@@ -1197,7 +1182,7 @@
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: ctx.params[0] as RepoName,
-      offset: ctx.params[2] ?? '0',
+      offset: ctx.params[2] || '0',
       filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
@@ -1209,7 +1194,7 @@
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.REPOS,
-      offset: ctx.params[2] ?? '0',
+      offset: ctx.params[2] || '0',
       filter: ctx.params[1] ?? null,
       openCreateModal:
         !ctx.params[1] && !ctx.params[2] && ctx.hash === 'create',
@@ -1239,7 +1224,7 @@
     const state: AdminViewState = {
       view: GerritView.ADMIN,
       adminView: AdminChildView.PLUGINS,
-      offset: ctx.params[2] ?? '0',
+      offset: ctx.params[2] || '0',
       filter: ctx.params[1] ?? null,
     };
     // Note that router model view must be updated before view models.
@@ -1251,7 +1236,7 @@
     const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
-      offset: ctx.params[2],
+      offset: ctx.params[2] || '0',
       loading: false,
       changes: [],
     };
@@ -1267,7 +1252,7 @@
     const state: SearchViewState = {
       view: GerritView.SEARCH,
       query: ctx.params[0],
-      offset: undefined,
+      offset: '0',
       loading: false,
       changes: [],
     };
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 3ca7bdc..565ddd9 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
@@ -5,7 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import './gr-router';
-import {Page, PageContext} from '../../../utils/page-wrapper-utils';
+import {Page, PageContext} from './gr-page';
 import {
   stubBaseUrl,
   stubRestApi,
@@ -125,7 +125,6 @@
     const requiresAuth: any = {};
     const doesNotRequireAuth: any = {};
     sinon.stub(page, 'start');
-    sinon.stub(page, 'base');
     sinon
       .stub(router, 'mapRoute')
       .callsFake((_pattern, methodName, _method, usesAuth) => {
@@ -212,20 +211,8 @@
 
   test('redirectIfNotLoggedIn while logged in', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    const ctx = {
-      save() {},
-      handled: true,
-      canonicalPath: '',
-      path: '',
-      querystring: '',
-      pathname: '',
-      state: '',
-      title: '',
-      hash: '',
-      params: {test: 'test'},
-    };
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    return router.redirectIfNotLoggedIn(ctx).then(() => {
+    return router.redirectIfNotLoggedIn('somepath').then(() => {
       assert.isFalse(redirectStub.called);
     });
   });
@@ -233,21 +220,9 @@
   test('redirectIfNotLoggedIn while logged out', () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     const redirectStub = sinon.stub(router, 'redirectToLogin');
-    const ctx = {
-      save() {},
-      handled: true,
-      canonicalPath: '',
-      path: '',
-      querystring: '',
-      pathname: '',
-      state: '',
-      title: '',
-      hash: '',
-      params: {test: 'test'},
-    };
     return new Promise(resolve => {
       router
-        .redirectIfNotLoggedIn(ctx)
+        .redirectIfNotLoggedIn('somepath')
         .then(() => {
           assert.isTrue(false, 'Should never execute');
         })
@@ -321,17 +296,6 @@
       await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
     }
 
-    function createPageContext(): PageContext {
-      return {
-        canonicalPath: '',
-        path: '',
-        querystring: '',
-        pathname: '',
-        hash: '',
-        params: {},
-      };
-    }
-
     setup(() => {
       stubRestApi('setInProjectLookup');
       redirectStub = sinon.stub(router, 'redirect');
@@ -395,9 +359,8 @@
       ) => {
         onExit = _onExit;
       };
-      sinon.stub(page, 'exit').callsFake(onRegisteringExit);
+      sinon.stub(page, 'registerExitRoute').callsFake(onRegisteringExit);
       sinon.stub(page, 'start');
-      sinon.stub(page, 'base');
       router._testOnly_startRouter();
 
       router.handleDefaultRoute();
@@ -465,7 +428,10 @@
 
     suite('ROOT', () => {
       test('closes for closeAfterLogin', () => {
-        const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
+        const ctx = {
+          querystring: 'closeAfterLogin',
+          canonicalPath: '',
+        } as PageContext;
         const closeStub = sinon.stub(window, 'close');
         const result = router.handleRootRoute(ctx);
         assert.isNotOk(result);
@@ -586,7 +552,7 @@
           adminView: AdminChildView.GROUPS,
           offset: '0',
           openCreateModal: false,
-          filter: null,
+          filter: '',
         };
 
         await checkUrlToState('/admin/groups', defaultState);
@@ -1020,7 +986,7 @@
         view: GerritView.DOCUMENTATION_SEARCH,
         filter: 'asdf',
       });
-      // Percent decoding works fine. page.js decodes twice, so the only problem
+      // Percent decoding works fine. gr-page decodes twice, so the only problem
       // is having `%25` in the URL, because the first decoding pass will yield
       // `%`, and then the second decoding pass will throw `URI malformed`.
       await checkUrlToState('/Documentation/q/filter:as%20%2fdf', {
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index c9b7801..b6089af 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
+import {PageContext} from '../../elements/core/gr-router/gr-page';
 import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
 import {AdminChildView, PLUGIN_LIST_ROUTE} from './admin';
@@ -20,7 +21,7 @@
       assert.isFalse(pattern.test('//admin/plugins?'));
       assert.isFalse(pattern.test('/admin/plugins//'));
 
-      assert.deepEqual(createState({}), {
+      assert.deepEqual(createState(new PageContext('')), {
         view: GerritView.ADMIN,
         adminView: AdminChildView.PLUGINS,
       });
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
index 72bec33..5c388f4 100644
--- a/polygerrit-ui/app/models/views/base.ts
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {PageContext} from '../../elements/core/gr-router/gr-page';
 import {GerritView} from '../../services/router/router-model';
 
 export interface ViewState {
@@ -10,22 +11,10 @@
 }
 
 /**
- * While we are using page.js this interface will normally be implemented by
- * PageContext, but it helps testing and independence to have our own type
- * here.
- */
-export interface UrlInfo {
-  querystring?: string;
-  hash?: string;
-  /** What the regular expression matching returns. */
-  params?: {[paramIndex: string]: string};
-}
-
-/**
  * Based on `urlPattern` knows whether a URL matches and if so, then
  * `createState()` can produce a `ViewState` from the matched URL.
  */
 export interface Route<T extends ViewState> {
   urlPattern: RegExp;
-  createState: (info: UrlInfo) => T;
+  createState: (ctx: PageContext) => T;
 }
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index b5b313e..c65e7a31 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -377,6 +377,10 @@
     license: SharedLicenses.Polymer2018,
   },
   {
+    name: 'polygerrit-gr-page',
+    license: SharedLicenses.Page,
+  },
+  {
     name: 'web-vitals',
     license: {
       name: 'web-vitals',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 392b5a8..8d33fe6 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -41,7 +41,6 @@
     "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
     "immer": "^9.0.5",
     "lit": "^2.2.3",
-    "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^4.0.0",
@@ -51,4 +50,4 @@
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index be60a63..bc9d7a8 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -73,14 +73,15 @@
     customResolveOptions: {
       // By default, it tries to use page.mjs file instead of page.js
       // when importing 'page/page'.
+      // TODO: page.was removed. Is something obsolete here?
       extensions: ['.js'],
       moduleDirectory: 'external/ui_npm/node_modules',
     },
   }),
   define({
-     replacements: {
-       'process.env.NODE_ENV': JSON.stringify('production'),
-     },
+    replacements: {
+      'process.env.NODE_ENV': JSON.stringify('production'),
+    },
   }),
   importLocalFontMetaUrlResolver()],
 };
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index b480bfe..58bf266 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -749,7 +749,7 @@
   return {
     view: GerritView.SEARCH,
     query: '',
-    offset: undefined,
+    offset: '0',
     loading: false,
     changes: [],
   };
@@ -767,7 +767,7 @@
     view: GerritView.ADMIN,
     adminView: AdminChildView.REPOS,
     offset: '0',
-    filter: null,
+    filter: '',
     openCreateModal: false,
   };
 }
@@ -777,7 +777,7 @@
     view: GerritView.ADMIN,
     adminView: AdminChildView.PLUGINS,
     offset: '0',
-    filter: null,
+    filter: '',
   };
 }
 
@@ -799,7 +799,7 @@
     view: GerritView.REPO,
     detail: RepoDetailView.BRANCHES,
     offset: '0',
-    filter: null,
+    filter: '',
   };
 }
 
@@ -808,7 +808,7 @@
     view: GerritView.REPO,
     detail: RepoDetailView.TAGS,
     offset: '0',
-    filter: null,
+    filter: '',
   };
 }
 
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
deleted file mode 100644
index 58bb024..0000000
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-// @ts-ignore: Bazel is not yet configured to download the types
-import pagejs from 'page';
-
-// Reexport page.js. To make it work rollup patches page.js and replace "this"
-// to "window". Otherwise, it can't assign global property. We can't import
-// page.mjs because typescript doesn't support mjs extensions
-export interface Page {
-  (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
-  (pageCallback: PageCallback): void;
-  show(url: string): void;
-  redirect(url: string): void;
-  replace(path: string, state: null, init: boolean, dispatch: boolean): void;
-  base(url: string): void;
-  start(opts: Options): void;
-  stop(): void;
-  exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
-}
-
-export interface Options {
-  popstate?: boolean;
-  dispatch?: boolean;
-}
-
-// See https://visionmedia.github.io/page.js/ for details
-export interface PageContext {
-  canonicalPath: string;
-  path: string;
-  querystring: string;
-  pathname: string;
-  hash: string;
-  params: {[paramIndex: string]: string};
-}
-
-export type PageNextCallback = () => void;
-
-export type PageCallback = (
-  context: PageContext,
-  next: PageNextCallback
-) => void;
-
-// Must only be used by gr-router!
-// TODO: Move this into gr-router. Note that there is a Google import rule
-// that would need to be modified.
-export const page = pagejs as unknown as {create(): Page};
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 355e54b..183671f 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -64,7 +64,7 @@
 export function convertToPatchSetNum(
   patchset: string | undefined
 ): PatchSetNum | undefined {
-  if (patchset === undefined) return patchset;
+  if (!patchset) return undefined;
   if (!isPatchSetNum(patchset)) {
     console.error('string is not of type PatchSetNum');
   }
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 22a9721..5e294cb 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -86,7 +86,7 @@
  * encodeURIComponent() with some tweaks.
  */
 export function encodeURL(url: string): string {
-  // page.js decodes the entire URL, and then decodes once more the
+  // gr-page decodes the entire URL, and then decodes once more the
   // individual regex matching groups. It uses `decodeURIComponent()`, which
   // will choke on singular `%` chars without two trailing digits. We prefer
   // to not double encode *everything* (just for readaiblity and simplicity),
@@ -127,7 +127,7 @@
   output = output.replace(/%40/g, '@');
   output = output.replace(/%2F/g, '/');
 
-  // page.js replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+  // gr-page replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
   // So we can use `+` to increase readability.
   output = output.replace(/%20/g, '+');
 
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 5a91fc0..8468ef5 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -677,11 +677,6 @@
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
 isarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -813,25 +808,11 @@
   dependencies:
     wrappy "1"
 
-page@^1.11.6:
-  version "1.11.6"
-  resolved "https://registry.yarnpkg.com/page/-/page-1.11.6.tgz#5ef4efc7073749b8085ccdaa0dcd7c9e0de12fe3"
-  integrity sha512-P6e2JfzkBrPeFCIPplLP7vDDiU84RUUZMrWdsH4ZBGJ8OosnwFkcUkBHp1DTIjuipLliw9yQn/ZJsXZvarsO+g==
-  dependencies:
-    path-to-regexp "~1.2.1"
-
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
-path-to-regexp@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
-  integrity sha1-szcFwUAjTYc8hyHHuf2LVB7Tr/k=
-  dependencies:
-    isarray "0.0.1"
-
 "polymer-bridges@file:../../polymer-bridges":
   version "1.0.0"
 
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 7dfb23e..642a749 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -97,6 +97,9 @@
    */
   public generateMap(nodeModulesFiles: ReadonlyArray<string>): LicensesMap {
     const installedPackages = this.getInstalledPackages(nodeModulesFiles);
+    // Static packages that are not inside `node_modules` directories.
+    // gr-page.ts was derived from page.js, so we reproduce the original LICENSE.
+    installedPackages.push({name: 'polygerrit-gr-page', version: 'current', rootPath: 'polygerrit-ui/app/elements/core/gr-router/', files: ['gr-page.ts']});
     const licensedFilesGroupedByLicense = this.getLicensedFilesGroupedByLicense(installedPackages);
 
     const result: LicensesMap = {};