Singleton, reference-counted third-party script loader for React/Next.js
apps. The shared base for recaptcha, and any future lead-form/vendor-script
package, instead of each one hand-rolling its own singleton-script-loading
state machine.
A single, shared implementation of singleton script loading — configure once, load with reference counting, switch variants, arbitrate ownership between competing components — so every vendor-script integration builds on the same tested base instead of each one maintaining its own copy.
npm install @silverassist/next-script-loaderEach vendor integration should own its own instance — new ScriptLoader() —
rather than sharing one across unrelated vendors, so one vendor's unload()
never tears down a different vendor's script.
import { ScriptLoader } from "@silverassist/next-script-loader";
const leadCaptureLoader = new ScriptLoader();
leadCaptureLoader.configure({
urls: {
desktop: "https://cdn.leadcapture.io/GLFT-desktop-token.js",
mobile: "https://cdn.leadcapture.io/GLFT-mobile-token.js",
},
});
useEffect(() => {
leadCaptureLoader.load(isMobile ? "mobile" : "desktop");
return () => leadCaptureLoader.unload();
}, [isMobile]);Switching variants for a caller that already holds a reference — e.g. a
device-size change — uses reload() instead of a fresh load(), so the
reference count doesn't drift:
useEffect(() => {
leadCaptureLoader.reload(isMobile ? "mobile" : "desktop");
}, [isMobile]);Ownership arbitration, for when two components might otherwise both try to drive the same script's lifecycle (e.g. a modal and an on-page form):
const claimed = leadCaptureLoader.setOwner("modal-form");
if (claimed) {
// this component now owns load/unload decisions
}
// on unmount:
leadCaptureLoader.releaseOwnership("modal-form");A delayed unmount cleanup (e.g. "unload 100ms after unmount, to survive a
same-tick remount") needs to skip itself if a newer mount already
reloaded the script in the meantime — otherwise it can tear down a script
the new mount is depending on. Capture the generation at mount time and
pass it back to unload():
useEffect(() => {
const mountGeneration = leadCaptureLoader.getGeneration();
return () => {
setTimeout(() => leadCaptureLoader.unload(mountGeneration), 100);
};
}, []);| Method | Behavior |
|---|---|
configure(config) |
Injects { urls, onLoad?, onError? }. Safe to call more than once. |
load(variant) |
Loads (or joins an in-flight load for) variant; increments ref count. |
reload(variant) |
Swaps the active variant without changing the ref count. A second call for the same variant while the first is still in flight shares that in-flight promise instead of tearing the script down again. |
unload(atGeneration?) |
Decrements ref count; removes the <script> only once it reaches zero. Skipped entirely (including the decrement) if atGeneration is older than the current generation. |
getGeneration() |
The counter load()/reload() bump on every call — capture it to detect a stale cleanup. |
reset() |
Full teardown — script, ref count, owner, generation, and config. For tests. |
setOwner(id) |
Claims ownership if unowned or already owned by id. Returns boolean. |
releaseOwnership(id) |
Releases ownership, only if id is the current owner. |
forceSetOwner(id) |
Unconditionally overrides the current owner. |
owner (getter) |
The current owner id, or null. |
load()/reload() never throw synchronously — every failure path
(unconfigured, no DOM, network/script error) resolves through the returned
promise's rejection, so a caller chaining .catch() always catches it.
Calling unload() while the very first load() for a script is still in
flight can still fire that load's onLoad/onError after the fact —
removing a <script> element does not reliably cancel its in-flight network
request across browsers. Real callers unload on unmount, well after load()
resolves, so this hasn't mattered in practice; revisit with an
AbortController if it ever does.
Published to npm as of 2026-08-30 (0.1.0). Wired into recaptcha and
into @silverassist/leadcapture-form, which is piloted live in
senioradvice-nextjs. Uses
@silverassist/next-testing-toolkit's packaging e2e
(builds against the packed tarball) — this package has no React
components, so there's no RSC-boundary contract to protect the way there
is for icons/recaptcha/consent-banner; what the harness confirms
here is that exports map resolves correctly.
npm install
npm run typecheck
npm test
npm run build
npx playwright install --with-deps chromium # once
npm run e2eMade with ❤️ by Silver Assist