From e92053b092c81b28401bec19fb32229be54a226c Mon Sep 17 00:00:00 2001 From: George Oastler Date: Thu, 24 Sep 2026 23:21:27 +0100 Subject: [PATCH 1/2] Boot the bundle once when its script is added after page load A script injected after the page loaded runs with document.readyState already complete and then gets its own load event, so both boot triggers fired: implicit widgets were mounted twice (the first copy orphaned but still running) and the onload callback ran twice. --- .changeset/bundle-boot-once.md | 5 + packages/procaptcha-bundle/src/index.ts | 44 +++--- .../src/tests/boot.unit.test.ts | 139 ++++++++++++++++++ 3 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 .changeset/bundle-boot-once.md create mode 100644 packages/procaptcha-bundle/src/tests/boot.unit.test.ts diff --git a/.changeset/bundle-boot-once.md b/.changeset/bundle-boot-once.md new file mode 100644 index 0000000000..d2dac28b36 --- /dev/null +++ b/.changeset/bundle-boot-once.md @@ -0,0 +1,5 @@ +--- +"@prosopo/procaptcha-bundle": patch +--- + +Loading the Procaptcha script after the page has finished loading (from a tag manager, or an app that adds the script on demand) no longer renders every widget twice or calls the `onload` callback twice. The first copy of each widget used to be left running in the background, doubling the requests it made. diff --git a/packages/procaptcha-bundle/src/index.ts b/packages/procaptcha-bundle/src/index.ts index a733f239af..14055780e9 100644 --- a/packages/procaptcha-bundle/src/index.ts +++ b/packages/procaptcha-bundle/src/index.ts @@ -431,32 +431,30 @@ const boot = () => { // onLoadUrlCallback defines the name of the callback function to be called when the script is loaded // onRenderExplicit takes values of either explicit or implicit const { onloadUrlCallback, renderExplicit } = extractParams(BUNDLE_NAMES); - let readyCalled = false; - - // Render the Procaptcha component implicitly if renderExplicit is not set to explicit - if (renderExplicit !== "explicit") { - getProcaptchaScript(BUNDLE_NAMES)?.addEventListener("load", () => { - ready(implicitRender); - readyCalled = true; - }); - // or if the document has already loaded, call the implicit render function - if (document.readyState === "complete" && !readyCalled) { + if (renderExplicit === "explicit" && !onloadUrlCallback) return; + + // A script injected after the page loaded runs with readyState already + // "complete" and then receives its own `load` event, so both triggers + // below fire. Without the guard every implicit widget was mounted twice + // (the first copy orphaned but still running) and the onload callback ran + // twice. + let booted = false; + const run = () => { + if (booted) return; + booted = true; + if (renderExplicit !== "explicit") { ready(implicitRender); } - } - - if (onloadUrlCallback) { - // Add event listener to the script tag to call the callback function when the script is loaded - getProcaptchaScript(BUNDLE_NAMES)?.addEventListener("load", () => { - const onloadCallback = getWindowCallback(onloadUrlCallback); - ready(onloadCallback); - }); - - // or if the document has already loaded, call the callback function - if (document.readyState === "complete" && !readyCalled) { - const onloadCallback = getWindowCallback(onloadUrlCallback); - ready(onloadCallback); + if (onloadUrlCallback) { + ready(getWindowCallback(onloadUrlCallback)); } + }; + + getProcaptchaScript(BUNDLE_NAMES)?.addEventListener("load", run, { + once: true, + }); + if (document.readyState === "complete") { + run(); } }; diff --git a/packages/procaptcha-bundle/src/tests/boot.unit.test.ts b/packages/procaptcha-bundle/src/tests/boot.unit.test.ts new file mode 100644 index 0000000000..f5c9a58873 --- /dev/null +++ b/packages/procaptcha-bundle/src/tests/boot.unit.test.ts @@ -0,0 +1,139 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * A script injected after the page has loaded (a tag manager, an SPA that + * lazy-loads the captcha) runs while `document.readyState` is already + * "complete", and its `load` event fires straight afterwards. Boot must render + * and call the onload callback once across both of those, not once for each. + */ + +import type { ProcaptchaRenderOptions } from "@prosopo/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreatedWidget } from "../util/widgetFactory.js"; + +const mocks = vi.hoisted(() => ({ + createWidgets: + vi.fn< + ( + containers: Element[], + renderOptions: ProcaptchaRenderOptions, + isWeb2?: boolean, + invisible?: boolean, + ) => Promise + >(), + prefetchDetector: vi.fn<() => void>(), +})); + +vi.mock("@prosopo/procaptcha-frictionless", () => ({ + prefetchDetector: mocks.prefetchDetector, +})); + +vi.mock("@prosopo/procaptcha-common", () => ({ + getWindowCallback: + (name: string) => + (...args: unknown[]): unknown => { + const fn: unknown = Reflect.get(window, name); + return typeof fn === "function" ? fn(...args) : undefined; + }, + pickIpMode: () => undefined, +})); + +vi.mock("../util/widgetFactory.js", () => ({ + WidgetFactory: vi.fn(function () { + return { createWidgets: mocks.createWidgets }; + }), +})); + +const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP"; + +const flush = async (): Promise => { + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); +}; + +const injectScript = (query: string): HTMLScriptElement => { + const script = document.createElement("script"); + // A non-JS type stops jsdom fetching the src; boot only needs the tag. + script.type = "text/plain"; + script.setAttribute( + "src", + `https://js.prosopo.io/js/procaptcha.bundle.js${query}`, + ); + document.head.appendChild(script); + return script; +}; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + document.head.innerHTML = ""; + document.body.innerHTML = ""; + mocks.createWidgets.mockImplementation(async (containers: Element[]) => + containers.map(() => ({ + handle: { destroy: vi.fn<() => void>() }, + container: document.createElement("div"), + })), + ); +}); + +afterEach(() => { + Reflect.deleteProperty(window, "onProcaptchaLoad"); +}); + +// The first import of the entry point transforms its whole dependency graph. +describe("boot after the page has loaded", { timeout: 60_000 }, () => { + it("renders implicit widgets once when the script's load event follows", async () => { + expect(document.readyState).toBe("complete"); + const widget = document.createElement("div"); + widget.className = "procaptcha"; + widget.setAttribute("data-sitekey", SITE_KEY); + document.body.appendChild(widget); + const script = injectScript(""); + + await import("../index.js"); + script.dispatchEvent(new Event("load")); + await flush(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); + }); + + it("calls the onload callback once when the script's load event follows", async () => { + const onload = vi.fn<() => void>(); + Reflect.set(window, "onProcaptchaLoad", onload); + const script = injectScript("?render=explicit&onload=onProcaptchaLoad"); + + await import("../index.js"); + script.dispatchEvent(new Event("load")); + await flush(); + + expect(onload).toHaveBeenCalledTimes(1); + }); + + it("still boots from the load event when the page is still loading", async () => { + const readyState = vi + .spyOn(document, "readyState", "get") + .mockReturnValue("interactive"); + const onload = vi.fn<() => void>(); + Reflect.set(window, "onProcaptchaLoad", onload); + const script = injectScript("?render=explicit&onload=onProcaptchaLoad"); + + await import("../index.js"); + expect(onload).not.toHaveBeenCalled(); + readyState.mockRestore(); + script.dispatchEvent(new Event("load")); + await flush(); + + expect(onload).toHaveBeenCalledTimes(1); + }); +}); From d46f7a4bc4157b54dee97c027e213b43399117d8 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Mon, 26 Oct 2026 17:07:04 +0000 Subject: [PATCH 2/2] Boot the bundle directly and skip widgets another copy already rendered The script's load event fires straight after the script runs, so boot no longer waits for it. That removes the double trigger outright and lets widgets render when the script tag can't be found while the page is still loading. Implicit render marks each element it takes, so a second copy of the bundle on the same page only renders new elements, and it leaves the first copy's window.procaptcha in place so reset() and remove() still reach the existing widgets. --- .changeset/bundle-boot-once.md | 4 + packages/procaptcha-bundle/src/index.ts | 89 +++++----- .../src/tests/boot.unit.test.ts | 161 ++++++++++++++---- 3 files changed, 182 insertions(+), 72 deletions(-) diff --git a/.changeset/bundle-boot-once.md b/.changeset/bundle-boot-once.md index d2dac28b36..67383f3ccd 100644 --- a/.changeset/bundle-boot-once.md +++ b/.changeset/bundle-boot-once.md @@ -3,3 +3,7 @@ --- Loading the Procaptcha script after the page has finished loading (from a tag manager, or an app that adds the script on demand) no longer renders every widget twice or calls the `onload` callback twice. The first copy of each widget used to be left running in the background, doubling the requests it made. + +Loading the script a second time on the same page no longer renders the existing widgets again: the second copy only renders `procaptcha` elements that don't have a widget yet, and `window.procaptcha` stays pointed at the first copy so `reset()` and `remove()` still reach those widgets. + +Widgets also now render when the script is served under a different filename, which used to leave the page with no widget if the script ran before the page finished loading. diff --git a/packages/procaptcha-bundle/src/index.ts b/packages/procaptcha-bundle/src/index.ts index 579b67bc76..0055186dc6 100644 --- a/packages/procaptcha-bundle/src/index.ts +++ b/packages/procaptcha-bundle/src/index.ts @@ -23,7 +23,7 @@ import { } from "@prosopo/types"; import { at } from "@prosopo/util"; import type { BundleCaptchaHandle } from "./util/captcha/components/bundleCaptcha.js"; -import { extractParams, getProcaptchaScript } from "./util/config.js"; +import { extractParams } from "./util/config.js"; import { siteLanguage } from "./util/language.js"; import { resolveStartMode } from "./util/startMode.js"; import { type CreatedWidget, WidgetFactory } from "./util/widgetFactory.js"; @@ -120,12 +120,27 @@ const PROCAPTCHA_EXECUTE_EVENT = "procaptcha:execute"; // Define a custom event name for procaptcha ready const PROCAPTCHA_READY_EVENT = "procaptcha:ready"; +const IMPLICIT_RENDER_MARK = "data-procaptcha-rendered"; + +// The mark lives on the element rather than in this module so that a second +// copy of the bundle loaded on the same page skips the element too. +const claimImplicitElements = (): Element[] => { + const unclaimed = Array.from( + document.getElementsByClassName("procaptcha"), + ).filter((element) => !element.hasAttribute(IMPLICIT_RENDER_MARK)); + for (const element of unclaimed) { + element.setAttribute(IMPLICIT_RENDER_MARK, ""); + } + return unclaimed; +}; + +const isButton = (element: Element): boolean => + element.tagName.toLowerCase() === "button"; + // Implicit render for targeting all elements with class 'procaptcha' const implicitRender = async () => { - // Get elements with class 'procaptcha', not including buttons - const elements: Element[] = Array.from( - document.getElementsByClassName("procaptcha"), - ).filter((element) => element.tagName.toLowerCase() !== "button"); + const claimed = claimImplicitElements(); + const elements = claimed.filter((element) => !isButton(element)); // Set siteKey from renderOptions or from the first element's data-sitekey attribute if (elements.length) { @@ -181,9 +196,7 @@ const implicitRender = async () => { } // Check for invisible mode indicators (procaptcha class on buttons) - const invisibleButtons = Array.from( - document.getElementsByClassName("procaptcha"), - ).filter((button) => button.tagName.toLowerCase() === "button"); + const invisibleButtons = claimed.filter(isButton); if (invisibleButtons.length) { for (const button of invisibleButtons) { @@ -291,11 +304,15 @@ export default function ready(fn: () => void) { fn(); } else { console.log("DOMContentLoaded listener!"); - document.addEventListener("DOMContentLoaded", () => { - console.log("DOMContentLoaded fired"); - console.log(window); - fn(); - }); + document.addEventListener( + "DOMContentLoaded", + () => { + console.log("DOMContentLoaded fired"); + console.log(window); + fn(); + }, + { once: true }, + ); } } @@ -444,34 +461,17 @@ declare global { } } +// Boots straight away rather than on the script's `load` event: that event +// fires immediately after this script has run, however the script was added. const boot = () => { // onLoadUrlCallback defines the name of the callback function to be called when the script is loaded // onRenderExplicit takes values of either explicit or implicit const { onloadUrlCallback, renderExplicit } = extractParams(BUNDLE_NAMES); - if (renderExplicit === "explicit" && !onloadUrlCallback) return; - - // A script injected after the page loaded runs with readyState already - // "complete" and then receives its own `load` event, so both triggers - // below fire. Without the guard every implicit widget was mounted twice - // (the first copy orphaned but still running) and the onload callback ran - // twice. - let booted = false; - const run = () => { - if (booted) return; - booted = true; - if (renderExplicit !== "explicit") { - ready(implicitRender); - } - if (onloadUrlCallback) { - ready(getWindowCallback(onloadUrlCallback)); - } - }; - - getProcaptchaScript(BUNDLE_NAMES)?.addEventListener("load", run, { - once: true, - }); - if (document.readyState === "complete") { - run(); + if (renderExplicit !== "explicit") { + ready(implicitRender); + } + if (onloadUrlCallback) { + ready(getWindowCallback(onloadUrlCallback)); } }; @@ -501,10 +501,6 @@ const clearHost = (entry: WidgetEntry): void => { * skeleton stayed in the DOM with no checkbox inside it and no fresh captcha * request was ever made. Rebuilding from the stored descriptor makes reset * behave the same way on both paths. - * - * `boot()` is deliberately not called here any more. It re-renders implicit - * widgets, which would double up with the remount below, and it attaches - * another `load` listener to the script tag on every call. */ export const reset = async (widgetId?: string): Promise => { const ids: string[] = @@ -570,12 +566,19 @@ export const remove = (widgetId?: string): void => { entry.unbindTrigger?.(); entry.handle.destroy(); clearHost(entry); + entry.element.removeAttribute(IMPLICIT_RENDER_MARK); procaptchaWidgets.delete(id); } }; -// set the procaptcha attribute on the window -window.procaptcha = { ready, render, reset, remove, execute, start }; +// A second copy of the bundle leaves the first copy's API in place: only the +// first copy tracks the widgets it has already rendered, so reset() and +// remove() must keep reaching it. +const BUNDLE_LOADED = Symbol.for("@prosopo/procaptcha-bundle/loaded"); +if (!Reflect.has(window, BUNDLE_LOADED)) { + Reflect.set(window, BUNDLE_LOADED, true); + window.procaptcha = { ready, render, reset, remove, execute, start }; +} // Dispatch a custom event to notify that window.procaptcha is ready const procaptchaReadyEvent = new CustomEvent(PROCAPTCHA_READY_EVENT, { diff --git a/packages/procaptcha-bundle/src/tests/boot.unit.test.ts b/packages/procaptcha-bundle/src/tests/boot.unit.test.ts index f5c9a58873..49bc4bcd97 100644 --- a/packages/procaptcha-bundle/src/tests/boot.unit.test.ts +++ b/packages/procaptcha-bundle/src/tests/boot.unit.test.ts @@ -12,13 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -/** - * A script injected after the page has loaded (a tag manager, an SPA that - * lazy-loads the captcha) runs while `document.readyState` is already - * "complete", and its `load` event fires straight afterwards. Boot must render - * and call the onload callback once across both of those, not once for each. - */ - import type { ProcaptchaRenderOptions } from "@prosopo/types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CreatedWidget } from "../util/widgetFactory.js"; @@ -57,6 +50,7 @@ vi.mock("../util/widgetFactory.js", () => ({ })); const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP"; +const BUNDLE_LOADED = Symbol.for("@prosopo/procaptcha-bundle/loaded"); const flush = async (): Promise => { await new Promise((resolve: () => void) => setTimeout(resolve, 0)); @@ -74,8 +68,36 @@ const injectScript = (query: string): HTMLScriptElement => { return script; }; -beforeEach(() => { +const addWidgetElement = (): HTMLDivElement => { + const widget = document.createElement("div"); + widget.className = "procaptcha"; + widget.setAttribute("data-sitekey", SITE_KEY); + document.body.appendChild(widget); + return widget; +}; + +const setOnloadCallback = (): ReturnType void>> => { + const onload = vi.fn<() => void>(); + Reflect.set(window, "onProcaptchaLoad", onload); + return onload; +}; + +const stillLoading = (): void => { + vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); +}; + +const finishLoading = (): void => { + vi.restoreAllMocks(); + document.dispatchEvent(new Event("DOMContentLoaded")); +}; + +const loadBundle = async (): Promise => { vi.resetModules(); + await import("../index.js"); + await flush(); +}; + +beforeEach(() => { vi.clearAllMocks(); document.head.innerHTML = ""; document.body.innerHTML = ""; @@ -88,52 +110,133 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); Reflect.deleteProperty(window, "onProcaptchaLoad"); + Reflect.deleteProperty(window, "procaptcha"); + Reflect.deleteProperty(window, BUNDLE_LOADED); }); // The first import of the entry point transforms its whole dependency graph. -describe("boot after the page has loaded", { timeout: 60_000 }, () => { - it("renders implicit widgets once when the script's load event follows", async () => { +describe("boot", { timeout: 60_000 }, () => { + it("renders implicit widgets once when added after page load", async () => { expect(document.readyState).toBe("complete"); - const widget = document.createElement("div"); - widget.className = "procaptcha"; - widget.setAttribute("data-sitekey", SITE_KEY); - document.body.appendChild(widget); + addWidgetElement(); const script = injectScript(""); - await import("../index.js"); + await loadBundle(); script.dispatchEvent(new Event("load")); await flush(); expect(mocks.createWidgets).toHaveBeenCalledTimes(1); }); - it("calls the onload callback once when the script's load event follows", async () => { - const onload = vi.fn<() => void>(); - Reflect.set(window, "onProcaptchaLoad", onload); + it("calls the onload callback once when added after page load", async () => { + const onload = setOnloadCallback(); const script = injectScript("?render=explicit&onload=onProcaptchaLoad"); - await import("../index.js"); + await loadBundle(); script.dispatchEvent(new Event("load")); await flush(); expect(onload).toHaveBeenCalledTimes(1); }); - it("still boots from the load event when the page is still loading", async () => { - const readyState = vi - .spyOn(document, "readyState", "get") - .mockReturnValue("interactive"); - const onload = vi.fn<() => void>(); - Reflect.set(window, "onProcaptchaLoad", onload); - const script = injectScript("?render=explicit&onload=onProcaptchaLoad"); + it("renders implicit widgets and calls onload once each from one script", async () => { + addWidgetElement(); + const onload = setOnloadCallback(); + injectScript("?onload=onProcaptchaLoad"); - await import("../index.js"); - expect(onload).not.toHaveBeenCalled(); - readyState.mockRestore(); + await loadBundle(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); + expect(onload).toHaveBeenCalledTimes(1); + }); + + it("waits for the DOM when the page is still loading", async () => { + stillLoading(); + addWidgetElement(); + const onload = setOnloadCallback(); + const script = injectScript("?onload=onProcaptchaLoad"); + + await loadBundle(); script.dispatchEvent(new Event("load")); + expect(mocks.createWidgets).not.toHaveBeenCalled(); + expect(onload).not.toHaveBeenCalled(); + + finishLoading(); await flush(); + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); expect(onload).toHaveBeenCalledTimes(1); }); + + it("renders while the page is loading even when its script tag can't be found", async () => { + stillLoading(); + addWidgetElement(); + + await loadBundle(); + finishLoading(); + await flush(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); + }); }); + +describe( + "a second copy of the bundle on the same page", + { timeout: 60_000 }, + () => { + it("does not render widgets the first copy already rendered", async () => { + addWidgetElement(); + injectScript(""); + + await loadBundle(); + await loadBundle(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); + }); + + it("renders widgets added since the first copy loaded", async () => { + addWidgetElement(); + injectScript(""); + await loadBundle(); + + const added = addWidgetElement(); + await loadBundle(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(2); + expect(mocks.createWidgets.mock.lastCall?.[0]).toEqual([added]); + }); + + it("renders a widget again after the first copy removed it", async () => { + addWidgetElement(); + injectScript(""); + await loadBundle(); + + window.procaptcha.remove(); + await loadBundle(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(2); + }); + + it("keeps the first copy's window.procaptcha", async () => { + injectScript(""); + await loadBundle(); + const first = window.procaptcha; + + await loadBundle(); + + expect(window.procaptcha).toBe(first); + }); + + it("still calls its own onload callback", async () => { + const onload = setOnloadCallback(); + injectScript("?render=explicit&onload=onProcaptchaLoad"); + + await loadBundle(); + await loadBundle(); + + expect(onload).toHaveBeenCalledTimes(2); + }); + }, +);