@silverassist/leadcapture-form 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [0.1.
|
|
5
|
+
## [0.1.2] - Unreleased
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The remount-detection effect added in 0.1.1 could reload the script
|
|
10
|
+
twice for the same mount under React Strict Mode's dev-only
|
|
11
|
+
effect-cleanup-effect replay (which reuses the same component instance
|
|
12
|
+
and its refs) — `setOwner` is idempotent for the same container id, so
|
|
13
|
+
it didn't block the second pass, and since removing a `<script>` doesn't
|
|
14
|
+
reliably cancel its in-flight network request, both the superseded and
|
|
15
|
+
the current script could execute and each populate the container,
|
|
16
|
+
rendering the widget twice on a second page. Added a `hasHandledRemountRef`
|
|
17
|
+
guard so the effect can only act once per real component instance.
|
|
18
|
+
|
|
19
|
+
## [0.1.1] - 2026-08-31
|
|
6
20
|
|
|
7
21
|
### Fixed
|
|
8
22
|
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
78
78
|
const formRef = (0, react.useRef)(null);
|
|
79
79
|
const mountGenRef = (0, react.useRef)(0);
|
|
80
80
|
const isMountedRef = (0, react.useRef)(true);
|
|
81
|
+
const hasHandledRemountRef = (0, react.useRef)(false);
|
|
81
82
|
const containerId = `leadcapture-container-${formVariant}-${usageContext}`;
|
|
82
83
|
(0, react.useEffect)(() => {
|
|
83
84
|
isMountedRef.current = true;
|
|
@@ -161,13 +162,26 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
161
162
|
* touchstart fires on the new page unless the user moves again). Detect
|
|
162
163
|
* that case immediately, using this variant's own load history instead
|
|
163
164
|
* of the interaction gate above.
|
|
165
|
+
*
|
|
166
|
+
* Guarded by `hasHandledRemountRef` so this can only ever act once per
|
|
167
|
+
* real component instance: `reload()` tears down and recreates the
|
|
168
|
+
* `<script>` element, and removing a `<script>` doesn't reliably cancel
|
|
169
|
+
* its in-flight network request, so calling it twice in a row for the
|
|
170
|
+
* same mount (e.g. React Strict Mode's dev-only effect-cleanup-effect
|
|
171
|
+
* replay, which reuses this same ref) can let both the superseded and
|
|
172
|
+
* the current script execute and each populate the container, rendering
|
|
173
|
+
* the widget twice. Claiming ownership isn't a substitute for this guard
|
|
174
|
+
* -- `setOwner` is idempotent for the same id, so a second call from the
|
|
175
|
+
* same instance succeeds too.
|
|
164
176
|
*/
|
|
165
177
|
(0, react.useEffect)(() => {
|
|
166
178
|
if (usageContext !== "onPage") return;
|
|
179
|
+
if (hasHandledRemountRef.current) return;
|
|
167
180
|
if (currentGeneration(formVariant) === 0) return;
|
|
168
181
|
if (!leadCaptureLoader.setOwner(containerId)) return;
|
|
169
182
|
const container = formRef.current?.querySelector(".leadforms-embd-form");
|
|
170
183
|
if (!container || container.children.length > 0) return;
|
|
184
|
+
hasHandledRemountRef.current = true;
|
|
171
185
|
window.form_token = formTokens[formVariant];
|
|
172
186
|
bumpGeneration(formVariant);
|
|
173
187
|
leadCaptureLoader.reload(formVariant).then(() => {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["ScriptLoader","useState","useRef"],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Handles a client-side navigation remount. The vendor script only scans\n * the DOM for `.leadforms-embd-form` divs once, when it first loads (see\n * the container comment below) -- it never repopulates a div added by a\n * later mount. Without this, a second page's form waits forever for a\n * *fresh* minimal-interaction event, which the click that triggered the\n * navigation doesn't itself produce (no new focus/mousemove/scroll/\n * touchstart fires on the new page unless the user moves again). Detect\n * that case immediately, using this variant's own load history instead\n * of the interaction gate above.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (currentGeneration(formVariant) === 0) return;\n if (!leadCaptureLoader.setOwner(containerId)) return;\n\n const container = formRef.current?.querySelector(\".leadforms-embd-form\");\n if (!container || container.children.length > 0) return;\n\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n bumpGeneration(formVariant);\n leadCaptureLoader\n .reload(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade -- the surrounding page stays usable without the embed.\n });\n }, [formVariant, containerId, usageContext]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["ScriptLoader","useState","useRef"],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const hasHandledRemountRef = useRef(false);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Handles a client-side navigation remount. The vendor script only scans\n * the DOM for `.leadforms-embd-form` divs once, when it first loads (see\n * the container comment below) -- it never repopulates a div added by a\n * later mount. Without this, a second page's form waits forever for a\n * *fresh* minimal-interaction event, which the click that triggered the\n * navigation doesn't itself produce (no new focus/mousemove/scroll/\n * touchstart fires on the new page unless the user moves again). Detect\n * that case immediately, using this variant's own load history instead\n * of the interaction gate above.\n *\n * Guarded by `hasHandledRemountRef` so this can only ever act once per\n * real component instance: `reload()` tears down and recreates the\n * `<script>` element, and removing a `<script>` doesn't reliably cancel\n * its in-flight network request, so calling it twice in a row for the\n * same mount (e.g. React Strict Mode's dev-only effect-cleanup-effect\n * replay, which reuses this same ref) can let both the superseded and\n * the current script execute and each populate the container, rendering\n * the widget twice. Claiming ownership isn't a substitute for this guard\n * -- `setOwner` is idempotent for the same id, so a second call from the\n * same instance succeeds too.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (hasHandledRemountRef.current) return;\n if (currentGeneration(formVariant) === 0) return;\n if (!leadCaptureLoader.setOwner(containerId)) return;\n\n const container = formRef.current?.querySelector(\".leadforms-embd-form\");\n if (!container || container.children.length > 0) return;\n\n hasHandledRemountRef.current = true;\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n bumpGeneration(formVariant);\n leadCaptureLoader\n .reload(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade -- the surrounding page stays usable without the embed.\n });\n }, [formVariant, containerId, usageContext]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -77,6 +77,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
77
77
|
const formRef = useRef(null);
|
|
78
78
|
const mountGenRef = useRef(0);
|
|
79
79
|
const isMountedRef = useRef(true);
|
|
80
|
+
const hasHandledRemountRef = useRef(false);
|
|
80
81
|
const containerId = `leadcapture-container-${formVariant}-${usageContext}`;
|
|
81
82
|
useEffect(() => {
|
|
82
83
|
isMountedRef.current = true;
|
|
@@ -160,13 +161,26 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
160
161
|
* touchstart fires on the new page unless the user moves again). Detect
|
|
161
162
|
* that case immediately, using this variant's own load history instead
|
|
162
163
|
* of the interaction gate above.
|
|
164
|
+
*
|
|
165
|
+
* Guarded by `hasHandledRemountRef` so this can only ever act once per
|
|
166
|
+
* real component instance: `reload()` tears down and recreates the
|
|
167
|
+
* `<script>` element, and removing a `<script>` doesn't reliably cancel
|
|
168
|
+
* its in-flight network request, so calling it twice in a row for the
|
|
169
|
+
* same mount (e.g. React Strict Mode's dev-only effect-cleanup-effect
|
|
170
|
+
* replay, which reuses this same ref) can let both the superseded and
|
|
171
|
+
* the current script execute and each populate the container, rendering
|
|
172
|
+
* the widget twice. Claiming ownership isn't a substitute for this guard
|
|
173
|
+
* -- `setOwner` is idempotent for the same id, so a second call from the
|
|
174
|
+
* same instance succeeds too.
|
|
163
175
|
*/
|
|
164
176
|
useEffect(() => {
|
|
165
177
|
if (usageContext !== "onPage") return;
|
|
178
|
+
if (hasHandledRemountRef.current) return;
|
|
166
179
|
if (currentGeneration(formVariant) === 0) return;
|
|
167
180
|
if (!leadCaptureLoader.setOwner(containerId)) return;
|
|
168
181
|
const container = formRef.current?.querySelector(".leadforms-embd-form");
|
|
169
182
|
if (!container || container.children.length > 0) return;
|
|
183
|
+
hasHandledRemountRef.current = true;
|
|
170
184
|
window.form_token = formTokens[formVariant];
|
|
171
185
|
bumpGeneration(formVariant);
|
|
172
186
|
leadCaptureLoader.reload(formVariant).then(() => {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Handles a client-side navigation remount. The vendor script only scans\n * the DOM for `.leadforms-embd-form` divs once, when it first loads (see\n * the container comment below) -- it never repopulates a div added by a\n * later mount. Without this, a second page's form waits forever for a\n * *fresh* minimal-interaction event, which the click that triggered the\n * navigation doesn't itself produce (no new focus/mousemove/scroll/\n * touchstart fires on the new page unless the user moves again). Detect\n * that case immediately, using this variant's own load history instead\n * of the interaction gate above.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (currentGeneration(formVariant) === 0) return;\n if (!leadCaptureLoader.setOwner(containerId)) return;\n\n const container = formRef.current?.querySelector(\".leadforms-embd-form\");\n if (!container || container.children.length > 0) return;\n\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n bumpGeneration(formVariant);\n leadCaptureLoader\n .reload(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade -- the surrounding page stays usable without the embed.\n });\n }, [formVariant, containerId, usageContext]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const hasHandledRemountRef = useRef(false);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Handles a client-side navigation remount. The vendor script only scans\n * the DOM for `.leadforms-embd-form` divs once, when it first loads (see\n * the container comment below) -- it never repopulates a div added by a\n * later mount. Without this, a second page's form waits forever for a\n * *fresh* minimal-interaction event, which the click that triggered the\n * navigation doesn't itself produce (no new focus/mousemove/scroll/\n * touchstart fires on the new page unless the user moves again). Detect\n * that case immediately, using this variant's own load history instead\n * of the interaction gate above.\n *\n * Guarded by `hasHandledRemountRef` so this can only ever act once per\n * real component instance: `reload()` tears down and recreates the\n * `<script>` element, and removing a `<script>` doesn't reliably cancel\n * its in-flight network request, so calling it twice in a row for the\n * same mount (e.g. React Strict Mode's dev-only effect-cleanup-effect\n * replay, which reuses this same ref) can let both the superseded and\n * the current script execute and each populate the container, rendering\n * the widget twice. Claiming ownership isn't a substitute for this guard\n * -- `setOwner` is idempotent for the same id, so a second call from the\n * same instance succeeds too.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (hasHandledRemountRef.current) return;\n if (currentGeneration(formVariant) === 0) return;\n if (!leadCaptureLoader.setOwner(containerId)) return;\n\n const container = formRef.current?.querySelector(\".leadforms-embd-form\");\n if (!container || container.children.length > 0) return;\n\n hasHandledRemountRef.current = true;\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n bumpGeneration(formVariant);\n leadCaptureLoader\n .reload(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade -- the surrounding page stays usable without the embed.\n });\n }, [formVariant, containerId, usageContext]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@silverassist/leadcapture-form",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "LeadCapture IO form integration for Next.js — variant switching, ownership arbitration, and ref-counted script lifecycle via @silverassist/next-script-loader",
|
|
5
5
|
"author": "Miguel Colmenares <me@miguelcolmenares.com>",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|