Make consent API calls fire-and-forget to avoid blocking UI

The server-side consent record is for audit purposes and does not need
to complete before the UI responds. Local state (cookie, script
activation) is applied synchronously, and the API call runs in the
background with queue fallback on failure.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-22 10:29:28 +04:00
parent 140e614e4f
commit c942aaa7bb
4 changed files with 57 additions and 100 deletions

View File

@@ -314,53 +314,29 @@ function deactivateElement(el: Element, label?: string): void {
}
}
const KNOWN_MULTI_PART_TLDS = new Set([
"co.uk",
"co.jp",
"co.kr",
"co.nz",
"co.za",
"co.in",
"co.id",
"com.au",
"com.br",
"com.cn",
"com.mx",
"com.tw",
"com.hk",
"com.sg",
"com.ar",
"com.co",
"com.tr",
"net.au",
"org.uk",
"org.au",
"ac.uk",
"gov.uk",
"ne.jp",
"or.jp",
]);
function getRootDomain(hostname: string): string {
function getCandidateDomains(hostname: string): string[] {
const parts = hostname.split(".");
if (parts.length <= 2) {
return parts.length === 2 ? "." + hostname : hostname;
if (parts.length <= 1) return [];
const candidates: string[] = [];
// Try progressively broader parent domains. The browser silently
// ignores attempts to clear cookies on public suffixes, so
// over-trying is safe and avoids maintaining a TLD list.
for (let i = 0; i < parts.length - 1; i++) {
candidates.push("." + parts.slice(i).join("."));
}
const lastTwo = parts.slice(-2).join(".");
if (KNOWN_MULTI_PART_TLDS.has(lastTwo)) {
return "." + parts.slice(-3).join(".");
}
return "." + lastTwo;
return candidates;
}
function removeCookies(names: string[]): void {
const rootDomain = getRootDomain(location.hostname);
const domains = getCandidateDomains(location.hostname);
for (const name of names) {
document.cookie = `${name}=; path=/; max-age=0`;
document.cookie = `${name}=; path=/; domain=${rootDomain}; max-age=0`;
for (const domain of domains) {
document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`;
}
}
}

View File

@@ -153,7 +153,7 @@ export class CookieBannerClient {
return this.consent !== null;
}
async acceptAll(): Promise<ConsentRecord> {
acceptAll(): void {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
@@ -161,10 +161,10 @@ export class CookieBannerClient {
consentData[cat.name] = true;
}
return this.recordConsent("ACCEPT_ALL", consentData);
this.recordConsent("ACCEPT_ALL", consentData);
}
async rejectAll(): Promise<ConsentRecord> {
rejectAll(): void {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
@@ -172,12 +172,10 @@ export class CookieBannerClient {
consentData[cat.name] = cat.kind === "NECESSARY";
}
return this.recordConsent("REJECT_ALL", consentData);
this.recordConsent("REJECT_ALL", consentData);
}
async customize(
categories: Record<string, boolean>,
): Promise<ConsentRecord> {
customize(categories: Record<string, boolean>): void {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
@@ -185,39 +183,21 @@ export class CookieBannerClient {
consentData[cat.name] = cat.kind === "NECESSARY" || !!categories[cat.name];
}
return this.recordConsent("CUSTOMIZE", consentData);
this.recordConsent("CUSTOMIZE", consentData);
}
private async recordConsent(
private recordConsent(
action: ConsentAction,
consentData: Record<string, boolean>,
): Promise<ConsentRecord> {
): void {
const cfg = this.config;
const url = new URL(`${this.bannerId}/consents`, this.baseUrl);
const body = {
visitor_id: this.visitorId,
version: cfg.version,
action,
consent_data: consentData,
};
let record: ConsentRecord | null = null;
try {
record = await fetchJSON<ConsentRecord>(url, {
method: "POST",
body,
});
void flush(this.bannerId);
} catch {
enqueue(this.bannerId, url.href, body);
}
this.consent = {
visitor_id: this.visitorId,
version: cfg.version,
action,
consent_data: consentData,
created_at: record?.created_at ?? "",
created_at: "",
};
setConsentCookie(
@@ -232,12 +212,16 @@ export class CookieBannerClient {
this.activate(consentData);
return record ?? {
id: "",
const url = new URL(`${this.bannerId}/consents`, this.baseUrl);
const body = {
visitor_id: this.visitorId,
version: cfg.version,
action,
created_at: "",
consent_data: consentData,
};
void fetchJSON<ConsentRecord>(url, { method: "POST", body })
.then(() => void flush(this.bannerId))
.catch(() => enqueue(this.bannerId, url.href, body));
}
private activate(consentData: Record<string, boolean>): void {

View File

@@ -34,32 +34,30 @@ class ProboActionButton extends ProboElement {
export class ProboAcceptButton extends ProboActionButton {
protected handleClick = (): void => {
if (!this.root) return;
void this.root.client.acceptAll().then(() => {
this.root!.setState("hidden");
this.root!.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "ACCEPT_ALL" },
}),
);
});
this.root.client.acceptAll();
this.root.setState("hidden");
this.root.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "ACCEPT_ALL" },
}),
);
};
}
export class ProboRejectButton extends ProboActionButton {
protected handleClick = (): void => {
if (!this.root) return;
void this.root.client.rejectAll().then(() => {
this.root!.setState("hidden");
this.root!.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "REJECT_ALL" },
}),
);
});
this.root.client.rejectAll();
this.root.setState("hidden");
this.root.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "REJECT_ALL" },
}),
);
};
}

View File

@@ -78,15 +78,14 @@ export class ProboSaveButton extends ProboElement {
private handleClick = (): void => {
if (!this.root) return;
const draft = { ...this.root.consentDraft };
void this.root.client.customize(draft).then(() => {
this.root!.setState("hidden");
this.root!.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "CUSTOMIZE", consent_data: draft },
}),
);
});
this.root.client.customize(draft);
this.root.setState("hidden");
this.root.dispatchEvent(
new CustomEvent("probo-consent", {
bubbles: true,
composed: true,
detail: { action: "CUSTOMIZE", consent_data: draft },
}),
);
};
}