All files / client/src/components AppUpdateBanner.tsx

96.42% Statements 27/28
100% Branches 17/17
100% Functions 6/6
96.42% Lines 27/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                            520x       520x 2481x 2481x     520x         520x 1961x   1961x 3756x     1961x   1961x 1961x                 1961x   1961x       1961x                   1961x 1743x 1743x 1743x           1734x   9x 9x     1961x 1921x                 6x               3x                 1x      
import { ReactElement, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import styled from "styled-components";
import { config } from "shared/config";
import { Button } from "client/components/Button";
import { DismissibleBanner } from "client/components/DismissibleBanner";
import { ButtonStyle, HighlightStyle } from "client/components/componentStyles";
import { useAppSelector } from "client/utils/hooks";
import {
  getAppUpdateReloadedBuildTime,
  saveAppUpdateReloadedBuildTime,
} from "client/utils/sessionStorage";
 
const { appBuildTime } = config.client();
 
// Empty when a build carried no timestamp, and any non-numeric value is
// treated the same way: without a usable time there is nothing to order by
const parseBuildTime = (value: string): number | null => {
  const parsed = Number(value);
  return value === "" || Number.isNaN(parsed) ? null : parsed;
};
 
const ownBuildTime = parseBuildTime(appBuildTime);
 
// Shown when the server reports a build newer than the one this bundle came
// from, i.e. a new Konsti version was deployed after this page was loaded.
// Reloading fetches it
export const AppUpdateBanner = (): ReactElement | null => {
  const { t } = useTranslation();
 
  const serverAppBuildTime = useAppSelector(
    (state) => state.admin.serverAppBuildTime,
  );
  // Keyed to the dismissed build so a later deploy notifies again
  const [dismissedBuildTime, setDismissedBuildTime] = useState<string>("");
 
  const routerLocation = useLocation();
  const lastLocationKey = useRef(routerLocation.key);
 
  // Strictly newer, not merely different. Images are built in the order they
  // are deployed, so this distinguishes a server that has moved on from one
  // that simply hasn't rolled yet: mid-rollout a page served by an
  // already-updated instance keeps polling instances still on the old build,
  // and must not be told to update to the version it is already running.
  // Either side missing a build time means there is nothing to compare, which
  // is the case in every environment that doesn't stamp its builds
  const serverBuildTime = parseBuildTime(serverAppBuildTime);
  const updateAvailable =
    ownBuildTime !== null &&
    serverBuildTime !== null &&
    serverBuildTime > ownBuildTime;
 
  const dismissed = dismissedBuildTime === serverAppBuildTime;
 
  // Reload transparently on the first route navigation after an update is
  // detected: a navigation replaces the whole view anyway, so a full page
  // load is not disruptive there. Only an actual navigation may trigger the
  // reload - never the update detection itself, which can happen mid-view.
  // Attempt it only once per server build so a reload that fails to deliver
  // it can't loop; after that the banner's reload button remains as the
  // manual path. Keyed to the build rather than the revision so redeploying
  // the same code - a rollback - is still allowed its own reload
  useEffect(() => {
    const isNavigation = routerLocation.key !== lastLocationKey.current;
    lastLocationKey.current = routerLocation.key;
    if (
      !isNavigation ||
      !updateAvailable ||
      dismissed ||
      getAppUpdateReloadedBuildTime() === serverAppBuildTime
    ) {
      return;
    }
    saveAppUpdateReloadedBuildTime(serverAppBuildTime);
    location.reload();
  }, [routerLocation.key, updateAvailable, dismissed, serverAppBuildTime]);
 
  if (!updateAvailable || dismissed) {
    return null;
  }
 
  return (
    <DismissibleBanner
      data-testid="app-update-banner"
      icon="arrows-rotate"
      highlightStyle={HighlightStyle.INFO}
      onDismiss={() => {
        setDismissedBuildTime(serverAppBuildTime);
      }}
      dismissAriaLabel={t("iconAltText.closeAppUpdateNotification")}
    >
      <Message>{t("appUpdateBanner.message")}</Message>
      <Button
        buttonStyle={ButtonStyle.PRIMARY}
        onClick={() => {
          location.reload();
        }}
      >
        {t("appUpdateBanner.reload")}
      </Button>
    </DismissibleBanner>
  );
};
 
const Message = styled.span`
  flex: 1;
`;