├── .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 | [](https://www.npmjs.com/package/react-script-loader-hoc) [](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 |
--------------------------------------------------------------------------------