diff --git a/src/__tests__/lib/thirdParty.test.ts b/src/__tests__/lib/thirdParty.test.ts
index 1c2e054f..c432fe75 100644
--- a/src/__tests__/lib/thirdParty.test.ts
+++ b/src/__tests__/lib/thirdParty.test.ts
@@ -134,6 +134,36 @@ describe("thirdParty script registry", () => {
}
});
+ it("handles script load errors via onError callback", () => {
+ const onError = jest.fn();
+ const mod = loadWithEnv({
+ NEXT_PUBLIC_ANALYTICS_PROVIDER: "plausible",
+ NEXT_PUBLIC_ANALYTICS_DOMAIN: "proofofheart.xyz",
+ });
+
+ // Script configs returned by the registry have no onError by default.
+ for (const s of mod.getThirdPartyScripts()) {
+ mod.handleScriptError(s);
+ }
+ expect(onError).not.toHaveBeenCalled();
+
+ // When a consumer wires onError, handleScriptError invokes it.
+ const script = mod.getThirdPartyScripts()[0];
+ mod.handleScriptError({ ...script, onError });
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not return duplicate script ids", () => {
+ const mod = loadWithEnv({
+ NEXT_PUBLIC_ANALYTICS_PROVIDER: "plausible",
+ NEXT_PUBLIC_ANALYTICS_DOMAIN: "proofofheart.xyz",
+ NEXT_PUBLIC_SUPPORT_WIDGET_SRC: "https://widget.example.com/w.js",
+ });
+
+ const ids = mod.getThirdPartyScripts().map((s) => s.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
it("derives CSP origins from the configured scripts", () => {
const mod = loadWithEnv({
NEXT_PUBLIC_ANALYTICS_PROVIDER: "plausible",
diff --git a/src/components/ThirdPartyScripts.tsx b/src/components/ThirdPartyScripts.tsx
index ec5947b0..2b212fd3 100644
--- a/src/components/ThirdPartyScripts.tsx
+++ b/src/components/ThirdPartyScripts.tsx
@@ -38,34 +38,19 @@ export default function ThirdPartyScripts() {
return (
<>
- {scripts.map(({ id, src, strategy, attributes }) => {
- // #647 — Guard: `beforeInteractive` runs during SSR and blocks the main
- // thread before hydration — exactly the problem this component exists to
- // solve. Any misconfigured entry is demoted to `lazyOnload` at runtime
- // and flagged in the dev console so it can be fixed in thirdParty.ts.
- const safeStrategy =
- strategy === "beforeInteractive"
- ? (process.env.NODE_ENV !== "production" &&
- console.warn(
- `[ThirdPartyScripts] Script "${id}" uses "beforeInteractive" which blocks` +
- ` the main thread. Downgraded to "lazyOnload". Fix the strategy in thirdParty.ts.`
- ),
- "lazyOnload" as const)
- : strategy;
-
- return (
-
- );
- })}
+ {scripts.map(({ id, src, strategy, attributes, onError }) => (
+
+ ))}
>
);
}
diff --git a/src/lib/thirdParty.ts b/src/lib/thirdParty.ts
index 4de3147e..d66bee21 100644
--- a/src/lib/thirdParty.ts
+++ b/src/lib/thirdParty.ts
@@ -33,6 +33,8 @@ export interface ThirdPartyScript {
strategy: ScriptStrategy;
/** Extra DOM attributes, e.g. Plausible's `data-domain`. */
attributes?: Record;
+ /** Called when the script fails to load. */
+ onError?: () => void;
}
/** Supported privacy-first analytics vendors. */
@@ -124,10 +126,16 @@ export function getSupportWidgetScript(): ThirdPartyScript | null {
};
}
-/** Every configured third-party script, in load order. */
+/** Every configured third-party script, in load order (deduplicated by id). */
export function getThirdPartyScripts(): ThirdPartyScript[] {
+ const seen = new Set();
return [getAnalyticsScript(), getSupportWidgetScript()].filter(
- (script): script is ThirdPartyScript => script !== null,
+ (script): script is ThirdPartyScript => {
+ if (script === null) return false;
+ if (seen.has(script.id)) return false;
+ seen.add(script.id);
+ return true;
+ },
);
}
@@ -155,3 +163,8 @@ export function getThirdPartyScriptOrigins(): string[] {
export function getAnalyticsProvider(): AnalyticsProvider | null {
return getAnalyticsScript() ? readProvider() : null;
}
+
+/** Invoke a script's error handler, if one is configured. */
+export function handleScriptError(script: ThirdPartyScript): void {
+ script.onError?.();
+}