@prismetic/form-utils
v1.3.4
Published
NeverBounce email gating for embedded ActiveCampaign forms — React 19 / Next 16
Maintainers
Readme
@prismetic/form-utils
Built for Prismetic's ITE sites, whose registration pages embed an ActiveCampaign form injected by a remote script. The hooks assume that shape.
NeverBounce email gating for embedded ActiveCampaign forms, for React 19 / Next 16.
The AC form is injected by a remote script after render, so this waits for it rather than assuming it exists, then intercepts submission in the capture phase to hold the form until NeverBounce returns a verdict.
Install
npm install @prismetic/form-utilsRequirements
1. The NeverBounce widget must be on the page. The package reads
window._nb; it never owns the API key:
<Script
id="neverbounce"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `_NBSettings = {"apiKey": "public_…"};`,
}}
/>
<Script
id="neverbounce-script-cdn"
strategy="afterInteractive"
src="https://cdn.neverbounce.com/widget/dist/NeverBounce.js"
/>If window._nb never appears the hook gives up quietly after ~10s
(50 polls × 200ms) and the form submits unvalidated.
2. Import the stylesheet:
/* globals.css */
@import "@prismetic/form-utils/styles.css";It styles the injected ActiveCampaign markup under a .container-registration
root — field layout, the _error _above tooltip this package draws, and the
Yandex form variant. Your site owns typography and colour; the sheet owns
structure. It is optional: the hooks work without it, the form just renders
unstyled.
3. Everything else:
| | |
| --- | --- |
| Peer | react >=18 — accurate as declared; nothing here uses a React 19-only API |
| Dependencies | none |
| Next.js | not a peer dep, never imported — useUtmIframe takes searchParams as an argument instead |
| "use client" | already in the source of every hook and of HTMLContent; you do not add it |
| Tested against | React 19 / Next 16 |
Quick start
const containerRef = useRef<HTMLDivElement>(null);
useNeverBounceEmailGate(containerRef, [formId]);
return (
<div ref={containerRef}>
<HTMLContent html={acFormMarkup} />
</div>
);The ref wraps the container the AC script injects into; the hook finds the
<form> inside it on its own.
Real-world usage
"use client";
import { useRef } from "react";
import {
HTMLContent,
useFormContentFlags,
useNeverBounceEmailGate,
} from "@prismetic/form-utils";
import { cn } from "@/lib/utils";
import Consent from "./Consent";
const Form = (props: any) => {
const containerRef = useRef<HTMLDivElement>(null);
const { isYandexForm, isFormContent } = useFormContentFlags(props.data.Data);
useNeverBounceEmailGate(containerRef, [props.data.id]);
return (
<div
ref={containerRef}
className={cn(
"container",
isYandexForm ? "yandex-form-style" : "form-style",
isFormContent ? "" : "py-0!",
)}
>
<HTMLContent
html={props.data.Data}
id={props.data.id}
key={props.data.id}
/>
<Consent />
</div>
);
};
export default Form;API reference
useNeverBounceEmailGate(containerRef, deps?)
useNeverBounceEmailGate<T extends HTMLElement>(
containerRef: RefObject<T | null>,
deps?: unknown[],
): void| param | type | default | |
| --- | --- | --- | --- |
| containerRef | RefObject<T \| null> | — | element wrapping the injected AC markup |
| deps | unknown[] | [] | re-run the wiring when these change — typically [formId] |
Returns nothing. Wires NeverBounce onto whatever <form> appears inside
containerRef.
The email field is found with
input[name="email"], input[type="email"], input[id="email"] — the first match
wins. A form whose email input matches none of those is left unwired.
What it does:
- Watches for the AC form with a
MutationObserver, and independently waits forwindow._nb(the bundle loadsafterInteractive, so either can arrive first). It keeps watching until the email field is registered, so a<form>whose fields arrive later is still wired. One readiness poll runs at a time. - Registers the email field with
registerListener(input, false)— the widget's own blur feedback is suppressed in favour of the tooltip below. - On input in the email field: clears any visible message, via a delegated listener on the form.
- On blur: shows the verdict if there is one, otherwise shows
Verifying email…and waits. Empty and format-invalid values are skipped entirely so AC handles its own required-field messaging. - On submit: if a verdict exists, blocks or allows. If none exists yet, holds
the submission, shows
Please wait, verifying email…, and re-fires the submit once the verdict lands. - Fails open. After
NB_VERIFY_TIMEOUT_MS(30s) with no verdict, the submission goes through. Holding back a real registrant costs more than letting one bounce through. - Removes every listener, timer and tooltip on unmount.
The submit listener is registered with capture: true so it runs before
ActiveCampaign's own handler.
useUtmIframe(formId, searchParams, deps?)
useUtmIframe(
formId: string | number | null | undefined,
searchParams: UtmSearchParams,
deps?: unknown[],
): voidFor pages whose form is an embedded CRM iframe rather than an ActiveCampaign
script. Appends UTM params to the iframe's src and keeps its height in sync
with the embed's postMessage.
| param | type | default | |
| --- | --- | --- | --- |
| formId | string \| number \| null \| undefined | — | id of the iframe the embed renders; falsy is a no-op |
| searchParams | UtmSearchParams | — | anything with get(name): string \| null; Next's useSearchParams() satisfies it |
| deps | unknown[] | [] | extra values that should re-run the wiring |
const searchParams = useSearchParams();
useUtmIframe(props.data.FormId, searchParams, [isYandexForm]);- The five keys read are
utm_campaign,utm_content,utm_medium,utm_source,utm_term. Values areencodeURIComponent-escaped. - UTMs come from the current URL when any key is present there; otherwise
from the
utmParamsentry insessionStorage(a JSON object) — so a visitor who lands on a campaign URL and later navigates to the form keeps their attribution. Malformed JSON is swallowed and treated as no UTMs. - The embed injects its iframe asynchronously, so this retries across up to 40 animation frames rather than assuming the element is already there.
- The UTMs go into the query, before any
#fragment, with the right separator (?vs&). The CRM's own query params are kept as they are. - They are built onto the iframe's original
src, remembered the first time the hook sees that element, so StrictMode or a re-run with new UTMs replaces them rather than appending another copy.srcis reassigned only when it changes. - Listens for
messageevents:{ height }sets the iframe's pixel height, and{ action: "form-sent", yandexCounterId, yandexCounterGoal }reports a Yandex Metrica goal viawindow.ym. A no-op on sites without Metrica.
searchParams is passed in rather than read from next/navigation inside the
package, which keeps react as the only peer dependency.
useFormContentFlags(html)
useFormContentFlags(html: string): FormContentFlags| param | type | |
| --- | --- | --- |
| html | string | the raw embed markup from the CMS |
Returns { isYandexForm, isFormContent }:
| field | true when |
| --- | --- |
| isYandexForm | the markup contains the substring wisent: the Wisent registration widget, on any host |
| isFormContent | parsing the markup yields at least one p, span or label |
isYandexForm keeps the name from when the widget was served only from Yandex Cloud.
Both start false and settle after mount, since the parse needs a DOM. Plan for
one render at false — that first paint is what a static export ships.
<HTMLContent />
<HTMLContent html={...} allowRerender={...} {...divProps} />Renders an HTML string with its scripts executed, via
createContextualFragment — the AC and Yandex embeds are <script> loaders, so
innerHTML alone renders nothing. Adapted from dangerously-set-html-content
for React 19.
| prop | type | default | |
| --- | --- | --- | --- |
| html | string | — | markup to inject |
| allowRerender | boolean | false | re-inject when html changes |
| recaptchaHost | string | — | rewrite www.google.com/recaptcha/ to this host before injecting; see reCAPTCHA host |
recaptchaHost never reaches the DOM. Any other prop passes through to the wrapping div. With allowRerender: false
the markup is injected once and later html changes are ignored; remount with a
key to force a fresh injection, as the real-world example above does.
An empty html logs html prop can't be null to the console and injects
nothing.
Wisent stylesheets are dropped. Before inserting, HTMLContent removes every <link> whose
rel includes stylesheet (any case) and whose href contains wisent, so it never loads. That
is the provider's legacy new.css, whose !important rules override styles.css. Every other
<link>, and the widget's own <script>, stays. A Wisent sheet loaded by @import inside a
<style> block is not caught.
reCAPTCHA host
www.google.com is blocked in mainland China, so a reCAPTCHA loaded from it fails there.
www.recaptcha.net is Google's documented host for regions where www.google.com is
not accessible, and serves the same API. The site decides per
locale whether to switch:
import { HTMLContent, RECAPTCHA_NET_HOST } from "@prismetic/form-utils";
<HTMLContent
html={props.data.Data}
id={props.data.id}
key={props.data.id}
recaptchaHost={locale === "zh-CN" ? RECAPTCHA_NET_HOST : undefined}
/>Without recaptchaHost, HTMLContent behaves exactly as in 1.2.1, except that it drops Wisent
stylesheets.
rewriteRecaptchaHost(html, host?)
rewriteRecaptchaHost(html: string, host?: string): string // host defaults to RECAPTCHA_NET_HOSTReplaces every www.google.com/recaptcha/ — script src, strings inside inline
scripts — with ${host}/recaptcha/. Nothing else on google.com is touched, and a
non-string input is returned as is. RECAPTCHA_NET_HOST is "www.recaptcha.net".
isActiveCampaignSimpleEmbed(html)
isActiveCampaignSimpleEmbed(html: string): booleanTrue when the markup loads an ActiveCampaign simple embed, that is, it has a <script>
tag whose src attribute ends its path (the part before any ? or #) with /f/embed.php.
The tag's attributes are read as an HTML parser reads them: the value may be double-quoted,
single-quoted or unquoted, whitespace around it is ignored, and only the first src counts.
The host is not checked, because ActiveCampaign forms can run on a custom domain. embed.php
without /f/, the path in visible text, in another attribute or element, in a query string,
or as part of a longer file name does not count. It reads the markup with regexes, not a DOM
parse, so it runs during a server render. That script fetches the form and its reCAPTCHA from
ActiveCampaign at runtime, so the host cannot be rewritten.
HTMLContent with recaptchaHost set logs one console.error naming the form when it
meets one: during a server render, and from its mount effect in the browser, once per
mounted component and html value. Paste the full embed code into the CMS instead.
Under a <Suspense> boundary with useSearchParams, as every ITE Form renders it, nothing
runs on the server, so the warning appears in the browser console. For a build-time log,
call isActiveCampaignSimpleEmbed in a server-rendered parent.
Validation helpers
isAcceptable(response)
isAcceptable(response: NeverBounceResponse): booleanWhether an address may proceed. See How isAcceptable decides.
getRejectMessage(response)
getRejectMessage(response: NeverBounceResponse): stringThe user-facing reason an address may not proceed. First match wins:
| condition | message |
| --- | --- |
| disposable_email flag or result: "disposable" | Disposable email addresses are not accepted. Please use another address. |
| suggested_correction present | Did you mean <correction>? |
| result: "invalid" | This email address doesn't appear to exist. Please check for typos. |
| result: "unknown" without has_dns_mx | This domain can't receive email. Please check the address. |
| anything else | Please enter a valid email address. |
The suggested_correction row is checked early on purpose — the API often spots
the typo for us, which is far more useful than a generic rejection.
isValidEmail(email) / EMAIL_REGEX
isValidEmail(email: string): booleanA format check only — no network call. EMAIL_REGEX is exported so a site can
apply the identical rule elsewhere:
/^[+_a-z0-9-'&=]+(\.[+_a-z0-9-']+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,})$/iUsed as a gate before every NeverBounce call: a malformed address never reaches the API, and AC's own required-field handling deals with it.
showValidationMessage(input, message, type)
showValidationMessage(
input: HTMLInputElement,
message: string,
type: "error" | "pending",
): voidDraws AC's _error _above tooltip below the input's parent, matching
ActiveCampaign's own styling so the two are indistinguishable. Clears any
existing message first.
| type | appearance | side effect |
| --- | --- | --- |
| "error" | red #FFDDDD, warning icon | adds _has_error to the input |
| "pending" | amber #FFF9E6, clock icon | none |
The tooltip is given role="alert" and flips to _below when the input sits
within 40px of the viewport top.
clearValidationMessage(input) / NB_ERROR_ID
clearValidationMessage(input: HTMLInputElement): voidRemoves the tooltip — looked up by NB_ERROR_ID
("nb-email-validation-error") on the whole document, which is the reliable
route given AC re-renders its own markup — and strips _has_error from the
input.
Timing constants
| export | value | |
| --- | --- | --- |
| NB_VERIFY_TIMEOUT_MS | 30000 | how long to wait for a verdict before failing open |
| NB_POLL_INTERVAL_MS | 200 | how often the verdict is polled |
30s is deliberate: the widget itself waits _NBSettings.timeout * 1000 + 2500
(27.5s at the default timeout of 25), so anything shorter gives up while a check
is still in flight. Greylisting hosts routinely take 11–13s to answer.
Type exports
NeverBounceResponse · NeverBounceListener · NeverBounceWidget ·
HTMLContentProps · FormContentFlags · UtmSearchParams
Also exported: RECAPTCHA_NET_HOST, rewriteRecaptchaHost, isActiveCampaignSimpleEmbed.
interface NeverBounceResponse {
result: "valid" | "invalid" | "disposable" | "catchall" | "unknown";
status: string;
flags?: string[]; // has_dns, has_dns_mx, smtp_connectable,
// accepts_all, free_email_host, role_account,
// disposable_email, spamtrap_network
allow_entry?: boolean; // NeverBounce's own POE verdict; not read here
suggested_correction?: string;
execution_time?: number;
}Importing the package also declares window._nb globally, so no cast is needed
to reach the widget.
How isAcceptable decides
It gates on evidence of a bad address rather than on result alone.
| response | verdict |
| --- | --- |
| disposable_email flag, or result: "disposable" | ✗ |
| result: "invalid" | ✗ |
| result: "valid" or "catchall" | ✓ |
| result: "unknown" + has_dns_mx | ✓ |
| result: "unknown", no flags at all | ✓ |
| result: "unknown", flags but no has_dns_mx | ✗ |
unknown is the case that matters. Many corporate mail gateways and hosts like
rambler.ru greylist NeverBounce's SMTP probe, so a perfectly deliverable work
address comes back unknown — an inconclusive check, not a bad address.
Rejecting those blocks real registrants, so when the mailbox can't be confirmed
the domain is judged instead. A domain with no mail exchanger has nowhere to
deliver, and is still rejected.
No flags at all is treated as ✓ for the same reason: it means the API gave up before gathering any evidence, and absence of evidence isn't grounds to reject.
Limits
- A held submission is re-fired by clicking
button[type="submit"]. A form whose submit control is an<input type="submit">, or a button withouttype="submit", will have its first submission held and then never resumed — the visitor sees the pending tooltip clear and nothing happen. Every AC form in the fleet renders a<button type="submit">. useUtmIframedoes not checkevent.origin. Any window with a handle to the page can post{ height }to resize the iframe, or{ action: "form-sent", … }to fire a Metrica goal. Low impact, but it is not a validated channel.- Messages are hardcoded English.
getRejectMessageand the two inline strings (Verifying email…,Please wait, verifying email…) take no translation, which is visible on the Russian-language sites. - One form per container. The hook wires the first
<form>it finds and disconnects its observer once that form's email field is registered; a container that injects a second form later is not picked up. - The gate is client-side only. It improves data quality at the point of entry; it is not a security control, and a determined submitter can bypass it.
isAcceptable'shas_dns_mx && smtp_connectablebranch is redundant — the fall-through returns the same verdict for anyhas_dns_mxresponse. Harmless, but it reads as if it does more than it does.
Upgrading to 1.3.4
No code change is needed. What a site sees:
useUtmIframeputs the UTMs in the iframe's query, before any#fragment:…/form#sectionbecomes…/form?utm_source=x#section, where 1.3.3 gave…/form#section?utm_source=xand the CRM never saw them. It builds from the iframe's originalsrc, so StrictMode in development or a re-run with new UTMs no longer appends another copy, and the iframe reloads only when itssrcchanges.- The validation tooltip stays
_abovean input on a scrolled page. 1.3.3 subtracted the scroll offset from a position that is already viewport-relative, so once the page was scrolled further than the input sat from the viewport top, the tooltip flipped_below. It now flips only when the input is within 40px of the viewport top. isActiveCampaignSimpleEmbed, and the simple-embed warningHTMLContentlogs from it, now match only a<script>whose ownsrcattribute, in any quote style and trimmed, has a path containing/f/embed.php. 1.3.3 matched that text anywhere, including visible copy and other elements, so markup that only mentions the path no longer triggers the warning.
Upgrading to 1.3.3
No code change is needed. HTMLContent now removes the Wisent stylesheet <link>
(…/new.css) from the embed before inserting it, so styles.css is the widget's only styling:
- On a page whose CMS embed carries that link, the form looks different: the provider's orange
step names, its progress bar and its muted text colours give way to
styles.css. Check each Wisent form page after upgrading. - Everything else in the embed, and every other embed, is inserted as before.
Upgrading to 1.3.2
No code change is needed. isYandexForm now looks for wisent instead of yandexcloud:
- The Wisent widget on
wisent-online.itegroup.onlineis now flagged. 1.3.1 missed it, so the wrapper fell back toform-style. - Markup that only links to Yandex Cloud, such as a PDF on
storage.yandexcloud.net, is no longer flagged.
Upgrading to 1.3.1
No code change is needed. What a site sees:
- A simple embed rendered with
recaptchaHostset now also logs itsconsole.errorin the browser. WithoutrecaptchaHostnothing changes: no log, and the same injected DOM. isActiveCampaignSimpleEmbedneeds/f/embed.php.embed.phpanywhere else, such as in visible text, no longer counts.package.jsondeclares"sideEffects": ["*.css"]: a bundler may drop an unused module, never the stylesheet.
Development
npm test -w form-utils # vitest — 113 tests
npm run build -w form-utilsTests cover the pure helpers in form-utils.ts and recaptcha.ts, and in happy-dom the tooltip
placement, all three hooks and HTMLContent. Without recaptchaHost, HTMLContent's injected
DOM and server markup must equal 1.2.1's (kept byte-identical in
src/__fixtures__/HTMLContent-1.2.1.ts), apart from the dropped Wisent stylesheets.
useNeverBounceEmailGate runs against a stub window._nb with the form markup inserted by
hand; the real ActiveCampaign script and NeverBounce widget are not exercised.
License
MIT
