├── .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 [](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