hooks_use-script.js

import { React } from '@gravityforms/libraries';

const { useState, useEffect } = React;

const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';

// Previously loading/loaded scripts and their current status
export const scripts = {};

// Check for existing <script> tags with this src. If so, update scripts[src]
// and return the new status; otherwise, return undefined.
const checkExisting = ( src ) => {
	const existing = document.querySelector(
		`script[src="${ src }"]`,
	);
	if ( existing ) {
		// Assume existing <script> tag is already loaded,
		// and cache that data for future use.
		return ( scripts[ src ] = {
			loading: false,
			error: null,
			scriptEl: existing,
		} );
	}
	return undefined;
};

/**
 * @module useScript
 * @description A hook to dynamically load an external script and know when it's loaded.
 *
 * @since 1.0.3
 *
 * @param {string}  src              The src for the external script to load.
 * @param {boolean} checkForExisting The hook automatically handles when the script was already loaded (or started loading)
 *                                   from another instance of the hook. This is for looking for the script existing in the dom from outside our code.
 * @param {object}  attributes       Additional attributes.
 *
 * @return {*} Either loading or error states.
 *
 * @example
 * import React from 'react';
 * import { StripeProvider } from 'react-stripe-elements';
 * import useScript from '@gravityforms/react-utils';
 *
 * import MyCheckout from './my-checkout';
 *
 * function App() {
 *     const [loading, error] = useScript({ src: 'https://js.stripe.com/v3/' });
 *
 *     if (loading) return <h3>Loading Stripe API...</h3>;
 *     if (error) return <h3>Failed to load Stripe API: {error.message}</h3>;
 *
 *     return (
 *         <StripeProvider apiKey="pk_test_6pRNASCoBOKtIshFeQd4XMUh">
 *             <MyCheckout />
 *         </StripeProvider>
 *     );
 * }
 *
 * export default App;
 *
 */
export default function useScript( {
	src,
	checkForExisting = false,
	...attributes
} ) {
	// Check whether some instance of this hook considered this src.
	let status = src ? scripts[ src ] : undefined;

	// If requested, check for existing <script> tags with this src
	// (unless we've already loaded the script ourselves).
	if ( ! status && checkForExisting && src && isBrowser ) {
		status = checkExisting( src );
	}

	const [ loading, setLoading ] = useState(
		status ? status.loading : Boolean( src ),
	);
	const [ error, setError ] = useState(
		status ? status.error : null,
	);

	useEffect( () => {
		// Nothing to do on server, or if no src specified, or
		// if loading has already resolved to "loaded" or "error" state.
		if ( ! isBrowser || ! src || ! loading || error ) {
			return;
		}

		// Check again for existing <script> tags with this src
		// in case it's changed since mount.
		// eslint-disable-next-line react-hooks/exhaustive-deps
		status = scripts[ src ];
		if ( ! status && checkForExisting ) {
			status = checkExisting( src );
		}

		// Determine or create <script> element to listen to.
		let scriptEl;
		if ( status ) {
			scriptEl = status.scriptEl;
		} else {
			scriptEl = document.createElement( 'script' );
			scriptEl.src = src;

			Object.keys( attributes ).forEach( ( key ) => {
				if ( scriptEl[ key ] === undefined ) {
					scriptEl.setAttribute( key, attributes[ key ] );
				} else {
					scriptEl[ key ] = attributes[ key ];
				}
			} );

			status = scripts[ src ] = {
				loading: true,
				error: null,
				scriptEl,
			};
		}
		// `status` is now guaranteed to be defined: either the old status
		// from a previous load, or a newly created one.

		const handleLoad = () => {
			if ( status ) {
				status.loading = false;
			}
			setLoading( false );
		};
		const handleError = ( err ) => {
			if ( status ) {
				status.error = err;
			}
			setError( err );
		};

		scriptEl.addEventListener( 'load', handleLoad );
		scriptEl.addEventListener( 'error', handleError );

		document.body.appendChild( scriptEl );

		return () => {
			scriptEl.removeEventListener( 'load', handleLoad );
			scriptEl.removeEventListener( 'error', handleError );
		};
		// we need to ignore the attributes as they're a new object per call, so we'd never skip an effect call
	}, [ src ] );

	return [ loading, error ];
}