├── .babelrc ├── .eslintrc.yml ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .storybook ├── example.legacy.stories.js ├── example.stories.js ├── main.js └── manager.js ├── .travis.yml ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── PUBLISH.md ├── README.md ├── examples ├── component.tsx ├── hooks.tsx ├── oauth.tsx └── simple.tsx ├── jest.config.js ├── package.json ├── rollup.config.js ├── scripts └── check-import ├── src ├── PlaidEmbeddedLink.test.tsx ├── PlaidEmbeddedLink.tsx ├── PlaidLink.tsx ├── constants.ts ├── factory.ts ├── index.ts ├── react-script-hook │ ├── index.test.tsx │ └── index.tsx ├── types │ └── index.ts ├── usePlaidLink.test.tsx └── usePlaidLink.ts ├── stories ├── embedded_link.tsx ├── hoc.js ├── hoc.legacy.js ├── hooks.js ├── hooks.legacy.js └── hooks.tsx ├── test ├── components │ └── PlaidLink.spec.js └── setup.js ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"], 3 | "plugins": [ 4 | "babel-plugin-typescript-to-proptypes", 5 | "@babel/plugin-proposal-class-properties" 6 | ], 7 | "env": { 8 | "testing": { 9 | "presets": [ 10 | ["@babel/preset-env", { "targets": { "node": "current" } }], 11 | "@babel/preset-typescript" 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | root: true 3 | extends: 4 | - eslint:recommended 5 | - plugin:@typescript-eslint/eslint-recommended 6 | - plugin:@typescript-eslint/recommended 7 | - plugin:react/recommended 8 | parser: '@typescript-eslint/parser' 9 | parserOptions: 10 | # project: './tsconfig.json' 11 | ecmaFeatures: 12 | jsx: true 13 | plugins: 14 | - '@typescript-eslint' 15 | - jest 16 | - react 17 | - react-hooks 18 | settings: 19 | react: 20 | version: detect 21 | env: 22 | jest/globals: true 23 | browser: true 24 | es6: true 25 | rules: 26 | no-console: 0 27 | func-style: 2 28 | consistent-return: 0 29 | prefer-arrow-callback: 30 | - 2 31 | - allowNamedFunctions: false 32 | allowUnboundThis: false 33 | jest/no-disabled-tests: 2 34 | jest/no-focused-tests: 2 35 | react/prop-types: 0 36 | react/forbid-prop-types: 0 37 | react/no-unused-prop-types: 0 38 | react-hooks/rules-of-hooks: 2 39 | react-hooks/exhaustive-deps: 0 40 | '@typescript-eslint/no-explicit-any': 0 41 | '@typescript-eslint/no-empty-interface': 0 42 | '@typescript-eslint/explicit-function-return-type': 0 43 | '@typescript-eslint/camelcase': 0 44 | '@typescript-eslint/no-empty-function': 0 45 | '@typescript-eslint/no-unused-vars': 46 | - 2 47 | - varsIgnorePattern: ^_ 48 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Use Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20.x 15 | - name: Install dependencies 16 | uses: borales/actions-yarn@v4 17 | with: 18 | cmd: install # will run `yarn install` command 19 | - run: make lint 20 | - run: make test 21 | - run: make clean build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /lib/ 3 | /node_modules/ 4 | npm-debug.log 5 | /web3/ 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.17.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "bracketSpacing": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/example.legacy.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import React, { useEffect, useState } from 'react'; 3 | import { storiesOf, module } from '@storybook/react'; 4 | import '@storybook/addon-console'; 5 | import { 6 | withKnobs, 7 | text, 8 | button, 9 | select, 10 | optionsKnob as options, 11 | } from '@storybook/addon-knobs'; 12 | 13 | let counter = 0; 14 | const reRender = () => (counter += 1); 15 | 16 | const ExampleComponent = ({ file, ...props }) => { 17 | const [example, setExample] = useState(null); 18 | useEffect(() => { 19 | import(`../stories/${file}`).then(({ default: Example }) => { 20 | setExample(); 21 | }); 22 | }, []); 23 | return example; 24 | }; 25 | 26 | const stories = storiesOf('Legacy public key integrations', module); 27 | stories.addDecorator(withKnobs); 28 | 29 | stories.add('hooks', () => { 30 | const props = { 31 | clientName: text('clientName', 'YOUR_CLIENT_NAME'), 32 | env: select('env', ['sandbox', 'development', 'production'], 'sandbox'), 33 | publicKey: text('publicKey', ''), 34 | token: text('token', ''), 35 | product: options( 36 | 'product', 37 | { auth: 'auth', transactions: 'transactions' }, 38 | ['auth', 'transactions'], 39 | { display: 'multi-select' } 40 | ), 41 | }; 42 | 43 | button('Save Link configuration', reRender); 44 | 45 | return ( 46 |
47 | 48 |
49 | ); 50 | }); 51 | 52 | stories.add('HOC', () => { 53 | const props = { 54 | clientName: text('clientName', 'YOUR_CLIENT_NAME'), 55 | env: select('env', ['sandbox', 'development', 'production'], 'sandbox'), 56 | publicKey: text('publicKey', ''), 57 | token: text('token', ''), 58 | product: options( 59 | 'product', 60 | { auth: 'auth', transactions: 'transactions' }, 61 | ['auth', 'transactions'], 62 | { display: 'multi-select' } 63 | ), 64 | }; 65 | 66 | button('Save Link configuration', reRender); 67 | 68 | return ( 69 |
70 | 71 |
72 | ); 73 | }); 74 | 75 | export default { 76 | title: 'Storybook Knobs', 77 | decorators: [withKnobs], 78 | }; 79 | -------------------------------------------------------------------------------- /.storybook/example.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import React, { useEffect, useState } from 'react'; 3 | import { storiesOf, module } from '@storybook/react'; 4 | import '@storybook/addon-console'; 5 | import { 6 | withKnobs, 7 | text, 8 | button, 9 | select, 10 | optionsKnob as options, 11 | } from '@storybook/addon-knobs'; 12 | 13 | let counter = 0; 14 | const reRender = () => (counter += 1); 15 | 16 | const ExampleComponent = ({ file, ...props }) => { 17 | const [example, setExample] = useState(null); 18 | useEffect(() => { 19 | import(`../stories/${file}`).then(({ default: Example }) => { 20 | setExample(); 21 | }); 22 | }, []); 23 | return example; 24 | }; 25 | 26 | const stories = storiesOf('Examples', module); 27 | stories.addDecorator(withKnobs); 28 | 29 | stories.add('hooks', () => { 30 | const props = { 31 | token: text('token', ''), 32 | }; 33 | 34 | button('Save Link configuration', reRender); 35 | 36 | return ( 37 |
38 | 39 |
40 | ); 41 | }); 42 | 43 | stories.add('Component', () => { 44 | const props = { 45 | token: text('token', ''), 46 | }; 47 | 48 | button('Save Link configuration', reRender); 49 | 50 | return ( 51 |
52 | 53 |
54 | ); 55 | }); 56 | 57 | stories.add('embedded Link', () => { 58 | const props = { 59 | token: text('token', ''), 60 | }; 61 | 62 | button('Save Link configuration', reRender); 63 | 64 | return ( 65 |
66 | 67 |
68 | ); 69 | }); 70 | 71 | export default { 72 | title: 'Storybook Knobs', 73 | decorators: [withKnobs], 74 | }; 75 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['./example.stories.js', './example.legacy.stories.js'], 5 | addons: [ 6 | { 7 | name: '@storybook/preset-typescript', 8 | options: { include: [path.resolve(__dirname, '..')] }, 9 | }, 10 | '@storybook/addon-actions', 11 | '@storybook/addon-knobs/register', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | 3 | addons.setConfig({ 4 | isFullscreen: false, 5 | showNav: true, 6 | showPanel: true, 7 | panelPosition: 'right', 8 | sidebarAnimations: true, 9 | enableShortcuts: true, 10 | isToolshown: true, 11 | theme: undefined, 12 | selectedPanel: 'storybookjs/knobs/panel', 13 | }); 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5" 5 | before_script: 6 | - sh -e /etc/init.d/xvfb start 7 | branches: 8 | only: 9 | - master 10 | email: 11 | on_failure: never 12 | on_success: never 13 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 4.0.1 4 | 5 | - Fixed bug/regression when unmounting while Link script is loading 6 | 7 | ## 4.0.0 (do not use) 8 | 9 | - Update peer dependencies to allow React 19 10 | - Remove react-script-hook dependency, it is now forked into this package 11 | - Remove Web3-related code 12 | 13 | ## 3.6.1 14 | 15 | - Update react-script-hook version to make peer dependencies work better for React 18 16 | 17 | ## 3.6.0 18 | 19 | - Fixed an issue that can occur when unmounting the usePlaidLink hook before the underlying script tag is loaded 20 | 21 | ## 3.5.2 22 | 23 | - Fix rerender issue with PlaidEmbeddedLink 24 | - Add support for handler.submit 25 | 26 | ## 3.5.1 27 | 28 | - Fix build issue from version 3.5.0 29 | 30 | ## 3.5.0 (do not use) 31 | 32 | Do not use version 3.5.0 because this version contains a build issue. 33 | 34 | - Add support for Plaid embedded Link, an experimental feature [#320](https://github.com/plaid/react-plaid-link/pull/320) 35 | 36 | ## 3.4.1 37 | 38 | - Add missing events to `PlaidLinkStableEvent` [#310](https://github.com/plaid/react-plaid-link/pull/310) 39 | - Update function return types for `usePlaidLink` [#312](https://github.com/plaid/react-plaid-link/pull/312) 40 | - Update early return condition in `usePlaidLink` for possible inputs [#316](https://github.com/plaid/react-plaid-link/pull/316) 41 | 42 | ## 3.4.0 43 | 44 | - Add APIs for [Wallet Onboard](https://plaid.com/docs/wallet-onboard/) [#283](https://github.com/plaid/react-plaid-link/pull/283) 45 | 46 | ## 3.3.2 47 | 48 | - Add `transfer_status` to `PlaidLinkOnSuccessMetadata` type [#252](https://github.com/plaid/react-plaid-link/pull/252) 49 | - Allow React 18 in `peerDependencies` [#256](https://github.com/plaid/react-plaid-link/pull/256) 50 | 51 | ## 3.3.1 52 | 53 | - Fix `usePlaidLink` hook for legacy `publicKey` config [#247](https://github.com/plaid/react-plaid-link/pull/247) 54 | - Bump `react-script-hook` dependency to 1.6.0 to fix script loading bug [#246](https://github.com/plaid/react-plaid-link/pull/246) 55 | 56 | ## 3.3.0 57 | 58 | Allow `token` to be `null` in `usePlaidLink` config [#197](https://github.com/plaid/react-plaid-link/pull/197) 59 | 60 | ## 3.2.2 61 | 62 | - Bump react-script-hook to 1.5.0 to fix 'error loading plaid' msg (#217) 63 | 64 | ## 3.2.1 65 | 66 | - (Internal) Fix invalid registry URLs in `yarn.lock` 67 | 68 | ## 3.2.0 69 | 70 | - Improve TypeScript types (may cause minor TS errors) 71 | 72 | ## 3.1.1 73 | 74 | - Allow React 17 in `peerDependencies` (#171) 75 | 76 | ## 3.1.0 77 | 78 | - Ensure Link is destroyed correctly (#146) 79 | - Export `PlaidLinkOptions` typescript types (#134) 80 | - (Internal) Bump node-fetch from 2.6.0 to 2.6.1 (#136) 81 | - (Internal) Bump elliptic from 6.5.2 to 6.5.3 (#127) 82 | - (Internal) Bump lodash from 4.17.15 to 4.17.19 (#124) 83 | - Fix a bug with the usePlaidLink `ready` state (#163) 84 | 85 | ## 3.0.0 86 | 87 | - Add Link token options to initialization and move onEvent types to their own interface. (#116) 88 | 89 | ## 2.2.3 90 | 91 | - (Internal) Bump websocket-extensions from 0.1.3 to 0.1.4 (#114) 92 | 93 | ## 2.2.2 94 | 95 | - Ensure Plaid link-initialize.js is embedded only once (#109) 96 | - Config `product` prop updates should refresh instance on change (#104) 97 | 98 | ## 2.2.1 99 | 100 | - (Internal) Bump yarn.lock dependencies for react-script-hook version bump (#99) 101 | 102 | ## 2.2.0 103 | 104 | - Pull in version 1.0.17 of react-script-hook (#97) 105 | - Fix race condition between script load and rerenders ([react-script-hook#10](https://github.com/hupe1980/react-script-hook/pull/10)) 106 | 107 | ## 2.1.0 108 | 109 | - Allows public_key to be optional if a token is provided (#95) 110 | - Fix `token` initialization (#95) 111 | - Use the new link handler `.destroy()` method instead of custom internal method (#95) 112 | 113 | ## 2.0.3 114 | 115 | - Add `accountSubtypes` prop to configuration options (#91) 116 | 117 | ## 2.0.2 118 | 119 | - Fix `publicKey` prop and auto-generate PropTypes for React component (#87) 120 | 121 | ## 2.0.1 122 | 123 | - Add `className` prop to `PlaidLink` React component (#83) 124 | - Add `type=button` to `PlaidLink` React component (#83) 125 | 126 | ## 2.0.0 127 | 128 | #### New 129 | 130 | - Add `usePlaidLink` hook for simpler and more configurable use-cases 131 | - Add Typescript support 132 | - Add Storybook support 133 | - Improve and simplify examples (located at `./examples`) 134 | - Simplify `PlaidLink` HOC internals and import 135 | 136 | #### Breaking changes 137 | 138 | - Remove support for React versions below `<16.8.x` 139 | - For the `PlaidLink` button component, we've removed the default import: 140 | 141 | ##### Before: 142 | 143 | ```jsx 144 | import PlaidLink from 'react-plaid-link'; 145 | ``` 146 | 147 | ##### After 148 | 149 | ```jsx 150 | import { PlaidLink } from 'react-plaid-link'; 151 | ``` 152 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Plaid Technologies, Inc. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NODE = node --harmony 2 | BABEL = ./node_modules/.bin/babel 3 | MOCHA = node --harmony node_modules/.bin/mocha --reporter spec --require test/setup.js --compilers js:babel-core/register 4 | ESLINT = node_modules/.bin/eslint 5 | JEST = node_modules/.bin/jest 6 | ROLLUP = node_modules/.bin/rollup 7 | PRETTIER = node_modules/.bin/prettier 8 | STORYBOOK_TO_PAGES = node_modules/.bin/storybook-to-ghpages 9 | NPM_ENV_VARS = npm_config_registry=https://registry.npmjs.org 10 | NPM = $(NPM_ENV_VARS) npm 11 | XYZ = $(NPM_ENV_VARS) node_modules/.bin/xyz --repo git@github.com:plaid/react-plaid-link.git 12 | STORYBOOK = node_modules/.bin/start-storybook 13 | RELEASE_BRANCH = $(shell git rev-parse --abbrev-ref HEAD) 14 | 15 | SRC_FILES = $(shell find src -name '*.js|*.tsx|*.ts' | sort) 16 | 17 | 18 | .PHONY: clean 19 | clean: 20 | @rm -rf dist lib 21 | 22 | 23 | .PHONY: build 24 | build: clean 25 | @$(ROLLUP) -c 26 | 27 | 28 | .PHONY: check-import 29 | check-import: 30 | ./scripts/check-import 31 | 32 | .PHONY: lint 33 | lint: 34 | @$(ESLINT) '{src,examples}/**/*.{ts,tsx,js,jsx}' 35 | 36 | .PHONY: test 37 | test: export NODE_ENV=test 38 | test: export BABEL_ENV=testing 39 | test: 40 | @$(JEST) 41 | 42 | .PHONY: test-watch 43 | test-watch: export NODE_ENV=test 44 | test-watch: export BABEL_ENV=testing 45 | test-watch: 46 | @$(JEST) --watch 47 | 48 | .PHONY: lint-fix 49 | lint-fix: 50 | @$(ESLINT) --fix '{src,examples}/**/*.{ts,tsx,js,jsx}' 51 | 52 | 53 | .PHONY: setup 54 | setup: 55 | yarn 56 | 57 | 58 | # .PHONY: test 59 | # test: 60 | # @$(MOCHA) -- test/components/PlaidLink.spec.js 61 | 62 | 63 | .PHONY: prettier 64 | prettier: 65 | @$(PRETTIER) './**/*.js' './**/*.css' --write 66 | 67 | 68 | .PHONY: storybook 69 | storybook: 70 | @$(STORYBOOK) -p 6006 71 | 72 | 73 | .PHONY: storybook-deploy 74 | storybook-deploy: 75 | $(STORYBOOK_TO_PAGES) 76 | 77 | 78 | .PHONY: release-major release-minor release-patch release-premajor release-preminor release-prepatch release-prerelease 79 | release-major release-minor release-patch release-premajor release-preminor release-prepatch release-prerelease: build 80 | @$(XYZ) --increment $(@:release-%=%) --branch $(RELEASE_BRANCH) --prerelease-label beta 81 | -------------------------------------------------------------------------------- /PUBLISH.md: -------------------------------------------------------------------------------- 1 | ## Publishing 2 | 3 | **We use the `xyz` NPM package to simplify releases. This library uses a feature that is available in Bash 4 or beyond, but latest versions of macOS 4 | ship with an older version of Bash. If you run into issues with `xyz`, use `brew install bash` to install a newer verison of Bash.** 5 | 6 | If you run into any `npm` package versioning issues, make sure you run `make setup` rather than calling `npm install` directly. 7 | 8 | 1. Create a branch to stage the release, for example `release-3.4.5` (Read [semver](https://semver.org/) to determine what type of version bump to use.) 9 | 10 | ``` 11 | git checkout -b release-3.4.5 12 | git fetch 13 | git reset --hard origin/master 14 | ``` 15 | 16 | 2. Run `make storybook` and verify that there are no regressions with Link. 17 | 18 | 3. Update `CHANGELOG.md` with changes that will be included in the release, commit and push to the release branch. To help confirm the changes 19 | that will be included in the release, consider using GitHub's compare feature: https://github.com/plaid/react-plaid-link/compare/v3.4.0...master 20 | (replace `v3.4.0` with the last published version). 21 | 22 | 4. When ready, publish the new version from the branch before merging 23 | 24 | ``` 25 | make release-(patch|minor|major) 26 | ``` 27 | 28 | 5. Merge the branch 29 | 30 | #### **If the `make` command errors out with an authentication issue** 31 | 32 | If `make release-*` from above errors out with an authentication issue, 33 | you may need to `npm login` or otherwise manually set up your `~/.npmrc` file to 34 | make sure you have access to publish to npm. 35 | 36 | #### (dangerous) Manually publish an already-built package to npm 37 | 38 | If you've set up your npm authentication as described above, but are still seeing authentication issues when running `make release-*`, you can 39 | directly publish a **built** package to the npm registry using the manual commands below. You should **only** publish the package manually using these steps 40 | if the build steps of the `make release-*` command were **successful**. If the build steps did not succeeed, running these commands could publish an unbuilt 41 | version of the package, which would lead to a broken release. 42 | 43 | 1. Publish already-built package to npm manually 44 | 45 | ```bash 46 | npm --registry=https://registry.npmjs.com publish 47 | ``` 48 | 49 | 2. Push tags manually 50 | 51 | ```bash 52 | git push --follow-tags 53 | ``` 54 | 55 | Also, under [github releases page](https://github.com/plaid/react-plaid-link/releases), draft a new release corresponding to the tag of the latest version. 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-plaid-link [![npm version](https://badge.fury.io/js/react-plaid-link.svg)](http://badge.fury.io/js/react-plaid-link) 2 | 3 | [React](https://facebook.github.io/react/) hook and components for integrating 4 | with [Plaid Link](https://plaid.com/docs/link/) 5 | 6 | ### Compatibility 7 | 8 | React 16.8-19.x.x 9 | 10 | ### Install 11 | 12 | With `npm`: 13 | 14 | ``` 15 | npm install --save react-plaid-link 16 | ``` 17 | 18 | With `yarn` 19 | 20 | ``` 21 | yarn add react-plaid-link 22 | ``` 23 | 24 | ## Documentation 25 | 26 | Please refer to the [official Plaid Link docs](https://plaid.com/docs/link/) 27 | for a more holistic understanding of Plaid Link. 28 | 29 | ## Examples 30 | 31 | Head to the `react-plaid-link` 32 | [storybook](https://plaid.github.io/react-plaid-link) to try out a live demo. 33 | 34 | See the [examples folder](examples) for various complete source code examples. 35 | 36 | ## Using React hooks 37 | 38 | This is the preferred approach for integrating with Plaid Link in React. 39 | 40 | **Note:** `token` can be `null` initially and then set once you fetch or generate 41 | a `link_token` asynchronously. 42 | 43 | ℹ️ See a full source code examples of using hooks: 44 | 45 | - [examples/simple.tsx](examples/simple.tsx): minimal example of using hooks 46 | - [examples/hooks.tsx](examples/hooks.tsx): example using hooks with all 47 | available callbacks 48 | - [examples/oauth.tsx](examples/oauth.tsx): example handling OAuth with hooks 49 | 50 | ```tsx 51 | import { usePlaidLink } from 'react-plaid-link'; 52 | 53 | // ... 54 | 55 | const { open, ready } = usePlaidLink({ 56 | token: '', 57 | onSuccess: (public_token, metadata) => { 58 | // send public_token to server 59 | }, 60 | }); 61 | 62 | return ( 63 | 66 | ); 67 | ``` 68 | 69 | ### Available Link configuration options 70 | 71 | ℹ️ See [src/types/index.ts][types] for exported types. 72 | 73 | Please refer to the [official Plaid Link 74 | docs](https://plaid.com/docs/link/web/) for a more holistic understanding of 75 | the various Link options and the 76 | [`link_token`](https://plaid.com/docs/api/tokens/#linktokencreate). 77 | 78 | #### `usePlaidLink` arguments 79 | 80 | | key | type | 81 | | --------------------- | ----------------------------------------------------------------------------------------- | 82 | | `token` | `string \| null` | 83 | | `onSuccess` | `(public_token: string, metadata: PlaidLinkOnSuccessMetadata) => void` | 84 | | `onExit` | `(error: null \| PlaidLinkError, metadata: PlaidLinkOnExitMetadata) => void` | 85 | | `onEvent` | `(eventName: PlaidLinkStableEvent \| string, metadata: PlaidLinkOnEventMetadata) => void` | 86 | | `onLoad` | `() => void` | 87 | | `receivedRedirectUri` | `string \| null \| undefined` | 88 | 89 | #### `usePlaidLink` return value 90 | 91 | | key | type | 92 | |----------|-----------------------------------------------------------------| 93 | | `open` | `() => void` | 94 | | `ready` | `boolean` | 95 | | `submit` | `(data: PlaidHandlerSubmissionData) => void` | 96 | | `error` | `ErrorEvent \| null` | 97 | | `exit` | `(options?: { force: boolean }, callback?: () => void) => void` | 98 | 99 | ### OAuth / opening Link without a button click 100 | 101 | Handling OAuth redirects requires opening Link without any user input (such as 102 | clicking a button). This can also be useful if you simply want Link to open 103 | immediately when your page or component renders. 104 | 105 | ℹ️ See full source code example at [examples/oauth.tsx](examples/oauth.tsx) 106 | 107 | ```tsx 108 | import { usePlaidLink } from 'react-plaid-link'; 109 | 110 | // ... 111 | 112 | const { open, ready } = usePlaidLink(config); 113 | 114 | // open Link immediately when ready 115 | React.useEffect(() => { 116 | if (ready) { 117 | open(); 118 | } 119 | }, [ready, open]); 120 | 121 | return <>; 122 | ``` 123 | 124 | ## Using the pre-built component instead of the usePlaidLink hook 125 | 126 | If you cannot use React hooks for legacy reasons such as incompatibility with 127 | class components, you can use the `PlaidLink` component. 128 | 129 | ℹ️ See full source code example at [examples/component.tsx](examples/component.tsx) 130 | 131 | ```tsx 132 | import { PlaidLink } from "react-plaid-link"; 133 | 134 | const App extends React.Component { 135 | // ... 136 | render() { 137 | return ( 138 | 144 | Link your bank account 145 | 146 | ); 147 | } 148 | } 149 | ``` 150 | 151 | ## Typescript support 152 | 153 | TypeScript definitions for `react-plaid-link` are built into the npm package. 154 | If you have previously installed `@types/react-plaid-link` before this package 155 | had types, please uninstall it in favor of built-in types. 156 | 157 | [types]: https://github.com/plaid/react-plaid-link/blob/master/src/types/index.ts 158 | -------------------------------------------------------------------------------- /examples/component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | PlaidLink, 5 | PlaidLinkOnSuccess, 6 | PlaidLinkOnEvent, 7 | PlaidLinkOnExit, 8 | } from 'react-plaid-link'; 9 | 10 | interface Props {} 11 | interface State { 12 | token: null; 13 | } 14 | class PlaidLinkClass extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | this.state = { token: null }; 18 | } 19 | async createLinkToken() { 20 | // get a link_token from your server 21 | const response = await fetch('/api/create_link_token', { method: 'POST' }); 22 | const { link_token } = await response.json(); 23 | return link_token; 24 | } 25 | 26 | async componentDidMount() { 27 | const token = await this.createLinkToken(); 28 | this.setState({ token }); 29 | } 30 | 31 | onSuccess: PlaidLinkOnSuccess = (publicToken, metadata) => { 32 | // send public_token to your server 33 | // https://plaid.com/docs/api/tokens/#token-exchange-flow 34 | console.log(publicToken, metadata); 35 | }; 36 | 37 | onEvent: PlaidLinkOnEvent = (eventName, metadata) => { 38 | // log onEvent callbacks from Link 39 | // https://plaid.com/docs/link/web/#onevent 40 | console.log(eventName, metadata); 41 | }; 42 | 43 | onExit: PlaidLinkOnExit = (error, metadata) => { 44 | // log onExit callbacks from Link, handle errors 45 | // https://plaid.com/docs/link/web/#onexit 46 | console.log(error, metadata); 47 | }; 48 | 49 | render() { 50 | return ( 51 | 59 | Link your bank account 60 | 61 | ); 62 | } 63 | } 64 | 65 | export default PlaidLinkClass; 66 | -------------------------------------------------------------------------------- /examples/hooks.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { 4 | usePlaidLink, 5 | PlaidLinkOnSuccess, 6 | PlaidLinkOnEvent, 7 | PlaidLinkOnExit, 8 | PlaidLinkOptions, 9 | } from 'react-plaid-link'; 10 | 11 | const PlaidLink = () => { 12 | const [token, setToken] = useState(null); 13 | 14 | // get a link_token from your API when component mounts 15 | React.useEffect(() => { 16 | const createLinkToken = async () => { 17 | const response = await fetch('/api/create_link_token', { 18 | method: 'POST', 19 | }); 20 | const { link_token } = await response.json(); 21 | setToken(link_token); 22 | }; 23 | createLinkToken(); 24 | }, []); 25 | 26 | const onSuccess = useCallback((publicToken, metadata) => { 27 | // send public_token to your server 28 | // https://plaid.com/docs/api/tokens/#token-exchange-flow 29 | console.log(publicToken, metadata); 30 | }, []); 31 | const onEvent = useCallback((eventName, metadata) => { 32 | // log onEvent callbacks from Link 33 | // https://plaid.com/docs/link/web/#onevent 34 | console.log(eventName, metadata); 35 | }, []); 36 | const onExit = useCallback((error, metadata) => { 37 | // log onExit callbacks from Link, handle errors 38 | // https://plaid.com/docs/link/web/#onexit 39 | console.log(error, metadata); 40 | }, []); 41 | 42 | const config: PlaidLinkOptions = { 43 | token, 44 | onSuccess, 45 | onEvent, 46 | onExit, 47 | }; 48 | 49 | const { 50 | open, 51 | ready, 52 | // error, 53 | // exit 54 | } = usePlaidLink(config); 55 | 56 | return ( 57 | 60 | ); 61 | }; 62 | 63 | export default PlaidLink; 64 | -------------------------------------------------------------------------------- /examples/oauth.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { 4 | usePlaidLink, 5 | PlaidLinkOnSuccess, 6 | PlaidLinkOnEvent, 7 | PlaidLinkOnExit, 8 | PlaidLinkOptions, 9 | } from 'react-plaid-link'; 10 | 11 | const PlaidLinkWithOAuth = () => { 12 | const [token, setToken] = useState(null); 13 | const isOAuthRedirect = window.location.href.includes('?oauth_state_id='); 14 | 15 | // generate a link_token when component mounts 16 | React.useEffect(() => { 17 | // do not generate a new token if page is handling an OAuth redirect. 18 | // instead setLinkToken to previously generated token from localStorage 19 | // https://plaid.com/docs/link/oauth/#reinitializing-link 20 | if (isOAuthRedirect) { 21 | setToken(localStorage.getItem('link_token')); 22 | return; 23 | } 24 | const createLinkToken = async () => { 25 | const response = await fetch('/api/create_link_token', { 26 | method: 'POST', 27 | }); 28 | const { link_token } = await response.json(); 29 | setToken(link_token); 30 | // store link_token temporarily in case of OAuth redirect 31 | localStorage.setItem('link_token', link_token); 32 | } 33 | createLinkToken(); 34 | }, []); 35 | 36 | const onSuccess = useCallback((publicToken, metadata) => { 37 | // send public_token to your server 38 | // https://plaid.com/docs/api/tokens/#token-exchange-flow 39 | console.log(publicToken, metadata); 40 | }, []); 41 | const onEvent = useCallback((eventName, metadata) => { 42 | // log onEvent callbacks from Link 43 | // https://plaid.com/docs/link/web/#onevent 44 | console.log(eventName, metadata); 45 | }, []); 46 | const onExit = useCallback((error, metadata) => { 47 | // log onExit callbacks from Link, handle errors 48 | // https://plaid.com/docs/link/web/#onexit 49 | console.log(error, metadata); 50 | }, []); 51 | 52 | const config: PlaidLinkOptions = { 53 | // token must be the same token used for the first initialization of Link 54 | token, 55 | onSuccess, 56 | onEvent, 57 | onExit, 58 | }; 59 | if (isOAuthRedirect) { 60 | // receivedRedirectUri must include the query params 61 | config.receivedRedirectUri = window.location.href; 62 | } 63 | 64 | const { 65 | open, 66 | ready, 67 | // error, 68 | // exit 69 | } = usePlaidLink(config); 70 | 71 | React.useEffect(() => { 72 | // If OAuth redirect, instantly open link when it is ready instead of 73 | // making user click the button 74 | if (isOAuthRedirect && ready) { 75 | open(); 76 | } 77 | }, [ready, open, isOAuthRedirect]); 78 | 79 | // No need to render a button on OAuth redirect as link opens instantly 80 | return isOAuthRedirect ? ( 81 | <> 82 | ) : ( 83 | 86 | ); 87 | }; 88 | 89 | export default PlaidLinkWithOAuth; 90 | -------------------------------------------------------------------------------- /examples/simple.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { usePlaidLink, PlaidLinkOnSuccess } from 'react-plaid-link'; 4 | 5 | const SimplePlaidLink = () => { 6 | const [token, setToken] = useState(null); 7 | 8 | // get link_token from your server when component mounts 9 | React.useEffect(() => { 10 | const createLinkToken = async () => { 11 | const response = await fetch('/api/create_link_token', { method: 'POST' }); 12 | const { link_token } = await response.json(); 13 | setToken(link_token); 14 | }; 15 | createLinkToken(); 16 | }, []); 17 | 18 | const onSuccess = useCallback((publicToken, metadata) => { 19 | // send public_token to your server 20 | // https://plaid.com/docs/api/tokens/#token-exchange-flow 21 | console.log(publicToken, metadata); 22 | }, []); 23 | 24 | const { open, ready } = usePlaidLink({ 25 | token, 26 | onSuccess, 27 | // onEvent 28 | // onExit 29 | }); 30 | 31 | return ( 32 | 35 | ); 36 | }; 37 | 38 | export default SimplePlaidLink; 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-jsdom', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-plaid-link", 3 | "version": "4.0.1", 4 | "description": "A React component for Plaid Link", 5 | "registry": "https://registry.npmjs.org", 6 | "files": [ 7 | "dist", 8 | "src", 9 | "LICENSE" 10 | ], 11 | "main": "dist/index.js", 12 | "module": "dist/index.esm.js", 13 | "jsnext:main": "dist/index.esm.js", 14 | "browser:min": "dist/index.umd.min.js", 15 | "browser": "dist/index.umd.js", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/plaid/react-plaid-link.git" 19 | }, 20 | "types": "dist/index.d.ts", 21 | "keywords": [ 22 | "react", 23 | "react-component", 24 | "plaid" 25 | ], 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/plaid/react-plaid-link/issues" 29 | }, 30 | "homepage": "https://github.com/plaid/react-plaid-link", 31 | "directories": { 32 | "example": "examples" 33 | }, 34 | "scripts": { 35 | "lint": "eslint '{src,examples}/**/*.{ts,tsx,js,jsx}' --ignore-path .gitignore", 36 | "test": "NODE_ENV=test BABEL_ENV=testing jest", 37 | "storybook": "start-storybook -p 9001", 38 | "deploy-storybook": "storybook-to-ghpages", 39 | "build-storybook": "build-storybook -s public" 40 | }, 41 | "husky": { 42 | "hooks": { 43 | "pre-push": "npm run lint" 44 | } 45 | }, 46 | "dependencies": { 47 | "prop-types": "^15.7.2" 48 | }, 49 | "peerDependencies": { 50 | "react": "^16.8.0 || ^17 || ^18 || ^19", 51 | "react-dom": "^16.8.0 || ^17 || ^18 || ^19" 52 | }, 53 | "devDependencies": { 54 | "@babel/cli": "^7.14.3", 55 | "@babel/core": "^7.14.3", 56 | "@babel/plugin-proposal-class-properties": "^7.14.3", 57 | "@babel/preset-env": "^7.14.3", 58 | "@babel/preset-react": "^7.14.3", 59 | "@babel/preset-typescript": "^7.16.7", 60 | "@storybook/addon-actions": "^5.3.17", 61 | "@storybook/addon-console": "^1.2.1", 62 | "@storybook/addon-knobs": "^5.3.17", 63 | "@storybook/addon-links": "^5.3.17", 64 | "@storybook/addons": "^5.3.17", 65 | "@storybook/preset-typescript": "^2.1.0", 66 | "@storybook/react": "^5.3.17", 67 | "@storybook/storybook-deployer": "^2.8.3", 68 | "@testing-library/react": "^12.1.4", 69 | "@testing-library/react-hooks": "^7.0.2", 70 | "@types/jest": "^27.4.1", 71 | "@types/react": "^17.0.9", 72 | "@types/react-dom": "^17.0.6", 73 | "@typescript-eslint/eslint-plugin": "^2.24.0", 74 | "@typescript-eslint/parser": "^2.24.0", 75 | "@wessberg/rollup-plugin-ts": "^1.2.21", 76 | "babel-loader": "^8.0.6", 77 | "babel-plugin-transform-class-properties": "^6.24.1", 78 | "babel-plugin-typescript-to-proptypes": "^1.3.2", 79 | "eslint": "6.6.0", 80 | "eslint-config-airbnb": "18.0.1", 81 | "eslint-config-prettier": "^6.10.0", 82 | "eslint-plugin-import": "^2.18.2", 83 | "eslint-plugin-jest": "^22.6.3", 84 | "eslint-plugin-jsx-a11y": "^6.2.3", 85 | "eslint-plugin-prettier": "^3.1.2", 86 | "eslint-plugin-react": "^7.14.3", 87 | "eslint-plugin-react-hooks": "^4.2.0", 88 | "husky": "^4.2.3", 89 | "jest": "^27.5.1", 90 | "mocha": "2.3.x", 91 | "prettier": "^1.19.1", 92 | "react": "^17.0.2", 93 | "react-dom": "^17.0.2", 94 | "react-tools": "0.13.x", 95 | "rollup": "^1.27.0", 96 | "rollup-plugin-babel": "^4.3.3", 97 | "rollup-plugin-commonjs": "^10.1.0", 98 | "rollup-plugin-node-resolve": "^5.2.0", 99 | "rollup-plugin-replace": "^2.2.0", 100 | "rollup-plugin-terser": "^5.1.2", 101 | "sinon": "1.17.x", 102 | "ts-loader": "^6.2.1", 103 | "typescript": "^3.8.3", 104 | "xyz": "^4.0.x" 105 | }, 106 | "tags": [ 107 | "react", 108 | "plaid" 109 | ], 110 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 111 | } 112 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import replace from 'rollup-plugin-replace'; 6 | import ts from '@wessberg/rollup-plugin-ts'; 7 | import pkg from './package.json'; 8 | 9 | export default [ 10 | { 11 | input: 'src/index.ts', 12 | external: ['react'], 13 | output: [ 14 | { file: pkg.main, format: 'cjs' }, 15 | { file: pkg.module, format: 'es' }, 16 | ], 17 | plugins: [ 18 | ts(), 19 | resolve(), 20 | babel({ 21 | extensions: ['.ts', '.js', '.tsx', '.jsx'], 22 | }), 23 | commonjs(), 24 | ], 25 | }, 26 | // UMD build with inline PropTypes 27 | { 28 | input: 'src/index.ts', 29 | external: ['react'], 30 | output: [ 31 | { 32 | name: 'PlaidLink', 33 | file: pkg.browser, 34 | format: 'umd', 35 | globals: { 36 | react: 'React', 37 | }, 38 | }, 39 | ], 40 | plugins: [ 41 | ts(), 42 | resolve(), 43 | babel({ 44 | extensions: ['.ts', '.js', '.tsx', '.jsx'], 45 | }), 46 | commonjs(), 47 | ], 48 | }, 49 | // Minified UMD Build without PropTypes 50 | { 51 | input: 'src/index.ts', 52 | external: ['react'], 53 | output: [ 54 | { 55 | name: 'PlaidLink', 56 | file: pkg['browser:min'], 57 | format: 'umd', 58 | globals: { 59 | react: 'React', 60 | }, 61 | }, 62 | ], 63 | plugins: [ 64 | ts(), 65 | resolve(), 66 | babel({ 67 | extensions: ['.ts', '.js', '.tsx', '.jsx'], 68 | }), 69 | replace({ 'process.env.NODE_ENV': JSON.stringify('production') }), 70 | commonjs(), 71 | terser(), 72 | ], 73 | }, 74 | ]; 75 | -------------------------------------------------------------------------------- /scripts/check-import: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BASE_DIR="$(dirname "$0")/.."; 4 | 5 | checkImport() { 6 | file=$1 7 | regexp=$2 8 | message=$3 9 | grep "${regexp}" "${BASE_DIR}${file}" 10 | 11 | case $? in 12 | 1) true 13 | ;; 14 | 0) 15 | echo "Found disallowed import in ${file}" 16 | echo "${message}" 17 | false 18 | ;; 19 | *) 20 | false 21 | ;; 22 | esac 23 | } 24 | 25 | checkImport "/dist/index.d.ts" 'import [^*{]' 'Please only use * or named imports for types' && \ 26 | checkImport "/dist/index.esm.js" 'import.*{' 'Please do not use named imports for dependencies' 27 | -------------------------------------------------------------------------------- /src/PlaidEmbeddedLink.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { PlaidEmbeddedLink, PlaidLinkOptions } from './'; 4 | 5 | import useScript from './react-script-hook'; 6 | jest.mock('./react-script-hook'); 7 | const mockedUseScript = useScript as jest.Mock; 8 | 9 | const ScriptLoadingState = { 10 | LOADING: [true, null], 11 | LOADED: [false, null], 12 | ERROR: [false, 'SCRIPT_LOAD_ERROR'], 13 | }; 14 | 15 | describe('PlaidEmbeddedLink', () => { 16 | const config: PlaidLinkOptions = { 17 | token: 'test-token', 18 | onSuccess: jest.fn(), 19 | onExit: jest.fn(), 20 | onLoad: jest.fn(), 21 | onEvent: jest.fn(), 22 | }; 23 | const createEmbeddedSpy = jest.fn(() => ({ 24 | destroy: jest.fn(), 25 | })); 26 | 27 | beforeEach(() => { 28 | mockedUseScript.mockImplementation(() => ScriptLoadingState.LOADED); 29 | window.Plaid = { 30 | createEmbedded: createEmbeddedSpy, 31 | }; 32 | }); 33 | 34 | afterEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | it('should not rerender if config did not change', () => { 39 | const styles = { height: '350px', width: '350px', backgroundColor: 'white' }; 40 | const { rerender } = render(); 41 | expect(createEmbeddedSpy).toHaveBeenCalledTimes(1); 42 | rerender(); 43 | expect(createEmbeddedSpy).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | it('should rerender if config did change', () => { 47 | const { rerender } = render(); 48 | expect(createEmbeddedSpy).toHaveBeenCalledTimes(1); 49 | const newConfig = { ...config, token: 'new-test-token' }; 50 | rerender(); 51 | expect(createEmbeddedSpy).toHaveBeenCalledTimes(2); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/PlaidEmbeddedLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useMemo } from 'react'; 2 | import useScript from './react-script-hook'; 3 | 4 | import { PLAID_LINK_STABLE_URL } from './constants'; 5 | import { 6 | PlaidEmbeddedLinkPropTypes, 7 | PlaidLinkOptionsWithLinkToken, 8 | } from './types'; 9 | 10 | export const PlaidEmbeddedLink = (props: PlaidEmbeddedLinkPropTypes) => { 11 | const { 12 | style, 13 | className, 14 | onSuccess, 15 | onExit, 16 | onLoad, 17 | onEvent, 18 | token, 19 | receivedRedirectUri, 20 | } = props; 21 | 22 | const config = useMemo( 23 | () => ({ 24 | onSuccess, 25 | onExit, 26 | onLoad, 27 | onEvent, 28 | token, 29 | receivedRedirectUri, 30 | }), 31 | [onSuccess, onExit, onLoad, onEvent, token, receivedRedirectUri] 32 | ); 33 | 34 | // Asynchronously load the plaid/link/stable url into the DOM 35 | const [loading, error] = useScript({ 36 | src: PLAID_LINK_STABLE_URL, 37 | checkForExisting: true, 38 | }); 39 | 40 | const embeddedLinkTarget = useRef(null); 41 | useEffect(() => { 42 | // If the external link JS script is still loading, return prematurely 43 | if (loading) { 44 | return; 45 | } 46 | 47 | if (error || !window.Plaid) { 48 | // eslint-disable-next-line no-console 49 | console.error('Error loading Plaid', error); 50 | return; 51 | } 52 | 53 | if (config.token == null || config.token == '') { 54 | console.error('A token is required to initialize embedded Plaid Link'); 55 | return; 56 | } 57 | 58 | // The embedded Link interface doesn't use the `usePlaidLink` hook to manage 59 | // its Plaid Link instance because the embedded Link integration in link-initialize 60 | // maintains its own handler internally. 61 | const { destroy } = window.Plaid.createEmbedded( 62 | { ...config }, embeddedLinkTarget.current as HTMLElement 63 | ); 64 | 65 | // Clean up embedded Link component on unmount 66 | return () => { 67 | destroy(); 68 | } 69 | }, [loading, error, config, embeddedLinkTarget]); 70 | 71 | return ( 72 |
73 | ); 74 | }; 75 | 76 | export default PlaidEmbeddedLink; 77 | -------------------------------------------------------------------------------- /src/PlaidLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { PlaidLinkPropTypes } from './types'; 4 | import { usePlaidLink } from './usePlaidLink'; 5 | 6 | export const PlaidLink: React.FC = props => { 7 | const { children, style, className, ...config } = props; 8 | const { error, open } = usePlaidLink({ ...config }); 9 | 10 | return ( 11 | 27 | ); 28 | }; 29 | 30 | PlaidLink.displayName = 'PlaidLink'; 31 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLAID_LINK_STABLE_URL = 2 | 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; 3 | -------------------------------------------------------------------------------- /src/factory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PlaidLinkOptions, 3 | PlaidHandler, 4 | PlaidHandlerSubmissionData, 5 | CommonPlaidLinkOptions, 6 | } from './types'; 7 | 8 | export interface PlaidFactory { 9 | open: (() => void) | Function; 10 | submit: ((data: PlaidHandlerSubmissionData) => void)| Function; 11 | exit: ((exitOptions: any, callback: () => void) => void) | Function; 12 | destroy: (() => void) | Function; 13 | } 14 | 15 | interface FactoryInternalState { 16 | plaid: PlaidHandler | null; 17 | open: boolean; 18 | onExitCallback: (() => void) | null | Function; 19 | } 20 | 21 | const renameKeyInObject = ( 22 | o: { [index: string]: any }, 23 | oldKey: string, 24 | newKey: string 25 | ): object => { 26 | const newObject = {}; 27 | delete Object.assign(newObject, o, { [newKey]: o[oldKey] })[oldKey]; 28 | return newObject; 29 | }; 30 | 31 | /** 32 | * Wrap link handler creation and instance to clean up iframe via destroy() method 33 | */ 34 | const createPlaidHandler = >( 35 | config: T, 36 | creator: (config: T) => PlaidHandler 37 | ) => { 38 | const state: FactoryInternalState = { 39 | plaid: null, 40 | open: false, 41 | onExitCallback: null, 42 | }; 43 | 44 | // If Plaid is not available, throw an Error 45 | if (typeof window === 'undefined' || !window.Plaid) { 46 | throw new Error('Plaid not loaded'); 47 | } 48 | 49 | state.plaid = creator({ 50 | ...config, 51 | onExit: (error, metadata) => { 52 | state.open = false; 53 | config.onExit && config.onExit(error, metadata); 54 | state.onExitCallback && state.onExitCallback(); 55 | }, 56 | }); 57 | 58 | const open = () => { 59 | if (!state.plaid) { 60 | return; 61 | } 62 | state.open = true; 63 | state.onExitCallback = null; 64 | state.plaid.open(); 65 | }; 66 | 67 | const submit = (data: PlaidHandlerSubmissionData) => { 68 | if (!state.plaid) { 69 | return; 70 | } 71 | state.plaid.submit(data) 72 | } 73 | 74 | const exit = (exitOptions: any, callback: (() => void) | Function) => { 75 | if (!state.open || !state.plaid) { 76 | callback && callback(); 77 | return; 78 | } 79 | state.onExitCallback = callback; 80 | state.plaid.exit(exitOptions); 81 | if (exitOptions && exitOptions.force) { 82 | state.open = false; 83 | } 84 | }; 85 | 86 | const destroy = () => { 87 | if (!state.plaid) { 88 | return; 89 | } 90 | 91 | state.plaid.destroy(); 92 | state.plaid = null; 93 | }; 94 | 95 | return { 96 | open, 97 | submit, 98 | exit, 99 | destroy, 100 | }; 101 | }; 102 | 103 | export const createPlaid = ( 104 | options: PlaidLinkOptions, 105 | creator: (options: PlaidLinkOptions) => PlaidHandler 106 | ) => { 107 | const config = renameKeyInObject( 108 | options, 109 | 'publicKey', 110 | 'key' 111 | ) as PlaidLinkOptions; 112 | 113 | return createPlaidHandler(config, creator); 114 | }; 115 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { usePlaidLink } from './usePlaidLink'; 2 | export { PlaidLink } from './PlaidLink'; 3 | export { PlaidEmbeddedLink } from './PlaidEmbeddedLink'; 4 | export * from './types' 5 | -------------------------------------------------------------------------------- /src/react-script-hook/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | 3 | import useScript, { scripts } from './'; 4 | 5 | describe('useScript', () => { 6 | beforeEach(() => { 7 | const html = document.querySelector('html'); 8 | if (html) { 9 | html.innerHTML = ''; 10 | } 11 | // Reset scripts status 12 | Object.keys(scripts).forEach((key) => delete scripts[key]); 13 | }); 14 | 15 | it('should append a script tag', () => { 16 | expect(document.querySelectorAll('script').length).toBe(0); 17 | 18 | const { result } = renderHook(() => 19 | useScript({ src: 'http://scriptsrc/' }), 20 | ); 21 | 22 | const [loading, error] = result.current; 23 | 24 | expect(loading).toBe(true); 25 | expect(error).toBeNull(); 26 | 27 | const script = document.querySelector('script'); 28 | expect(script).not.toBeNull(); 29 | if (script) { 30 | expect(script.getAttribute('src')).toEqual('http://scriptsrc/'); 31 | } 32 | }); 33 | 34 | it('should append a script tag with attributes', async () => { 35 | expect(document.querySelectorAll('script').length).toBe(0); 36 | 37 | const { result } = renderHook(() => 38 | useScript({ 39 | src: 'http://scriptsrc/', 40 | 'data-test': 'test', 41 | async: true, 42 | }), 43 | ); 44 | 45 | const [loading, error] = result.current; 46 | 47 | expect(loading).toBe(true); 48 | expect(error).toBeNull(); 49 | 50 | const script = document.querySelector('script'); 51 | expect(script).not.toBeNull(); 52 | if (script) { 53 | expect(script.getAttribute('src')).toEqual('http://scriptsrc/'); 54 | expect(script.getAttribute('data-test')).toEqual('test'); 55 | expect(script.getAttribute('async')).toBe('true'); 56 | } 57 | }); 58 | 59 | it('should render a script only once, single hook', () => { 60 | expect(document.querySelectorAll('script').length).toBe(0); 61 | 62 | const props = { src: 'http://scriptsrc/' }; 63 | const handle = renderHook((p) => useScript(p), { 64 | initialProps: props, 65 | }); 66 | expect(document.querySelectorAll('script').length).toBe(1); 67 | 68 | handle.rerender(); 69 | expect(document.querySelectorAll('script').length).toBe(1); 70 | }); 71 | 72 | it('should render a script only once, multiple hooks', () => { 73 | expect(document.querySelectorAll('script').length).toBe(0); 74 | 75 | const props = { src: 'http://scriptsrc/' }; 76 | const handle1 = renderHook((p) => useScript(p), { 77 | initialProps: props, 78 | }); 79 | const handle2 = renderHook((p) => useScript(p), { 80 | initialProps: props, 81 | }); 82 | 83 | expect(document.querySelectorAll('script').length).toBe(1); 84 | handle2.rerender(); 85 | expect(document.querySelectorAll('script').length).toBe(1); 86 | handle1.rerender(); 87 | expect(document.querySelectorAll('script').length).toBe(1); 88 | handle2.rerender(); 89 | expect(document.querySelectorAll('script').length).toBe(1); 90 | }); 91 | 92 | it('should render a script only once, multiple hooks in same component', () => { 93 | expect(document.querySelectorAll('script').length).toBe(0); 94 | 95 | const props = { src: 'http://scriptsrc/' }; 96 | const handle = renderHook( 97 | (p) => { 98 | useScript(p); 99 | useScript(p); 100 | }, 101 | { 102 | initialProps: props, 103 | }, 104 | ); 105 | 106 | expect(document.querySelectorAll('script').length).toBe(1); 107 | handle.rerender(); 108 | expect(document.querySelectorAll('script').length).toBe(1); 109 | }); 110 | 111 | it('should set loading false on load', async () => { 112 | const props = { src: 'http://scriptsrc/' }; 113 | const handle = renderHook((p) => useScript(p), { 114 | initialProps: props, 115 | }); 116 | 117 | const [loading, error] = handle.result.current; 118 | expect(loading).toBe(true); 119 | expect(error).toBe(null); 120 | 121 | const el = document.querySelector('script'); 122 | expect(el).toBeDefined(); 123 | act(() => { 124 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 125 | el!.dispatchEvent(new Event('load')); 126 | }); 127 | 128 | const [loadingAfter, errorAfter] = handle.result.current; 129 | expect(loadingAfter).toBe(false); 130 | expect(errorAfter).toBe(null); 131 | }); 132 | 133 | it('should set loading false on load, multiple hooks', async () => { 134 | const props = { src: 'http://scriptsrc/' }; 135 | const handle1 = renderHook((p) => useScript(p), { 136 | initialProps: props, 137 | }); 138 | const handle2 = renderHook((p) => useScript(p), { 139 | initialProps: props, 140 | }); 141 | 142 | expect(handle1.result.current).toStrictEqual([true, null]); 143 | expect(handle2.result.current).toStrictEqual([true, null]); 144 | 145 | const el = document.querySelector('script'); 146 | expect(el).toBeDefined(); 147 | act(() => { 148 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 149 | el!.dispatchEvent(new Event('load')); 150 | }); 151 | 152 | expect(handle1.result.current).toStrictEqual([false, null]); 153 | expect(handle2.result.current).toStrictEqual([false, null]); 154 | }); 155 | 156 | it('should set loading true if previously loaded', async () => { 157 | const props = { src: 'http://scriptsrc/' }; 158 | const handle1 = renderHook((p) => useScript(p), { 159 | initialProps: props, 160 | }); 161 | expect(handle1.result.current).toStrictEqual([true, null]); 162 | 163 | const el = document.querySelector('script'); 164 | expect(el).toBeDefined(); 165 | act(() => { 166 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 167 | el!.dispatchEvent(new Event('load')); 168 | }); 169 | expect(handle1.result.current).toStrictEqual([false, null]); 170 | 171 | const handle2 = renderHook((p) => useScript(p), { 172 | initialProps: props, 173 | }); 174 | expect(handle2.result.current).toStrictEqual([false, null]); 175 | }); 176 | 177 | it('should not cause issues on unmount', async () => { 178 | const props = { src: 'http://scriptsrc/' }; 179 | const handle = renderHook((p) => useScript(p), { 180 | initialProps: props, 181 | }); 182 | 183 | handle.unmount(); 184 | 185 | act(() => { 186 | const el = document.querySelector('script'); 187 | if (el) { 188 | el.dispatchEvent(new Event('load')); 189 | } 190 | }); 191 | }); 192 | 193 | it('should check for script existing on the page before rendering when checkForExisting is true', () => { 194 | expect(document.querySelectorAll('script').length).toBe(0); 195 | 196 | const previousScript = document.createElement('script'); 197 | previousScript.src = 'http://scriptsrc/'; 198 | document.body.appendChild(previousScript); 199 | 200 | expect(document.querySelectorAll('script').length).toBe(1); 201 | 202 | const props = { src: 'http://scriptsrc/', checkForExisting: true }; 203 | const handle = renderHook( 204 | (p) => { 205 | // Check that state is immediately "loaded" 206 | const result = useScript(p); 207 | expect(result).toStrictEqual([false, null]); 208 | return result; 209 | }, 210 | { 211 | initialProps: props, 212 | }, 213 | ); 214 | expect(document.querySelectorAll('script').length).toBe(1); 215 | expect(handle.result.current).toStrictEqual([false, null]); 216 | 217 | handle.rerender(); 218 | expect(document.querySelectorAll('script').length).toBe(1); 219 | expect(handle.result.current).toStrictEqual([false, null]); 220 | }); 221 | 222 | it('should not check for script existing on the page before rendering when checkForExisting is not set', () => { 223 | expect(document.querySelectorAll('script').length).toBe(0); 224 | 225 | const previousScript = document.createElement('script'); 226 | previousScript.src = 'http://scriptsrc/'; 227 | document.body.appendChild(previousScript); 228 | 229 | expect(document.querySelectorAll('script').length).toBe(1); 230 | 231 | const props = { src: 'http://scriptsrc/' }; 232 | const handle = renderHook((p) => useScript(p), { 233 | initialProps: props, 234 | }); 235 | expect(document.querySelectorAll('script').length).toBe(2); 236 | 237 | handle.rerender(); 238 | expect(document.querySelectorAll('script').length).toBe(2); 239 | }); 240 | 241 | it('should handle null src and not append a script tag', () => { 242 | expect(document.querySelectorAll('script').length).toBe(0); 243 | 244 | const { result } = renderHook(() => useScript({ src: null })); 245 | 246 | const [loading, error] = result.current; 247 | 248 | expect(loading).toBe(false); 249 | expect(error).toBeNull(); 250 | 251 | expect(document.querySelectorAll('script').length).toBe(0); 252 | }); 253 | 254 | it('should append script after src change from null', async () => { 255 | expect(document.querySelectorAll('script').length).toBe(0); 256 | 257 | const props = { src: null }; 258 | const { result, rerender, waitFor } = renderHook((p) => useScript(p), { 259 | initialProps: props, 260 | }); 261 | 262 | const [loading, error] = result.current; 263 | 264 | expect(loading).toBe(false); 265 | expect(error).toBeNull(); 266 | 267 | expect(document.querySelectorAll('script').length).toBe(0); 268 | 269 | props.src = 'http://scriptsrc/' as any; 270 | rerender(props); 271 | await waitFor(() => { 272 | expect(document.querySelectorAll('script').length).toBe(1); 273 | }); 274 | }); 275 | 276 | it('should remove script from DOM and scripts cache when unmounted during loading', () => { 277 | expect(document.querySelectorAll('script').length).toBe(0); 278 | expect(Object.keys(scripts).length).toBe(0); 279 | 280 | const testSrc = 'http://scriptsrc/test'; 281 | const { unmount } = renderHook(() => useScript({ src: testSrc })); 282 | 283 | // Verify script was added 284 | expect(document.querySelectorAll('script').length).toBe(1); 285 | expect(Object.keys(scripts).length).toBe(1); 286 | expect(scripts[testSrc]).toBeDefined(); 287 | expect(scripts[testSrc].loading).toBe(true); 288 | 289 | // Unmount the component while script is still loading (before load event) 290 | unmount(); 291 | 292 | // Verify script was removed from DOM and cache 293 | expect(document.querySelectorAll('script').length).toBe(0); 294 | expect(Object.keys(scripts).length).toBe(0); 295 | expect(scripts[testSrc]).toBeUndefined(); 296 | }); 297 | }); -------------------------------------------------------------------------------- /src/react-script-hook/index.tsx: -------------------------------------------------------------------------------- 1 | // This is a fork of https://github.com/hupe1980/react-script-hook 2 | // We originally started with patch-package, but with React 19 we also needed 3 | // to update its dependency versions, so (given the size) we just forked it. 4 | 5 | import { useState, useEffect } from 'react'; 6 | 7 | interface ScriptProps { 8 | src: HTMLScriptElement['src'] | null; 9 | checkForExisting?: boolean; 10 | [key: string]: any; 11 | } 12 | 13 | type ErrorState = ErrorEvent | null; 14 | type ScriptStatus = { 15 | loading: boolean; 16 | error: ErrorState; 17 | scriptEl: HTMLScriptElement; 18 | }; 19 | type ScriptStatusMap = { 20 | [key: string]: ScriptStatus; 21 | }; 22 | 23 | // Previously loading/loaded scripts and their current status 24 | export const scripts: ScriptStatusMap = {}; 25 | 26 | // Check for existing