diff --git a/demos/nextjs/src/components/widget-methods.tsx b/demos/nextjs/src/components/widget-methods.tsx index b46b0f7..0c7e393 100644 --- a/demos/nextjs/src/components/widget-methods.tsx +++ b/demos/nextjs/src/components/widget-methods.tsx @@ -1,4 +1,5 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile' +import { useState } from 'react' import { Button } from './button' interface StateLabelsProps { @@ -7,10 +8,21 @@ interface StateLabelsProps { } const WidgetMethods: React.FC = ({ turnstile, onRestartStates }) => { + const [isGettingResponse, setIsGettingResponse] = useState(false) + const onGetResponse = () => { alert(turnstile.current?.getResponse()) } + const onGetResponsePromise = () => { + setIsGettingResponse(true) + turnstile.current + ?.getResponsePromise() + .then(alert) + .catch(alert) + .finally(() => setIsGettingResponse(false)) + } + const onReset = () => { turnstile.current?.reset() onRestartStates() @@ -28,6 +40,9 @@ const WidgetMethods: React.FC = ({ turnstile, onRestartStates return (
+ diff --git a/docs/use-ref-methods.mdx b/docs/use-ref-methods.mdx index 130da5a..5f6c55a 100644 --- a/docs/use-ref-methods.mdx +++ b/docs/use-ref-methods.mdx @@ -6,14 +6,15 @@ title: React Turnstile - useRef methods The library exposes some methods within the `Turnstile` ref, in order to interact with the widget. -| **Method** | **Description** | **Returns** | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `getResponse()` | Returns the widget’s response token. | string | -| `reset()` | Resets the widget. Useful if a given widget has timed out, expired or needs to be reloaded. | void | -| `remove()` | Removes the widget. Useful when a widget is no longer needed. This will not call any callback and will remove all related DOM elements. | void | -| `render()` | Renders the widget. Since all widgets are rendered automatically, this only takes effect if the widget was previously removed. If the widget is already rendered, this method will not re-render the widget. | void | -| `execute()` | If `options.execution` is set to `'execute'`, this method is used to render the widget. If the widget is already shown (rendered and executed), this method will not re-render the widget. If the widget got removed (`.remove()`), you need to call `.render()` and then `.execute()`. If `options.execution` is set to `'render'` (default), this method takes no effect. | void | -| `isExpired()` | Returns `true` if the widget has expired. | boolean | +| **Method** | **Description** | **Returns** | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | +| `getResponse()` | Returns the widget’s response token. | `string` | +| `getResponsePromise()` | Returns the widget’s response as a promise, it waits until the widget is rendered and solved. It has a timeout of 30 seconds. | `Promise` | +| `reset()` | Resets the widget. Useful if a given widget has timed out, expired or needs to be reloaded. | `void` | +| `remove()` | Removes the widget. Useful when a widget is no longer needed. This will not call any callback and will remove all related DOM elements. | `void` | +| `render()` | Renders the widget. Since all widgets are rendered automatically, this only takes effect if the widget was previously removed. If the widget is already rendered, this method will not re-render the widget. | `void` | +| `execute()` | If `options.execution` is set to `'execute'`, this method is used to render the widget. If the widget is already shown (rendered and executed), this method will not re-render the widget. If the widget got removed (`.remove()`), you need to call `.render()` and then `.execute()`. If `options.execution` is set to `'render'` (default), this method takes no effect. | `void` | +| `isExpired()` | Returns `true` if the widget has expired. | `boolean` | ```jsx diff --git a/packages/lib/src/lib.tsx b/packages/lib/src/lib.tsx index 0f6bb68..72c9c2a 100644 --- a/packages/lib/src/lib.tsx +++ b/packages/lib/src/lib.tsx @@ -1,4 +1,12 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' import Container from './container' import { RenderOptions, TurnstileInstance, TurnstileProps } from './types' import useObserveScript from './use-observe-script' @@ -42,8 +50,9 @@ export const Turnstile = forwardRef(null) const firstRendered = useRef(false) - const [widgetId, setWidgetId] = useState() const [turnstileLoaded, setTurnstileLoaded] = useState(false) + const widgetId = useRef() + const widgetSolved = useRef(false) const containerId = id ?? DEFAULT_CONTAINER_ID const scriptId = injectScript ? scriptOptions?.id || `${DEFAULT_SCRIPT_ID}__${containerId}` @@ -59,7 +68,10 @@ export const Turnstile = forwardRef { + widgetSolved.current = true + onSuccess?.(token) + }, 'error-callback': onError, 'expired-callback': onExpire, 'before-interactive-callback': onBeforeInteractive, @@ -92,26 +104,63 @@ export const Turnstile = forwardRef JSON.stringify(renderConfig), [renderConfig]) + const checkIfTurnstileLoaded = useCallback(() => { + return typeof window !== 'undefined' && !!window.turnstile + }, []) + useImperativeHandle( ref, () => { - if (typeof window === 'undefined' || !scriptLoaded) { - return - } - const { turnstile } = window return { getResponse() { - if (!turnstile?.getResponse || !widgetId) { + if (!turnstile?.getResponse || !widgetId.current || !checkIfTurnstileLoaded()) { console.warn('Turnstile has not been loaded') return } - return turnstile.getResponse(widgetId) + return turnstile.getResponse(widgetId.current) + }, + + async getResponsePromise(timeout = 30000, retry = 100) { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined + + const checkLoaded = async () => { + if (widgetSolved.current && window.turnstile && widgetId.current) { + try { + const token = window.turnstile.getResponse(widgetId.current) + if (timeoutId) clearTimeout(timeoutId) + + if (token) { + return resolve(token) + } + + return reject(new Error('No response received')) + } catch (error) { + if (timeoutId) clearTimeout(timeoutId) + console.warn('Failed to get response', error) + return reject(new Error('Failed to get response')) + } + } + + if (!timeoutId) { + timeoutId = setTimeout(() => { + if (timeoutId) clearTimeout(timeoutId) + reject(new Error('Timeout')) + }, timeout) + } + + await new Promise(resolve => setTimeout(resolve, retry)) + await checkLoaded() + } + + checkLoaded() + }) }, reset() { - if (!turnstile?.reset || !widgetId) { + if (!turnstile?.reset || !widgetId.current || !checkIfTurnstileLoaded()) { console.warn('Turnstile has not been loaded') return } @@ -121,31 +170,39 @@ export const Turnstile = forwardRef { @@ -221,7 +283,7 @@ export const Turnstile = forwardRef { if (!window.turnstile) return - if (containerRef.current && widgetId) { - if (checkElementExistence(widgetId)) { - window.turnstile.remove(widgetId) + if (containerRef.current && widgetId.current) { + if (checkElementExistence(widgetId.current)) { + window.turnstile.remove(widgetId.current) } const newWidgetId = window.turnstile.render(containerRef.current, renderConfig) - setWidgetId(newWidgetId) + widgetId.current = newWidgetId firstRendered.current = true } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -242,13 +304,13 @@ export const Turnstile = forwardRef { if (!window.turnstile) return - if (!widgetId) return - if (!checkElementExistence(widgetId)) return + if (!widgetId.current) return + if (!checkElementExistence(widgetId.current)) return - onWidgetLoad?.(widgetId) + onWidgetLoad?.(widgetId.current) return () => { - window.turnstile!.remove(widgetId) + if (widgetId.current && window.turnstile) window.turnstile!.remove(widgetId.current) } }, [widgetId, onWidgetLoad]) diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 8be780f..1d77905 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -77,6 +77,14 @@ interface TurnstileInstance { */ getResponse: () => string | undefined + /** + * Method to get the token of the current rendered widget as a promise, it waits until the widget is rendered and solved. + * @param timeout - Optional. Timeout in milliseconds. Default to 30000. + * @param retry - Optional. Retry interval in milliseconds. Default to 250. + * @returns The token response. + */ + getResponsePromise: (timeout?: number, retry?: number) => Promise + /** * Check if the current rendered widget is expired. * @returns `true` if the widget is expired, `false` otherwise. diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index 1ce0689..71d9166 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -71,12 +71,25 @@ test('widget can be reset', async () => { }) test('can get the token', async () => { - page.on('dialog', async dialog => { + page.once('dialog', async dialog => { expect(dialog.message()).toContain(demoToken) await dialog.accept() }) - await page.locator('button', { hasText: 'Get response' }).click() + await page.getByRole('button', { name: 'Get response', exact: true }).click() +}) + +test('can get the token using the promise method', async () => { + page.once('dialog', async dialog => { + expect(dialog.message()).toContain(demoToken) + await dialog.accept() + }) + + await page.locator('button', { hasText: 'Remove' }).click() + await ensureFrameHidden(page) + await page.locator('button', { hasText: 'Render' }).click() + + await page.getByRole('button', { name: 'Get response (promise)', exact: true }).click() }) test('widget can be sized', async () => { diff --git a/test/e2e/manual-script-injection-with-custom-script-props.test.ts b/test/e2e/manual-script-injection-with-custom-script-props.test.ts index a113a70..5efe183 100644 --- a/test/e2e/manual-script-injection-with-custom-script-props.test.ts +++ b/test/e2e/manual-script-injection-with-custom-script-props.test.ts @@ -71,12 +71,25 @@ test('widget can be reset', async () => { }) test('can get the token', async () => { - page.on('dialog', async dialog => { + page.once('dialog', async dialog => { expect(dialog.message()).toContain(demoToken) await dialog.accept() }) - await page.locator('button', { hasText: 'Get response' }).click() + await page.getByRole('button', { name: 'Get response', exact: true }).click() +}) + +test('can get the token using the promise method', async () => { + page.once('dialog', async dialog => { + expect(dialog.message()).toContain(demoToken) + await dialog.accept() + }) + + await page.locator('button', { hasText: 'Remove' }).click() + await ensureFrameHidden(page) + await page.locator('button', { hasText: 'Render' }).click() + + await page.getByRole('button', { name: 'Get response (promise)', exact: true }).click() }) test('widget can be sized', async () => { diff --git a/test/e2e/manual-script-injection.test.ts b/test/e2e/manual-script-injection.test.ts index 05b4bc1..dc2721d 100644 --- a/test/e2e/manual-script-injection.test.ts +++ b/test/e2e/manual-script-injection.test.ts @@ -71,12 +71,25 @@ test('widget can be reset', async () => { }) test('can get the token', async () => { - page.on('dialog', async dialog => { + page.once('dialog', async dialog => { expect(dialog.message()).toContain(demoToken) await dialog.accept() }) - await page.locator('button', { hasText: 'Get response' }).click() + await page.getByRole('button', { name: 'Get response', exact: true }).click() +}) + +test('can get the token using the promise method', async () => { + page.once('dialog', async dialog => { + expect(dialog.message()).toContain(demoToken) + await dialog.accept() + }) + + await page.locator('button', { hasText: 'Remove' }).click() + await ensureFrameHidden(page) + await page.locator('button', { hasText: 'Render' }).click() + + await page.getByRole('button', { name: 'Get response (promise)', exact: true }).click() }) test('widget can be sized', async () => { diff --git a/test/e2e/multiple-widgets.test.ts b/test/e2e/multiple-widgets.test.ts index 2a66f16..8e5e9e9 100644 --- a/test/e2e/multiple-widgets.test.ts +++ b/test/e2e/multiple-widgets.test.ts @@ -83,11 +83,27 @@ test('widget can be reset', async () => { }) test('can get the token', async () => { - page.on('dialog', async dialog => { + page.once('dialog', async dialog => { expect(dialog.message()).toContain(demoToken) await dialog.accept() }) - await page.locator('button', { hasText: 'Get response' }).first().click() - await page.locator('button', { hasText: 'Get response' }).last().click() + await page.getByRole('button', { name: 'Get response', exact: true }).first().click() + await page.getByRole('button', { name: 'Get response', exact: true }).last().click() +}) + +test('can get the token using the promise method', async () => { + page.once('dialog', async dialog => { + expect(dialog.message()).toContain(demoToken) + await dialog.accept() + }) + + await page.locator('button', { hasText: 'Remove' }).first().click() + await page.locator('button', { hasText: 'Remove' }).last().click() + await expect(page.locator('iframe')).toHaveCount(0) + await page.locator('button', { hasText: 'Render' }).first().click() + await page.locator('button', { hasText: 'Render' }).last().click() + + await page.getByRole('button', { name: 'Get response (promise)', exact: true }).first().click() + await page.getByRole('button', { name: 'Get response (promise)', exact: true }).last().click() })