├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .lintstagedrc ├── .npmignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ └── index.js └── index.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | # editorconfig-tools is unable to ignore longs strings or urls 20 | max_line_length = null 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | parserOptions: { 4 | ecmaVersion: '2018', 5 | }, 6 | 7 | extends: ['airbnb', 'eslint-config-prettier'], 8 | plugins: ['import', 'jest'], 9 | env: { 10 | browser: true, 11 | 'jest/globals': true, 12 | }, 13 | 14 | rules: { 15 | 'no-console': [ 16 | 'error', 17 | { 18 | allow: ['warn', 'error', 'info'], 19 | }, 20 | ], 21 | 22 | 'react/jsx-filename-extension': ['error', {extensions: ['.js', '.jsx']}], 23 | 24 | 'react/prefer-stateless-function': 'off', 25 | 'no-confusing-arrow': 'off', 26 | 'no-underscore-dangle': 'off', 27 | 'no-mixed-operators': 'off', 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | dist 61 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "linters": { 3 | "*.js": [ 4 | "eslint" 5 | ], 6 | "**/*.+(js|jsx|json)": [ 7 | "prettier --write", 8 | "git add" 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "jsxBracketSameLine": false, 5 | "printWidth": 80, 6 | "proseWrap": "always", 7 | "semi": true, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "trailingComma": "all", 11 | "useTabs": false 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sesilio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-script-loader-hoc 2 | [![npm version](https://img.shields.io/badge/npm-v1.2.2-brightgreen.svg)](https://www.npmjs.com/package/react-script-loader-hoc) [![license](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/sesilio/react-script-loader-hoc/blob/master/LICENSE) 3 | 4 | A higher-order React component that assists in the asynchronous loading of third party JS libraries (eg. Stripe.js) 5 | 6 | ### Installation 7 | ``` 8 | yarn add react-script-loader-hoc 9 | ``` 10 | or 11 | ``` 12 | npm install --save react-script-loader-hoc 13 | ``` 14 | 15 | ### Example usage with Stripe React Elements 16 | Simple example of asynchronously loading the Stripe.js library. The `LoadIcon` component will be rendered until the Stripe.js library loads asynchronously, at which point the `LoadIcon` will be replaced by the `StripeProvider`. 17 | ```jsx 18 | import React from 'react'; 19 | import ScriptLoader from 'react-script-loader-hoc'; 20 | import { StripeProvider, Elements } from 'react-stripe-elements'; 21 | import { LoadIcon, CheckoutForm } from '../components'; 22 | 23 | const StripePayment = ({ scriptsLoadedSuccessfully }) => { 24 | if (!scriptsLoadedSuccessfully) return ; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default ScriptLoader('https://js.stripe.com/v3/')(StripePayment); 36 | ``` 37 | 38 | ### API 39 | `ScriptLoader` takes `n` string arguments, each of which should be a URL of a javascript resource to load. The higher-order `ScriptLoader` component will pass two boolean-valued props to the wrapped component: 40 | - `scriptsLoaded` 41 | - `scriptsLoadedSuccessfully` 42 | 43 | `scriptsLoaded` will be `false` until either all scripts load successfully or one or more of the scripts fail to load (eg. if a 404 occurs), at which point it will be `true`. `scriptsLoadedSuccessfully` will be `true` if all scripts load successfully or `false` if either an error occurs or if some scripts are still loading. `scriptsLoadedSuccessfully` will always be `false` while `scriptsLoaded` is false. 44 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const isTest = String(process.env.NODE_ENV) === 'test'; 2 | 3 | module.exports = { 4 | presets: [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | browsers: ['>1%', 'last 4 versions', 'Firefox ESR', 'not ie < 11'], 10 | }, 11 | modules: isTest ? 'commonjs' : false, 12 | }, 13 | ], 14 | '@babel/preset-react', 15 | ], 16 | plugins: ['@babel/plugin-proposal-class-properties'], 17 | 18 | ignore: ['/node_modules/'], 19 | }; 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleDirectories: ['node_modules'], 3 | testEnvironment: 'jest-environment-jsdom', 4 | collectCoverageFrom: ['**/src/**/*.js'], 5 | watchPlugins: [ 6 | 'jest-watch-typeahead/filename', 7 | 'jest-watch-typeahead/testname', 8 | ], 9 | transform: { 10 | '\\.js$': 'babel-jest', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-script-loader-hoc", 3 | "description": "A higher-order React component that assists in the asynchronous loading of third party JS libraries (eg. Stripe.js)", 4 | "version": "1.2.2", 5 | "keywords": [ 6 | "react", 7 | "reactjs", 8 | "react-component", 9 | "script-loader", 10 | "async", 11 | "stripe", 12 | "loader", 13 | "asynchronous", 14 | "stripe-react-elements", 15 | "higher-order-component" 16 | ], 17 | "author": { 18 | "name": "Nick Rogers" 19 | }, 20 | "contributors": [ 21 | { 22 | "name": "Dom Lyons" 23 | } 24 | ], 25 | "main": "dist/scriptLoader.cjs.js", 26 | "module": "dist/scriptLoader.esm.js", 27 | "license": "MIT", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/sesilio/react-script-loader-hoc" 31 | }, 32 | "peerDependencies": { 33 | "react": ">=15.0.0 || >= 16.0.0" 34 | }, 35 | "dependencies": { 36 | "hoist-non-react-statics": "^2.3.1", 37 | "react": "^16.8.6" 38 | }, 39 | "devDependencies": { 40 | "@babel/cli": "^7.1.2", 41 | "@babel/core": "^7.1.2", 42 | "@babel/plugin-proposal-class-properties": "^7.1.0", 43 | "@babel/preset-env": "^7.1.0", 44 | "@babel/preset-react": "^7.0.0", 45 | "babel-core": "7.0.0-bridge.0", 46 | "babel-eslint": "^10.0.1", 47 | "babel-jest": "^24.5.0", 48 | "babel-polyfill": "^6.26.0", 49 | "eslint": "^5.3.0", 50 | "eslint-config-airbnb": "17.1.0", 51 | "eslint-config-prettier": "^3.1.0", 52 | "eslint-plugin-import": "^2.14.0", 53 | "eslint-plugin-jest": "^21.26.1", 54 | "eslint-plugin-jsx-a11y": "^6.1.1", 55 | "eslint-plugin-react": "^7.11.0", 56 | "husky": "^1.1.2", 57 | "jest": "^24.5.0", 58 | "jest-dom": "^2.1.0", 59 | "jest-watch-typeahead": "^0.2.0", 60 | "lint-staged": "^7.3.0", 61 | "prettier": "^1.14.3", 62 | "react-dom": "^16.8.4", 63 | "react-testing-library": "^5.2.1", 64 | "rimraf": "^2.6.2", 65 | "rollup": "^0.66.6", 66 | "rollup-plugin-babel": "^4.0.3", 67 | "rollup-plugin-commonjs": "^9.2.0", 68 | "rollup-plugin-node-resolve": "^3.4.0", 69 | "rollup-plugin-replace": "^2.1.0" 70 | }, 71 | "scripts": { 72 | "test": "jest", 73 | "test:watch": "jest --watch", 74 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand", 75 | "lint": "eslint src", 76 | "prettier": "prettier \"**/*.+(js|jsx|json)\"", 77 | "format": "yarn prettier --write", 78 | "validate": "yarn lint && yarn prettier --list-different && yarn test", 79 | "precommit": "lint-staged", 80 | "clean": "rimraf ./dist", 81 | "build": "yarn clean && rollup -c ./rollup.config.js", 82 | "prepublish": "yarn validate && yarn build" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import replace from 'rollup-plugin-replace'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import babel from 'rollup-plugin-babel'; 5 | import pkg from './package.json'; 6 | 7 | export default [ 8 | // CommonJS (for Node) and ES module (for bundlers) build. 9 | // (We could have three entries in the configuration array 10 | // instead of two, but it's quicker to generate multiple 11 | // builds from a single configuration where possible, using 12 | // an array for the `output` option, where we can specify 13 | // `file` and `format` for each target) 14 | { 15 | input: 'src/index.js', 16 | external: ['hoist-non-react-statics', 'react'], 17 | output: [{file: pkg.main, format: 'cjs'}, {file: pkg.module, format: 'es'}], 18 | plugins: [ 19 | replace({ 20 | 'process.env.NODE_ENV': JSON.stringify('production'), 21 | }), 22 | resolve(), 23 | commonjs({ 24 | include: 'node_modules/**', 25 | }), 26 | babel({ 27 | exclude: 'node_modules/**', 28 | }), 29 | ], 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import 'jest-dom/extend-expect'; 3 | import 'react-testing-library/cleanup-after-each'; 4 | import {render, fireEvent} from 'react-testing-library'; 5 | import React from 'react'; 6 | 7 | const flushPromises = async () => new Promise(resolve => setImmediate(resolve)); 8 | 9 | const loadingText = 'scripts loading'; 10 | const errorText = 'some scripts failed'; 11 | const successfulText = 'success'; 12 | // eslint-disable-next-line 13 | const TestComponent = ({scriptsLoaded, scriptsLoadedSuccessfully}) => { 14 | if (!scriptsLoaded) { 15 | return
{loadingText}
; 16 | } 17 | 18 | if (!scriptsLoadedSuccessfully) { 19 | return
{errorText}
; 20 | } 21 | 22 | return
{successfulText}
; 23 | }; 24 | 25 | const getScripts = () => document.getElementsByTagName('script'); 26 | 27 | beforeEach(() => { 28 | jest.resetModules(); 29 | 30 | const scripts = getScripts(); 31 | 32 | // eslint-disable-next-line 33 | const length = scripts.length; 34 | 35 | // crazy stuff to deal with the html collection 36 | // (its not an array) 37 | for (let i = 0; i < length; i += 1) { 38 | scripts[0].remove(); 39 | } 40 | }); 41 | 42 | test('it will add scripts to the dom with the correct src', () => { 43 | // need to require here rather than globally due to the cache 44 | // not resetting with jest.resetModules() with es6 imports 45 | // eslint-disable-next-line 46 | const scriptLoader = require('..').default; 47 | const Component = scriptLoader('test1', 'test2')(TestComponent); 48 | 49 | render(); 50 | 51 | const scripts = getScripts(); 52 | 53 | expect(scripts).toHaveLength(2); 54 | expect(scripts[0].src).toBe('http://localhost/test1'); 55 | expect(scripts[1].src).toBe('http://localhost/test2'); 56 | }); 57 | 58 | test('initially the scriptsLoaded prop will be passed', () => { 59 | // eslint-disable-next-line 60 | const scriptLoader = require('..').default; 61 | const Component = scriptLoader('test1', 'test2')(TestComponent); 62 | const {getByText} = render(); 63 | 64 | expect(getByText(loadingText)); 65 | }); 66 | 67 | test('if both scripts load successfully both props will be true', async () => { 68 | // eslint-disable-next-line 69 | const scriptLoader = require('..').default; 70 | const Component = scriptLoader('test1', 'test2')(TestComponent); 71 | const {getByText} = render(); 72 | 73 | const [script1, script2] = getScripts(); 74 | 75 | fireEvent.load(script1); 76 | fireEvent.load(script2); 77 | 78 | await flushPromises(); 79 | 80 | expect(getByText(successfulText)).toBeInTheDocument(); 81 | }); 82 | 83 | test('if one scripts loads successfully and the other fails scriptsLoadingSuccessfully will be false', async () => { 84 | // eslint-disable-next-line 85 | const scriptLoader = require('..').default; 86 | const Component = scriptLoader('test1', 'test2')(TestComponent); 87 | const {getByText} = render(); 88 | 89 | const [script1, script2] = getScripts(); 90 | 91 | fireEvent.load(script1); 92 | fireEvent.error(script2); 93 | 94 | await flushPromises(); 95 | 96 | expect(getByText(errorText)).toBeInTheDocument(); 97 | }); 98 | 99 | test('if both scripts are successful the cache will stop them being readded', async () => { 100 | // eslint-disable-next-line 101 | const scriptLoader = require('..').default; 102 | const Component = scriptLoader('test1', 'test2')(TestComponent); 103 | render(); 104 | 105 | const [script1, script2] = getScripts(); 106 | 107 | fireEvent.load(script1); 108 | fireEvent.load(script2); 109 | 110 | await flushPromises(); 111 | 112 | const Component2 = scriptLoader('test1', 'test2')(TestComponent); 113 | render(); 114 | 115 | const newScripts = getScripts(); 116 | 117 | expect(newScripts.length).toBe(2); 118 | }); 119 | 120 | test('if a script fails once it can be rerendered and succeed a second time (its not put in the cache)', async () => { 121 | // eslint-disable-next-line 122 | const scriptLoader = require('..').default; 123 | const Component = scriptLoader('test1', 'test2')(TestComponent); 124 | render(); 125 | 126 | const [script1, script2] = getScripts(); 127 | 128 | fireEvent.load(script1); 129 | fireEvent.error(script2); 130 | 131 | await flushPromises(); 132 | 133 | expect(getScripts().length).toBe(1); 134 | 135 | const Component2 = scriptLoader('test2')(TestComponent); 136 | 137 | render(); 138 | 139 | const [, newScript2] = getScripts(); 140 | 141 | fireEvent.load(newScript2); 142 | 143 | await flushPromises(); 144 | 145 | expect(getScripts().length).toBe(2); 146 | }); 147 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import hoistStatics from 'hoist-non-react-statics'; 3 | 4 | const cachedScripts = []; 5 | 6 | const scriptLoader = (...scriptSrcs) => WrappedComponent => { 7 | class ScriptLoader extends React.Component { 8 | state = { 9 | scriptsLoaded: false, 10 | scriptsLoadedSuccessfully: false, 11 | }; 12 | 13 | _isMounted = false; 14 | 15 | componentDidMount() { 16 | this._isMounted = true; 17 | this.loadScripts(scriptSrcs); 18 | } 19 | 20 | componentWillUnmount() { 21 | this._isMounted = false; 22 | } 23 | 24 | loadScripts = srcs => { 25 | const promises = srcs 26 | .filter(src => !cachedScripts.includes(src)) 27 | .map(src => this.loadScript(src)); 28 | 29 | let success = true; 30 | Promise.all(promises) 31 | .catch(() => { 32 | success = false; 33 | }) 34 | .then(() => { 35 | if (!this._isMounted) { 36 | return; 37 | } 38 | 39 | this.setState({ 40 | scriptsLoaded: true, 41 | scriptsLoadedSuccessfully: success, 42 | }); 43 | }); 44 | }; 45 | 46 | loadScript = src => { 47 | cachedScripts.push(src); 48 | 49 | const script = document.createElement('script'); 50 | script.src = src; 51 | script.async = true; 52 | 53 | const promise = new Promise((resolve, reject) => { 54 | script.addEventListener('load', () => resolve(src)); 55 | script.addEventListener('error', e => reject(e)); 56 | }).catch(e => { 57 | const index = cachedScripts.indexOf(src); 58 | if (index >= 0) cachedScripts.splice(index, 1); 59 | script.remove(); 60 | 61 | throw e; 62 | }); 63 | 64 | document.body.appendChild(script); 65 | 66 | return promise; 67 | }; 68 | 69 | render() { 70 | const props = { 71 | ...this.props, 72 | ...this.state, 73 | }; 74 | 75 | return ; 76 | } 77 | } 78 | 79 | return hoistStatics(ScriptLoader, WrappedComponent); 80 | }; 81 | 82 | export default scriptLoader; 83 | --------------------------------------------------------------------------------