Skip to content

Commit

Permalink
Merge pull request #72 from marsidev/fix/scripts-and-multi-widgets
Browse files Browse the repository at this point in the history
Prevent injecting script multiple times
  • Loading branch information
marsidev committed Jun 3, 2024
2 parents 66c2934 + 34923d4 commit eb6eb62
Show file tree
Hide file tree
Showing 13 changed files with 5,181 additions and 3,931 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ test/output
test/e2e/output
.eslintcache
TODO.md
demos/temp_*
2 changes: 1 addition & 1 deletion docs/manual-script-injection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,5 @@ If you want to use a custom script ID:
</CodeGroup>

<Info>
Note that the only `scriptOptions` property available when manually injecting the script is `id`.
When manually injecting the script, the only valid property for `scriptOptions` is the `id`, and it needs to match the ID of the script tag.
</Info>
33 changes: 3 additions & 30 deletions docs/multiple-widgets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ title: React Turnstile - Multiple widgets

# Multiple widgets

You can have multiple widgets on the same page. You only need to make sure that each widget has a unique `id`.
You can have multiple widgets on the same page, you just need to use different `<Turnstile />` components.

For semantic purposes, it's recommended to use a unique `id` for each widget. Otherwise, you will have more then one container with the same `id` in the DOM.

<CodeGroup>
```jsx
Expand All @@ -28,14 +30,6 @@ You can have multiple widgets on the same page. You only need to make sure that
<>
<Turnstile id='widget-1' siteKey='{{ siteKey }}' />
<Turnstile id='widget-2' siteKey='{{ siteKey }}' />

<button onClick={() => alert(widget1.current?.getResponse())}>
Get widget 1 response
</button>

<button onClick={() => alert(widget2.current?.getResponse())}>
Get widget 2 response
</button>
</>
)
}
Expand Down Expand Up @@ -132,24 +126,3 @@ You even can add multiple widgets while manually injecting the Cloudflare script
<Info>
This is not the only way to do it. You can also manually inject the script by using a native `<script />` tag in your HTML entry file or inside an useEffect hook with the `document.body.appendChild` function. The key is to make sure that the script is loaded with the `src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"`.
</Info>
Before version 0.2.0, to render multiple widgets, you need to force inject a Cloudflare script per widget. Something like this:
```jsx
import { Turnstile } from '@marsidev/react-turnstile'
export default function Widgets() {
return (
<>
<Turnstile id='widget-1' siteKey='{{ siteKey }}' />
<Turnstile
id='widget-2'
siteKey='{{ siteKey }}'
scriptOptions={{
id: "second-script", // unique id
onLoadCallbackName: "secondCallback" // unique callback name
}}
/>
</>
)
}
```
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@
},
"devDependencies": {
"@antfu/ni": "0.21.12",
"@antfu/utils": "0.7.7",
"@antfu/utils": "0.7.8",
"@playwright/test": "1.43.1",
"@types/node": "20.12.7",
"@types/react": "18.3.0",
"@types/node": "20.14.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"concurrently": "8.2.2",
"eslint-config-custom": "workspace:*",
"lint-staged": "15.2.2",
"lint-staged": "15.2.5",
"playwright": "1.43.1",
"pnpm": "9.0.6",
"pnpm": "9.1.4",
"prettier": "3.2.5",
"simple-git-hooks": "2.11.1",
"typescript": "5.4.5"
Expand Down
5 changes: 3 additions & 2 deletions packages/eslint-config-custom/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
module.exports = {
extends: ['plugin:react-hooks/recommended', 'marsi/react-ts', 'prettier'],
extends: ['marsi/react-ts', 'prettier'],
rules: {
'no-control-regex': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'jsx-quotes': ['warn', 'prefer-double']
'jsx-quotes': ['warn', 'prefer-double'],
'react-hooks/exhaustive-deps': 'off'
}
}
2 changes: 1 addition & 1 deletion packages/eslint-config-custom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"eslint-plugin-n": "16.0.2",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "5.0.0-canary-7118f5dd7-20230705",
"eslint-plugin-react-hooks": "4.6.2",
"typescript": "5.4.5"
}
}
180 changes: 96 additions & 84 deletions packages/lib/src/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,36 @@ import {
DEFAULT_CONTAINER_ID,
DEFAULT_ONLOAD_NAME,
DEFAULT_SCRIPT_ID,
checkElementExistence,
getTurnstileSizeOpts,
injectTurnstileScript
} from './utils'

let turnstileState: 'unloaded' | 'loading' | 'ready' = 'unloaded'

let turnstileLoad: {
resolve: (value?: unknown) => void
reject: (reason?: unknown) => void
}

const turnstileLoadPromise = new Promise((resolve, reject) => {
turnstileLoad = { resolve, reject }
if (turnstileState === 'ready') resolve(undefined)
})

const ensureTurnstile = (onLoadCallbackName = DEFAULT_ONLOAD_NAME) => {
if (turnstileState === 'unloaded') {
turnstileState = 'loading'
// @ts-expect-error implicit any
window[onLoadCallbackName] = () => {
turnstileLoad.resolve()
turnstileState = 'ready'
// @ts-expect-error implicit any
delete window[onLoadCallbackName]
}
}
return turnstileLoadPromise
}

export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProps>((props, ref) => {
const {
scriptOptions,
Expand Down Expand Up @@ -49,19 +74,16 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
: CONTAINER_STYLE_SET[widgetSize]
)
const containerRef = useRef<HTMLElement | null>(null)
const firstRendered = useRef(false)
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}`
: scriptOptions?.id || DEFAULT_SCRIPT_ID

const scriptId = scriptOptions?.id || DEFAULT_SCRIPT_ID
const scriptLoaded = useObserveScript(scriptId)
const onLoadCallbackName = scriptOptions?.onLoadCallbackName || DEFAULT_ONLOAD_NAME

const onLoadCallbackName = scriptOptions?.onLoadCallbackName
? `${scriptOptions.onLoadCallbackName}__${containerId}`
: `${DEFAULT_ONLOAD_NAME}__${containerId}`
const appearance = options.appearance || 'always'

const renderConfig = useMemo(
(): RenderOptions => ({
Expand Down Expand Up @@ -90,24 +112,73 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
appearance: options.appearance || 'always'
}),
[
options.action,
options.appearance,
options.cData,
options.execution,
options.language,
options.refreshExpired,
options.responseField,
options.responseFieldName,
options.retry,
options.retryInterval,
options.tabIndex,
options.theme,
siteKey,
options,
onSuccess,
onError,
onExpire,
widgetSize,
onBeforeInteractive,
onAfterInteractive,
onUnsupported
widgetSize
]
)

const renderConfigStringified = useMemo(() => JSON.stringify(renderConfig), [renderConfig])

const checkIfTurnstileLoaded = useCallback(() => {
return typeof window !== 'undefined' && !!window.turnstile
}, [])

useEffect(
function inject() {
if (injectScript && !turnstileLoaded) {
injectTurnstileScript({
onLoadCallbackName,
scriptOptions: {
...scriptOptions,
id: scriptId
}
})
}
},
[injectScript, turnstileLoaded, scriptOptions, scriptId]
)

useEffect(function waitForTurnstile() {
if (turnstileState !== 'ready') {
ensureTurnstile(onLoadCallbackName)
.then(() => setTurnstileLoaded(true))
.catch(console.error)
}
}, [])

useEffect(
function renderWidget() {
if (!containerRef.current) return
if (!turnstileLoaded) return
let cancelled = false

const render = async () => {
if (cancelled || !containerRef.current) return
const id = window.turnstile!.render(containerRef.current, renderConfig)
widgetId.current = id
if (widgetId.current) onWidgetLoad?.(widgetId.current)
}

render()

return () => {
cancelled = true
if (widgetId.current) window.turnstile!.remove(widgetId.current)
}
},
[containerId, turnstileLoaded, renderConfig]
)

useImperativeHandle(
ref,
() => {
Expand Down Expand Up @@ -202,6 +273,7 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp

const id = turnstile.render(containerRef.current, renderConfig)
widgetId.current = id
if (widgetId.current) onWidgetLoad?.(widgetId.current)

if (options.execution !== 'execute') {
setContainerStyle(CONTAINER_STYLE_SET[widgetSize])
Expand Down Expand Up @@ -240,101 +312,41 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
widgetId,
options.execution,
widgetSize,
renderConfig,
containerRef,
checkIfTurnstileLoaded,
turnstileLoaded
turnstileLoaded,
onWidgetLoad
]
)

useEffect(() => {
// @ts-expect-error implicit any
window[onLoadCallbackName] = () => setTurnstileLoaded(true)

return () => {
// @ts-expect-error implicit any
delete window[onLoadCallbackName]
}
}, [onLoadCallbackName])

useEffect(() => {
if (injectScript && !turnstileLoaded) {
injectTurnstileScript({
onLoadCallbackName,
scriptOptions: {
...scriptOptions,
id: scriptId
}
})
}
}, [injectScript, turnstileLoaded, onLoadCallbackName, scriptOptions, scriptId])

/* Set the turnstile as loaded, in case the onload callback never runs. (e.g., when manually injecting the script without specifying the `onload` param) */
useEffect(() => {
if (scriptLoaded && !turnstileLoaded && window.turnstile) {
setTurnstileLoaded(true)
}
}, [turnstileLoaded, scriptLoaded])

useEffect(() => {
if (!siteKey) {
console.warn('sitekey was not provided')
return
}

if (!scriptLoaded || !containerRef.current || !turnstileLoaded || firstRendered.current) {
return
}

const id = window.turnstile!.render(containerRef.current, renderConfig)
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.current) {
if (checkElementExistence(widgetId.current)) {
window.turnstile.remove(widgetId.current)
}
const newWidgetId = window.turnstile.render(containerRef.current, renderConfig)
widgetId.current = newWidgetId
firstRendered.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderConfigStringified, siteKey])

useEffect(() => {
if (!window.turnstile) return
if (!widgetId.current) return
if (!checkElementExistence(widgetId.current)) return

onWidgetLoad?.(widgetId.current)
}, [widgetId, onWidgetLoad])

// Update style
useEffect(() => {
setContainerStyle(
options.execution === 'execute'
? CONTAINER_STYLE_SET.invisible
: renderConfig.appearance === 'interaction-only'
: appearance === 'interaction-only'
? CONTAINER_STYLE_SET.interactionOnly
: CONTAINER_STYLE_SET[widgetSize]
)
}, [options.execution, widgetSize, renderConfig.appearance])
}, [options.execution, widgetSize, appearance])

// onLoadScript callback
useEffect(() => {
if (!scriptLoaded || typeof onLoadScript !== 'function') return

onLoadScript()
}, [scriptLoaded, onLoadScript])
}, [scriptLoaded])

return (
<Container
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const injectTurnstileScript = ({

if (onError) {
script.onerror = onError
// @ts-expect-error implicit any
delete window[onLoadCallbackName]
}

const parentEl = appendTo === 'body' ? document.body : document.getElementsByTagName('head')[0]
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('Basic setup', () => {
it('injects the script', async () => {
const script = document.querySelector('script')
expect(script).toBeTruthy()
expect(script?.id).toBe(`${DEFAULT_SCRIPT_ID}__${DEFAULT_CONTAINER_ID}`)
expect(script?.id).toBe(DEFAULT_SCRIPT_ID)
expect(script?.src).toContain(SCRIPT_URL)
})

Expand Down
Loading

0 comments on commit eb6eb62

Please sign in to comment.