Skip to content

Repository files navigation

@silverassist/next-script-loader

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.

Why this exists

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.

Install

npm install @silverassist/next-script-loader

Usage

Each 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);
  };
}, []);

API

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.

Known, accepted gap

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.

Status

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.

Development

npm install
npm run typecheck
npm test
npm run build
npx playwright install --with-deps chromium   # once
npm run e2e

License

PolyForm Noncommercial 1.0.0


Made with ❤️ by Silver Assist

About

Singleton, reference-counted third-party script loader for React/Next.js — the shared base recaptcha, leads, and future vendor-script packages build on.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages