diff --git a/site/src/hooks/images.test.ts b/site/src/hooks/images.test.ts new file mode 100644 index 0000000000000..311992a757038 --- /dev/null +++ b/site/src/hooks/images.test.ts @@ -0,0 +1,102 @@ +import { useImagePreloading, useThrottledImageLoader } from "./images"; + +/** + * 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 { + #src = ""; + onload: (() => void) | undefined = undefined; + onerror: (() => void) | undefined = undefined; + + get src() { + return this.#src; + } + + set src(newSrc: string) { + this.#src = newSrc; + this.#simulateHttpRequest(newSrc); + } + + #simulateHttpRequest(src: string) { + const promise = new Promise((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(() => { + return new MockImage() as unknown as HTMLImageElement; + }); +}); + +beforeEach(() => { + mode = "alwaysSucceed"; +}); + +afterAll(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +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..380154a82fb4f --- /dev/null +++ b/site/src/hooks/images.ts @@ -0,0 +1,203 @@ +/** + * @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" | "aborted" | "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(); + +/** + * 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 = { + // Actual HTMLImageElement treats empty strings as an error, but that's + // not a concern for the tracker + status: imgUrl === "" ? "aborted" : "loading", + image: dummyImage, + retries: 0, + }; + + dummyImage.onload = () => { + entry.status = "success"; + }; + + dummyImage.onerror = () => { + if (imgUrl !== "aborted") { + entry.status = "error"; + } + }; + + imageTracker.set(imgUrl, entry); + continue; + } + + const skipRetry = + 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.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); + } + + 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) { + /** + * 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 componentCleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + componentCleanupRef.current?.(); + }, [throttleTimeMs]); + + return useCallback( + (imgUrls?: readonly string[]) => { + if (throttledRef.current || imgUrls === undefined) { + // Noop + return () => {}; + } + + throttledRef.current = true; + const imagesCleanup = preloadImages(imgUrls); + + const timeoutId = window.setTimeout(() => { + throttledRef.current = false; + }, throttleTimeMs); + + const componentCleanup = () => { + imagesCleanup(); + window.clearTimeout(timeoutId); + + throttledRef.current = false; + componentCleanupRef.current = null; + }; + + componentCleanupRef.current = componentCleanup; + return componentCleanup; + }, + [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); + + // 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(() => { + return preloadImages(cachedUrls); + }, [cachedUrls]); +}