@silverassist/leadcapture-form 0.1.1 → 0.1.3
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 +30 -1
- package/dist/index.js +10 -26
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [0.1.
|
|
5
|
+
## [0.1.3] - Unreleased
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Bumped `@silverassist/next-script-loader` to `^0.1.1` and removed the
|
|
10
|
+
package-local generation map (`bumpGeneration`/`currentGeneration`) and
|
|
11
|
+
the 0.1.2 `hasHandledRemountRef` guard, now that both are handled at the
|
|
12
|
+
source: `ScriptLoader.reload()` itself shares the in-flight promise for
|
|
13
|
+
a same-variant reload already in progress (the actual fix for the
|
|
14
|
+
React Strict Mode double-render), and `ScriptLoader.getGeneration()` /
|
|
15
|
+
`unload(atGeneration?)` replace the hand-rolled generation tracking this
|
|
16
|
+
package carried since 0.1.0. No behavior change for consumers — same
|
|
17
|
+
fix, now enforced one layer down so every `ScriptLoader` consumer gets
|
|
18
|
+
it, not just this one.
|
|
19
|
+
|
|
20
|
+
## [0.1.2] - 2026-08-31
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- The remount-detection effect added in 0.1.1 could reload the script
|
|
25
|
+
twice for the same mount under React Strict Mode's dev-only
|
|
26
|
+
effect-cleanup-effect replay (which reuses the same component instance
|
|
27
|
+
and its refs) — `setOwner` is idempotent for the same container id, so
|
|
28
|
+
it didn't block the second pass, and since removing a `<script>` doesn't
|
|
29
|
+
reliably cancel its in-flight network request, both the superseded and
|
|
30
|
+
the current script could execute and each populate the container,
|
|
31
|
+
rendering the widget twice on a second page. Added a `hasHandledRemountRef`
|
|
32
|
+
guard so the effect can only act once per real component instance.
|
|
33
|
+
|
|
34
|
+
## [0.1.1] - 2026-08-31
|
|
6
35
|
|
|
7
36
|
### Fixed
|
|
8
37
|
|
package/dist/index.js
CHANGED
|
@@ -24,26 +24,6 @@ let react_jsx_runtime = require("react/jsx-runtime");
|
|
|
24
24
|
* the README for the workaround.
|
|
25
25
|
*/
|
|
26
26
|
const leadCaptureLoader = new _silverassist_next_script_loader.ScriptLoader();
|
|
27
|
-
/**
|
|
28
|
-
* Generation counter per variant, incremented on every {@link ScriptLoader.load}/
|
|
29
|
-
* {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the
|
|
30
|
-
* generation at mount time and skips its `unload()` call if the generation has
|
|
31
|
-
* since advanced — i.e. a new mount already reloaded the script before the old
|
|
32
|
-
* mount's delayed cleanup ran (a page-navigation remount, not a real teardown).
|
|
33
|
-
* `ScriptLoader`'s own ref-counting handles the common case; this guards the
|
|
34
|
-
* one case it doesn't: `reload()` doesn't change the reference count, so a
|
|
35
|
-
* stale `unload()` after a `reload()` could drop the count to zero and tear
|
|
36
|
-
* down a script a fresh mount is now depending on.
|
|
37
|
-
*/
|
|
38
|
-
const generationByVariant = /* @__PURE__ */ new Map();
|
|
39
|
-
function bumpGeneration(variant) {
|
|
40
|
-
const next = (generationByVariant.get(variant) ?? 0) + 1;
|
|
41
|
-
generationByVariant.set(variant, next);
|
|
42
|
-
return next;
|
|
43
|
-
}
|
|
44
|
-
function currentGeneration(variant) {
|
|
45
|
-
return generationByVariant.get(variant) ?? 0;
|
|
46
|
-
}
|
|
47
27
|
const DEFAULT_LEADCAPTURE_SCRIPT_URL = "https://api.useleadbot.com/lead-bots/get-pixel-script.js";
|
|
48
28
|
/**
|
|
49
29
|
* LeadCaptureForm — renders a LeadCapture IO form with support for
|
|
@@ -117,7 +97,6 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
117
97
|
if (!(usageContext === "modal" ? isModalOpen === true : isInViewport)) return;
|
|
118
98
|
const load = () => {
|
|
119
99
|
window.form_token = formTokens[formVariant];
|
|
120
|
-
bumpGeneration(formVariant);
|
|
121
100
|
leadCaptureLoader.load(formVariant).then(() => {
|
|
122
101
|
if (!isMountedRef.current) return;
|
|
123
102
|
leadCaptureLoader.forceSetOwner(containerId);
|
|
@@ -161,15 +140,21 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
161
140
|
* touchstart fires on the new page unless the user moves again). Detect
|
|
162
141
|
* that case immediately, using this variant's own load history instead
|
|
163
142
|
* of the interaction gate above.
|
|
143
|
+
*
|
|
144
|
+
* Safe to call on every render this condition holds, including twice in
|
|
145
|
+
* a row for the same mount under React Strict Mode's dev-only effect
|
|
146
|
+
* replay: `ScriptLoader.reload()` (0.1.1+) shares the in-flight promise
|
|
147
|
+
* for a same-variant reload already in progress instead of tearing the
|
|
148
|
+
* script down again, so a second call here is a no-op rather than a
|
|
149
|
+
* second script execution.
|
|
164
150
|
*/
|
|
165
151
|
(0, react.useEffect)(() => {
|
|
166
152
|
if (usageContext !== "onPage") return;
|
|
167
|
-
if (
|
|
153
|
+
if (leadCaptureLoader.getGeneration() === 0) return;
|
|
168
154
|
if (!leadCaptureLoader.setOwner(containerId)) return;
|
|
169
155
|
const container = formRef.current?.querySelector(".leadforms-embd-form");
|
|
170
156
|
if (!container || container.children.length > 0) return;
|
|
171
157
|
window.form_token = formTokens[formVariant];
|
|
172
|
-
bumpGeneration(formVariant);
|
|
173
158
|
leadCaptureLoader.reload(formVariant).then(() => {
|
|
174
159
|
if (!isMountedRef.current) return;
|
|
175
160
|
leadCaptureLoader.forceSetOwner(containerId);
|
|
@@ -185,7 +170,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
185
170
|
* already reloaded) is a no-op.
|
|
186
171
|
*/
|
|
187
172
|
(0, react.useEffect)(() => {
|
|
188
|
-
mountGenRef.current =
|
|
173
|
+
mountGenRef.current = leadCaptureLoader.getGeneration();
|
|
189
174
|
}, [formVariant]);
|
|
190
175
|
/**
|
|
191
176
|
* Cleanup on unmount only.
|
|
@@ -199,8 +184,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
199
184
|
if (usageContext === "onPage") {
|
|
200
185
|
const gen = mountGenRef.current;
|
|
201
186
|
setTimeout(() => {
|
|
202
|
-
|
|
203
|
-
leadCaptureLoader.unload();
|
|
187
|
+
leadCaptureLoader.unload(gen);
|
|
204
188
|
}, 100);
|
|
205
189
|
}
|
|
206
190
|
};
|
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\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 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 * Safe to call on every render this condition holds, including twice in\n * a row for the same mount under React Strict Mode's dev-only effect\n * replay: `ScriptLoader.reload()` (0.1.1+) shares the in-flight promise\n * for a same-variant reload already in progress instead of tearing the\n * script down again, so a second call here is a no-op rather than a\n * second script execution.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (leadCaptureLoader.getGeneration() === 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 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 = leadCaptureLoader.getGeneration();\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 leadCaptureLoader.unload(gen);\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
|
@@ -23,26 +23,6 @@ import { jsx } from "react/jsx-runtime";
|
|
|
23
23
|
* the README for the workaround.
|
|
24
24
|
*/
|
|
25
25
|
const leadCaptureLoader = new ScriptLoader();
|
|
26
|
-
/**
|
|
27
|
-
* Generation counter per variant, incremented on every {@link ScriptLoader.load}/
|
|
28
|
-
* {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the
|
|
29
|
-
* generation at mount time and skips its `unload()` call if the generation has
|
|
30
|
-
* since advanced — i.e. a new mount already reloaded the script before the old
|
|
31
|
-
* mount's delayed cleanup ran (a page-navigation remount, not a real teardown).
|
|
32
|
-
* `ScriptLoader`'s own ref-counting handles the common case; this guards the
|
|
33
|
-
* one case it doesn't: `reload()` doesn't change the reference count, so a
|
|
34
|
-
* stale `unload()` after a `reload()` could drop the count to zero and tear
|
|
35
|
-
* down a script a fresh mount is now depending on.
|
|
36
|
-
*/
|
|
37
|
-
const generationByVariant = /* @__PURE__ */ new Map();
|
|
38
|
-
function bumpGeneration(variant) {
|
|
39
|
-
const next = (generationByVariant.get(variant) ?? 0) + 1;
|
|
40
|
-
generationByVariant.set(variant, next);
|
|
41
|
-
return next;
|
|
42
|
-
}
|
|
43
|
-
function currentGeneration(variant) {
|
|
44
|
-
return generationByVariant.get(variant) ?? 0;
|
|
45
|
-
}
|
|
46
26
|
const DEFAULT_LEADCAPTURE_SCRIPT_URL = "https://api.useleadbot.com/lead-bots/get-pixel-script.js";
|
|
47
27
|
/**
|
|
48
28
|
* LeadCaptureForm — renders a LeadCapture IO form with support for
|
|
@@ -116,7 +96,6 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
116
96
|
if (!(usageContext === "modal" ? isModalOpen === true : isInViewport)) return;
|
|
117
97
|
const load = () => {
|
|
118
98
|
window.form_token = formTokens[formVariant];
|
|
119
|
-
bumpGeneration(formVariant);
|
|
120
99
|
leadCaptureLoader.load(formVariant).then(() => {
|
|
121
100
|
if (!isMountedRef.current) return;
|
|
122
101
|
leadCaptureLoader.forceSetOwner(containerId);
|
|
@@ -160,15 +139,21 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
160
139
|
* touchstart fires on the new page unless the user moves again). Detect
|
|
161
140
|
* that case immediately, using this variant's own load history instead
|
|
162
141
|
* of the interaction gate above.
|
|
142
|
+
*
|
|
143
|
+
* Safe to call on every render this condition holds, including twice in
|
|
144
|
+
* a row for the same mount under React Strict Mode's dev-only effect
|
|
145
|
+
* replay: `ScriptLoader.reload()` (0.1.1+) shares the in-flight promise
|
|
146
|
+
* for a same-variant reload already in progress instead of tearing the
|
|
147
|
+
* script down again, so a second call here is a no-op rather than a
|
|
148
|
+
* second script execution.
|
|
163
149
|
*/
|
|
164
150
|
useEffect(() => {
|
|
165
151
|
if (usageContext !== "onPage") return;
|
|
166
|
-
if (
|
|
152
|
+
if (leadCaptureLoader.getGeneration() === 0) return;
|
|
167
153
|
if (!leadCaptureLoader.setOwner(containerId)) return;
|
|
168
154
|
const container = formRef.current?.querySelector(".leadforms-embd-form");
|
|
169
155
|
if (!container || container.children.length > 0) return;
|
|
170
156
|
window.form_token = formTokens[formVariant];
|
|
171
|
-
bumpGeneration(formVariant);
|
|
172
157
|
leadCaptureLoader.reload(formVariant).then(() => {
|
|
173
158
|
if (!isMountedRef.current) return;
|
|
174
159
|
leadCaptureLoader.forceSetOwner(containerId);
|
|
@@ -184,7 +169,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
184
169
|
* already reloaded) is a no-op.
|
|
185
170
|
*/
|
|
186
171
|
useEffect(() => {
|
|
187
|
-
mountGenRef.current =
|
|
172
|
+
mountGenRef.current = leadCaptureLoader.getGeneration();
|
|
188
173
|
}, [formVariant]);
|
|
189
174
|
/**
|
|
190
175
|
* Cleanup on unmount only.
|
|
@@ -198,8 +183,7 @@ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isM
|
|
|
198
183
|
if (usageContext === "onPage") {
|
|
199
184
|
const gen = mountGenRef.current;
|
|
200
185
|
setTimeout(() => {
|
|
201
|
-
|
|
202
|
-
leadCaptureLoader.unload();
|
|
186
|
+
leadCaptureLoader.unload(gen);
|
|
203
187
|
}, 100);
|
|
204
188
|
}
|
|
205
189
|
};
|
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\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 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 * Safe to call on every render this condition holds, including twice in\n * a row for the same mount under React Strict Mode's dev-only effect\n * replay: `ScriptLoader.reload()` (0.1.1+) shares the in-flight promise\n * for a same-variant reload already in progress instead of tearing the\n * script down again, so a second call here is a no-op rather than a\n * second script execution.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\") return;\n if (leadCaptureLoader.getGeneration() === 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 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 = leadCaptureLoader.getGeneration();\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 leadCaptureLoader.unload(gen);\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.3",
|
|
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",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"react-dom": ">=18.0.0"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@silverassist/next-script-loader": "^0.1.
|
|
74
|
+
"@silverassist/next-script-loader": "^0.1.1"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@playwright/test": "^1.62.1",
|