Skip to content

Commit eb6eb62

Browse files
authored
Merge pull request #72 from marsidev/fix/scripts-and-multi-widgets
Prevent injecting script multiple times
2 parents 66c2934 + 34923d4 commit eb6eb62

File tree

13 files changed

+5181
-3931
lines changed

13 files changed

+5181
-3931
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ test/output
1414
test/e2e/output
1515
.eslintcache
1616
TODO.md
17+
demos/temp_*

docs/manual-script-injection.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,5 @@ If you want to use a custom script ID:
115115
</CodeGroup>
116116

117117
<Info>
118-
Note that the only `scriptOptions` property available when manually injecting the script is `id`.
118+
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.
119119
</Info>

docs/multiple-widgets.mdx

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ title: React Turnstile - Multiple widgets
44

55
# Multiple widgets
66

7-
You can have multiple widgets on the same page. You only need to make sure that each widget has a unique `id`.
7+
You can have multiple widgets on the same page, you just need to use different `<Turnstile />` components.
8+
9+
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.
810

911
<CodeGroup>
1012
```jsx
@@ -28,14 +30,6 @@ You can have multiple widgets on the same page. You only need to make sure that
2830
<>
2931
<Turnstile id='widget-1' siteKey='{{ siteKey }}' />
3032
<Turnstile id='widget-2' siteKey='{{ siteKey }}' />
31-
32-
<button onClick={() => alert(widget1.current?.getResponse())}>
33-
Get widget 1 response
34-
</button>
35-
36-
<button onClick={() => alert(widget2.current?.getResponse())}>
37-
Get widget 2 response
38-
</button>
3933
</>
4034
)
4135
}
@@ -132,24 +126,3 @@ You even can add multiple widgets while manually injecting the Cloudflare script
132126
<Info>
133127
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"`.
134128
</Info>
135-
136-
Before version 0.2.0, to render multiple widgets, you need to force inject a Cloudflare script per widget. Something like this:
137-
138-
```jsx
139-
import { Turnstile } from '@marsidev/react-turnstile'
140-
export default function Widgets() {
141-
return (
142-
<>
143-
<Turnstile id='widget-1' siteKey='{{ siteKey }}' />
144-
<Turnstile
145-
id='widget-2'
146-
siteKey='{{ siteKey }}'
147-
scriptOptions={{
148-
id: "second-script", // unique id
149-
onLoadCallbackName: "secondCallback" // unique callback name
150-
}}
151-
/>
152-
</>
153-
)
154-
}
155-
```

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@
2020
},
2121
"devDependencies": {
2222
"@antfu/ni": "0.21.12",
23-
"@antfu/utils": "0.7.7",
23+
"@antfu/utils": "0.7.8",
2424
"@playwright/test": "1.43.1",
25-
"@types/node": "20.12.7",
26-
"@types/react": "18.3.0",
25+
"@types/node": "20.14.0",
26+
"@types/react": "18.3.3",
2727
"@types/react-dom": "18.3.0",
2828
"concurrently": "8.2.2",
2929
"eslint-config-custom": "workspace:*",
30-
"lint-staged": "15.2.2",
30+
"lint-staged": "15.2.5",
3131
"playwright": "1.43.1",
32-
"pnpm": "9.0.6",
32+
"pnpm": "9.1.4",
3333
"prettier": "3.2.5",
3434
"simple-git-hooks": "2.11.1",
3535
"typescript": "5.4.5"
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
module.exports = {
2-
extends: ['plugin:react-hooks/recommended', 'marsi/react-ts', 'prettier'],
2+
extends: ['marsi/react-ts', 'prettier'],
33
rules: {
44
'no-control-regex': 'off',
55
'@typescript-eslint/no-non-null-assertion': 'off',
6-
'jsx-quotes': ['warn', 'prefer-double']
6+
'jsx-quotes': ['warn', 'prefer-double'],
7+
'react-hooks/exhaustive-deps': 'off'
78
}
89
}

packages/eslint-config-custom/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"eslint-plugin-n": "16.0.2",
1616
"eslint-plugin-promise": "6.1.1",
1717
"eslint-plugin-react": "7.34.1",
18-
"eslint-plugin-react-hooks": "5.0.0-canary-7118f5dd7-20230705",
18+
"eslint-plugin-react-hooks": "4.6.2",
1919
"typescript": "5.4.5"
2020
}
2121
}

packages/lib/src/lib.tsx

Lines changed: 96 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,36 @@ import {
1515
DEFAULT_CONTAINER_ID,
1616
DEFAULT_ONLOAD_NAME,
1717
DEFAULT_SCRIPT_ID,
18-
checkElementExistence,
1918
getTurnstileSizeOpts,
2019
injectTurnstileScript
2120
} from './utils'
2221

22+
let turnstileState: 'unloaded' | 'loading' | 'ready' = 'unloaded'
23+
24+
let turnstileLoad: {
25+
resolve: (value?: unknown) => void
26+
reject: (reason?: unknown) => void
27+
}
28+
29+
const turnstileLoadPromise = new Promise((resolve, reject) => {
30+
turnstileLoad = { resolve, reject }
31+
if (turnstileState === 'ready') resolve(undefined)
32+
})
33+
34+
const ensureTurnstile = (onLoadCallbackName = DEFAULT_ONLOAD_NAME) => {
35+
if (turnstileState === 'unloaded') {
36+
turnstileState = 'loading'
37+
// @ts-expect-error implicit any
38+
window[onLoadCallbackName] = () => {
39+
turnstileLoad.resolve()
40+
turnstileState = 'ready'
41+
// @ts-expect-error implicit any
42+
delete window[onLoadCallbackName]
43+
}
44+
}
45+
return turnstileLoadPromise
46+
}
47+
2348
export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProps>((props, ref) => {
2449
const {
2550
scriptOptions,
@@ -49,19 +74,16 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
4974
: CONTAINER_STYLE_SET[widgetSize]
5075
)
5176
const containerRef = useRef<HTMLElement | null>(null)
52-
const firstRendered = useRef(false)
5377
const [turnstileLoaded, setTurnstileLoaded] = useState(false)
5478
const widgetId = useRef<string | undefined | null>()
5579
const widgetSolved = useRef(false)
5680
const containerId = id || DEFAULT_CONTAINER_ID
57-
const scriptId = injectScript
58-
? scriptOptions?.id || `${DEFAULT_SCRIPT_ID}__${containerId}`
59-
: scriptOptions?.id || DEFAULT_SCRIPT_ID
81+
82+
const scriptId = scriptOptions?.id || DEFAULT_SCRIPT_ID
6083
const scriptLoaded = useObserveScript(scriptId)
84+
const onLoadCallbackName = scriptOptions?.onLoadCallbackName || DEFAULT_ONLOAD_NAME
6185

62-
const onLoadCallbackName = scriptOptions?.onLoadCallbackName
63-
? `${scriptOptions.onLoadCallbackName}__${containerId}`
64-
: `${DEFAULT_ONLOAD_NAME}__${containerId}`
86+
const appearance = options.appearance || 'always'
6587

6688
const renderConfig = useMemo(
6789
(): RenderOptions => ({
@@ -90,24 +112,73 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
90112
appearance: options.appearance || 'always'
91113
}),
92114
[
115+
options.action,
116+
options.appearance,
117+
options.cData,
118+
options.execution,
119+
options.language,
120+
options.refreshExpired,
121+
options.responseField,
122+
options.responseFieldName,
123+
options.retry,
124+
options.retryInterval,
125+
options.tabIndex,
126+
options.theme,
93127
siteKey,
94-
options,
95-
onSuccess,
96-
onError,
97-
onExpire,
98-
widgetSize,
99-
onBeforeInteractive,
100-
onAfterInteractive,
101-
onUnsupported
128+
widgetSize
102129
]
103130
)
104131

105-
const renderConfigStringified = useMemo(() => JSON.stringify(renderConfig), [renderConfig])
106-
107132
const checkIfTurnstileLoaded = useCallback(() => {
108133
return typeof window !== 'undefined' && !!window.turnstile
109134
}, [])
110135

136+
useEffect(
137+
function inject() {
138+
if (injectScript && !turnstileLoaded) {
139+
injectTurnstileScript({
140+
onLoadCallbackName,
141+
scriptOptions: {
142+
...scriptOptions,
143+
id: scriptId
144+
}
145+
})
146+
}
147+
},
148+
[injectScript, turnstileLoaded, scriptOptions, scriptId]
149+
)
150+
151+
useEffect(function waitForTurnstile() {
152+
if (turnstileState !== 'ready') {
153+
ensureTurnstile(onLoadCallbackName)
154+
.then(() => setTurnstileLoaded(true))
155+
.catch(console.error)
156+
}
157+
}, [])
158+
159+
useEffect(
160+
function renderWidget() {
161+
if (!containerRef.current) return
162+
if (!turnstileLoaded) return
163+
let cancelled = false
164+
165+
const render = async () => {
166+
if (cancelled || !containerRef.current) return
167+
const id = window.turnstile!.render(containerRef.current, renderConfig)
168+
widgetId.current = id
169+
if (widgetId.current) onWidgetLoad?.(widgetId.current)
170+
}
171+
172+
render()
173+
174+
return () => {
175+
cancelled = true
176+
if (widgetId.current) window.turnstile!.remove(widgetId.current)
177+
}
178+
},
179+
[containerId, turnstileLoaded, renderConfig]
180+
)
181+
111182
useImperativeHandle(
112183
ref,
113184
() => {
@@ -202,6 +273,7 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
202273

203274
const id = turnstile.render(containerRef.current, renderConfig)
204275
widgetId.current = id
276+
if (widgetId.current) onWidgetLoad?.(widgetId.current)
205277

206278
if (options.execution !== 'execute') {
207279
setContainerStyle(CONTAINER_STYLE_SET[widgetSize])
@@ -240,101 +312,41 @@ export const Turnstile = forwardRef<TurnstileInstance | undefined, TurnstileProp
240312
}
241313
}
242314
},
243-
// eslint-disable-next-line react-hooks/exhaustive-deps
244315
[
245316
widgetId,
246317
options.execution,
247318
widgetSize,
248319
renderConfig,
249320
containerRef,
250321
checkIfTurnstileLoaded,
251-
turnstileLoaded
322+
turnstileLoaded,
323+
onWidgetLoad
252324
]
253325
)
254326

255-
useEffect(() => {
256-
// @ts-expect-error implicit any
257-
window[onLoadCallbackName] = () => setTurnstileLoaded(true)
258-
259-
return () => {
260-
// @ts-expect-error implicit any
261-
delete window[onLoadCallbackName]
262-
}
263-
}, [onLoadCallbackName])
264-
265-
useEffect(() => {
266-
if (injectScript && !turnstileLoaded) {
267-
injectTurnstileScript({
268-
onLoadCallbackName,
269-
scriptOptions: {
270-
...scriptOptions,
271-
id: scriptId
272-
}
273-
})
274-
}
275-
}, [injectScript, turnstileLoaded, onLoadCallbackName, scriptOptions, scriptId])
276-
277327
/* Set the turnstile as loaded, in case the onload callback never runs. (e.g., when manually injecting the script without specifying the `onload` param) */
278328
useEffect(() => {
279329
if (scriptLoaded && !turnstileLoaded && window.turnstile) {
280330
setTurnstileLoaded(true)
281331
}
282332
}, [turnstileLoaded, scriptLoaded])
283333

284-
useEffect(() => {
285-
if (!siteKey) {
286-
console.warn('sitekey was not provided')
287-
return
288-
}
289-
290-
if (!scriptLoaded || !containerRef.current || !turnstileLoaded || firstRendered.current) {
291-
return
292-
}
293-
294-
const id = window.turnstile!.render(containerRef.current, renderConfig)
295-
widgetId.current = id
296-
firstRendered.current = true
297-
}, [scriptLoaded, siteKey, renderConfig, firstRendered, turnstileLoaded])
298-
299-
// re-render widget when renderConfig changes
300-
useEffect(() => {
301-
if (!window.turnstile) return
302-
303-
if (containerRef.current && widgetId.current) {
304-
if (checkElementExistence(widgetId.current)) {
305-
window.turnstile.remove(widgetId.current)
306-
}
307-
const newWidgetId = window.turnstile.render(containerRef.current, renderConfig)
308-
widgetId.current = newWidgetId
309-
firstRendered.current = true
310-
}
311-
// eslint-disable-next-line react-hooks/exhaustive-deps
312-
}, [renderConfigStringified, siteKey])
313-
314-
useEffect(() => {
315-
if (!window.turnstile) return
316-
if (!widgetId.current) return
317-
if (!checkElementExistence(widgetId.current)) return
318-
319-
onWidgetLoad?.(widgetId.current)
320-
}, [widgetId, onWidgetLoad])
321-
334+
// Update style
322335
useEffect(() => {
323336
setContainerStyle(
324337
options.execution === 'execute'
325338
? CONTAINER_STYLE_SET.invisible
326-
: renderConfig.appearance === 'interaction-only'
339+
: appearance === 'interaction-only'
327340
? CONTAINER_STYLE_SET.interactionOnly
328341
: CONTAINER_STYLE_SET[widgetSize]
329342
)
330-
}, [options.execution, widgetSize, renderConfig.appearance])
343+
}, [options.execution, widgetSize, appearance])
331344

332345
// onLoadScript callback
333346
useEffect(() => {
334347
if (!scriptLoaded || typeof onLoadScript !== 'function') return
335-
336348
onLoadScript()
337-
}, [scriptLoaded, onLoadScript])
349+
}, [scriptLoaded])
338350

339351
return (
340352
<Container

packages/lib/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const injectTurnstileScript = ({
6161

6262
if (onError) {
6363
script.onerror = onError
64+
// @ts-expect-error implicit any
65+
delete window[onLoadCallbackName]
6466
}
6567

6668
const parentEl = appendTo === 'body' ? document.body : document.getElementsByTagName('head')[0]

packages/lib/test/index.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('Basic setup', () => {
2424
it('injects the script', async () => {
2525
const script = document.querySelector('script')
2626
expect(script).toBeTruthy()
27-
expect(script?.id).toBe(`${DEFAULT_SCRIPT_ID}__${DEFAULT_CONTAINER_ID}`)
27+
expect(script?.id).toBe(DEFAULT_SCRIPT_ID)
2828
expect(script?.src).toContain(SCRIPT_URL)
2929
})
3030

0 commit comments

Comments
 (0)