Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bundle-boot-once.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 43 additions & 42 deletions packages/procaptcha-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 },
);
}
}

Expand Down Expand Up @@ -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));
}
};

Expand Down Expand Up @@ -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<void> => {
const ids: string[] =
Expand Down Expand Up @@ -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, {
Expand Down
242 changes: 242 additions & 0 deletions packages/procaptcha-bundle/src/tests/boot.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<CreatedWidget[]>
>(),
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<void> => {
await new Promise<void>((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<typeof vi.fn<() => 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<void> => {
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);
});
},
);
Loading