From 370d4a842bd7c011f10d899d786beaf31d544b1b Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 14 Jan 2025 13:37:26 -0800 Subject: [PATCH 1/2] nativeRendering: fix bug where click trackers are not fired --- creative/renderers/native/renderer.js | 37 ++++++++++--- .../creative-renderer-native/renderer.js | 2 +- modules/nativeRendering.js | 3 +- src/native.js | 9 ++- test/spec/creative/nativeRenderer_spec.js | 55 +++++++++++++++---- test/spec/native_spec.js | 6 +- 6 files changed, 87 insertions(+), 25 deletions(-) diff --git a/creative/renderers/native/renderer.js b/creative/renderers/native/renderer.js index 5cc8f100108..b88d1126b25 100644 --- a/creative/renderers/native/renderer.js +++ b/creative/renderers/native/renderer.js @@ -45,6 +45,16 @@ function loadScript(url, doc) { }); } +function getRenderFrames(node) { + return Array.from(node.querySelectorAll('iframe[srcdoc*="render"]')) +} + +function getInnerHTML(node) { + const clone = node.cloneNode(true); + getRenderFrames(clone).forEach(node => node.parentNode.removeChild(node)); + return clone.innerHTML; +} + export function getAdMarkup(adId, nativeData, replacer, win, load = loadScript) { const {rendererUrl, assets, ortb, adTemplate} = nativeData; const doc = win.document; @@ -58,21 +68,32 @@ export function getAdMarkup(adId, nativeData, replacer, win, load = loadScript) return win.renderAd(payload); }); } else { - return Promise.resolve(replacer(adTemplate ?? doc.body.innerHTML)); + return Promise.resolve(replacer(adTemplate ?? getInnerHTML(doc.body))); } } export function render({adId, native}, {sendMessage}, win, getMarkup = getAdMarkup) { const {head, body} = win.document; - const resize = () => sendMessage(MESSAGE_NATIVE, { - action: ACTION_RESIZE, - height: body.offsetHeight, - width: body.offsetWidth - }); + const resize = () => { + // force redraw - for some reason this is needed to get the right dimensions + body.style.display = 'none'; + body.style.display = 'block'; + sendMessage(MESSAGE_NATIVE, { + action: ACTION_RESIZE, + height: body.offsetHeight, + width: body.offsetWidth + }); + } + const renderFrames = Array.from(win.document.querySelectorAll('iframe[srcdoc*="render"]')); + function replaceMarkup(target, markup) { + // do not remove the rendering logic if it's embedded in this window; things will break otherwise + Array.from(target.childNodes).filter(node => !renderFrames.includes(node)).forEach(node => target.removeChild(node)); + target.insertAdjacentHTML('afterbegin', markup); + } const replacer = getReplacer(adId, native); - head && (head.innerHTML = replacer(head.innerHTML)); + replaceMarkup(head, replacer(getInnerHTML(head))); return getMarkup(adId, native, replacer, win).then(markup => { - body.innerHTML = markup; + replaceMarkup(body, markup); if (typeof win.postRenderAd === 'function') { win.postRenderAd({adId, ...native}); } diff --git a/libraries/creative-renderer-native/renderer.js b/libraries/creative-renderer-native/renderer.js index d7d85cdd7ba..bd9076dddaf 100644 --- a/libraries/creative-renderer-native/renderer.js +++ b/libraries/creative-renderer-native/renderer.js @@ -1,2 +1,2 @@ // this file is autogenerated, see creative/README.md -export const RENDERER = "(()=>{\"use strict\";const e=\"Prebid Native\",t={title:\"text\",data:\"value\",img:\"url\",video:\"vasttag\"};function n(e,t){return new Promise(((n,r)=>{const i=t.createElement(\"script\");i.onload=n,i.onerror=r,i.src=e,t.body.appendChild(i)}))}function r(e,t,r,i,o=n){const{rendererUrl:s,assets:a,ortb:d,adTemplate:c}=t,l=i.document;return s?o(s,l).then((()=>{if(\"function\"!=typeof i.renderAd)throw new Error(`Renderer from '${s}' does not define renderAd()`);const e=a||[];return e.ortb=d,i.renderAd(e)})):Promise.resolve(r(c??l.body.innerHTML))}window.render=function({adId:n,native:i},{sendMessage:o},s,a=r){const{head:d,body:c}=s.document,l=()=>o(e,{action:\"resizeNativeHeight\",height:c.offsetHeight,width:c.offsetWidth}),u=function(e,{assets:n=[],ortb:r,nativeKeys:i={}}){const o=Object.fromEntries(n.map((({key:e,value:t})=>[e,t])));let s=Object.fromEntries(Object.entries(i).flatMap((([t,n])=>{const r=o.hasOwnProperty(t)?o[t]:void 0;return[[`##${n}##`,r],[`${n}:${e}`,r]]})));return r&&Object.assign(s,{\"##hb_native_linkurl##\":r.link?.url,\"##hb_native_privacy##\":r.privacy},Object.fromEntries((r.assets||[]).flatMap((e=>{const n=Object.keys(t).find((t=>e[t]));return[n&&[`##hb_native_asset_id_${e.id}##`,e[n][t[n]]],e.link?.url&&[`##hb_native_asset_link_id_${e.id}##`,e.link.url]].filter((e=>e))})))),s=Object.entries(s).concat([[/##hb_native_asset_(link_)?id_\\d+##/g]]),function(e){return s.reduce(((e,[t,n])=>e.replaceAll(t,n||\"\")),e)}}(n,i);return d&&(d.innerHTML=u(d.innerHTML)),a(n,i,u,s).then((t=>{c.innerHTML=t,\"function\"==typeof s.postRenderAd&&s.postRenderAd({adId:n,...i}),s.document.querySelectorAll(\".pb-click\").forEach((t=>{const n=t.getAttribute(\"hb_native_asset_id\");t.addEventListener(\"click\",(()=>o(e,{action:\"click\",assetId:n})))})),o(e,{action:\"fireNativeImpressionTrackers\"}),\"complete\"===s.document.readyState?l():s.onload=l}))}})();" \ No newline at end of file +export const RENDERER = "(()=>{\"use strict\";const e=\"Prebid Native\",t={title:\"text\",data:\"value\",img:\"url\",video:\"vasttag\"};function r(e,t){return new Promise(((r,n)=>{const i=t.createElement(\"script\");i.onload=r,i.onerror=n,i.src=e,t.body.appendChild(i)}))}function n(e){const t=e.cloneNode(!0);return function(e){return Array.from(e.querySelectorAll('iframe[srcdoc*=\"render\"]'))}(t).forEach((e=>e.parentNode.removeChild(e))),t.innerHTML}function i(e,t,i,o,s=r){const{rendererUrl:c,assets:d,ortb:a,adTemplate:l}=t,u=o.document;return c?s(c,u).then((()=>{if(\"function\"!=typeof o.renderAd)throw new Error(`Renderer from '${c}' does not define renderAd()`);const e=d||[];return e.ortb=a,o.renderAd(e)})):Promise.resolve(i(l??n(u.body)))}window.render=function({adId:r,native:o},{sendMessage:s},c,d=i){const{head:a,body:l}=c.document,u=()=>{l.style.display=\"none\",l.style.display=\"block\",s(e,{action:\"resizeNativeHeight\",height:l.offsetHeight,width:l.offsetWidth})},f=Array.from(c.document.querySelectorAll('iframe[srcdoc*=\"render\"]'));function b(e,t){Array.from(e.childNodes).filter((e=>!f.includes(e))).forEach((t=>e.removeChild(t))),e.insertAdjacentHTML(\"afterbegin\",t)}const h=function(e,{assets:r=[],ortb:n,nativeKeys:i={}}){const o=Object.fromEntries(r.map((({key:e,value:t})=>[e,t])));let s=Object.fromEntries(Object.entries(i).flatMap((([t,r])=>{const n=o.hasOwnProperty(t)?o[t]:void 0;return[[`##${r}##`,n],[`${r}:${e}`,n]]})));return n&&Object.assign(s,{\"##hb_native_linkurl##\":n.link?.url,\"##hb_native_privacy##\":n.privacy},Object.fromEntries((n.assets||[]).flatMap((e=>{const r=Object.keys(t).find((t=>e[t]));return[r&&[`##hb_native_asset_id_${e.id}##`,e[r][t[r]]],e.link?.url&&[`##hb_native_asset_link_id_${e.id}##`,e.link.url]].filter((e=>e))})))),s=Object.entries(s).concat([[/##hb_native_asset_(link_)?id_\\d+##/g]]),function(e){return s.reduce(((e,[t,r])=>e.replaceAll(t,r||\"\")),e)}}(r,o);return b(a,h(n(a))),d(r,o,h,c).then((t=>{b(l,t),\"function\"==typeof c.postRenderAd&&c.postRenderAd({adId:r,...o}),c.document.querySelectorAll(\".pb-click\").forEach((t=>{const r=t.getAttribute(\"hb_native_asset_id\");t.addEventListener(\"click\",(()=>s(e,{action:\"click\",assetId:r})))})),s(e,{action:\"fireNativeImpressionTrackers\"}),\"complete\"===c.document.readyState?u():c.onload=u}))}})();" \ No newline at end of file diff --git a/modules/nativeRendering.js b/modules/nativeRendering.js index 8e6b6baab55..a6a404a0253 100644 --- a/modules/nativeRendering.js +++ b/modules/nativeRendering.js @@ -7,7 +7,8 @@ import {getCreativeRendererSource} from '../src/creativeRenderers.js'; function getRenderingDataHook(next, bidResponse, options) { if (isNativeResponse(bidResponse)) { next.bail({ - native: getNativeRenderingData(bidResponse, auctionManager.index.getAdUnit(bidResponse)) + native: getNativeRenderingData(bidResponse, auctionManager.index.getAdUnit(bidResponse)), + rendererVersion: 2 // 9.28 fixed a rendering bug; this signals to PUC that the native renderer is safe to use }) } else { next(bidResponse, options) diff --git a/src/native.js b/src/native.js index 19833406451..d1ac4ea17d7 100644 --- a/src/native.js +++ b/src/native.js @@ -436,13 +436,16 @@ function assetsMessage(data, adObject, keys, {index = auctionManager.index} = {} message: 'assetResponse', adId: data.adId, }; - let renderData = getRenderingData(adObject).native; + let {native: renderData, rendererVersion} = getRenderingData(adObject); if (renderData) { // if we have native rendering data (set up by the nativeRendering module) // include it in full ("all assets") together with the renderer. // this is to allow PUC to use dynamic renderers without requiring changes in creative setup - msg.native = Object.assign({}, renderData); - msg.renderer = getCreativeRendererSource(adObject); + Object.assign(msg, { + native: Object.assign({}, renderData), + renderer: getCreativeRendererSource(adObject), + rendererVersion, + }) if (keys != null) { renderData.assets = renderData.assets.filter(({key}) => keys.includes(key)) } diff --git a/test/spec/creative/nativeRenderer_spec.js b/test/spec/creative/nativeRenderer_spec.js index 66e81a517c7..935f3db1ad3 100644 --- a/test/spec/creative/nativeRenderer_spec.js +++ b/test/spec/creative/nativeRenderer_spec.js @@ -34,10 +34,16 @@ describe('Native creative renderer', () => { }); }); describe('otherwise, calls replacer', () => { - let replacer; + let replacer, frame; beforeEach(() => { replacer = sinon.stub().returns('markup'); + frame = document.createElement('iframe'); + document.body.appendChild(frame); + win.document = frame.contentDocument; }); + afterEach(() => { + document.body.removeChild(frame); + }) it('with adTemplate, if present', () => { return getAdMarkup('123', {adTemplate: 'tpl'}, replacer, win).then((result) => { expect(result).to.eql('markup'); @@ -45,7 +51,7 @@ describe('Native creative renderer', () => { }); }); it('with document body otherwise', () => { - win.document = {body: {innerHTML: 'body'}}; + win.document.body.innerHTML = 'body' return getAdMarkup('123', {}, replacer, win).then((result) => { expect(result).to.eql('markup'); sinon.assert.calledWith(replacer, 'body'); @@ -186,26 +192,29 @@ describe('Native creative renderer', () => { }); describe('render', () => { - let getMarkup, sendMessage, adId, nativeData, exc; + let getMarkup, sendMessage, adId, nativeData, exc, frame; beforeEach(() => { adId = '123'; nativeData = {} getMarkup = sinon.stub(); sendMessage = sinon.stub() exc = sinon.stub(); - win.document = { - querySelectorAll() { return [] }, - body: {} - } + frame = document.createElement('iframe'); + document.body.appendChild(frame); + win.document = frame.contentDocument; }); + afterEach(() => { + document.body.removeChild(frame); + }) + function runRender() { return render({adId, native: nativeData}, {sendMessage, exc}, win, getMarkup) } it('replaces placeholders in head, if present', () => { getMarkup.returns(Promise.resolve('')) - win.document.head = {innerHTML: '##hb_native_asset_id_1##'}; + win.document.head.innerHTML = '##hb_native_asset_id_1##'; nativeData.ortb = { assets: [ {id: 1, data: {value: 'repl'}} @@ -216,6 +225,14 @@ describe('Native creative renderer', () => { }) }); + it('does not replace iframes with srcdoc that contain "renderer"', () => { + win.document.head.innerHTML = win.document.body.innerHTML = ''; + getMarkup.returns(Promise.resolve('')) + return runRender().then(() => { + expect(Array.from(win.document.querySelectorAll('iframe[srcdoc="renderer"]')).length).to.eql(2); + }) + }) + it('drops markup on body, and fires imp trackers', () => { getMarkup.returns(Promise.resolve('markup')); return runRender().then(() => { @@ -246,9 +263,27 @@ describe('Native creative renderer', () => { describe('requests resize', () => { beforeEach(() => { + const mkNode = () => { + const node = { + innerHTML: '', + childNodes: [], + insertAdjacentHTML: () => {}, + style: {}, + querySelectorAll: () => [], + cloneNode: () => node + }; + return node; + } + win.document = { + head: mkNode(), + body: Object.assign(mkNode(), { + offsetHeight: 123, + offsetWidth: 321 + }), + querySelectorAll: () => [], + style: {} + }; getMarkup.returns(Promise.resolve('markup')); - win.document.body.offsetHeight = 123; - win.document.body.offsetWidth = 321; }); it('immediately, if document is loaded', () => { diff --git a/test/spec/native_spec.js b/test/spec/native_spec.js index 01214cdb3ae..7ea9b5949fe 100644 --- a/test/spec/native_spec.js +++ b/test/spec/native_spec.js @@ -402,7 +402,8 @@ describe('native.js', function () { 'returns native data': { renderDataHook(next, bidResponse) { next.bail({ - native: getNativeRenderingData(bidResponse, adUnit) + native: getNativeRenderingData(bidResponse, adUnit), + rendererVersion: 'native-render-version' }); }, renderSourceHook(next) { @@ -433,8 +434,9 @@ describe('native.js', function () { function checkRenderer(message) { if (withRenderer) { expect(message.renderer).to.eql('mock-native-renderer') + expect(message.rendererVersion).to.eql('native-render-version'); Object.entries(message).forEach(([key, val]) => { - if (!['native', 'adId', 'message', 'assets', 'renderer'].includes(key)) { + if (!['native', 'adId', 'message', 'assets', 'renderer', 'rendererVersion'].includes(key)) { expect(message.native[key]).to.eql(val); } }) From fc74eefd352ef38704ed0f29bfc946320382f22e Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 14 Jan 2025 13:49:42 -0800 Subject: [PATCH 2/2] Cleanup --- creative/renderers/native/renderer.js | 2 +- libraries/creative-renderer-native/renderer.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/creative/renderers/native/renderer.js b/creative/renderers/native/renderer.js index b88d1126b25..f7c124b41eb 100644 --- a/creative/renderers/native/renderer.js +++ b/creative/renderers/native/renderer.js @@ -84,9 +84,9 @@ export function render({adId, native}, {sendMessage}, win, getMarkup = getAdMark width: body.offsetWidth }); } - const renderFrames = Array.from(win.document.querySelectorAll('iframe[srcdoc*="render"]')); function replaceMarkup(target, markup) { // do not remove the rendering logic if it's embedded in this window; things will break otherwise + const renderFrames = getRenderFrames(target); Array.from(target.childNodes).filter(node => !renderFrames.includes(node)).forEach(node => target.removeChild(node)); target.insertAdjacentHTML('afterbegin', markup); } diff --git a/libraries/creative-renderer-native/renderer.js b/libraries/creative-renderer-native/renderer.js index bd9076dddaf..5651cc3f0ca 100644 --- a/libraries/creative-renderer-native/renderer.js +++ b/libraries/creative-renderer-native/renderer.js @@ -1,2 +1,2 @@ // this file is autogenerated, see creative/README.md -export const RENDERER = "(()=>{\"use strict\";const e=\"Prebid Native\",t={title:\"text\",data:\"value\",img:\"url\",video:\"vasttag\"};function r(e,t){return new Promise(((r,n)=>{const i=t.createElement(\"script\");i.onload=r,i.onerror=n,i.src=e,t.body.appendChild(i)}))}function n(e){const t=e.cloneNode(!0);return function(e){return Array.from(e.querySelectorAll('iframe[srcdoc*=\"render\"]'))}(t).forEach((e=>e.parentNode.removeChild(e))),t.innerHTML}function i(e,t,i,o,s=r){const{rendererUrl:c,assets:d,ortb:a,adTemplate:l}=t,u=o.document;return c?s(c,u).then((()=>{if(\"function\"!=typeof o.renderAd)throw new Error(`Renderer from '${c}' does not define renderAd()`);const e=d||[];return e.ortb=a,o.renderAd(e)})):Promise.resolve(i(l??n(u.body)))}window.render=function({adId:r,native:o},{sendMessage:s},c,d=i){const{head:a,body:l}=c.document,u=()=>{l.style.display=\"none\",l.style.display=\"block\",s(e,{action:\"resizeNativeHeight\",height:l.offsetHeight,width:l.offsetWidth})},f=Array.from(c.document.querySelectorAll('iframe[srcdoc*=\"render\"]'));function b(e,t){Array.from(e.childNodes).filter((e=>!f.includes(e))).forEach((t=>e.removeChild(t))),e.insertAdjacentHTML(\"afterbegin\",t)}const h=function(e,{assets:r=[],ortb:n,nativeKeys:i={}}){const o=Object.fromEntries(r.map((({key:e,value:t})=>[e,t])));let s=Object.fromEntries(Object.entries(i).flatMap((([t,r])=>{const n=o.hasOwnProperty(t)?o[t]:void 0;return[[`##${r}##`,n],[`${r}:${e}`,n]]})));return n&&Object.assign(s,{\"##hb_native_linkurl##\":n.link?.url,\"##hb_native_privacy##\":n.privacy},Object.fromEntries((n.assets||[]).flatMap((e=>{const r=Object.keys(t).find((t=>e[t]));return[r&&[`##hb_native_asset_id_${e.id}##`,e[r][t[r]]],e.link?.url&&[`##hb_native_asset_link_id_${e.id}##`,e.link.url]].filter((e=>e))})))),s=Object.entries(s).concat([[/##hb_native_asset_(link_)?id_\\d+##/g]]),function(e){return s.reduce(((e,[t,r])=>e.replaceAll(t,r||\"\")),e)}}(r,o);return b(a,h(n(a))),d(r,o,h,c).then((t=>{b(l,t),\"function\"==typeof c.postRenderAd&&c.postRenderAd({adId:r,...o}),c.document.querySelectorAll(\".pb-click\").forEach((t=>{const r=t.getAttribute(\"hb_native_asset_id\");t.addEventListener(\"click\",(()=>s(e,{action:\"click\",assetId:r})))})),s(e,{action:\"fireNativeImpressionTrackers\"}),\"complete\"===c.document.readyState?u():c.onload=u}))}})();" \ No newline at end of file +export const RENDERER = "(()=>{\"use strict\";const e=\"Prebid Native\",t={title:\"text\",data:\"value\",img:\"url\",video:\"vasttag\"};function n(e,t){return new Promise(((n,r)=>{const i=t.createElement(\"script\");i.onload=n,i.onerror=r,i.src=e,t.body.appendChild(i)}))}function r(e){return Array.from(e.querySelectorAll('iframe[srcdoc*=\"render\"]'))}function i(e){const t=e.cloneNode(!0);return r(t).forEach((e=>e.parentNode.removeChild(e))),t.innerHTML}function o(e,t,r,o,s=n){const{rendererUrl:c,assets:d,ortb:a,adTemplate:l}=t,u=o.document;return c?s(c,u).then((()=>{if(\"function\"!=typeof o.renderAd)throw new Error(`Renderer from '${c}' does not define renderAd()`);const e=d||[];return e.ortb=a,o.renderAd(e)})):Promise.resolve(r(l??i(u.body)))}window.render=function({adId:n,native:s},{sendMessage:c},d,a=o){const{head:l,body:u}=d.document,f=()=>{u.style.display=\"none\",u.style.display=\"block\",c(e,{action:\"resizeNativeHeight\",height:u.offsetHeight,width:u.offsetWidth})};function b(e,t){const n=r(e);Array.from(e.childNodes).filter((e=>!n.includes(e))).forEach((t=>e.removeChild(t))),e.insertAdjacentHTML(\"afterbegin\",t)}const h=function(e,{assets:n=[],ortb:r,nativeKeys:i={}}){const o=Object.fromEntries(n.map((({key:e,value:t})=>[e,t])));let s=Object.fromEntries(Object.entries(i).flatMap((([t,n])=>{const r=o.hasOwnProperty(t)?o[t]:void 0;return[[`##${n}##`,r],[`${n}:${e}`,r]]})));return r&&Object.assign(s,{\"##hb_native_linkurl##\":r.link?.url,\"##hb_native_privacy##\":r.privacy},Object.fromEntries((r.assets||[]).flatMap((e=>{const n=Object.keys(t).find((t=>e[t]));return[n&&[`##hb_native_asset_id_${e.id}##`,e[n][t[n]]],e.link?.url&&[`##hb_native_asset_link_id_${e.id}##`,e.link.url]].filter((e=>e))})))),s=Object.entries(s).concat([[/##hb_native_asset_(link_)?id_\\d+##/g]]),function(e){return s.reduce(((e,[t,n])=>e.replaceAll(t,n||\"\")),e)}}(n,s);return b(l,h(i(l))),a(n,s,h,d).then((t=>{b(u,t),\"function\"==typeof d.postRenderAd&&d.postRenderAd({adId:n,...s}),d.document.querySelectorAll(\".pb-click\").forEach((t=>{const n=t.getAttribute(\"hb_native_asset_id\");t.addEventListener(\"click\",(()=>c(e,{action:\"click\",assetId:n})))})),c(e,{action:\"fireNativeImpressionTrackers\"}),\"complete\"===d.document.readyState?f():d.onload=f}))}})();" \ No newline at end of file