Use suspense to ensure desktop images load atomically

This commit is contained in:
Jordan Eldredge 2020-11-01 19:45:31 -08:00
parent 94a681a8a0
commit e2b5cb76bf
5 changed files with 132 additions and 20 deletions

View file

@ -1,5 +1,5 @@
import WebampLazy from "../../js/webampLazy";
import React from "react";
import React, { Suspense } from "react";
import WebampIcon from "./WebampIcon";
// import Mp3Icon from "./Mp3Icon";
import SkinIcon from "./SkinIcon";
@ -66,23 +66,27 @@ const DemoDesktop = ({ webamp }: Props) => {
marginLeft: HORIZONTAL_MARGIN,
}}
>
{icons.map((icon, i) => {
const row = Math.floor(i / columns);
const column = i % columns;
return (
<div
key={i}
style={{
left: column * ICON_WIDTH,
top: row * ICON_HEIGHT,
width: ICON_WIDTH,
position: "absolute",
}}
>
{icon}
</div>
);
})}
<Suspense
fallback={null /* Wait for all icons to load before showing any */}
>
{icons.map((icon, i) => {
const row = Math.floor(i / columns);
const column = i % columns;
return (
<div
key={i}
style={{
left: column * ICON_WIDTH,
top: row * ICON_HEIGHT,
width: ICON_WIDTH,
position: "absolute",
}}
>
{icon}
</div>
);
})}
</Suspense>
</div>
);
};

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef } from "react";
import classnames from "classnames";
import IconImage from "./IconImage";
interface Props {
iconUrl: string;
@ -37,7 +38,7 @@ const DesktopIcon = ({ iconUrl, onOpen, name, onDragStart }: Props) => {
className={classnames("desktop-icon", { selected })}
onDragStart={onDragStart}
>
<img src={iconUrl} style={{ width: 32, height: 32 }} />
<IconImage src={iconUrl} />
<div className="desktop-icon-title">{name}</div>
</div>
);

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef } from "react";
import classnames from "classnames";
import IconImage from "./IconImage";
interface Props {
iconUrl: string;
@ -43,7 +44,7 @@ const DesktopLinkIcon = ({ iconUrl, href, name }: Props) => {
}}
className={classnames("desktop-icon", { selected })}
>
<img src={iconUrl} style={{ width: 32, height: 32 }} />
<IconImage src={iconUrl} />
<div className="desktop-icon-title">{name}</div>
</a>
);

View file

@ -0,0 +1,9 @@
import React from "react";
import SuspenseImage from "./SuspenseImage";
type Props = {
src: string;
};
export default function IconImage({ src }: Props) {
return <SuspenseImage src={src} style={{ width: 32, height: 32 }} />;
}

View file

@ -0,0 +1,97 @@
import React from "react";
// A Resource is an object with a read method returning the payload
interface Resource<Payload> {
read: () => Payload;
}
type status = "pending" | "success" | "error";
// this function let us get a new function using the asyncFn we pass
// this function also receives a payload and return us a resource with
// that payload assigned as type
function createResource<Payload>(
asyncFn: () => Promise<Payload>
): Resource<Payload> {
// we start defining our resource is on a pending status
let status: status = "pending";
// and we create a variable to store the result
let result: any;
// then we immediately start running the `asyncFn` function
// and we store the resulting promise
const promise = asyncFn().then(
(r: Payload) => {
// once it's fulfilled we change the status to success
// and we save the returned value as result
status = "success";
result = r;
},
(e: Error) => {
// once it's rejected we change the status to error
// and we save the returned error as result
status = "error";
result = e;
}
);
// lately we return an error object with the read method
return {
read(): Payload {
// here we will check the status value
switch (status) {
case "pending":
// if it's still pending we throw the promise
// throwing a promise is how Suspense know our component is not ready
throw promise;
case "error":
// if it's error we throw the error
throw result;
case "success":
// if it's success we return the result
return result;
}
},
};
}
// First we need a type of cache to avoid creating resources for images
// we have already fetched in the past
const cache = new Map<string, any>();
// then we create our loadImage function, this function receives the source
// of the image and returns a resource
function loadImage(source: string): Resource<string> {
// here we start getting the resource from the cache
let resource = cache.get(source);
// and if it's there we return it immediately
if (resource) return resource;
// but if it's not we create a new resource
resource = createResource<string>(
() =>
// in our async function we create a promise
new Promise((resolve, reject) => {
// then create a new image element
const img = new window.Image();
// set the src to our source
img.src = source;
// and start listening for the load event to resolve the promise
img.addEventListener("load", () => resolve(source));
// and also the error event to reject the promise
img.addEventListener("error", () =>
reject(new Error(`Failed to load image ${source}`))
);
})
);
// before finishing we save the new resource in the cache
cache.set(source, resource);
// and return return it
return resource;
}
export default function SuspenseImage(
props: React.ImgHTMLAttributes<HTMLImageElement>
): JSX.Element {
if (props.src != null) {
loadImage(props.src).read();
}
return <img {...props} />;
}