import React from "react";
import Observer from "react-intersection-observer";
import { unionize, ofType, UnionOf } from "unionize";
/**
* Valid props for LazyImage components
*/
export type CommonLazyImageProps = ImageProps & {
// NOTE: if you add props here, remember to destructure them out of being
// passed to the children, in the render() callback.
/** Whether to skip checking for viewport and always show the 'actual' component
* @see https://github.com/fpapado/react-lazy-images/#eager-loading--server-side-rendering-ssr
*/
loadEagerly?: boolean;
/** Subset of props for the IntersectionObserver
* @see https://github.com/thebuilder/react-intersection-observer#props
*/
observerProps?: ObserverProps;
/** Use the Image Decode API;
* The call to a new HTML
element’s decode() function returns a promise, which,
* when fulfilled, ensures that the image can be appended to the DOM without causing
* a decoding delay on the next frame.
* @see: https://www.chromestatus.com/feature/5637156160667648
*/
experimentalDecode?: boolean;
/** Whether to log out internal state transitions for the component */
debugActions?: boolean;
/** Delay a certain duration before starting to load, in ms.
* This can help avoid loading images while the user scrolls quickly past them.
* TODO: naming things.
*/
debounceDurationMs?: number;
};
/** Valid props for LazyImageFull */
export interface LazyImageFullProps extends CommonLazyImageProps {
/** Children should be either a function or a node */
children: (args: RenderCallbackArgs) => React.ReactNode;
}
/** Values that the render props take */
export interface RenderCallbackArgs {
imageState: ImageState;
imageProps: ImageProps;
/** When not loading eagerly, a ref to bind to the DOM element. This is needed for the intersection calculation to work. */
ref?: React.RefObject;
}
export interface ImageProps {
/** The source of the image to load */
src: string;
/** The source set of the image to load */
srcSet?: string;
/** The alt text description of the image you are loading */
alt?: string;
/** Sizes descriptor */
sizes?: string;
}
/** Subset of react-intersection-observer's props */
export interface ObserverProps {
/**
* Margin around the root that expands the area for intersection.
* @see https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin
* @default "50px 0px"
* @example Declaration same as CSS margin:
* `"10px 20px 30px 40px"` (top, right, bottom, left).
*/
rootMargin?: string;
/** Number between 0 and 1 indicating the the percentage that should be
* visible before triggering.
* @default `0.01`
*/
threshold?: number;
}
/** States that the image loading can be in.
* Used together with LazyImageFull render props.
* External representation of the internal state.
* */
export enum ImageState {
NotAsked = "NotAsked",
Loading = "Loading",
LoadSuccess = "LoadSuccess",
LoadError = "LoadError"
}
/** The component's state */
const LazyImageFullState = unionize({
NotAsked: {},
Buffering: {},
// Could try to make it Promise,
// but we don't use the element anyway, and we cache promises
Loading: {},
LoadSuccess: {},
LoadError: ofType<{ msg: string }>()
});
type LazyImageFullState = UnionOf;
/** Actions that change the component's state.
* These are not unlike Actions in Redux or, the ones I'm inspired by,
* Msg in Elm.
*/
const Action = unionize({
ViewChanged: ofType<{ inView: boolean }>(),
BufferingEnded: {},
// MAYBE: Load: {},
LoadSuccess: {},
LoadError: ofType<{ msg: string }>()
});
type Action = UnionOf;
/** Commands (Cmd) describe side-effects as functions that take the instance */
// FUTURE: These should be tied to giving back a Msg / asynchronoulsy giving a Msg with conditions
type Cmd = (instance: LazyImageFull) => void;
/** The output from a reducer is the next state and maybe a command */
type ReducerResult = {
nextState: LazyImageFullState;
cmd?: Cmd;
};
///// Commands, things that perform side-effects /////
/** Get a command that sets a buffering Promise */
const getBufferingCmd = (durationMs: number): Cmd => instance => {
// Make cancelable buffering Promise
const bufferingPromise = makeCancelable(delayedPromise(durationMs));
// Kick off promise chain
bufferingPromise.promise
.then(() => instance.update(Action.BufferingEnded()))
.catch(
_reason => {}
//console.log({ isCanceled: _reason.isCanceled })
);
// Side-effect; set the promise in the cache
instance.promiseCache.buffering = bufferingPromise;
};
/** Get a command that sets an image loading Promise */
const getLoadingCmd = (
imageProps: ImageProps,
experimentalDecode?: boolean
): Cmd => instance => {
// Make cancelable loading Promise
const loadingPromise = makeCancelable(
loadImage(imageProps, experimentalDecode)
);
// Kick off request for Image and attach listeners for response
loadingPromise.promise
.then(_res => instance.update(Action.LoadSuccess({})))
.catch(e => {
// If the Loading Promise was canceled, it means we have stopped
// loading due to unmount, rather than an error.
if (!e.isCanceled) {
// TODO: think more about the error here
instance.update(Action.LoadError({ msg: "Failed to load" }));
}
});
// Side-effect; set the promise in the cache
instance.promiseCache.loading = loadingPromise;
};
/** Command that cancels the buffering Promise */
const cancelBufferingCmd: Cmd = instance => {
// Side-effect; cancel the promise in the cache
// We know this exists if we are in a Buffering state
instance.promiseCache.buffering.cancel();
};
/**
* Component that preloads the image once it is in the viewport,
* and then swaps it in. Takes a render prop that allows to specify
* what is rendered based on the loading state.
*/
export class LazyImageFull extends React.Component<
LazyImageFullProps,
LazyImageFullState
> {
static displayName = "LazyImageFull";
/** A central place to store promises.
* A bit silly, but passing promsises directly in the state
* was giving me weird timing issues. This way we can keep
* the promises in check, and pick them up from the respective methods.
* FUTURE: Could pass the relevant key in Buffering and Loading, so
* that at least we know where they are from a single source.
*/
promiseCache: {
[key: string]: CancelablePromise;
} = {};
initialState = LazyImageFullState.NotAsked();
/** Emit the next state based on actions.
* This is the core of the component!
*/
static reducer(
action: Action,
prevState: LazyImageFullState,
props: LazyImageFullProps
): ReducerResult {
return Action.match(action, {
ViewChanged: ({ inView }) => {
if (inView === true) {
// If src is not specified, then there is nothing to preload; skip to Loaded state
if (!props.src) {
return { nextState: LazyImageFullState.LoadSuccess() }; // Error wtf
} else {
// If in view, only load something if NotAsked, otherwise leave untouched
return LazyImageFullState.match(prevState, {
NotAsked: () => {
// If debounce is specified, then start buffering
if (!!props.debounceDurationMs) {
return {
nextState: LazyImageFullState.Buffering(),
cmd: getBufferingCmd(props.debounceDurationMs)
};
} else {
// If no debounce is specified, then start loading immediately
return {
nextState: LazyImageFullState.Loading(),
cmd: getLoadingCmd(props, props.experimentalDecode)
};
}
},
// Do nothing in other states
default: () => ({ nextState: prevState })
});
}
} else {
// If out of view, cancel if Buffering, otherwise leave untouched
return LazyImageFullState.match(prevState, {
Buffering: () => ({
nextState: LazyImageFullState.NotAsked(),
cmd: cancelBufferingCmd
}),
// Do nothing in other states
default: () => ({ nextState: prevState })
});
}
},
// Buffering has ended/succeeded, kick off request for image
BufferingEnded: () => ({
nextState: LazyImageFullState.Loading(),
cmd: getLoadingCmd(props, props.experimentalDecode)
}),
// Loading the image succeeded, simple
LoadSuccess: () => ({ nextState: LazyImageFullState.LoadSuccess() }),
// Loading the image failed, simple
LoadError: e => ({ nextState: LazyImageFullState.LoadError(e) })
});
}
constructor(props: LazyImageFullProps) {
super(props);
this.state = this.initialState;
// Bind methods
this.update = this.update.bind(this);
}
update(action: Action) {
// Get the next state and any effects
const { nextState, cmd } = LazyImageFull.reducer(
action,
this.state,
this.props
);
// Debugging
if (this.props.debugActions) {
if (process.env.NODE_ENV === "production") {
console.warn(
'You are running LazyImage with debugActions="true" in production. This might have performance implications.'
);
}
console.log({ action, prevState: this.state, nextState });
}
// Actually set the state, and kick off any effects after that
this.setState(nextState, () => cmd && cmd(this));
}
componentWillUnmount() {
// Clear the Promise Cache
if (this.promiseCache.loading) {
// NOTE: This does not cancel the request, only the callback.
// We weould need fetch() and an AbortHandler for that.
this.promiseCache.loading.cancel();
}
if (this.promiseCache.buffering) {
this.promiseCache.buffering.cancel();
}
this.promiseCache = {};
}
// Render function
render() {
// This destructuring is silly
const {
children,
loadEagerly,
observerProps,
experimentalDecode,
debounceDurationMs,
debugActions,
...imageProps
} = this.props;
if (loadEagerly) {
// If eager, skip the observer and view changing stuff; resolve the imageState as loaded.
return children({
// We know that the state tags and the enum match up
imageState: LazyImageFullState.LoadSuccess().tag as ImageState,
imageProps
});
} else {
return (
this.update(Action.ViewChanged({ inView }))}
>
{({ ref }) =>
children({
// We know that the state tags and the enum match up, apart
// from Buffering not being exposed
imageState:
this.state.tag === "Buffering"
? ImageState.Loading
: (this.state.tag as ImageState),
imageProps,
ref
})
}
);
}
}
}
///// Utilities /////
/** Promise constructor for loading an image */
const loadImage = (
{ src, srcSet, alt, sizes }: ImageProps,
experimentalDecode = false
) =>
new Promise((resolve, reject) => {
const image = new Image();
if (srcSet) {
image.srcset = srcSet;
}
if (alt) {
image.alt = alt;
}
if (sizes) {
image.sizes = sizes;
}
image.src = src;
/** @see: https://www.chromestatus.com/feature/5637156160667648 */
if (experimentalDecode && "decode" in image) {
return (
image
// NOTE: .decode() is not in the TS defs yet
// TODO: consider writing the .decode() definition and sending a PR
//@ts-ignore
.decode()
.then((image: HTMLImageElement) => resolve(image))
.catch((err: any) => reject(err))
);
}
image.onload = resolve;
image.onerror = reject;
});
/** Promise that resolves after a specified number of ms */
const delayedPromise = (ms: number) =>
new Promise(resolve => setTimeout(resolve, ms));
interface CancelablePromise {
promise: Promise<{}>;
cancel: () => void;
}
/** Make a Promise "cancelable".
*
* Rejects with {isCanceled: true} if canceled.
*
* The way this works is by wrapping it with internal hasCanceled_ state
* and checking it before resolving.
*/
const makeCancelable = (promise: Promise): CancelablePromise => {
let hasCanceled_ = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
(val: any) => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val))
);
promise.catch(
(error: any) =>
hasCanceled_ ? reject({ isCanceled: true }) : reject(error)
);
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
}
};
};