altcha-org / altcha

GDPR compliant, self-hosted CAPTCHA alternative with PoW mechanism and advanced anti-spam filter.
https://altcha.org
MIT License
509 stars 17 forks source link

Problem integrating Altcha to Formik, event handling behaves wrongly #77

Closed teolaz closed 1 day ago

teolaz commented 2 weeks ago

Hi there! I'm having an issue that I'm struggling to solve, I've lost many hours and I'd need a hand understanding if there's something that I'm missing of the whole... I'm under GatsbyJS, and using Formik to handle a contact form in React, and I created a custom Field for Altcha that I use for form validation. Here the code of my field(including some test console.log), code developed from your React example available in online documentation:


import { useField } from "formik";
import React, { useEffect, useRef } from "react";

import { getConfig } from "../../../utils/commonMethodsFrontend";

export default function Altcha() {
  const [field, meta, helpers] = useField("altcha");

  const { setValue } = helpers;

  const widgetRef = useRef(null);

  useEffect(() => {
    console.log("entering useEffect");
    const handleStateChange = (ev) => {
      console.log(ev);
      if (ev.detail) {
        // state can be: unverified, verifying, verified, error
        if (ev.detail.state === "verified") {
          // payload contains base64 encoded data for the server
          setValue(ev.detail.payload);
          console.log("set payload");
        } else {
          setValue("");
          console.log("set empty");
        }
      }
    };

    const current = widgetRef.current;

    if (current) {
      current.addEventListener("statechange", handleStateChange);
      console.log("added event listener");
      return () => {
        current.removeEventListener("statechange", handleStateChange);
        console.log("removed event listener");
      };
    }
  }, []);

  return (
    <div className="field-container">
      <div className="info">Accept the captcha to continue*</div>
      <altcha-widget
        ref={widgetRef}
        challengeurl={getConfig("netlify_functions_url") + "/altcha-challenge"}
      />
      {meta.error && meta.touched ? (
        <div className="error">
          <span>{meta.error}</span>
        </div>
      ) : null}
    </div>
  );
}

For what I know, I only instance an object of such component when I'm loading the form page, so I'm not passing it through any function that modifies the instance. The only doubt I have is for that const [field, meta, helpers] = useField("altcha"); that is giving me the possibility to set field value directly from within this component and not by passing from a parent component. The field value is saved on a virtual object of values that Formik keep for validation, so "altcha" is the name of the saved element to be checked in Formik.

The control lifecycle works correctly both in frontend and backend, but there's a strange behaviour that I cannot explain, I also tried to switch from 0.4.3 to new 1.1.0 to see if the problem changed but it seems to be worse than before. Somehow the first time the React DOM loads, the event handler doesn't work. If you press on Altcha verification checkbox, no console.log is launched. Instead, if you go back and enter contact page again and verify with Altcha, you can see in debug console the event handler worked correctly.

You can check the strange behaviour here.

Do you have any idea about the possible problem here? Thank you

teolaz commented 1 week ago

Just a small update, if it could help...

I've tried to append the "statechange" event handler only on "load" event like this

import { useField } from "formik";
import React, { useEffect, useRef } from "react";

import { getConfig } from "../../../utils/commonMethodsFrontend";

export default function Altcha() {
  const [field, meta, helpers] = useField("altcha");

  // const { value } = meta;
  const { setValue } = helpers;

  const widgetRef = useRef(null);

  useEffect(() => {
    console.log("entering useEffect");
    const handleStateChange = (ev) => {
      console.log(ev);
      if (ev.detail) {
        // state can be: unverified, verifying, verified, error
        if (ev.detail.state === "verified") {
          // payload contains base64 encoded data for the server
          setValue(ev.detail.payload);
          console.log("set payload");
        } else {
          setValue("");
          console.log("set empty");
        }
      }
    };

    const current = widgetRef.current;

    const handleLoad = () => {
      console.log("Widget fully loaded");
      current.addEventListener("statechange", handleStateChange);
    };

    if (current) {
      console.log("added load event listener");
      current.addEventListener("load", handleLoad);
    }

    return () => {
      if (current) {
        console.log("removing event listeners...");
        current.removeEventListener("load", handleLoad);
        current.removeEventListener("statechange", handleStateChange);
      }
    };
  }, [setValue]);

  return (
    <div className="field-container">
      <div className="info">Accept the captcha to continue*</div>
      <altcha-widget
        ref={widgetRef}
        challengeurl={getConfig("netlify_functions_url") + "/altcha-challenge"}
      />
      {meta.error && meta.touched ? (
        <div className="error">
          <span>{meta.error}</span>
        </div>
      ) : null}
    </div>
  );
}

but nothing changes, the widget launch events only when going back and forth between pages.

ovx commented 1 week ago

Hi, I don't see any import('altcha') in your code. When attaching event listeners, the widget must be loaded to make it work, so make sure, the altcha script is imported before calling addEventListener. Adding import('altcha') should do it.

teolaz commented 1 week ago

Hi @ovx , yeah that was because I (wrongly) imported it in the parent component(s), I added the line in my component but nothing changed.

Unfortunately this didn't change problem.

I read this post on stack overflow and made some tests. On a local env, the code below works as per screenshots. Instead on Netlify it doesn't, or better, it works only after the second time you enter into contact page. Seems that caching the resource, even when you reload the page, the browser make the process work! The only thing I have in mind is having problems with timing... Maybe my local env is slower so it has got enough time to append the hanlder?

import { useField } from "formik";
import React, { useEffect, useRef } from "react";
import { getConfig } from "../../../utils/commonMethodsFrontend";

if (global.window) {
  // Need to import like this as this shouldn't be bundled by webpack and Gatsby SSR
  import("altcha");
}

export default function Altcha() {
  const [field, meta, helpers] = useField("altcha");
  const { setValue } = helpers;

  // Reference to the widget
  const widgetRef = useRef(null);

  useEffect(() => {
    const handleStateChange = (ev) => {
      console.log("statechange handler triggered");
      if (ev.detail) {
        if (ev.detail.state === "verified") {
          setValue(ev.detail.payload);
        } else {
          setValue("");
        }
      }
    };

    const addWidgetListener = () => {
      // Ensure the widget is loaded and available, then add the event listener
      if (widgetRef.current) {
        console.log("adding statechange event listener on widget");
        widgetRef.current.addEventListener("statechange", handleStateChange);
      }
    };

    console.log("document.readyState = " + document.readyState);

    // If the document is fully loaded, attach the listener immediately
    if (document.readyState === "complete") {
      console.log('document.readyState is "complete", adding widget listener');
      addWidgetListener();
    } else {
      // Otherwise, wait for the document to load before attaching the listener
      console.log(
        'document.readyState is not "complete", adding widget listener on window load'
      );
      window.addEventListener("load", addWidgetListener);
    }

    // Cleanup function to remove the event listeners
    return () => {
      if (widgetRef.current) {
        widgetRef.current.removeEventListener("statechange", handleStateChange);
      }
      window.removeEventListener("load", addWidgetListener);
    };
  }, [setValue]);

  return (
    <div className="field-container">
      <div className="info">Accept the captcha to continue*</div>
      {/* Static widget directly in the JSX */}
      <altcha-widget
        ref={widgetRef}
        challengeurl={`${getConfig("netlify_functions_url")}/altcha-challenge`}
      ></altcha-widget>
      {meta.error && meta.touched && (
        <div className="error">
          <span>{meta.error}</span>
        </div>
      )}
    </div>
  );
}

image

image

ovx commented 1 week ago

Hi, as I wrote earlier, it's important to add the listener after the script loaded. Because you're using SSR, you've added import('altcha') which is asynchronous and thus the code after the import is executed before the altcha script loads. The react example (https://github.com/altcha-org/altcha-starter-react-ts) uses synchronous import 'altcha' (note the missing parentheses) which guarantees, that the altcha script loads before the following code.

To fix your SSR code, I recommend adding the listener after the import resolves instead of listening for the global 'load' event, something like this:

if (widgetRef.current && global.window) {
  import("altcha").then(() => {
    widgetRef.current.addEventListener('statechange', handleStateChange)
  });
  return () => widgetRef.current.removeEventListener('statechange', handleStateChange)
}
teolaz commented 1 week ago

Hi @ovx, it seems that my beautiful low knowledge in modern js code brought me to lose 3 days of life for 2 parethesis. How much I love being a programmer!

I'm testing it now and it seems to work properly with your mod.

I'm asking a big favour for goofy people like me, if you have space on documentation could you please wrote what you explained specifying the behaviour for SSR?

ovx commented 1 day ago

More detailed info about the issue added to the documentation: https://altcha.org/docs/troubleshooting/#event-listeners-dont-work