chromium/ui/webui/resources/cr_components/color_change_listener/colors_css_updater.ts

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

/**
 * @fileoverview This file holds the functions that allow WebUI to update its
 * colors CSS stylesheet when a ColorProvider change in the browser is detected.
 */

import {assert} from '//resources/js/assert.js';

import {BrowserProxy} from './browser_proxy.js';

/**
 * The CSS selector used to get the <link> node with the colors.css stylesheet.
 * The wildcard is needed since the URL ends with a timestamp.
 */
export const COLORS_CSS_SELECTOR: string = 'link[href*=\'//theme/colors.css\']';

let documentInstance: ColorChangeUpdater|null = null;

// <if expr="chromeos_ash">
// Event fired after updated colors have been fetched and applied.
export const COLOR_PROVIDER_CHANGED: string = 'color-provider-changed';
// </if>

export class ColorChangeUpdater {
  private listenerId_: null|number = null;
  private root_: Document|ShadowRoot;

  // <if expr="chromeos_ash">
  eventTarget: EventTarget = new EventTarget();
  // </if>

  constructor(root: Document|ShadowRoot) {
    assert(documentInstance === null || root !== document);
    this.root_ = root;
  }

  /**
   * Starts listening for ColorProvider changes from the browser and updates the
   * `root_` whenever changes occur.
   */
  start() {
    if (this.listenerId_ !== null) {
      return;
    }

    this.listenerId_ = BrowserProxy.getInstance()
                           .callbackRouter.onColorProviderChanged.addListener(
                               this.onColorProviderChanged.bind(this));
  }

  // TODO(dpapad): Figure out how to properly trigger
  // `callbackRouter.onColorProviderChanged` listeners from tests and make this
  // method private.
  async onColorProviderChanged() {
    await this.refreshColorsCss();
    // <if expr="chromeos_ash">
    this.eventTarget.dispatchEvent(new CustomEvent(COLOR_PROVIDER_CHANGED));
    // </if>
  }

  /**
   * Forces `root_` to refresh its colors.css stylesheet. This is used to
   * fetch an updated stylesheet when the ColorProvider associated with the
   * WebUI has changed.
   * @return A promise which resolves to true once the new colors are loaded and
   *     installed into the DOM. In the case of an error returns false. When a
   *     new colors.css is loaded, this will always freshly query the existing
   *     colors.css, allowing multiple calls to successfully remove existing,
   *     outdated CSS.
   */
  async refreshColorsCss(): Promise<boolean> {
    const colorCssNode = this.root_.querySelector(COLORS_CSS_SELECTOR);
    if (!colorCssNode) {
      return false;
    }

    const href = colorCssNode.getAttribute('href');
    if (!href) {
      return false;
    }

    const hrefURL = new URL(href, location.href);
    const params = new URLSearchParams(hrefURL.search);
    params.set('version', new Date().getTime().toString());
    const newHref = `${hrefURL.origin}${hrefURL.pathname}?${params.toString()}`;

    // A flickering effect may take place when setting the href property of
    // the existing color css node with a new value. In order to avoid
    // flickering, we create a new link element and once it is loaded we
    // remove the old one. See crbug.com/1365320 for additional details.
    const newColorsCssLink = document.createElement('link');
    newColorsCssLink.setAttribute('href', newHref);
    newColorsCssLink.rel = 'stylesheet';
    newColorsCssLink.type = 'text/css';
    const newColorsLoaded = new Promise(resolve => {
      newColorsCssLink.onload = resolve;
    });
    if (this.root_ === document) {
      document.getElementsByTagName('body')[0]!.appendChild(newColorsCssLink);
    } else {
      this.root_.appendChild(newColorsCssLink);
    }

    await newColorsLoaded;

    const oldColorCssNode = document.querySelector(COLORS_CSS_SELECTOR);
    if (oldColorCssNode) {
      oldColorCssNode.remove();
    }
    return true;
  }

  static forDocument(): ColorChangeUpdater {
    return documentInstance ||
        (documentInstance = new ColorChangeUpdater(document));
  }
}