diff --git a/.changeset/bundle-boot-once.md b/.changeset/bundle-boot-once.md new file mode 100644 index 0000000000..67383f3ccd --- /dev/null +++ b/.changeset/bundle-boot-once.md @@ -0,0 +1,9 @@ +--- +"@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. + +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 1743d7b9a5..ad4229e93f 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 { siteIntegration } from "./util/integration.js"; import { siteLanguage } from "./util/language.js"; import { resolveStartMode } from "./util/startMode.js"; @@ -123,12 +123,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) { @@ -185,9 +200,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) { @@ -297,11 +310,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 }, + ); } } @@ -450,36 +467,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); - 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) { - ready(implicitRender); - } + 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); - } + ready(getWindowCallback(onloadUrlCallback)); } }; @@ -509,10 +507,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[] = @@ -578,12 +572,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 new file mode 100644 index 0000000000..49bc4bcd97 --- /dev/null +++ b/packages/procaptcha-bundle/src/tests/boot.unit.test.ts @@ -0,0 +1,242 @@ +// 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. + +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 BUNDLE_LOADED = Symbol.for("@prosopo/procaptcha-bundle/loaded"); + +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; +}; + +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 = ""; + mocks.createWidgets.mockImplementation(async (containers: Element[]) => + containers.map(() => ({ + handle: { destroy: vi.fn<() => void>() }, + container: document.createElement("div"), + })), + ); +}); + +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", { timeout: 60_000 }, () => { + it("renders implicit widgets once when added after page load", async () => { + expect(document.readyState).toBe("complete"); + addWidgetElement(); + const script = injectScript(""); + + await loadBundle(); + script.dispatchEvent(new Event("load")); + await flush(); + + expect(mocks.createWidgets).toHaveBeenCalledTimes(1); + }); + + it("calls the onload callback once when added after page load", async () => { + const onload = setOnloadCallback(); + const script = injectScript("?render=explicit&onload=onProcaptchaLoad"); + + await loadBundle(); + script.dispatchEvent(new Event("load")); + await flush(); + + expect(onload).toHaveBeenCalledTimes(1); + }); + + it("renders implicit widgets and calls onload once each from one script", async () => { + addWidgetElement(); + const onload = setOnloadCallback(); + injectScript("?onload=onProcaptchaLoad"); + + 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); + }); + }, +);