Skip to content

Commit

Permalink
fix(recaptcha v3 component): state update warning
Browse files Browse the repository at this point in the history
* add component priv. prop to keep track of unmounting
* check in grecaptcha.execute.then() for unmounting before proceeding
* move getToken method call from constructor to componentDidMount
* refactor tests to smaller files
* add tests for provider being loaded before the component
* add tests for unmount of component before the grecaptcha.execute resolves

fix #32
  • Loading branch information
antokara committed Jun 7, 2020
1 parent 5e95dc2 commit 55dcb12
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 29 deletions.
105 changes: 105 additions & 0 deletions src/reCaptchaV3/component/ReCaptchaV3.test/withProviderLoaded.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { render, RenderResult } from '@testing-library/react';
import * as React from 'react';
import { IContext } from 'src/provider/IContext';
import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3';
import { TCallback } from 'src/reCaptchaV3/component/TCallback';
import { TRefreshToken } from 'src/reCaptchaV3/component/TRefreshToken';

// mocked global functions types
declare let global: {
grecaptcha: {
render: jest.Mock;
reset: jest.Mock;
getResponse: jest.Mock;
execute: jest.Mock<(siteKey: string, options?: options) => Promise<string>>;
};
};

describe('ReCaptchaV3 component', (): void => {
let callback: jest.Mock<TCallback>;
let refreshTokenFn: TRefreshToken | undefined;
let providerContext: IContext;
let rr: RenderResult;
let node: ChildNode | null;

describe('with a V3 site key but providerContext.loaded:true', (): void => {
beforeEach((): void => {
callback = jest
.fn()
.mockImplementation(
(token: string | void, refreshToken: TRefreshToken | void): void => {
if (refreshToken) {
refreshTokenFn = refreshToken;
}
}
);
refreshTokenFn = undefined;
// mock the google reCaptcha object
global.grecaptcha = {
render: jest.fn(),
reset: jest.fn(),
getResponse: jest.fn(),
execute: jest
.fn()
.mockImplementation(
(siteKey: string, options?: options): Promise<string> =>
Promise.resolve('test-token')
)
};
providerContext = {
siteKeyV2: undefined,
siteKeyV3: 'test',
loaded: true
};
rr = render(
<ReCaptchaV3
action="test-action"
callback={callback}
providerContext={providerContext}
/>
);
node = rr.container.firstChild;
});

it('renders nothing', (): void => {
expect(node).toBeFalsy();
});

it('invokes the google reCaptcha execute once', (): void => {
expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1);
});

it('invokes the callback twice', (): void => {
expect(callback).toHaveBeenCalledTimes(2);
});

it('invokes the callback without any arguments', (): void => {
expect(callback).toHaveBeenNthCalledWith(1);
});

it('invokes the callback with the token and refreshToken function', (): void => {
expect(callback).toHaveBeenNthCalledWith(
2,
'test-token',
expect.any(Function)
);
});

it('sets the refresh token function', (): void => {
expect(refreshTokenFn).toBeInstanceOf(Function);
});

describe('refresh token function', (): void => {
beforeEach((): void => {
global.grecaptcha.execute.mockClear();
if (refreshTokenFn) {
refreshTokenFn();
}
});

it('invokes the google reCaptcha execute once', (): void => {
expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { render, RenderResult } from '@testing-library/react';
import * as React from 'react';
import { IContext } from 'src/provider/IContext';
import { ReCaptchaV3 } from './ReCaptchaV3';
import { TCallback } from './TCallback';
import { TRefreshToken } from './TRefreshToken';
import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3';
import { TCallback } from 'src/reCaptchaV3/component/TCallback';
import { TRefreshToken } from 'src/reCaptchaV3/component/TRefreshToken';

// mocked global functions types
declare let global: {
Expand All @@ -22,31 +22,7 @@ describe('ReCaptchaV3 component', (): void => {
let rr: RenderResult;
let node: ChildNode | null;

describe('without the V3 site key', (): void => {
beforeEach((): void => {
callback = jest.fn();
providerContext = {
siteKeyV2: undefined,
siteKeyV3: undefined,
loaded: false
};
});

it('throws an Error', (): void => {
expect(
(): ReCaptchaV3 =>
new ReCaptchaV3({
action: 'test-action',
callback,
providerContext: providerContext
})
).toThrow(
'The prop "siteKeyV3" must be set on the ReCaptchaProvider before using the ReCaptchaV3 component'
);
});
});

describe('with a V3 site key', (): void => {
describe('with a V3 site key but providerContext.loaded:false', (): void => {
beforeEach((): void => {
callback = jest
.fn()
Expand Down Expand Up @@ -148,6 +124,63 @@ describe('ReCaptchaV3 component', (): void => {
});
});
});

describe('when component gets unmounted before the grecaptcha.execute resolves', (): void => {
beforeEach((): void => {
// make sure the mocked callback hasn't been called before
callback.mockClear();
let promiseResolver: (token: string) => void = (
token: string
): void => {
// dummy resolver until we assign the real one
};
// we must disable this rule for this specific test
// tslint:disable-next-line:promise-must-complete
const executePromise: PromiseLike<string> = new Promise(
(resolve: (token: string) => void): void => {
promiseResolver = resolve;
}
);
// mock the google reCaptcha object
global.grecaptcha = {
render: jest.fn(),
reset: jest.fn(),
getResponse: jest.fn(),
execute: jest
.fn()
.mockImplementation(
(siteKey: string, options?: options): PromiseLike<string> =>
executePromise
)
};
// change loaded to true
providerContext = {
...providerContext,
loaded: true
};
rr.rerender(
<ReCaptchaV3
action="test-action"
callback={callback}
providerContext={providerContext}
/>
);
rr.unmount();
promiseResolver('test-token');
});

it('invokes the google reCaptcha execute once', (): void => {
expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1);
});

it('invokes the callback once', (): void => {
expect(callback).toHaveBeenCalledTimes(1);
});

it('invokes the callback without any arguments', (): void => {
expect(callback).toHaveBeenNthCalledWith(1);
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { IContext } from 'src/provider/IContext';
import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3';
import { TCallback } from 'src/reCaptchaV3/component/TCallback';

describe('ReCaptchaV3 component', (): void => {
let callback: jest.Mock<TCallback>;
let providerContext: IContext;

describe('without the V3 site key', (): void => {
beforeEach((): void => {
callback = jest.fn();
providerContext = {
siteKeyV2: undefined,
siteKeyV3: undefined,
loaded: false
};
});

it('throws an Error', (): void => {
expect(
(): ReCaptchaV3 =>
new ReCaptchaV3({
action: 'test-action',
callback,
providerContext: providerContext
})
).toThrow(
'The prop "siteKeyV3" must be set on the ReCaptchaProvider before using the ReCaptchaV3 component'
);
});
});
});
21 changes: 20 additions & 1 deletion src/reCaptchaV3/component/ReCaptchaV3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { IState } from './IState';
* a React reCAPTCHA version 3 component
*/
class ReCaptchaV3 extends React.Component<IProps & IConsumer, IState> {
/**
* if true, the component is in the process of being unmounted
*/
private unMounting: boolean = false;

public constructor(props: IProps & IConsumer) {
super(props);

Expand All @@ -23,8 +28,10 @@ class ReCaptchaV3 extends React.Component<IProps & IConsumer, IState> {
retrieving: false
};
this.getToken = this.getToken.bind(this);
}

// in case the js api is already loaded, get the token immediatelly
public componentDidMount(): void {
// in case the js api is already loaded, get the token immediately
this.getToken();
}

Expand All @@ -46,6 +53,13 @@ class ReCaptchaV3 extends React.Component<IProps & IConsumer, IState> {
return false;
}

/**
* mark our component as being unmounted
*/
public componentWillUnmount(): void {
this.unMounting = true;
}

/**
* if the js api is loaded and is not currently retrieving the token.
* it attempts to retrieve it by
Expand All @@ -69,6 +83,11 @@ class ReCaptchaV3 extends React.Component<IProps & IConsumer, IState> {
grecaptcha
.execute(siteKeyV3, { action })
.then((token: string): void => {
// do not attempt to set state or invoke the callback
// if the component is being unmounted
if (this.unMounting) {
return;
}
this.setState(
{
token,
Expand Down

0 comments on commit 55dcb12

Please sign in to comment.