diff --git a/.eslintrc.yml b/.eslintrc.yml index 18aea8da..c0e36524 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -26,3 +26,4 @@ rules: jest/no-focused-tests: 2 '@typescript-eslint/no-explicit-any': 0 '@typescript-eslint/no-empty-interface': 0 + '@typescript-eslint/triple-slash-reference': 0 diff --git a/README.md b/README.md index 83af3476..df71b4e6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,18 @@ one. When you call `loadStripe`, it will use the existing script tag. ``` +### Importing `loadStripe` without side effects + +If you would like to use `loadStripe` in your application, but defer loading the +Stripe.js script until `loadStripe` is first called, use the alternative +`@stripe/stripe-js/pure` import path: + +``` +import {loadStripe} from '@stripe/stripe-js/pure'; + +// Stripe.js will not be loaded until `loadStripe` is called +``` + ## Stripe.js Documentation - [Stripe.js Docs](https://stripe.com/docs/stripe-js) diff --git a/package.json b/package.json index 0f799502..764aa9cd 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "files": [ "dist", "src", - "types" + "types", + "pure.js", + "pure.d.ts" ], "devDependencies": { "@babel/core": "^7.7.2", diff --git a/pure.d.ts b/pure.d.ts new file mode 100644 index 00000000..7d7f7fa1 --- /dev/null +++ b/pure.d.ts @@ -0,0 +1,3 @@ +/// + +export const loadStripe: typeof import('@stripe/stripe-js').loadStripe; diff --git a/pure.js b/pure.js new file mode 100644 index 00000000..8c390fc0 --- /dev/null +++ b/pure.js @@ -0,0 +1 @@ +module.exports = require('./dist/pure.js'); diff --git a/rollup.config.js b/rollup.config.js index fdc48fcf..260a9c98 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,8 +1,20 @@ import babel from 'rollup-plugin-babel'; -import pkg from './package.json'; import ts from 'rollup-plugin-typescript2'; import replace from '@rollup/plugin-replace'; -import {version} from './package.json'; + +import pkg from './package.json'; + +const PLUGINS = [ + ts({ + tsconfigOverride: {exclude: ['**/*.test.ts']}, + }), + babel({ + extensions: ['.ts', '.js', '.tsx', '.jsx'], + }), + replace({ + _VERSION: JSON.stringify(pkg.version), + }), +]; export default [ { @@ -11,16 +23,11 @@ export default [ {file: pkg.main, format: 'cjs'}, {file: pkg.module, format: 'es'}, ], - plugins: [ - ts({ - tsconfigOverride: {exclude: ['**/*.test.ts']}, - }), - babel({ - extensions: ['.ts', '.js', '.tsx', '.jsx'], - }), - replace({ - _VERSION: JSON.stringify(version), - }), - ], + plugins: PLUGINS, + }, + { + input: 'src/pure.ts', + output: [{file: 'dist/pure.js', format: 'cjs'}], + plugins: PLUGINS, }, ]; diff --git a/src/index.test.ts b/src/index.test.ts index e44cfe0d..3342f72a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -84,13 +84,14 @@ describe('Stripe module loader', () => { }); }); - describe('loadStripe', () => { + describe.each(['./index', './pure'])('loadStripe (%s.ts)', (requirePath) => { beforeEach(() => { + jest.restoreAllMocks(); jest.spyOn(console, 'warn').mockReturnValue(); }); it('resolves loadStripe with Stripe object', async () => { - const {loadStripe} = require('./index'); + const {loadStripe} = require(requirePath); const stripePromise = loadStripe('pk_test_foo'); await new Promise((resolve) => setTimeout(resolve)); @@ -101,7 +102,7 @@ describe('Stripe module loader', () => { }); it('rejects when the script fails', async () => { - const {loadStripe} = require('./index'); + const {loadStripe} = require(requirePath); const stripePromise = loadStripe('pk_test_foo'); await Promise.resolve(); @@ -114,6 +115,20 @@ describe('Stripe module loader', () => { expect(console.warn).not.toHaveBeenCalled(); }); + it('rejects when Stripe is not added to the window for some reason', async () => { + const {loadStripe} = require(requirePath); + const stripePromise = loadStripe('pk_test_foo'); + + await Promise.resolve(); + dispatchScriptEvent('load'); + + return expect(stripePromise).rejects.toEqual( + new Error('Stripe.js not available') + ); + }); + }); + + describe('loadStripe (index.ts)', () => { it('does not cause unhandled rejects when the script fails', async () => { require('./index'); @@ -127,17 +142,5 @@ describe('Stripe module loader', () => { new Error('Failed to load Stripe.js') ); }); - - it('rejects when Stripe is not added to the window for some reason', async () => { - const {loadStripe} = require('./index'); - const stripePromise = loadStripe('pk_test_foo'); - - await Promise.resolve(); - dispatchScriptEvent('load'); - - return expect(stripePromise).rejects.toEqual( - new Error('Stripe.js not available') - ); - }); }); }); diff --git a/src/index.ts b/src/index.ts index 4b51d7ae..8f3c8a81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,77 +1,17 @@ -// eslint-disable-next-line @typescript-eslint/triple-slash-reference /// import {Stripe as StripeInstance, StripeConstructor} from '@stripe/stripe-js'; +import {loadScript, initStripe} from './shared'; -// `_VERSION` will be rewritten by `@rollup/plugin-replace` as a string literal -// containing the package.json version -declare const _VERSION: string; - -const V3_URL = 'https://js.stripe.com/v3'; - -const injectScript = (): HTMLScriptElement => { - const script = document.createElement('script'); - script.src = V3_URL; - - const headOrBody = document.head || document.body; - - if (!headOrBody) { - throw new Error( - 'Expected document.body not to be null. Stripe.js requires a element.' - ); - } - - headOrBody.appendChild(script); - - return script; -}; - -const registerWrapper = (stripe: any): void => { - if (!stripe || !stripe._registerWrapper) { - return; - } - - stripe._registerWrapper({name: 'stripe-js', version: _VERSION}); -}; - -// Execute our own script injection after a tick to give users time to -// do their own script injection. -const stripePromise: Promise = Promise.resolve().then( - () => { - if (typeof window === 'undefined') { - // Resolve to null when imported server side. This makes the module - // safe to import in an isomorphic code base. - return null; - } - - if (window.Stripe) { - return window.Stripe; - } - - const script: HTMLScriptElement = - document.querySelector( - `script[src="${V3_URL}"], script[src="${V3_URL}/"]` - ) || injectScript(); - - return new Promise((resolve, reject) => { - script.addEventListener('load', () => { - if (window.Stripe) { - resolve(window.Stripe); - } else { - reject(new Error('Stripe.js not available')); - } - }); - - script.addEventListener('error', () => { - reject(new Error('Failed to load Stripe.js')); - }); - }); - } -); +// Execute our own script injection after a tick to give users time to do their +// own script injection. +const stripePromise = Promise.resolve().then(loadScript); let loadCalled = false; stripePromise.catch((err) => { - if (!loadCalled) console.warn(err); + if (!loadCalled) { + console.warn(err); + } }); export const loadStripe = ( @@ -79,13 +19,5 @@ export const loadStripe = ( ): Promise => { loadCalled = true; - return stripePromise.then((maybeStripe) => { - if (maybeStripe === null) { - return null; - } - - const stripe = maybeStripe(...args); - registerWrapper(stripe); - return stripe; - }); + return stripePromise.then((maybeStripe) => initStripe(maybeStripe, args)); }; diff --git a/src/pure.test.ts b/src/pure.test.ts new file mode 100644 index 00000000..f1d32485 --- /dev/null +++ b/src/pure.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const SCRIPT_SELECTOR = + 'script[src="https://js.stripe.com/v3"], script[src="https://js.stripe.com/v3/"]'; + +describe('pure module', () => { + afterEach(() => { + const script = document.querySelector(SCRIPT_SELECTOR); + if (script && script.parentElement) { + script.parentElement.removeChild(script); + } + + delete window.Stripe; + jest.resetModules(); + }); + + test('does not inject the script if loadStripe is not called', async () => { + require('./pure'); + + expect(document.querySelector(SCRIPT_SELECTOR)).toBe(null); + }); + + test('it injects the script if loadStripe is called', async () => { + const {loadStripe} = require('./pure'); + loadStripe('pk_test_foo'); + + expect(document.querySelector(SCRIPT_SELECTOR)).not.toBe(null); + }); +}); diff --git a/src/pure.ts b/src/pure.ts new file mode 100644 index 00000000..1c881929 --- /dev/null +++ b/src/pure.ts @@ -0,0 +1,8 @@ +/// +import {Stripe as StripeInstance, StripeConstructor} from '@stripe/stripe-js'; +import {loadScript, initStripe} from './shared'; + +export const loadStripe = ( + ...args: Parameters +): Promise => + loadScript().then((maybeStripe) => initStripe(maybeStripe, args)); diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 00000000..927e73cd --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,87 @@ +import {Stripe, StripeConstructor} from '@stripe/stripe-js'; + +// `_VERSION` will be rewritten by `@rollup/plugin-replace` as a string literal +// containing the package.json version +declare const _VERSION: string; + +const V3_URL = 'https://js.stripe.com/v3'; + +const injectScript = (): HTMLScriptElement => { + const script = document.createElement('script'); + script.src = V3_URL; + + const headOrBody = document.head || document.body; + + if (!headOrBody) { + throw new Error( + 'Expected document.body not to be null. Stripe.js requires a element.' + ); + } + + headOrBody.appendChild(script); + + return script; +}; + +const registerWrapper = (stripe: any): void => { + if (!stripe || !stripe._registerWrapper) { + return; + } + + stripe._registerWrapper({name: 'stripe-js', version: _VERSION}); +}; + +let stripePromise: Promise | null = null; + +export const loadScript = (): Promise => { + // Ensure that we only attempt to load Stripe.js at most once + if (stripePromise !== null) { + return stripePromise; + } + + stripePromise = new Promise((resolve, reject) => { + if (typeof window === 'undefined') { + // Resolve to null when imported server side. This makes the module + // safe to import in an isomorphic code base. + resolve(null); + return; + } + + if (window.Stripe) { + resolve(window.Stripe); + return; + } + + const script: HTMLScriptElement = + document.querySelector( + `script[src="${V3_URL}"], script[src="${V3_URL}/"]` + ) || injectScript(); + + script.addEventListener('load', () => { + if (window.Stripe) { + resolve(window.Stripe); + } else { + reject(new Error('Stripe.js not available')); + } + }); + + script.addEventListener('error', () => { + reject(new Error('Failed to load Stripe.js')); + }); + }); + + return stripePromise; +}; + +export const initStripe = ( + maybeStripe: StripeConstructor | null, + args: Parameters +): Stripe | null => { + if (maybeStripe === null) { + return null; + } + + const stripe = maybeStripe(...args); + registerWrapper(stripe); + return stripe; +};