Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prebid Core: add documentResolver callback and allow the user to supply a different document object to render #8262

Merged
merged 11 commits into from
Apr 12, 2022
23 changes: 16 additions & 7 deletions modules/appnexusBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
logInfo,
logMessage,
logWarn,
transformBidderParamKeywords
transformBidderParamKeywords,
getWindowFromDocument
} from '../src/utils.js';
import {Renderer} from '../src/Renderer.js';
import {config} from '../src/config.js';
Expand Down Expand Up @@ -699,7 +700,10 @@ function newBid(serverBid, rtbBid, bidderRequest) {

if (rtbBid.renderer_url) {
const videoBid = find(bidderRequest.bids, bid => bid.bidId === serverBid.uuid);
const rendererOptions = deepAccess(videoBid, 'renderer.options');
let rendererOptions = deepAccess(videoBid, 'mediaTypes.video.renderer.options'); // mediaType definition has preference (shouldn't options be .config?)
if (!rendererOptions) {
rendererOptions = deepAccess(videoBid, 'renderer.options'); // second the adUnit definition has preference (shouldn't options be .config?)
}
bid.renderer = newRenderer(bid.adUnitCode, rtbBid, rendererOptions);
}
break;
Expand Down Expand Up @@ -1120,9 +1124,13 @@ function buildNativeRequest(params) {
* @param {string} elementId element id
*/
function hidedfpContainer(elementId) {
var el = document.getElementById(elementId).querySelectorAll("div[id^='google_ads']");
if (el[0]) {
el[0].style.setProperty('display', 'none');
try {
const el = document.getElementById(elementId).querySelectorAll("div[id^='google_ads']");
if (el[0]) {
el[0].style.setProperty('display', 'none');
}
} catch (e) {
// element not found!
}
}

Expand All @@ -1138,12 +1146,13 @@ function hideSASIframe(elementId) {
}
}

function outstreamRender(bid) {
function outstreamRender(bid, doc) {
hidedfpContainer(bid.adUnitCode);
hideSASIframe(bid.adUnitCode);
// push to render queue because ANOutstreamVideo may not be loaded yet
bid.renderer.push(() => {
window.ANOutstreamVideo.renderAd({
const win = getWindowFromDocument(doc) || window;
win.ANOutstreamVideo.renderAd({
tagId: bid.adResponse.tag_id,
sizes: [bid.getSize().split('x')],
targetId: bid.adUnitCode, // target div id to render video
Expand Down
15 changes: 12 additions & 3 deletions src/Renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Renderer(options) {
if (!isRendererPreferredFromAdUnit(adUnitCode)) {
// we expect to load a renderer url once only so cache the request to load script
this.cmd.unshift(runRender) // should render run first ?
loadExternalScript(url, moduleCode, this.callback);
loadExternalScript(url, moduleCode, this.callback, this.documentContext);
} else {
logWarn(`External Js not loaded by Renderer since renderer url and callback is already defined on adUnit ${adUnitCode}`);
runRender()
Expand Down Expand Up @@ -112,9 +112,18 @@ export function isRendererRequired(renderer) {
* Render the bid returned by the adapter
* @param {Object} renderer Renderer object installed by adapter
* @param {Object} bid Bid response
* @param {Document} doc context document of bid
*/
export function executeRenderer(renderer, bid) {
renderer.render(bid);
export function executeRenderer(renderer, bid, doc) {
let docContext = null;
if (renderer.config && renderer.config.documentResolver) {
docContext = renderer.config.documentResolver(bid, document, doc);// a user provided callback, which should return a Document, and expect the parameters; bid, sourceDocument, renderDocument
}
if (!docContext) {
docContext = document;
}
renderer.documentContext = docContext;
renderer.render(bid, renderer.documentContext);
}

function isRendererPreferredFromAdUnit(adUnitCode) {
Expand Down
56 changes: 39 additions & 17 deletions src/adloader.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {includes} from './polyfill.js';
import { logError, logWarn, insertElement } from './utils.js';

const _requestCache = {};
const _requestCache = new WeakMap();
// The below list contains modules or vendors whom Prebid allows to load external JS.
const _approvedLoadExternalJSList = [
'adloox',
Expand All @@ -18,9 +18,10 @@ const _approvedLoadExternalJSList = [
* Each unique URL will be loaded at most 1 time.
* @param {string} url the url to load
* @param {string} moduleCode bidderCode or module code of the module requesting this resource
* @param {function} [callback] callback function to be called after the script is loaded.
* @param {function} [callback] callback function to be called after the script is loaded
* @param {Document} [doc] the context document, in which the script will be loaded, defaults to loaded document
*/
export function loadExternalScript(url, moduleCode, callback) {
export function loadExternalScript(url, moduleCode, callback, doc) {
if (!moduleCode || !url) {
logError('cannot load external script without url and moduleCode');
return;
Expand All @@ -29,46 +30,60 @@ export function loadExternalScript(url, moduleCode, callback) {
logError(`${moduleCode} not whitelisted for loading external JavaScript`);
return;
}
if (!doc) {
doc = document; // provide a "valid" key for the WeakMap
}
// only load each asset once
if (_requestCache[url]) {
const storedCachedObject = getCacheObject(doc, url);
if (storedCachedObject) {
if (callback && typeof callback === 'function') {
if (_requestCache[url].loaded) {
if (storedCachedObject.loaded) {
// invokeCallbacks immediately
callback();
} else {
// queue the callback
_requestCache[url].callbacks.push(callback);
storedCachedObject.callbacks.push(callback);
}
}
return _requestCache[url].tag;
return storedCachedObject.tag;
}
_requestCache[url] = {
const cachedDocObj = _requestCache.get(doc) || {};
const cacheObject = {
loaded: false,
tag: null,
callbacks: []
};
cachedDocObj[url] = cacheObject;
_requestCache.set(doc, cachedDocObj);

if (callback && typeof callback === 'function') {
_requestCache[url].callbacks.push(callback);
cacheObject.callbacks.push(callback);
}

logWarn(`module ${moduleCode} is loading external JavaScript`);
return requestResource(url, function () {
_requestCache[url].loaded = true;
cacheObject.loaded = true;
try {
for (let i = 0; i < _requestCache[url].callbacks.length; i++) {
_requestCache[url].callbacks[i]();
for (let i = 0; i < cacheObject.callbacks.length; i++) {
cacheObject.callbacks[i]();
}
} catch (e) {
logError('Error executing callback', 'adloader.js:loadExternalScript', e);
}
});
}, doc);

function requestResource(tagSrc, callback) {
var jptScript = document.createElement('script');
function requestResource(tagSrc, callback, doc) {
if (!doc) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

here you check that doc and cacheObject are defined before using them. It doesn't hurt, except maybe for adding a couple bytes, but they should always be defined here - conceptually, if you're going to check for that, it would make more sense to throw an error when they are undefined (but I wouldn't do that either).

It's not that consequential so I don't mind if it stays this way, just noting it as an odd choice IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It depends, doc being not set defaults to the loaded document, which in theory does also happen down the line in insertElement and some other methods. But now it's specific from here on. But i do agree it could also be an Argument Exception if there is a need to be specific about this.
The cacheObject could theoeratically be gone for what ever reason it's resolved there. This could actually be an error, since the tag property won't be set, but the callback would flag the object as loaded, which is sort of inconsistent. But not failing there and just loading the script, without failing there is i think not that troublesome.

doc = document;
}
var jptScript = doc.createElement('script');
jptScript.type = 'text/javascript';
jptScript.async = true;

_requestCache[url].tag = jptScript;
const cacheObject = getCacheObject(doc, url);
if (cacheObject) {
cacheObject.tag = jptScript;
}

if (jptScript.readyState) {
jptScript.onreadystatechange = function () {
Expand All @@ -86,8 +101,15 @@ export function loadExternalScript(url, moduleCode, callback) {
jptScript.src = tagSrc;

// add the new script tag to the page
insertElement(jptScript);
insertElement(jptScript, doc);

return jptScript;
}
function getCacheObject(doc, url) {
const cachedDocObj = _requestCache.get(doc);
if (cachedDocObj && cachedDocObj[url]) {
return cachedDocObj[url];
}
return null; // return new cache object?
}
};
3 changes: 2 additions & 1 deletion src/auction.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,8 @@ function getPreparedBidForAuction({adUnitCode, bid, auctionId}, {index = auction
}

if (renderer) {
bidObject.renderer = Renderer.install({ url: renderer.url });
// be aware, an adapter could already have installed the bidder, in which case this overwrite's the existing adapter
bidObject.renderer = Renderer.install({ url: renderer.url, config: renderer.options });// rename options to config, to make it consistent?
bidObject.renderer.setRender(renderer.render);
}

Expand Down
2 changes: 1 addition & 1 deletion src/prebid.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ $$PREBID_GLOBAL$$.renderAd = hook('async', function (doc, id, options) {
insertElement(creativeComment, doc, 'html');

if (isRendererRequired(renderer)) {
executeRenderer(renderer, bid);
executeRenderer(renderer, bid, doc);
reinjectNodeIfRemoved(creativeComment, doc, 'html');
emitAdRenderSucceeded({ doc, bid, id });
} else if ((doc === document && !inIframe()) || mediaType === 'video') {
Expand Down
9 changes: 9 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1355,3 +1355,12 @@ export function cyrb53Hash(str, seed = 0) {
h2 = imul(h2 ^ (h2 >>> 16), 2246822507) ^ imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString();
}

/**
* returns a window object, which holds the provided document or null
* @param {Document} doc
* @returns {Window}
*/
export function getWindowFromDocument(doc) {
return (doc) ? doc.defaultView : null;
}
30 changes: 30 additions & 0 deletions test/spec/adloader_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,35 @@ describe('adLoader', function () {
adLoader.loadExternalScript('someURL1', 'criteo', callback);
expect(utilsinsertElementStub.called).to.be.true;
});

it('requires a url to be included once per document', function () {
function getDocSpec() {
return {
createElement: function() {
return {

}
},
getElementsByTagName: function() {
return {
firstChild: {
insertBefore: function() {

}
}
}
}

}
}
const doc1 = getDocSpec();
const doc2 = getDocSpec();
adLoader.loadExternalScript('someURL', 'criteo', () => {}, doc1);
adLoader.loadExternalScript('someURL', 'criteo', () => {}, doc1);
adLoader.loadExternalScript('someURL', 'criteo', () => {}, doc1);
adLoader.loadExternalScript('someURL', 'criteo', () => {}, doc2);
adLoader.loadExternalScript('someURL', 'criteo', () => {}, doc2);
expect(utilsinsertElementStub.callCount).to.equal(2);
});
});
});
17 changes: 16 additions & 1 deletion test/spec/renderer_spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from 'chai';
import { Renderer } from 'src/Renderer.js';
import { Renderer, executeRenderer } from 'src/Renderer.js';
import * as utils from 'src/utils.js';
import { loadExternalScript } from 'src/adloader.js';
require('test/mocks/adloaderStub.js');
Expand Down Expand Up @@ -212,5 +212,20 @@ describe('Renderer', function () {
testRenderer.render()
expect(loadExternalScript.called).to.be.true;
});

it('call\'s documentResolver when configured', function () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add a case to adloater_spec to check that when the same script, but different document is passed to loadExternalScript, it does not hit the cache?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

const documentResolver = sinon.spy(function(bid, sDoc, tDoc) {
return document;
});

let testRenderer = Renderer.install({
url: 'https://httpbin.org/post',
config: { documentResolver: documentResolver }
});

executeRenderer(testRenderer, {}, {});

expect(documentResolver.called).to.be.true;
});
});
});