From 0f8cd9a2462d52947a34c8a6bc1b9402eea70fc6 Mon Sep 17 00:00:00 2001
From: Parkreiner <michaelsmith@coder.com>
Date: Tue, 3 Oct 2023 12:04:12 +0000
Subject: [PATCH 1/5] chore: add image pre-loading file and scaffolding for
 test

---
 site/src/hooks/images.test.ts |  77 ++++++++++++++++
 site/src/hooks/images.ts      | 162 ++++++++++++++++++++++++++++++++++
 2 files changed, 239 insertions(+)
 create mode 100644 site/src/hooks/images.test.ts
 create mode 100644 site/src/hooks/images.ts

diff --git a/site/src/hooks/images.test.ts b/site/src/hooks/images.test.ts
new file mode 100644
index 0000000000000..88c4486d38cb9
--- /dev/null
+++ b/site/src/hooks/images.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Work in progress. Mainly because I need to figure out how best to deal with
+ * a global constructor that implicitly makes HTTP requests in the background.
+ */
+import { useImagePreloading, useThrottledImageLoader } from "./images";
+
+/**
+ * Probably not on the right track with this one. Probably need to redo.
+ */
+class MockImage {
+  #unusedWidth = 0;
+  #unusedHeight = 0;
+  #src = "";
+  completed = false;
+
+  constructor(width?: number, height?: number) {
+    this.#unusedWidth = width ?? 0;
+    this.#unusedHeight = height ?? 0;
+  }
+
+  get src() {
+    return this.#src;
+  }
+
+  set src(newSrc: string) {
+    this.#src = newSrc;
+  }
+}
+
+beforeAll(() => {
+  jest.useFakeTimers();
+  jest.spyOn(global, "Image").mockImplementation(MockImage);
+});
+
+test(`${useImagePreloading.name}`, () => {
+  it.skip("Should passively preload images after a render", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should kick off a new pre-load if the content of the images changes after a re-render", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should not kick off a new pre-load if the array changes by reference, but the content is the same", () => {
+    expect.hasAssertions();
+  });
+});
+
+test(`${useThrottledImageLoader.name}`, () => {
+  it.skip("Should pre-load all images passed in the first time the function is called, no matter what", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should throttle all calls to the function made within the specified throttle time", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should always return a cleanup function", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should reset its own state if the returned-out cleanup function is called", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should not trigger the throttle if the images argument is undefined", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should support arbitrary throttle values", () => {
+    expect.hasAssertions();
+  });
+
+  it.skip("Should reset all of its state if the throttle value passed into the hook changes in a re-render", () => {
+    expect.hasAssertions();
+  });
+});
diff --git a/site/src/hooks/images.ts b/site/src/hooks/images.ts
new file mode 100644
index 0000000000000..0e6cbc11fd74f
--- /dev/null
+++ b/site/src/hooks/images.ts
@@ -0,0 +1,162 @@
+/**
+ * @file Defines general-purpose hooks/utilities for working with images.
+ *
+ * Mainly for pre-loading images to minimize periods when a UI renders with data
+ * but takes a few extra seconds for the images to load in.
+ */
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const MAX_RETRIES = 3;
+
+/**
+ * Tracks the loading status of an individual image.
+ */
+type ImageTrackerEntry = {
+  image: HTMLImageElement;
+  status: "loading" | "error" | "success";
+  retries: number;
+};
+
+/**
+ * Tracks all images pre-loaded by hooks/utilities defined in this file. This
+ * is a single source of shared mutable state.
+ */
+const imageTracker = new Map<string, ImageTrackerEntry>();
+
+/**
+ * General pre-load utility used throughout the file. Returns a cleanup function
+ * to help work with React.useEffect.
+ *
+ * Currently treated as an implementation detail, so it's not exported, but it
+ * might make sense to break it out into a separate file down the line.
+ */
+function preloadImages(imageUrls?: readonly string[]): () => void {
+  if (imageUrls === undefined) {
+    // Just a noop
+    return () => {};
+  }
+
+  const retryTimeoutIds: number[] = [];
+
+  for (const imgUrl of imageUrls) {
+    const prevEntry = imageTracker.get(imgUrl);
+
+    if (prevEntry === undefined) {
+      const dummyImage = new Image();
+      dummyImage.src = imgUrl;
+
+      const entry: ImageTrackerEntry = {
+        image: dummyImage,
+        status: "loading",
+        retries: 0,
+      };
+
+      dummyImage.onload = () => {
+        entry.status = "success";
+      };
+
+      dummyImage.onerror = () => {
+        if (imgUrl !== "") {
+          entry.status = "error";
+        }
+      };
+
+      imageTracker.set(imgUrl, entry);
+      continue;
+    }
+
+    const skipRetry =
+      prevEntry.status === "loading" ||
+      prevEntry.status === "success" ||
+      prevEntry.retries === MAX_RETRIES;
+
+    if (skipRetry) {
+      continue;
+    }
+
+    prevEntry.image.src = "";
+    const retryId = window.setTimeout(() => {
+      prevEntry.image.src = imgUrl;
+      prevEntry.retries++;
+    }, 0);
+
+    retryTimeoutIds.push(retryId);
+  }
+
+  return () => {
+    for (const id of retryTimeoutIds) {
+      window.clearTimeout(id);
+    }
+  };
+}
+
+/**
+ * Exposes a throttled version of preloadImages. Useful for tying pre-loads to
+ * things like mouse hovering.
+ *
+ * The throttling state is always associated with the component instance,
+ * meaning that one component being throttled won't prevent other components
+ * from making requests.
+ */
+export function useThrottledImageLoader(throttleTimeMs = 500) {
+  const throttledRef = useRef(false);
+  const loadedCleanupRef = useRef<(() => void) | null>(null);
+
+  useEffect(() => {
+    loadedCleanupRef.current?.();
+  }, [throttleTimeMs]);
+
+  return useCallback(
+    (imgUrls?: readonly string[]) => {
+      if (throttledRef.current || imgUrls === undefined) {
+        // Noop
+        return () => {};
+      }
+
+      throttledRef.current = true;
+      const cleanup = preloadImages(imgUrls);
+      loadedCleanupRef.current = cleanup;
+
+      const timeoutId = window.setTimeout(() => {
+        throttledRef.current = false;
+      }, throttleTimeMs);
+
+      return () => {
+        cleanup();
+        loadedCleanupRef.current = null;
+
+        window.clearTimeout(timeoutId);
+        throttledRef.current = false;
+      };
+    },
+    [throttleTimeMs],
+  );
+}
+
+/**
+ * Sets up passive image-preloading for a component.
+ *
+ * Has logic in place to minimize the risks of an array being passed in, even
+ * if the array's memory reference changes every render.
+ */
+export function useImagePreloading(imgUrls?: readonly string[]) {
+  // Doing weird, hacky nonsense to guarantee useEffect doesn't run too often,
+  // even if consuming component doesn't stabilize value of imgUrls
+  const [cachedUrls, setCachedUrls] = useState(imgUrls);
+
+  // Very uncommon pattern, but it's based on something from the official React
+  // docs, and the comparison should have no perceivable effect on performance
+  if (cachedUrls !== imgUrls) {
+    const changedByValue =
+      imgUrls?.length !== cachedUrls?.length ||
+      !cachedUrls?.every((url, index) => url === imgUrls?.[index]);
+
+    if (changedByValue) {
+      setCachedUrls(imgUrls);
+    }
+  }
+
+  useEffect(() => {
+    return preloadImages(cachedUrls);
+  }, [cachedUrls]);
+}

From cf824af4351bf0b869c4eaf055c4e18a6ad74641 Mon Sep 17 00:00:00 2001
From: Parkreiner <michaelsmith@coder.com>
Date: Wed, 4 Oct 2023 15:59:32 +0000
Subject: [PATCH 2/5] docs: beef up docs for weird behavior

---
 site/src/hooks/images.ts | 41 ++++++++++++++++++++++++++++++----------
 1 file changed, 31 insertions(+), 10 deletions(-)

diff --git a/site/src/hooks/images.ts b/site/src/hooks/images.ts
index 0e6cbc11fd74f..8ff0b4306631f 100644
--- a/site/src/hooks/images.ts
+++ b/site/src/hooks/images.ts
@@ -144,16 +144,37 @@ export function useImagePreloading(imgUrls?: readonly string[]) {
   // even if consuming component doesn't stabilize value of imgUrls
   const [cachedUrls, setCachedUrls] = useState(imgUrls);
 
-  // Very uncommon pattern, but it's based on something from the official React
-  // docs, and the comparison should have no perceivable effect on performance
-  if (cachedUrls !== imgUrls) {
-    const changedByValue =
-      imgUrls?.length !== cachedUrls?.length ||
-      !cachedUrls?.every((url, index) => url === imgUrls?.[index]);
-
-    if (changedByValue) {
-      setCachedUrls(imgUrls);
-    }
+  // For performance reasons, this array comparison only goes one level deep
+  const changedByValue =
+    cachedUrls !== imgUrls &&
+    (imgUrls?.length !== cachedUrls?.length ||
+      !cachedUrls?.every((url, index) => url === imgUrls?.[index]));
+
+  /**
+   * If this state dispatch were called inside useEffect, that would mean that
+   * the component and all of its children would render in full and get painted
+   * to the screen, the effect would fire, and then the component and all of its
+   * children would need to re-render and re-paint.
+   *
+   * Not a big deal for small components; possible concern when this hook is
+   * designed to be used anywhere and could be used at the top of the app.
+   *
+   * Calling the state dispatch inline means that the React invalidates the
+   * current component's output immediately. So the current render finishes,
+   * React flushes the state changes, throws away the render result, and
+   * immediately redoes the render with the new state. This happens before any
+   * painting happens, and before React even tries to touch the children.
+   *
+   * Basically, this pattern is weird and ugly, but it guarantees that no matter
+   * how complicated a component is, the cost of updating the state is always
+   * limited to one component total, and never any of its descendants. And the
+   * cost is limited to redoing an internal React render, not a render plus a
+   * set of DOM updates.
+   *
+   * @see {@link https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes}
+   */
+  if (changedByValue) {
+    setCachedUrls(imgUrls);
   }
 
   useEffect(() => {

From 9ed0dc3cd625f6fb7522b15194ddc50be0593ac0 Mon Sep 17 00:00:00 2001
From: Parkreiner <michaelsmith@coder.com>
Date: Wed, 4 Oct 2023 16:41:53 +0000
Subject: [PATCH 3/5] fix: update logic issue with cleanup functions

---
 site/src/hooks/images.ts | 22 ++++++++++++++--------
 1 file changed, 14 insertions(+), 8 deletions(-)

diff --git a/site/src/hooks/images.ts b/site/src/hooks/images.ts
index 8ff0b4306631f..0d329eb125305 100644
--- a/site/src/hooks/images.ts
+++ b/site/src/hooks/images.ts
@@ -99,11 +99,15 @@ function preloadImages(imageUrls?: readonly string[]): () => void {
  * from making requests.
  */
 export function useThrottledImageLoader(throttleTimeMs = 500) {
+  /**
+   * It's possible to reduce the amount of state by only having the cleanup ref;
+   * tried it, but it made the code a lot harder to read and reason about
+   */
   const throttledRef = useRef(false);
-  const loadedCleanupRef = useRef<(() => void) | null>(null);
+  const componentCleanupRef = useRef<(() => void) | null>(null);
 
   useEffect(() => {
-    loadedCleanupRef.current?.();
+    componentCleanupRef.current?.();
   }, [throttleTimeMs]);
 
   return useCallback(
@@ -114,20 +118,22 @@ export function useThrottledImageLoader(throttleTimeMs = 500) {
       }
 
       throttledRef.current = true;
-      const cleanup = preloadImages(imgUrls);
-      loadedCleanupRef.current = cleanup;
+      const imagesCleanup = preloadImages(imgUrls);
 
       const timeoutId = window.setTimeout(() => {
         throttledRef.current = false;
       }, throttleTimeMs);
 
-      return () => {
-        cleanup();
-        loadedCleanupRef.current = null;
-
+      const componentCleanup = () => {
+        imagesCleanup();
         window.clearTimeout(timeoutId);
+
         throttledRef.current = false;
+        componentCleanupRef.current = null;
       };
+
+      componentCleanupRef.current = componentCleanup;
+      return componentCleanup;
     },
     [throttleTimeMs],
   );

From 2c987ba4be50bc482f8cbcd354181978b7b434f0 Mon Sep 17 00:00:00 2001
From: Parkreiner <michaelsmith@coder.com>
Date: Wed, 4 Oct 2023 16:42:06 +0000
Subject: [PATCH 4/5] wip: commit progress for tests

---
 site/src/hooks/images.test.ts | 53 ++++++++++++++++++++++++++---------
 1 file changed, 39 insertions(+), 14 deletions(-)

diff --git a/site/src/hooks/images.test.ts b/site/src/hooks/images.test.ts
index 88c4486d38cb9..311992a757038 100644
--- a/site/src/hooks/images.test.ts
+++ b/site/src/hooks/images.test.ts
@@ -1,22 +1,18 @@
-/**
- * Work in progress. Mainly because I need to figure out how best to deal with
- * a global constructor that implicitly makes HTTP requests in the background.
- */
 import { useImagePreloading, useThrottledImageLoader } from "./images";
 
 /**
- * Probably not on the right track with this one. Probably need to redo.
+ * This is weird and clunky, and the mocking seems to have more edge cases than
+ * the code I'm trying to test.
+ *
+ * HTMLImageElement doesn't go through fetch, but still it might be worth trying
+ * to integrate this with MSW
  */
+let mode: "alwaysSucceed" | "alwaysFail" = "alwaysSucceed";
+
 class MockImage {
-  #unusedWidth = 0;
-  #unusedHeight = 0;
   #src = "";
-  completed = false;
-
-  constructor(width?: number, height?: number) {
-    this.#unusedWidth = width ?? 0;
-    this.#unusedHeight = height ?? 0;
-  }
+  onload: (() => void) | undefined = undefined;
+  onerror: (() => void) | undefined = undefined;
 
   get src() {
     return this.#src;
@@ -24,12 +20,41 @@ class MockImage {
 
   set src(newSrc: string) {
     this.#src = newSrc;
+    this.#simulateHttpRequest(newSrc);
+  }
+
+  #simulateHttpRequest(src: string) {
+    const promise = new Promise<void>((resolve, reject) => {
+      if (src === "") {
+        reject();
+      }
+
+      const settlePromise = mode === "alwaysSucceed" ? resolve : reject;
+      setTimeout(settlePromise, 100);
+    });
+
+    // Need arrow functions because onload/onerror are allowed to mutate in the
+    // original HTMLImageElement
+    void promise.then(() => this.onload?.());
+    void promise.catch(() => this.onerror?.());
   }
 }
 
 beforeAll(() => {
   jest.useFakeTimers();
-  jest.spyOn(global, "Image").mockImplementation(MockImage);
+
+  jest.spyOn(global, "Image").mockImplementation(() => {
+    return new MockImage() as unknown as HTMLImageElement;
+  });
+});
+
+beforeEach(() => {
+  mode = "alwaysSucceed";
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+  jest.clearAllMocks();
 });
 
 test(`${useImagePreloading.name}`, () => {

From 672414c614134ccc44e4936aeef36a4dd8add635 Mon Sep 17 00:00:00 2001
From: Parkreiner <michaelsmith@coder.com>
Date: Wed, 4 Oct 2023 17:08:56 +0000
Subject: [PATCH 5/5] fix: clean up logic with images

---
 site/src/hooks/images.ts | 34 ++++++++++++++++++++++++----------
 1 file changed, 24 insertions(+), 10 deletions(-)

diff --git a/site/src/hooks/images.ts b/site/src/hooks/images.ts
index 0d329eb125305..380154a82fb4f 100644
--- a/site/src/hooks/images.ts
+++ b/site/src/hooks/images.ts
@@ -13,7 +13,7 @@ const MAX_RETRIES = 3;
  */
 type ImageTrackerEntry = {
   image: HTMLImageElement;
-  status: "loading" | "error" | "success";
+  status: "loading" | "aborted" | "error" | "success";
   retries: number;
 };
 
@@ -46,8 +46,10 @@ function preloadImages(imageUrls?: readonly string[]): () => void {
       dummyImage.src = imgUrl;
 
       const entry: ImageTrackerEntry = {
+        // Actual HTMLImageElement treats empty strings as an error, but that's
+        // not a concern for the tracker
+        status: imgUrl === "" ? "aborted" : "loading",
         image: dummyImage,
-        status: "loading",
         retries: 0,
       };
 
@@ -56,7 +58,7 @@ function preloadImages(imageUrls?: readonly string[]): () => void {
       };
 
       dummyImage.onerror = () => {
-        if (imgUrl !== "") {
+        if (imgUrl !== "aborted") {
           entry.status = "error";
         }
       };
@@ -66,19 +68,31 @@ function preloadImages(imageUrls?: readonly string[]): () => void {
     }
 
     const skipRetry =
-      prevEntry.status === "loading" ||
-      prevEntry.status === "success" ||
-      prevEntry.retries === MAX_RETRIES;
+      prevEntry.status !== "error" || prevEntry.retries === MAX_RETRIES;
 
     if (skipRetry) {
       continue;
     }
 
+    // Have to disconnect error handler while resetting the src, or else an
+    // error will be processed
+    const prevErrorHandler = prevEntry.image.onerror;
+    prevEntry.image.onerror = null;
     prevEntry.image.src = "";
-    const retryId = window.setTimeout(() => {
-      prevEntry.image.src = imgUrl;
-      prevEntry.retries++;
-    }, 0);
+
+    const retryId = window.setTimeout(
+      () => {
+        prevEntry.image.src = imgUrl;
+        prevEntry.image.onerror = prevErrorHandler;
+
+        prevEntry.status = "loading";
+        prevEntry.retries++;
+      },
+      // Zero is intentional; only purpose of the timeout call is to make the
+      // update logic take a brief trip through the task queue while making it
+      // easy to cancel the operation
+      0,
+    );
 
     retryTimeoutIds.push(retryId);
   }