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 1 commit
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
Next Next commit
chore: add image pre-loading file and scaffolding for test
  • Loading branch information
Parkreiner committed Oct 3, 2023
commit 0f8cd9a2462d52947a34c8a6bc1b9402eea70fc6
77 changes: 77 additions & 0 deletions site/src/hooks/images.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
162 changes: 162 additions & 0 deletions site/src/hooks/images.ts
Original file line number Diff line number Diff line change
@@ -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]);
}