chromium/ui/webui/resources/js/cr_router.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

let instance: CrRouter|null = null;

export class CrRouter extends EventTarget {
  private path_: string = window.decodeURIComponent(window.location.pathname);
  private query_: string = window.location.search.slice(1);
  private hash_: string =
      window.decodeURIComponent(window.location.hash.slice(1));

  /**
   * If the user was on a URL for less than `dwellTime_` milliseconds, it
   * won't be added to the browser's history, but instead will be replaced
   * by the next entry.
   *
   * This is to prevent large numbers of entries from clogging up the user's
   * browser history. Disable by setting to a negative number.
   */
  private dwellTime_: number = 2000;

  private lastChangedAt_: number;

  constructor() {
    super();
    this.lastChangedAt_ = window.performance.now() - (this.dwellTime_ - 200);
    window.addEventListener('hashchange', () => this.hashChanged_());
    window.addEventListener('popstate', () => this.urlChanged_());
  }

  setDwellTime(dwellTime: number) {
    this.dwellTime_ = dwellTime;
    this.lastChangedAt_ = window.performance.now() - this.dwellTime_;
  }

  getPath(): string {
    return this.path_;
  }

  getQueryParams(): URLSearchParams {
    return new URLSearchParams(this.query_);
  }

  getHash(): string {
    return this.hash_;
  }

  setHash(hash: string) {
    this.hash_ = hash;
    if (this.hash_ !==
        window.decodeURIComponent(window.location.hash.substring(1))) {
      this.updateState_();
    }
  }

  setQueryParams(params: URLSearchParams) {
    this.query_ = params.toString();
    if (this.query_ !== window.location.search.substring(1)) {
      this.updateState_();
    }
  }

  setPath(path: string) {
    this.path_ = path;
    if (this.path_ !== window.decodeURIComponent(window.location.pathname)) {
      this.updateState_();
    }
  }

  private hashChanged_() {
    const oldHash = this.hash_;
    this.hash_ = window.decodeURIComponent(window.location.hash.substring(1));
    if (this.hash_ !== oldHash) {
      this.dispatchEvent(new CustomEvent(
          'cr-router-hash-changed',
          {bubbles: true, composed: true, detail: this.hash_}));
    }
  }

  // Dispatches cr-router-*-changed events if portions of the URL change from
  // window events.
  private urlChanged_() {
    this.hashChanged_();

    const oldPath = this.path_;
    this.path_ = window.decodeURIComponent(window.location.pathname);
    if (oldPath !== this.path_) {
      this.dispatchEvent(new CustomEvent(
          'cr-router-path-changed',
          {bubbles: true, composed: true, detail: this.path_}));
    }

    const oldQuery = this.query_;
    this.query_ = window.location.search.substring(1);
    if (oldQuery !== this.query_) {
      this.dispatchEvent(new CustomEvent(
          'cr-router-query-params-changed',
          {bubbles: true, composed: true, detail: this.getQueryParams()}));
    }
  }

  // Updates the window history state if the URL is updated from setters.
  private updateState_() {
    const url = new URL(window.location.origin);
    const pathPieces = this.path_.split('/');
    url.pathname =
        pathPieces.map(piece => window.encodeURIComponent(piece)).join('/');
    if (this.query_) {
      url.search = this.query_;
    }
    if (this.hash_) {
      url.hash = window.encodeURIComponent(this.hash_);
    }

    const now = window.performance.now();
    const shouldReplace = this.lastChangedAt_ + this.dwellTime_ > now;
    this.lastChangedAt_ = now;

    if (shouldReplace) {
      window.history.replaceState({}, '', url.href);
    } else {
      window.history.pushState({}, '', url.href);
    }
  }

  static getInstance(): CrRouter {
    return instance || (instance = new CrRouter());
  }

  static resetForTesting() {
    instance = null;
  }
}