Skip to content

feat: add image pre-loading hooks #10015

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions site/src/hooks/images.test.ts
Original file line number Diff line number Diff line change
@@ -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<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(() => {
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();
});
});
203 changes: 203 additions & 0 deletions site/src/hooks/images.ts
Original file line number Diff line number Diff line change
@@ -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<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 = {
// 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]);
}