Skip to content
Open
5 changes: 5 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ The following data attributes are supported on the ad placement element:
A placement identifier. If you define an ``id`` and :ref:`enable placements reporting <placements>`,
this will allow you to see reports for each ``id``.

``data-ea-priority`` (optional)
A numerical priority for the placement. If multiple placements on a page define a priority,
the server will choose only one ad to return for the group based on the priority and available inventory.
Setting a priority when the page has a single placement will have no effect.

``data-ea-style`` (optional)
Use a custom :ref:`placement style <placement-styles>`.

Expand Down
178 changes: 147 additions & 31 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,12 @@ export class Placement {
this.campaign_types = ["paid", "publisher-house", "community", "house"];
}

this.priority = options.priority || null;
this.div_id =
target.id ||
"ad_" + Date.now() + "_" + Math.floor(Math.random() * 1000000000);
this.fetchPromise = null;

// Initialized and will be used in the future
this.view_time = 0;
this.view_time_sent = false; // true once the view time is sent to the server
Expand Down Expand Up @@ -531,6 +537,7 @@ export class Placement {
const style = element.getAttribute(ATTR_PREFIX + "style");
const force_ad = element.getAttribute(ATTR_PREFIX + "force-ad");
const force_campaign = element.getAttribute(ATTR_PREFIX + "force-campaign");
const priority = element.getAttribute(ATTR_PREFIX + "priority");

// Add version to ad type to verison the HTML return
if (ad_type === "image" || ad_type === "text") {
Expand Down Expand Up @@ -558,6 +565,7 @@ export class Placement {
load_manually,
force_ad,
force_campaign,
priority,
});
}

Expand All @@ -574,7 +582,9 @@ export class Placement {
// Detect the keywords
this.keywords = this.keywords.concat(this.detectKeywords());

return this.fetch()
let fetchPromise = this.fetchPromise || this.fetch();
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a prioritized placement rotates (via the rotate() method which calls load() again), it will call this.fetchPromise || this.fetch(). However, the fetchPromise was set during initial grouping, and rotate() clears this.response but not this.fetchPromise. This means a rotated priority placement will reuse the old fetchPromise, which may no longer be valid. Additionally, the rotate() method doesn't re-trigger fetchGroup for priority placements, so they'll fetch individually instead of as a group. Consider either clearing fetchPromise on rotation or handling priority placement rotation differently.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is OK I think. What this means is that only the winning ad position can rotate. It doesn't go into a priority again with all the different ad placements.


return fetchPromise
.then((element) => {
if (element === undefined) {
throw new EthicalAdsWarning(
Expand Down Expand Up @@ -782,10 +792,7 @@ export class Placement {
// Make sure callbacks don't collide even with multiple placements
const callback =
"ad_" + Date.now() + "_" + Math.floor(Math.random() * 1000000);
var div_id = callback;
if (this.target.id) {
div_id = this.target.id;
}
var div_id = this.div_id;

// There's no hard maximum on URL lengths (all of these get added to the query params)
// but ideally we want to keep our URLs below ~2k which should work basically everywhere
Expand Down Expand Up @@ -841,6 +848,93 @@ export class Placement {
});
}

/* Fetch multiple grouped placements from the decision API
*
* @param {Array<Placement>} placements - Placements to fetch
*/
static fetchGroup(placements) {
if (!placements || !placements.length) return;
const callback =
"ad_" + Date.now() + "_" + Math.floor(Math.random() * 1000000);

const publisher = placements[0].publisher;

let group_keywords = [];
placements.forEach((p) => {
p.keywords = p.keywords.concat(p.detectKeywords());
group_keywords = group_keywords.concat(p.keywords);
});
group_keywords = [...new Set(group_keywords)];

let params = {
publisher: publisher,
ad_types: placements.map((p) => p.ad_type).join("|"),
div_ids: placements.map((p) => p.div_id).join("|"),
priorities: placements.map((p) => p.priority).join("|"),
callback: callback,
keywords: group_keywords.join("|"),
campaign_types: [
...new Set(
placements.reduce((acc, p) => acc.concat(p.campaign_types), [])
),
].join("|"),
format: "jsonp",
client_version: AD_CLIENT_VERSION,
placement_index: placements.map((p) => p.index).join("|"),
url: (window.location.origin + window.location.pathname).slice(0, 256),
};

const force_ad = placements.find((p) => p.force_ad)?.force_ad;
if (force_ad) params["force_ad"] = force_ad;
const force_campaign = placements.find(
(p) => p.force_campaign
)?.force_campaign;
if (force_campaign) params["force_campaign"] = force_campaign;

const max_rotations = Math.max(...placements.map((p) => p.rotations));
if (max_rotations > 1) params["rotations"] = max_rotations;

const url_params = new URLSearchParams(params);
const url = new URL(AD_DECISION_URL + "?" + url_params.toString());

const promise = new Promise((resolve, reject) => {
window[callback] = (response) => {
resolve(response);
};

var script = document.createElement("script");
script.src = url;
script.type = "text/javascript";
script.async = true;
script.addEventListener("error", (err) => {
resolve();
});
document.getElementsByTagName("head")[0].appendChild(script);
Comment on lines +940 to +951
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetchGroup method doesn't have a reject path or error handling in the Promise. If the script fails to load or the callback is never called, the promise will hang indefinitely. The regular fetch() method resolves with undefined on script error (line 845), but fetchGroup's error handler only resolves without a value (line 910), and the per-placement promises in line 916 will then try to check response.html and response.view_url on an undefined response, which could cause issues. Consider ensuring consistent error handling between fetch() and fetchGroup().

Suggested change
window[callback] = (response) => {
resolve(response);
};
var script = document.createElement("script");
script.src = url;
script.type = "text/javascript";
script.async = true;
script.addEventListener("error", (err) => {
resolve();
});
document.getElementsByTagName("head")[0].appendChild(script);
let script = null;
let timeoutId = null;
const cleanup = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (script && script.parentNode) {
script.parentNode.removeChild(script);
}
try {
delete window[callback];
} catch (e) {
// Older browsers may not allow delete on window properties
window[callback] = undefined;
}
};
window[callback] = (response) => {
cleanup();
resolve(response);
};
script = document.createElement("script");
script.src = url;
script.type = "text/javascript";
script.async = true;
script.addEventListener("error", (err) => {
cleanup();
// Resolve with undefined on script error, consistent with single-placement fetch
resolve();
});
document.getElementsByTagName("head")[0].appendChild(script);
// Fallback timeout in case neither callback nor error is triggered
timeoutId = window.setTimeout(() => {
cleanup();
resolve();
}, 10000);

Copilot uses AI. Check for mistakes.
});

placements.forEach((placement, idx) => {
placement.fetchPromise = promise.then((response) => {
let is_winner = false;
if (response && response.html && response.view_url) {
if (response.div_id) {
is_winner = response.div_id === placement.div_id;
} else {
is_winner = idx === 0;
}
}

if (is_winner) {
placement.response = response;
const node_convert = document.createElement("div");
node_convert.innerHTML = response.html;
return node_convert.firstChild;
} else {
return null;
}
});
});
}

/* Sends the view time of the ad to the server
*/
sendViewTime() {
Expand Down Expand Up @@ -1029,39 +1123,61 @@ export function load_placements(force_load = false) {
logger.warn("No ad placements found.");
}

// Create main promise. Iterator `all()` Promise will surround array of found
// elements. If any of these elements have issues, this main promise will
// reject.
return Promise.all(
elements.map((element, index) => {
const placement = Placement.from_element(element);
let placements = elements.map((element, index) => {
const placement = Placement.from_element(element);
if (!placement) return null;
placement.index = index;
return placement;
});

if (!placement) {
// Placement has already been loaded
return null;
// Run AcceptableAds detection code once for the first valid placement
const first_placement = placements.find((p) => p !== null);
if (first_placement && !force_load) {
first_placement.detectABP(ABP_DETECTION_PX, function (usesABP) {
uplifted = usesABP;
if (usesABP) {
logger.debug(
"Acceptable Ads enabled. Thanks for allowing our non-tracking ads :)"
);
}
});
}

placement.index = index;

// Run AcceptableAds detection code
// This lets us know how many impressions are attributed to AceeptableAds
// Only run this once even for multiple placements
// All impressions will be correctly attributed
if (index === 0 && placement && !force_load) {
placement.detectABP(ABP_DETECTION_PX, function (usesABP) {
uplifted = usesABP;
if (usesABP) {
logger.debug(
"Acceptable Ads enabled. Thanks for allowing our non-tracking ads :)"
);
}
});
// Group prioritized placements
let priority_groups = {};
placements.forEach((placement) => {
if (
placement &&
placement.priority !== null &&
(force_load || !placement.load_manually)
) {
if (!priority_groups[placement.publisher]) {
priority_groups[placement.publisher] = [];
}
priority_groups[placement.publisher].push(placement);
}
});

Object.values(priority_groups).forEach((group) => {
if (group.length > 0) {
Placement.fetchGroup(group);
}
});

// Create main promise. Iterator `all()` Promise will surround array of
// placements.
return Promise.all(
placements.map((placement) => {
if (placement && (force_load || !placement.load_manually)) {
return placement.load();
return placement.load().catch((err) => {
if (err instanceof EthicalAdsWarning) {
logger.warn(err.message);
} else {
logger.error(err.message);
}
return null;
});
} else {
// This will be manually loaded later or has already been loaded
return null;
}
})
Expand Down
71 changes: 71 additions & 0 deletions tests/priority-placement.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<html>
<body>
<!-- 3 placements, two with priorities -->
<!-- There will be 2 calls to the ad server: one for ad1 and ad2, and one for ad3 -->

<div id="ad1" data-ea-publisher="test" data-ea-priority="1"></div>
<div id="ad2" data-ea-publisher="test" data-ea-priority="2"></div>
<!-- standalone ad -->
<div id="ad3" data-ea-publisher="test"></div>

<script type="module">
import { expect } from "@open-wc/testing";
import { runTests } from "@web/test-runner-mocha";
import { default as sinon } from "sinon";

import { wait, Placement } from "../index";
import { mockAdDecision } from "./common.inc.js";

// Mock the regular fetch for ad3
let fetchStub = mockAdDecision();

// Mock the fetchGroup for ad1 and ad2
let groupStub = sinon
.stub(Placement, "fetchGroup")
.callsFake((placements) => {
// Let's declare the second one as the winner, as if its priority won
let winner = placements.find((p) => p.target.id === "ad2");
placements.forEach((p) => {
p.fetchPromise = Promise.resolve().then(() => {
if (p === winner) {
const response_html =
"<div class='ad-rendered'><!-- A real ad would be here normally --></div>";
const elem_placement = document.createElement("div");
elem_placement.innerHTML = response_html;
return elem_placement.firstChild;
}
return null;
});
});
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test mock for fetchGroup doesn't set placement.response for the winner, only returns the DOM element. However, the real fetchGroup implementation (line 927) sets placement.response = response before returning the element. The test should also set response on the winner placement to more accurately simulate the real behavior, especially since other methods like inViewport() depend on this.response being set.

Copilot uses AI. Check for mistakes.
});

runTests(async () => {
describe("EthicalAds library", () => {
it("loads prioritized placements correctly", async () => {
const placements = await wait;
// Placements that resolved with ads: only ad2 and ad3
expect(placements.length).to.equal(2);
expect(placements[0].target.id).to.equal("ad2");
expect(placements[1].target.id).to.equal("ad3");

// Look at DOM directly
const ad1 = document.getElementById("ad1");
const ad2 = document.getElementById("ad2");
const ad3 = document.getElementById("ad3");

// ad1 should NOT have the loaded class
expect(ad1.className).to.not.include("loaded");

// ad2 and ad3 should have the loaded class
expect(ad2.className).to.include("loaded");
expect(ad3.className).to.include("loaded");

// fetchGroup should have been called once with a group of 2
expect(groupStub.calledOnce).to.be.true;
expect(groupStub.firstCall.args[0].length).to.equal(2);
});
Comment on lines +47 to +68
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only covers the happy path where fetchGroup is called with two prioritized placements. Consider adding tests for edge cases such as: a single prioritized placement (should it use fetchGroup or regular fetch?), mixed publishers with priorities, priority placements with load_manually=true, and rotation behavior of priority placements. These scenarios aren't covered by the current test.

Copilot uses AI. Check for mistakes.
});
});
</script>
</body>
</html>
Loading