From d633596c41c49f3f37aa57a661896e1f5e92b8fa Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Thu, 23 Jul 2026 13:03:57 -0700 Subject: [PATCH 1/3] Improve Layer and Link type support --- CHANGELOG.md | 7 +++ README.md | 54 ++++++++++++++++- examples/layer.tsx | 105 +++++++++++++++++++++++++++++++++ src/PlaidEmbeddedLink.test.tsx | 25 ++++++-- src/factory.ts | 27 +++++---- src/index.ts | 2 +- src/types/index.ts | 51 ++++++++++++---- src/usePlaidLink.test.tsx | 62 +++++++++++++++++-- src/usePlaidLink.ts | 3 +- 9 files changed, 301 insertions(+), 35 deletions(-) create mode 100644 examples/layer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0d8cd9..e298c3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Correct Layer submission types so phone number and date of birth can be + submitted separately. +- Add stable Layer and Identity Match events. +- Add a complete Layer React example and integration guidance. +- Add explicit `usePlaidLink` return types and update callback metadata types + to match the Link Web SDK. + ## 4.2.0 - Add `cspNonce` to support nonce-based Content Security Policies on `usePlaidLink` and `PlaidEmbeddedLink` (partially fixes [#118](https://github.com/plaid/react-plaid-link/issues/118)). diff --git a/README.md b/README.md index 60e4f0af..85b4baaa 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ a `link_token` asynchronously. - [examples/hooks.tsx](examples/hooks.tsx): example using hooks with all available callbacks - [examples/oauth.tsx](examples/oauth.tsx): example handling OAuth with hooks +- [examples/layer.tsx](examples/layer.tsx): example implementing Plaid Layer ```tsx import React from 'react'; @@ -67,6 +68,54 @@ return ( ); ``` +### Using Plaid Layer + +Create a Layer Link token with +[`/session/token/create`](https://plaid.com/docs/api/products/layer/#sessiontokencreate) +on your server, then initialize `usePlaidLink` as early as possible so Link can +preload. Submit the user's phone number and wait for a Layer event before +opening Link: + +```tsx +import { PlaidLinkStableEvent, usePlaidLink } from 'react-plaid-link'; + +const [layerReady, setLayerReady] = React.useState(false); +const { open, ready, submit } = usePlaidLink({ + token: layerLinkToken, + onSuccess, + onEvent: eventName => { + if (eventName === PlaidLinkStableEvent.LAYER_READY) { + setLayerReady(true); + } + }, +}); + +React.useEffect(() => { + if (ready && layerReady) { + open(); + } +}, [layerReady, open, ready]); + +const submitPhoneNumber = () => { + submit({ phone_number: '+14155550123' }); +}; +``` + +If the phone number produces `LAYER_NOT_AVAILABLE`, Layer Extended Autofill +can be attempted with a separate submission: + +```tsx +const submitDateOfBirth = () => { + submit({ date_of_birth: '1975-01-18' }); +}; +``` + +Fall back to a non-Layer onboarding flow if Extended Autofill produces +`LAYER_AUTOFILL_NOT_AVAILABLE`. See the +[complete Layer example](examples/layer.tsx) and +[Plaid Layer integration guide](https://plaid.com/docs/layer/add-to-app/) for +the full flow. + ### Available Link configuration options ℹ️ See [src/types/index.ts][types] for exported types. @@ -81,13 +130,16 @@ the various Link options and the | key | type | | --------------------- | ----------------------------------------------------------------------------------------- | | `token` | `string \| null` | -| `onSuccess` | `(public_token: string, metadata: PlaidLinkOnSuccessMetadata) => void` | +| `onSuccess` | `(public_token: string \| null, metadata: PlaidLinkOnSuccessMetadata) => void` | | `onExit` | `(error: null \| PlaidLinkError, metadata: PlaidLinkOnExitMetadata) => void` | | `onEvent` | `(eventName: PlaidLinkStableEvent \| string, metadata: PlaidLinkOnEventMetadata) => void` | | `onLoad` | `() => void` | | `receivedRedirectUri` | `string \| undefined` | | `cspNonce` | `string \| undefined` | +`public_token` is `null` for products such as Identity Verification and +Beacon that do not create an Item. + #### Content Security Policy nonce If your app uses a nonce-based Content Security Policy, generate a fresh nonce diff --git a/examples/layer.tsx b/examples/layer.tsx new file mode 100644 index 00000000..49e5bac5 --- /dev/null +++ b/examples/layer.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + PlaidLinkOnEvent, + PlaidLinkOnSuccess, + PlaidLinkStableEvent, + usePlaidLink, +} from 'react-plaid-link'; + +interface LayerExampleProps { + linkToken: string; + onSuccess: PlaidLinkOnSuccess; + onFallback: () => void; +} + +export const LayerExample: React.FC = ({ + linkToken, + onSuccess, + onFallback, +}) => { + const [phoneNumber, setPhoneNumber] = React.useState(''); + const [dateOfBirth, setDateOfBirth] = React.useState(''); + const [ + layerEvent, + setLayerEvent, + ] = React.useState(null); + + const onEvent = React.useCallback(eventName => { + switch (eventName) { + case PlaidLinkStableEvent.LAYER_READY: + setLayerEvent(PlaidLinkStableEvent.LAYER_READY); + break; + case PlaidLinkStableEvent.LAYER_NOT_AVAILABLE: + setLayerEvent(PlaidLinkStableEvent.LAYER_NOT_AVAILABLE); + break; + case PlaidLinkStableEvent.LAYER_AUTOFILL_NOT_AVAILABLE: + setLayerEvent(PlaidLinkStableEvent.LAYER_AUTOFILL_NOT_AVAILABLE); + break; + default: + break; + } + }, []); + + // Initialize Link as soon as the view mounts so Layer can preload. + const { open, ready, submit } = usePlaidLink({ + token: linkToken, + onSuccess, + onEvent, + }); + + React.useEffect(() => { + if (ready && layerEvent === PlaidLinkStableEvent.LAYER_READY) { + open(); + } + }, [layerEvent, open, ready]); + + React.useEffect(() => { + if (layerEvent === PlaidLinkStableEvent.LAYER_AUTOFILL_NOT_AVAILABLE) { + onFallback(); + } + }, [layerEvent, onFallback]); + + const submitPhoneNumber = (event: React.FormEvent) => { + event.preventDefault(); + submit({ phone_number: phoneNumber }); + }; + + const submitDateOfBirth = (event: React.FormEvent) => { + event.preventDefault(); + submit({ date_of_birth: dateOfBirth }); + }; + + if (layerEvent === PlaidLinkStableEvent.LAYER_NOT_AVAILABLE) { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +}; diff --git a/src/PlaidEmbeddedLink.test.tsx b/src/PlaidEmbeddedLink.test.tsx index 0e00a0be..19b644a3 100644 --- a/src/PlaidEmbeddedLink.test.tsx +++ b/src/PlaidEmbeddedLink.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { PlaidEmbeddedLink, PlaidLinkOptions } from './'; +import { PlaidEmbeddedHandler, PlaidEmbeddedLink, PlaidLinkOptions } from './'; import useScript from './react-script-hook'; jest.mock('./react-script-hook'); @@ -20,13 +20,17 @@ describe('PlaidEmbeddedLink', () => { onLoad: jest.fn(), onEvent: jest.fn(), }; - const createEmbeddedSpy = jest.fn(() => ({ + const createEmbeddedSpy = jest.fn< + PlaidEmbeddedHandler, + [PlaidLinkOptions, HTMLElement] + >(() => ({ destroy: jest.fn(), })); beforeEach(() => { mockedUseScript.mockImplementation(() => ScriptLoadingState.LOADED); window.Plaid = { + create: jest.fn(), createEmbedded: createEmbeddedSpy, }; }); @@ -36,10 +40,21 @@ describe('PlaidEmbeddedLink', () => { }); it('should not rerender if config did not change', () => { - const styles = { height: '350px', width: '350px', backgroundColor: 'white' }; - const { rerender } = render(); + const styles = { + height: '350px', + width: '350px', + backgroundColor: 'white', + }; + const { rerender } = render( + + ); expect(createEmbeddedSpy).toHaveBeenCalledTimes(1); - rerender(); + rerender( + + ); expect(createEmbeddedSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/factory.ts b/src/factory.ts index ba37d7f1..f9e39882 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -5,19 +5,20 @@ import { PlaidLinkOnSuccess, PlaidLinkOnSuccessMetadata, CommonPlaidLinkOptions, + PlaidHandlerExitOptions, } from './types'; export interface PlaidFactory { - open: (() => void) | Function; - submit: ((data: PlaidHandlerSubmissionData) => void)| Function; - exit: ((exitOptions: any, callback: () => void) => void) | Function; - destroy: (() => void) | Function; + open: () => void; + submit: (data: PlaidHandlerSubmissionData) => void; + exit: (exitOptions?: PlaidHandlerExitOptions, callback?: () => void) => void; + destroy: () => void; } interface FactoryInternalState { plaid: PlaidHandler | null; open: boolean; - onExitCallback: (() => void) | null | Function; + onExitCallback: (() => void) | null; } const renameKeyInObject = ( @@ -52,7 +53,10 @@ const createPlaidHandler = < state.plaid = creator({ ...config, - onSuccess: (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => { + onSuccess: ( + publicToken: string | null, + metadata: PlaidLinkOnSuccessMetadata + ) => { state.open = false; config.onSuccess(publicToken, metadata); }, @@ -76,15 +80,18 @@ const createPlaidHandler = < if (!state.plaid) { return; } - state.plaid.submit(data) - } + state.plaid.submit(data); + }; - const exit = (exitOptions: any, callback: (() => void) | Function) => { + const exit = ( + exitOptions?: PlaidHandlerExitOptions, + callback?: () => void + ) => { if (!state.open || !state.plaid) { callback && callback(); return; } - state.onExitCallback = callback; + state.onExitCallback = callback || null; state.plaid.exit(exitOptions); if (exitOptions && exitOptions.force) { state.open = false; diff --git a/src/index.ts b/src/index.ts index f51f5f1b..8da095d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { usePlaidLink } from './usePlaidLink'; export { PlaidLink } from './PlaidLink'; export { PlaidEmbeddedLink } from './PlaidEmbeddedLink'; -export * from './types' +export * from './types'; diff --git a/src/types/index.ts b/src/types/index.ts index 016447fc..4d0602c9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,14 +19,14 @@ export interface PlaidLinkError { error_type: string; error_code: string; error_message: string; - display_message: string; + display_message: string | null; } export interface PlaidLinkOnSuccessMetadata { institution: null | PlaidInstitution; accounts: Array; link_session_id: string; - transfer_status?: string; + transfer_status?: string | null; } export interface PlaidLinkOnExitMetadata { @@ -38,6 +38,7 @@ export interface PlaidLinkOnExitMetadata { } export interface PlaidLinkOnEventMetadata { + account_number_mask: null | string; error_type: null | string; error_code: null | string; error_message: null | string; @@ -45,7 +46,10 @@ export interface PlaidLinkOnEventMetadata { institution_id: null | string; institution_name: null | string; institution_search_query: null | string; + is_update_mode: null | string; + match_reason: null | string; mfa_type: null | string; + routing_number: null | string; // see possible values for view_name at https://plaid.com/docs/link/web/#link-web-onevent-view-name view_name: null | string; // see possible values for selection at https://plaid.com/docs/link/web/#link-web-onevent-selection @@ -57,7 +61,7 @@ export interface PlaidLinkOnEventMetadata { } export type PlaidLinkOnSuccess = ( - public_token: string, + public_token: string | null, metadata: PlaidLinkOnSuccessMetadata ) => void; @@ -74,8 +78,13 @@ export enum PlaidLinkStableEvent { SELECT_INSTITUTION = 'SELECT_INSTITUTION', ERROR = 'ERROR', BANK_INCOME_INSIGHTS_COMPLETED = 'BANK_INCOME_INSIGHTS_COMPLETED', + IDENTITY_MATCH_FAILED = 'IDENTITY_MATCH_FAILED', + IDENTITY_MATCH_PASSED = 'IDENTITY_MATCH_PASSED', IDENTITY_VERIFICATION_PASS_SESSION = 'IDENTITY_VERIFICATION_PASS_SESSION', - IDENTITY_VERIFICATION_FAIL_SESSION = 'IDENTITY_VERIFICATION_FAIL_SESSION' + IDENTITY_VERIFICATION_FAIL_SESSION = 'IDENTITY_VERIFICATION_FAIL_SESSION', + LAYER_READY = 'LAYER_READY', + LAYER_NOT_AVAILABLE = 'LAYER_NOT_AVAILABLE', + LAYER_AUTOFILL_NOT_AVAILABLE = 'LAYER_AUTOFILL_NOT_AVAILABLE', } export type PlaidLinkOnEvent = ( @@ -90,7 +99,7 @@ export type PlaidLinkOnEvent = ( export type PlaidLinkOnLoad = () => void; export interface CommonPlaidLinkOptions { - // A function that is called when a user has successfully connected an Item. + // A function that is called when a user has successfully completed Link. // The function should expect two arguments, the public_token and a metadata object onSuccess: T; // A callback that is called when a user has specifically exited Link flow @@ -174,25 +183,45 @@ export type PlaidEmbeddedLinkPropTypes = PlaidLinkOptionsWithLinkToken & { style?: React.CSSProperties; }; -export type PlaidHandlerSubmissionData = { - phone_number: string | null; - date_of_birth: string | null; +export type PlaidHandlerSubmissionData = + | { + phone_number: string; + date_of_birth?: never; + } + | { + phone_number?: never; + date_of_birth: string; + }; + +export interface PlaidHandlerExitOptions { + force?: boolean; } export interface PlaidHandler { open: () => void; submit: (data: PlaidHandlerSubmissionData) => void; - exit: (force?: boolean) => void; + exit: (options?: PlaidHandlerExitOptions) => void; destroy: () => void; } +export interface PlaidLinkResult { + error: ErrorEvent | null; + ready: boolean; + submit: (data: PlaidHandlerSubmissionData) => void; + exit: (options?: PlaidHandlerExitOptions, callback?: () => void) => void; + open: () => void; +} + export interface PlaidEmbeddedHandler { destroy: () => void; } -export interface Plaid extends PlaidHandler { +export interface Plaid { create: (config: PlaidLinkOptions) => PlaidHandler; - createEmbedded: (config: PlaidLinkOptions, domTarget: HTMLElement) => PlaidEmbeddedHandler; + createEmbedded: ( + config: PlaidLinkOptions, + domTarget: HTMLElement + ) => PlaidEmbeddedHandler; } declare global { diff --git a/src/usePlaidLink.test.tsx b/src/usePlaidLink.test.tsx index 5c3d079c..efd6d822 100644 --- a/src/usePlaidLink.test.tsx +++ b/src/usePlaidLink.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { usePlaidLink, PlaidLinkOptions, PlaidLinkOptionsWithLinkToken } from './'; +import { + usePlaidLink, + PlaidLinkOptions, + PlaidLinkOptionsWithLinkToken, + PlaidLinkStableEvent, +} from './'; import useScript from './react-script-hook'; jest.mock('./react-script-hook'); @@ -30,6 +35,22 @@ const HookComponent: React.FC<{ config: PlaidLinkOptions }> = ({ config }) => { ); }; +const LayerHookComponent: React.FC<{ config: PlaidLinkOptions }> = ({ + config, +}) => { + const { submit } = usePlaidLink(config); + return ( +
+ + +
+ ); +}; + describe('usePlaidLink', () => { const config: PlaidLinkOptions = { token: 'test-token', @@ -50,10 +71,7 @@ describe('usePlaidLink', () => { destroy: jest.fn(), }; }), - open: jest.fn(), - submit: jest.fn(), - exit: jest.fn(), - destroy: jest.fn(), + createEmbedded: jest.fn(), }; }); @@ -68,8 +86,40 @@ describe('usePlaidLink', () => { expect(screen.getByText(ReadyState.NO_ERROR)); }); + it('should expose stable Layer events', () => { + expect(PlaidLinkStableEvent.LAYER_READY).toBe('LAYER_READY'); + expect(PlaidLinkStableEvent.LAYER_NOT_AVAILABLE).toBe( + 'LAYER_NOT_AVAILABLE' + ); + expect(PlaidLinkStableEvent.LAYER_AUTOFILL_NOT_AVAILABLE).toBe( + 'LAYER_AUTOFILL_NOT_AVAILABLE' + ); + }); + + it('should submit Layer phone number and date of birth separately', () => { + render(); + const plaidHandler = (window.Plaid.create as jest.Mock).mock.results[0] + .value; + + fireEvent.click( + screen.getByRole('button', { name: 'Submit phone number' }) + ); + expect(plaidHandler.submit).toHaveBeenCalledWith({ + phone_number: '+14155550123', + }); + + fireEvent.click( + screen.getByRole('button', { name: 'Submit date of birth' }) + ); + expect(plaidHandler.submit).toHaveBeenCalledWith({ + date_of_birth: '1975-01-18', + }); + }); + it('should pass cspNonce to the Plaid script tag only', async () => { - render(); + render( + + ); expect(mockedUseScript).toHaveBeenCalledWith({ src: 'https://cdn.plaid.com/link/v2/stable/link-initialize.js', diff --git a/src/usePlaidLink.ts b/src/usePlaidLink.ts index e9e9c6b4..6793ec16 100644 --- a/src/usePlaidLink.ts +++ b/src/usePlaidLink.ts @@ -6,6 +6,7 @@ import { PlaidLinkOptions, PlaidLinkOptionsWithLinkToken, PlaidLinkOptionsWithPublicKey, + PlaidLinkResult, } from './types'; import { PLAID_LINK_STABLE_URL } from './constants'; @@ -21,7 +22,7 @@ const noop = () => {}; * A new Plaid instance is created every time the token and products options change. * It's up to you to prevent unnecessary re-creations on re-render. */ -export const usePlaidLink = (options: PlaidLinkOptions) => { +export const usePlaidLink = (options: PlaidLinkOptions): PlaidLinkResult => { // Asynchronously load the plaid/link/stable url into the DOM const [loading, error] = useScript({ src: PLAID_LINK_STABLE_URL, From bf6f19e6800f091ab4de2909f66ad40ee4a95e5e Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Thu, 23 Jul 2026 15:48:36 -0700 Subject: [PATCH 2/3] Remove Beacon reference from Link documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85b4baaa..1615326f 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,8 @@ the various Link options and the | `receivedRedirectUri` | `string \| undefined` | | `cspNonce` | `string \| undefined` | -`public_token` is `null` for products such as Identity Verification and -Beacon that do not create an Item. +`public_token` is `null` for flows such as Identity Verification that do not +create an Item. #### Content Security Policy nonce From c60a2c3dc266cfc817c550b6b2d5bbb6b2e3c906 Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Thu, 23 Jul 2026 15:59:26 -0700 Subject: [PATCH 3/3] Condense Layer README guidance --- README.md | 52 ++++------------------------------------------------ 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 1615326f..84e82a58 100644 --- a/README.md +++ b/README.md @@ -68,54 +68,6 @@ return ( ); ``` -### Using Plaid Layer - -Create a Layer Link token with -[`/session/token/create`](https://plaid.com/docs/api/products/layer/#sessiontokencreate) -on your server, then initialize `usePlaidLink` as early as possible so Link can -preload. Submit the user's phone number and wait for a Layer event before -opening Link: - -```tsx -import { PlaidLinkStableEvent, usePlaidLink } from 'react-plaid-link'; - -const [layerReady, setLayerReady] = React.useState(false); -const { open, ready, submit } = usePlaidLink({ - token: layerLinkToken, - onSuccess, - onEvent: eventName => { - if (eventName === PlaidLinkStableEvent.LAYER_READY) { - setLayerReady(true); - } - }, -}); - -React.useEffect(() => { - if (ready && layerReady) { - open(); - } -}, [layerReady, open, ready]); - -const submitPhoneNumber = () => { - submit({ phone_number: '+14155550123' }); -}; -``` - -If the phone number produces `LAYER_NOT_AVAILABLE`, Layer Extended Autofill -can be attempted with a separate submission: - -```tsx -const submitDateOfBirth = () => { - submit({ date_of_birth: '1975-01-18' }); -}; -``` - -Fall back to a non-Layer onboarding flow if Extended Autofill produces -`LAYER_AUTOFILL_NOT_AVAILABLE`. See the -[complete Layer example](examples/layer.tsx) and -[Plaid Layer integration guide](https://plaid.com/docs/layer/add-to-app/) for -the full flow. - ### Available Link configuration options ℹ️ See [src/types/index.ts][types] for exported types. @@ -184,6 +136,10 @@ const { open, ready } = usePlaidLink({ | `error` | `ErrorEvent \| null` | | `exit` | `(options?: { force?: boolean }, callback?: () => void) => void` | +For Layer, call `submit` with either `phone_number` or `date_of_birth`. See the +[complete Layer example](examples/layer.tsx) and +[Plaid Layer integration guide](https://plaid.com/docs/layer/add-to-app/). + ### Handling an invalid Link token If `onExit` receives an `INVALID_LINK_TOKEN` error, fetch a new Link token and