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; } }; };