Source: react/hooks/paragon/useParagonTheme.js

import {
  useCallback, useEffect, useReducer, useState,
} from 'react';

import { SELECTED_THEME_VARIANT_KEY } from '../../constants';
import { logError } from '../../../logging';
import { paragonThemeActions, paragonThemeReducer } from '../../reducers';
import { isEmptyObject } from './utils';

import useParagonThemeCore from './useParagonThemeCore';
import useParagonThemeUrls from './useParagonThemeUrls';
import useParagonThemeVariants from './useParagonThemeVariants';

/**
* Finds the default theme variant from the given theme variants object. If no default theme exists, the light theme
* variant is returned as a fallback.
*
* It prioritizes:
*   1. A persisted theme variant from localStorage.
*   2. A system preference (`prefers-color-scheme`).
*   3. The configured default theme variant.
*
* @param {Object.<string, ParagonThemeVariant>|undefined} themeVariants - An object where the keys are theme variant
* names (e.g., "light", "dark") and the values are objects containing URLs for theme CSS files.
* @param {Object} [options.themeVariantDefaults={}] - An object containing default theme variant preferences.
*
* @returns {Object|undefined} The default theme variant, or `undefined` if no valid theme variant is found.
*
*/
export const getDefaultThemeVariant = ({ themeVariants, themeVariantDefaults = {} }) => {
  if (!themeVariants) {
    return undefined;
  }

  const themeVariantKeys = Object.keys(themeVariants);

  // If there is only one theme variant, return it since it's the only one that may be used.
  if (themeVariantKeys.length === 1) {
    const themeVariantKey = themeVariantKeys[0];
    return {
      name: themeVariantKey,
      metadata: themeVariants[themeVariantKey],
    };
  }

  // Prioritize persisted localStorage theme variant preference.
  const persistedSelectedParagonThemeVariant = localStorage.getItem(SELECTED_THEME_VARIANT_KEY);
  if (persistedSelectedParagonThemeVariant && themeVariants[persistedSelectedParagonThemeVariant]) {
    return {
      name: persistedSelectedParagonThemeVariant,
      metadata: themeVariants[persistedSelectedParagonThemeVariant],
    };
  }

  // Then, detect system preference via `prefers-color-scheme` media query and use
  // the default dark theme variant, if one exists.
  const hasDarkSystemPreference = !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
  const defaultDarkThemeVariant = themeVariantDefaults.dark;
  const darkThemeVariantMetadata = themeVariants[defaultDarkThemeVariant];

  if (hasDarkSystemPreference && defaultDarkThemeVariant && darkThemeVariantMetadata) {
    return {
      name: defaultDarkThemeVariant,
      metadata: darkThemeVariantMetadata,
    };
  }

  const defaultLightThemeVariant = themeVariantDefaults.light;
  const lightThemeVariantMetadata = themeVariants[defaultLightThemeVariant];

  // Handle edge case where the default light theme variant is not configured or provided.
  if (!defaultLightThemeVariant || !lightThemeVariantMetadata) {
    return undefined;
  }

  // Otherwise, fallback to using the default light theme variant as configured.
  return {
    name: defaultLightThemeVariant,
    metadata: lightThemeVariantMetadata,
  };
};

/**
 * A custom React hook that manages the application's theme state and injects the appropriate CSS for the theme core
 * and theme variants (e.g., light and dark modes) into the HTML document. It handles dynamically loading the theme
 * CSS based on the current theme variant, and ensures that the theme variant's CSS is preloaded for runtime theme
 * switching.This is done using "alternate" stylesheets. That is, the browser will download the CSS for the
 * non-current theme variants with a lower priority than the current one.
 *
 * The hook also responds to system theme preference changes (e.g., via the `prefers-color-scheme` media query),
 * and can automatically switch the theme based on the system's dark mode or light mode preference.
 *
 * @memberof module:React
 *
 * @returns {Array} - An array containing:
 *  1. An object representing the current theme state.
 *  2. A dispatch function to mutate the app theme state (e.g., change the theme variant).
 *
 * * @example
 * const [themeState, dispatch] = useParagonTheme();
 * console.log(themeState.isThemeLoaded); // true when the theme has been successfully loaded.
 *
 * // Dispatch an action to change the theme variant
 * dispatch(paragonThemeActions.setParagonThemeVariant('dark'));
 */
const useParagonTheme = () => {
  const paragonThemeUrls = useParagonThemeUrls();
  const {
    core: themeCore,
    defaults: themeVariantDefaults,
    variants: themeVariants,
  } = paragonThemeUrls || {};
  const initialParagonThemeState = {
    isThemeLoaded: false,
    themeVariant: getDefaultThemeVariant({ themeVariants, themeVariantDefaults })?.name,
  };
  const [themeState, dispatch] = useReducer(paragonThemeReducer, initialParagonThemeState);

  const [isCoreThemeLoaded, setIsCoreThemeLoaded] = useState(false);
  const onLoadThemeCore = useCallback(() => {
    setIsCoreThemeLoaded(true);
  }, []);

  const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false);
  const onLoadThemeVariants = useCallback(() => {
    setHasLoadedThemeVariants(true);
  }, []);

  // load the core theme CSS
  useParagonThemeCore({
    themeCore,
    onComplete: onLoadThemeCore,
  });

  // respond to system preference changes with regard to `prefers-color-scheme: dark`.
  const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode) => {
    // Ignore system preference change if the theme variant is already set in localStorage.
    if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) {
      return;
    }

    if (prefersDarkMode && themeVariantDefaults?.dark) {
      dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.dark));
    } else if (!prefersDarkMode && themeVariantDefaults?.light) {
      dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.light));
    } else {
      logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`, themeVariantDefaults, themeVariants);
    }
  }, [themeVariantDefaults, themeVariants]);

  // load the theme variant(s) CSS
  useParagonThemeVariants({
    themeVariants,
    onComplete: onLoadThemeVariants,
    currentThemeVariant: themeState.themeVariant,
    onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange,
  });

  useEffect(() => {
    // theme is already loaded, do nothing
    if (themeState.isThemeLoaded) {
      return;
    }

    const hasThemeConfig = (themeCore?.urls && !isEmptyObject(themeVariants));
    if (!hasThemeConfig) {
      // no theme URLs to load, set loading to false.
      dispatch(paragonThemeActions.setParagonThemeLoaded(true));
    }

    // Return early if neither the core theme CSS nor any theme variant CSS is loaded.
    if (!isCoreThemeLoaded || !hasLoadedThemeVariants) {
      return;
    }

    // All application theme URLs are loaded
    dispatch(paragonThemeActions.setParagonThemeLoaded(true));
  }, [
    themeState.isThemeLoaded,
    isCoreThemeLoaded,
    hasLoadedThemeVariants,
    themeCore?.urls,
    themeVariants,
  ]);

  return [themeState, dispatch];
};

export default useParagonTheme;