react-turnstile icon indicating copy to clipboard operation
react-turnstile copied to clipboard

🐛 Bug: react-turstile is not compatible with NextJS and Jest

Open FSeidinger-XI opened this issue 1 month ago • 1 comments

Bug Report Checklist

  • [x] I have tried restarting my IDE and the issue persists.
  • [x] I have pulled the latest main branch of the repository.
  • [x] I have searched for related issues and found none that matched my issue.

Expected

I can use Jest to write tests that renders a contact form that has the turnstile widget embedded.

Actual

I have a simple component that looks like this:

"use client";

import { sendContactMessage } from "@/lib/actions";
import { useEffect, useRef, useState } from "react";
import { Turnstile, TurnstileInstance } from "@marsidev/react-turnstile";

export type MessageType = "absent" | "success" | "error";

export type MessageParams = {
  messageType: MessageType;
  messages: string[]
};

export const DefaultMessageParams: MessageParams = {
  messageType: "absent",
  messages: []
};

export default function ContactForm(messageParams: MessageParams) {
  const messageType = messageParams.messageType;
  const messages = messageParams.messages

  const isSuccess = messageType === "success";
  const isError = messageType === "error";

  const [showSuccessToast, setShowSuccessToast] = useState(isSuccess);
  const [showErrorToast, setShowErrorToast] = useState(isError);

  const refTurnstile = useRef<TurnstileInstance>(null);

  const [canSubmit, setCanSubmit] = useState(false);
  const [consentGiven, setConsentGiven] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => {
      setShowSuccessToast(false);
      setShowErrorToast(false);
    }, 5000);

    return () => clearTimeout(timer);
  }, []);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    await new Promise((resolve) => setTimeout(resolve, 500));

    refTurnstile.current?.reset();
    setCanSubmit(false);
  }

  return (
    <div>
      <form action={sendContactMessage} onSubmit={handleSubmit}>
        <div className="w-full rounded-lg border border-base-200 overflow-hidden">
          <Turnstile
            ref={refTurnstile}
            siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
            onSuccess={() => setCanSubmit(true)}
            options={{ theme: "light", size: "flexible" }}
          />
        </div>

        <label className="floating-label">
          <span>Vorname</span>
          <input
            className="input input-xl mt-10 w-full"
            name="givenName"
            type="text"
            placeholder="Vorname"
          />
        </label>

        <label className="floating-label">
          <span>Nachname</span>
          <input
            className="input input-xl w-full mt-10"
            name="name"
            type="text"
            placeholder="Nachname"
          />
        </label>

        <label className="floating-label">
          <span>Email</span>
          <input
            className="input input-xl w-full mt-10 validator"
            type="email"
            name="email"
            autoComplete="on"
            required
            placeholder="[email protected]"
          />
          <div className="validator-hint">Bitte gib eine gültige E-Mail Adresse an</div>
        </label>

        <label className="floating-label">
          <span className="mt-6">Deine Nachricht an uns</span>
          <textarea
            className="textarea textarea-xl w-full h-58 mt-6"
            name="message"
            required
            placeholder="Deine Nachricht an uns"
          />
        </label>

        <label className="label">
          <input
            className="checkbox mt-6"
            name="consent"
            type="checkbox"
            required
            onChange={(e) => setConsentGiven(e.target.checked)}
          />
          <span className="ml-4 mt-4 text-wrap text-sm">
            Zur Weiterverarbeitung bin ich mit den Nutzungsbedingungen und
            Datenschutzrichtlinien einverstanden.
          </span>
        </label>

        <button
          className="btn btn-xl mt-6 mb-6"
          type="submit"
          disabled={(consentGiven && canSubmit) === false}
        >
          Absenden
        </button>
      </form>

      {showSuccessToast && (
        <div className="toast">
          <div className="alert alert-success">
            <span  className="text-lg">
              {
                messages.map((message, key) => (
                  <p key={key}>{message}</p>
                ))
              }
            </span>
          </div>
        </div>
      )}

      {showErrorToast && (
        <div className="toast">
          <div className="alert alert-error">
            <span className="text-lg">
              {
                messages.map((message, key) => (
                  <p key={key}>{message}</p>
                ))
              }
            </span>
          </div>
        </div>
      )}

    </div>
  );
}

And a jest test like this:

/**
 * @jest-environment jsdom
 */

import ContactForm, { DefaultMessageParams, MessageParams } from '../contact-form';
import { render, screen } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import '@testing-library/jest-dom';

describe("ContactForm", () => {
  beforeEach(() => {
    render(<ContactForm {...DefaultMessageParams} />);
  });
  describe("rendering", () => {
    it("should verify that givenName is present", () => {
      expect(screen.getByLabelText("Vorname")).toBeInTheDocument();
    });
    });
  })
});

Running this tests leads to:

 FAIL  app/components/ui/__tests__/contact-form.tsx
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

    By default "node_modules" folder is ignored by transformers.

    Here's what you can do:
     • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
     • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation, specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/configuration
    For information about custom transformations, see:
    https://jestjs.io/docs/code-transformation

    Details:

    /home/fs/projects/aixo.com/website-next-js/node_modules/@marsidev/react-turnstile/dist/index.js:2
    import{forwardRef as pe,useCallback as Q,useEffect as h,useImperativeHandle as fe,useMemo as me,useRef as D,useState as Z}from"react";import{forwardRef as ce}from"react";import{jsx as ue}from"react/jsx-runtime";var ae=({as:n="div",...a},s)=>ue(n,{...a,ref:s}),J=ce(ae);import{useEffect as le,useState as de}from"react";var K="https://challenges.cloudflare.com/turnstile/v0/api.js",b="cf-turnstile-script",U="cf-turnstile",P="onloadTurnstileCallback",M=n=>!!document.getElementById(n),X=({render:n="explicit",onLoadCallbackName:a=P,scriptOptions:{nonce:s="",defer:e=!0,async:m=!0,id:v="",appendTo:g,onError:T,crossOrigin:w=""}={}})=>{let E=v||b;if(M(E))return;let i=document.createElement("script");if(i.id=E,i.src=`${K}?onload=${a}&render=${n}`,document.querySelector(`script[src="${i.src}"]`))return;i.defer=!!e,i.async=!!m,s&&(i.nonce=s),w&&(i.crossOrigin=w),T&&(i.onerror=T,delete window[a]),(g==="body"?document.body:document.getElementsByTagName("head")[0]).appendChild(i)},f={normal:{width:300,height:65},compact:{width:150,height:140},invisible:{width:0,height:0,overflow:"hidden"},flexible:{minWidth:300,width:"100%",height:65},interactionOnly:{width:"fit-content",height:"auto",display:"flex"}};function G(n){if(n!=="invisible"&&n!=="interactionOnly")return n}function z(n=b){let[a,s]=de(!1);return le(()=>{let e=()=>{M(n)&&s(!0)},m=new MutationObserver(e);return m.observe(document,{childList:!0,subtree:!0}),e(),()=>{m.disconnect()}},[n]),a}import{jsx as xe}from"react/jsx-runtime";var _="unloaded",ee,Te=new Promise((n,a)=>{ee={resolve:n,reject:a},_==="ready"&&n(void 0)}),we=(n=P)=>(_==="unloaded"&&(_="loading",window[n]=()=>{ee.resolve(),_="ready",delete window[n]}),Te),Ee=pe((n,a)=>{let{scriptOptions:s,options:e={},siteKey:m,onWidgetLoad:v,onSuccess:g,onExpire:T,onError:w,onBeforeInteractive:E,onAfterInteractive:i,onUnsupported:I,onTimeout:k,onLoadScript:W,id:te,style:re,as:ne="div",injectScript:$=!0,rerenderOnCallbackChange:o=!1,...oe}=n,c=e.size,j=Q(()=>typeof c>"u"?{}:e.execution==="execute"?f.invisible:e.appearance==="interaction-only"?f.interactionOnly:f[c],[e.execution,c,e.appearance]),[ie,R]=Z(j()),u=D(null),[x,B]=Z(!1),r=D(),L=D(!1),H=te||U,d=D({onSuccess:g,onError:w,onExpire:T,onBeforeInteractive:E,onAfterInteractive:i,onUnsupported:I,onTimeout:k});h(()=>{o||(d.current={onSuccess:g,onError:w,onExpire:T,onBeforeInteractive:E,onAfterInteractive:i,onUnsupported:I,onTimeout:k})});let O=s?.id||b,A=z(O),V=s?.onLoadCallbackName||P,se=e.appearance||"always",C=me(()=>({sitekey:m,action:e.action,cData:e.cData,theme:e.theme||"auto",language:e.language||"auto",tabindex:e.tabIndex,"response-field":e.responseField,"response-field-name":e.responseFieldName,size:G(c),retry:e.retry||"auto","retry-interval":e.retryInterval||8e3,"refresh-expired":e.refreshExpired||"auto","refresh-timeout":e.refreshTimeout||"auto",execution:e.execution||"render",appearance:e.appearance||"always","feedback-enabled":e.feedbackEnabled||!0,callback:t=>{L.current=!0,o?g?.(t):d.current.onSuccess?.(t)},"error-callback":o?w:(...t)=>d.current.onError?.(...t),"expired-callback":o?T:(...t)=>d.current.onExpire?.(...t),"before-interactive-callback":o?E:(...t)=>d.current.onBeforeInteractive?.(...t),"after-interactive-callback":o?i:(...t)=>d.current.onAfterInteractive?.(...t),"unsupported-callback":o?I:(...t)=>d.current.onUnsupported?.(...t),"timeout-callback":o?k:(...t)=>d.current.onTimeout?.(...t)}),[e.action,e.appearance,e.cData,e.execution,e.language,e.refreshExpired,e.responseField,e.responseFieldName,e.retry,e.retryInterval,e.tabIndex,e.theme,e.feedbackEnabled,e.refreshTimeout,m,c,o,o?g:null,o?w:null,o?T:null,o?E:null,o?i:null,o?I:null,o?k:null]),y=Q(()=>typeof window<"u"&&!!window.turnstile,[]);return h(function(){$&&!x&&X({onLoadCallbackName:V,scriptOptions:{...s,id:O}})},[$,x,s,O]),h(function(){_!=="ready"&&we(V).then(()=>B(!0)).catch(console.error)},[]),h(function(){if(!u.current||!x)return;let l=!1;return(async()=>{if(l||!u.current)return;let F=window.turnstile.render(u.current,C);r.current=F,r.current&&v?.(r.current)})(),()=>{l=!0,r.current&&(window.turnstile.remove(r.current),L.current=!1)}},[H,x,C]),fe(a,()=>{let{turnstile:t}=window;return{getResponse(){if(!t?.getResponse||!r.current||!y()){console.warn("Turnstile has not been loaded");return}return t.getResponse(r.current)},async getResponsePromise(l=3e4,Y=100){return new Promise((F,N)=>{let p,q=async()=>{if(L.current&&window.turnstile&&r.current)try{let S=window.turnstile.getResponse(r.current);return p&&clearTimeout(p),S?F(S):N(new Error("No response received"))}catch(S){return p&&clearTimeout(p),console.warn("Failed to get response",S),N(new Error("Failed to get response"))}p||(p=setTimeout(()=>{p&&clearTimeout(p),N(new Error("Timeout"))},l)),await new Promise(S=>setTimeout(S,Y)),await q()};q()})},reset(){if(!t?.reset||!r.current||!y()){console.warn("Turnstile has not been loaded");return}e.execution==="execute"&&R(f.invisible);try{L.current=!1,t.reset(r.current)}catch(l){console.warn(`Failed to reset Turnstile widget ${r}`,l)}},remove(){if(!t?.remove||!r.current||!y()){console.warn("Turnstile has not been loaded");return}R(f.invisible),L.current=!1,t.remove(r.current),r.current=null},render(){if(!t?.render||!u.current||!y()||r.current){console.warn("Turnstile has not been loaded or container not found");return}let l=t.render(u.current,C);return r.current=l,r.current&&v?.(r.current),e.execution!=="execute"&&R(c?f[c]:{}),l},execute(){if(e.execution!=="execute"){console.warn('Execution mode is not set to "execute"');return}if(!t?.execute||!u.current||!r.current||!y()){console.warn("Turnstile has not been loaded or container not found");return}t.execute(u.current,C),R(c?f[c]:{})},isExpired(){return!t?.isExpired||!r.current||!y()?(console.warn("Turnstile has not been loaded"),!1):t.isExpired(r.current)}}},[r,e.execution,c,C,u,y,x,v]),h(()=>{A&&!x&&window.turnstile&&B(!0)},[x,A]),h(()=>{R(j())},[e.execution,c,se]),h(()=>{!A||typeof W!="function"||W()},[A]),xe(J,{ref:u,as:ne,id:H,style:{...ie,...re},...oe})});Ee.displayName="Turnstile";export{U as DEFAULT_CONTAINER_ID,P as DEFAULT_ONLOAD_NAME,b as DEFAULT_SCRIPT_ID,K as SCRIPT_URL,Ee as Turnstile};
    ^^^^^^

    SyntaxError: Cannot use import statement outside a module

      21 |   const messages = messageParams.messages
      22 |
    > 23 |   const isSuccess = messageType === "success";
         |                         ^
      24 |   const isError = messageType === "error";
      25 |
      26 |   const [showSuccessToast, setShowSuccessToast] = useState(isSuccess);

      at Runtime.createScriptFromCode (../node_modules/jest-runtime/build/index.js:1318:40)
      at Object.<anonymous> (components/ui/contact-form.tsx:23:25)
      at Object.<anonymous> (components/ui/__tests__/contact-form.tsx:8:62)

It seems, that Jest cannot handle the imports.

Package Version

1.3.1

Browsers

Other

Additional Info

The tests ran fine without embedding the Turnstile component.

FSeidinger-XI avatar Oct 18 '25 16:10 FSeidinger-XI

Hello, our library ships esm-only output. That may be the issue. Did you checked this?

marsidev avatar Oct 20 '25 17:10 marsidev