Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
marsidev committed Jan 29, 2024
2 parents 7ffdf4b + 088990a commit 21652e7
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 46 deletions.
15 changes: 15 additions & 0 deletions demos/nextjs/src/components/widget-methods.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile'
import { useState } from 'react'
import { Button } from './button'

interface StateLabelsProps {
Expand All @@ -7,10 +8,21 @@ interface StateLabelsProps {
}

const WidgetMethods: React.FC<StateLabelsProps> = ({ 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()
Expand All @@ -28,6 +40,9 @@ const WidgetMethods: React.FC<StateLabelsProps> = ({ turnstile, onRestartStates
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onGetResponse}>Get response</Button>
<Button disabled={isGettingResponse} onClick={onGetResponsePromise}>
Get response (promise)
</Button>
<Button onClick={onReset}>Reset</Button>
<Button onClick={onRemove}>Remove</Button>
<Button onClick={onRender}>Render</Button>
Expand Down
17 changes: 9 additions & 8 deletions docs/use-ref-methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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` |

<CodeGroup>
```jsx
Expand Down
120 changes: 91 additions & 29 deletions packages/lib/src/lib.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -42,8 +50,9 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
)
const containerRef = useRef<HTMLElement | null>(null)
const firstRendered = useRef(false)
const [widgetId, setWidgetId] = useState<string | undefined | null>()
const [turnstileLoaded, setTurnstileLoaded] = useState(false)
const widgetId = useRef<string | undefined | null>()
const widgetSolved = useRef(false)
const containerId = id ?? DEFAULT_CONTAINER_ID
const scriptId = injectScript
? scriptOptions?.id || `${DEFAULT_SCRIPT_ID}__${containerId}`
Expand All @@ -59,7 +68,10 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
sitekey: siteKey,
action: options.action,
cData: options.cData,
callback: onSuccess,
callback: token => {
widgetSolved.current = true
onSuccess?.(token)
},
'error-callback': onError,
'expired-callback': onExpire,
'before-interactive-callback': onBeforeInteractive,
Expand Down Expand Up @@ -92,26 +104,63 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp

const renderConfigStringified = useMemo(() => 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<typeof setTimeout> | 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
}
Expand All @@ -121,31 +170,39 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
}

try {
turnstile.reset(widgetId)
console.log('resetting...')
widgetSolved.current = false
turnstile.reset(widgetId.current)
} catch (error) {
console.warn(`Failed to reset Turnstile widget ${widgetId}`, error)
}
},

remove() {
if (!turnstile?.remove || !widgetId) {
if (!turnstile?.remove || !widgetId.current || !checkIfTurnstileLoaded()) {
console.warn('Turnstile has not been loaded')
return
}

setWidgetId('')
setContainerStyle(CONTAINER_STYLE_SET.invisible)
turnstile.remove(widgetId)
widgetSolved.current = false
turnstile.remove(widgetId.current)
widgetId.current = null
},

render() {
if (!turnstile?.render || !containerRef.current || widgetId) {
if (
!turnstile?.render ||
!containerRef.current ||
!checkIfTurnstileLoaded() ||
widgetId.current
) {
console.warn('Turnstile has not been loaded or widget already rendered')
return
}

const id = turnstile.render(containerRef.current, renderConfig)
setWidgetId(id)
widgetId.current = id

if (options.execution !== 'execute') {
setContainerStyle(CONTAINER_STYLE_SET[widgetSize])
Expand All @@ -159,7 +216,12 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
return
}

if (!turnstile?.execute || !containerRef.current || !widgetId) {
if (
!turnstile?.execute ||
!containerRef.current ||
!widgetId.current ||
!checkIfTurnstileLoaded()
) {
console.warn('Turnstile has not been loaded or widget has not been rendered')
return
}
Expand All @@ -169,16 +231,16 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
},

isExpired() {
if (!turnstile?.isExpired || !widgetId) {
if (!turnstile?.isExpired || !widgetId.current || !checkIfTurnstileLoaded()) {
console.warn('Turnstile has not been loaded')
return
}

return turnstile.isExpired(widgetId)
return turnstile.isExpired(widgetId.current)
}
}
},
[scriptLoaded, widgetId, options.execution, widgetSize, renderConfig, containerRef]
[widgetId, options.execution, widgetSize, renderConfig, containerRef, checkIfTurnstileLoaded]
)

useEffect(() => {
Expand Down Expand Up @@ -221,34 +283,34 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
}

const id = window.turnstile!.render(containerRef.current, renderConfig)
setWidgetId(id)
widgetId.current = id
firstRendered.current = true
}, [scriptLoaded, siteKey, renderConfig, firstRendered, turnstileLoaded])

// re-render widget when renderConfig changes
useEffect(() => {
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
}, [renderConfigStringified, siteKey])

useEffect(() => {
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])

Expand Down
8 changes: 8 additions & 0 deletions packages/lib/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>

/**
* Check if the current rendered widget is expired.
* @returns `true` if the widget is expired, `false` otherwise.
Expand Down
17 changes: 15 additions & 2 deletions test/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 15 additions & 2 deletions test/e2e/manual-script-injection-with-custom-script-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 15 additions & 2 deletions test/e2e/manual-script-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading

0 comments on commit 21652e7

Please sign in to comment.