Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Google DFP in SPA (Angular, Vue) - how to destroy ad and all it's references to avoid memory leak

I tried to use google's DFP in both Vue.js and Angular SPA but it seems to be causing a memory leak.

In Angular here you can see the proof of concept https://github.com/jbojcic1/angular-dfp-memory-leak-proof-of-concept. For ads, I am using ngx-dfp npm package (https://github.com/atwwei/ngx-dfp). To reproduce pull and run the proof of concept project, go to the home page, which will have 3 ads in the feed initially, and do the heap snapshot. After that go to the page without ads by using a link in the header, do heap snapshot again and you will see that slot references are kept after the slot is destroyed which is causing the memory leak.

In Vue I have a component which creates and destroys ad slot and I am adding those dynamically in the content feed. When I leave the page, the component is destroyed and in beforeDestroy hook, I call destroySlots but it seems that some references are still there.

Here is my dfp-ad component:

<template>
  <div :id="id" class="ad" ref="adContainer"></div>
</template>

<script>
  export default {
    name: 'dfp-ad',
    props: {
      id: { type: String, default: null },
      adName: { type: String, default: null },
      forceSafeFrame: { type: Boolean, default: false },
      safeFrameConfig: { type: String, default: null },
      recreateOnRouteChange: { type: Boolean, default: true },
      collapseIfEmpty: { type: Boolean, default: true },
      sizes: { type: Array, default: () => [] },
      responsiveMapping: { type: Array, default: () => [] },
      targetings: { type: Array, default: () => [] },
      outOfPageSlot: { type: Boolean, default: false }
    },
    data () {
      return {
        slot: null,
        networkCode: 'something',
        topLevelAdUnit: 'something_else'
      }
    },
    computed: {
      slotName () {
        return `/${this.networkCode}/${this.topLevelAdUnit}/${this.adName}`
      }
    },
    mounted () {
      this.$defineTask(() => {
        this.defineSlot()
      })
    },
    watch: {
      '$route': function (to, from) {
        if (this.recreateOnRouteChange) {
          this.$defineTask(() => {
            // this.resetTargetings()
            // We can't just change targetings because slot name is different on different pages (not sure why though)
            // too so we need to recreate it.
            this.recreateSlot()
            this.refreshContent()
          })
        }
      }
    },
    methods: {
      getState () {
        return Object.freeze({
          sizes: this.sizes,
          responsiveMapping: this.responsiveMapping,
          targetings: this.targetings,
          slotName: this.slotName,
          forceSafeFrame: this.forceSafeFrame === true,
          safeFrameConfig: this.safeFrameConfig,
          clickUrl: this.clickUrl,
          recreateOnRouteChange: this.recreateOnRouteChange,
          collapseIfEmpty: this.collapseIfEmpty === true,
          outOfPageSlot: this.outOfPageSlot
        })
      },

      setResponsiveMapping (slot) {
        const ad = this.getState()

        const sizeMapping = googletag.sizeMapping()

        if (ad.responsiveMapping.length === 0) {
          ad.sizes.forEach(function (size) {
            sizeMapping.addSize([size[0], 0], [size])
          })
        } else {
          ad.responsiveMapping.forEach(function (mapping) {
            sizeMapping.addSize(mapping.viewportSize, mapping.adSizes)
          })
        }

        slot.defineSizeMapping(sizeMapping.build())
      },

      refreshContent () {
        googletag.pubads().refresh([this.slot])
      },

      defineSlot () {
        const ad = this.getState()
        const element = this.$refs.adContainer

        this.slot = ad.outOfPageSlot
          ? googletag.defineOutOfPageSlot(ad.slotName, this.id)
          : googletag.defineSlot(ad.slotName, ad.sizes, this.id)

        if (ad.forceSafeFrame) {
          this.slot.setForceSafeFrame(true)
        }

        if (ad.clickUrl) {
          this.slot.setClickUrl(ad.clickUrl)
        }

        if (ad.collapseIfEmpty) {
          this.slot.setCollapseEmptyDiv(true, true)
        }

        if (ad.safeFrameConfig) {
          this.slot.setSafeFrameConfig(
            /** @type {googletag.SafeFrameConfig} */
            (JSON.parse(ad.safeFrameConfig))
          )
        }

        if (!ad.outOfPageSlot) {
          this.setResponsiveMapping(this.slot)
        }

        this.setTargetings(ad.targetings)

        this.slot.addService(googletag.pubads())

        googletag.display(element.id)

        this.refreshContent()
      },

      setTargetings (targetings) {
        targetings.forEach(targeting => {
          this.slot.setTargeting(targeting.key, targeting.values)
        })
      },

      resetTargetings () {
        this.slot.clearTargeting()
        this.setTargetings(this.targetings)
      },

      recreateSlot () {
        googletag.destroySlots([this.slot])
        this.defineSlot()
      }
    },

    created () {
    },

    beforeDestroy () {
      if (this.slot) {
        googletag.destroySlots([this.slot])
      }
    }
  }
</script>

<style lang="scss" scoped>

...

</style>

I am injecting GPT and setting global config in a plugin:

const dfpConfig = {
  enableVideoAds: true,
  collapseIfEmpty: true,
  centering: false,
  location: null,
  ppid: null,
  globalTargeting: null,
  forceSafeFrame: false,
  safeFrameConfig: null,
  loadGPT: true,
  loaded: false
}

const GPT_LIBRARY_URL = '//www.googletagservices.com/tag/js/gpt.js'

const googletag = window.googletag || {}
googletag.cmd = googletag.cmd || []

var scriptInjector

exports.install = function (Vue, options) {
  initialize(options)

  Vue.prototype.$hasLoaded = function () {
    return dfpConfig.loaded
  }

  Vue.prototype.$defineTask = function (task) {
    googletag.cmd.push(task)
  }
}

function initialize (options) {
  scriptInjector = options.scriptInjector

  googletag.cmd.push(() => {
    setup()
  })

  if (dfpConfig.loadGPT) {
    scriptInjector.injectScript(GPT_LIBRARY_URL).then((script) => {
      dfpConfig.loaded = true
    })

    window['googletag'] = googletag
  }
}

function setup () {
  const pubads = googletag.pubads()

  if (dfpConfig.enableVideoAds) {
    pubads.enableVideoAds()
  }

  if (dfpConfig.collapseIfEmpty) {
    pubads.collapseEmptyDivs()
  }

  // We always refresh ourselves
  pubads.disableInitialLoad()

  pubads.setForceSafeFrame(dfpConfig.forceSafeFrame)
  pubads.setCentering(dfpConfig.centering)

  addLocation(pubads)
  addPPID(pubads)
  addTargeting(pubads)
  addSafeFrameConfig(pubads)

  pubads.enableAsyncRendering()
  // pubads.enableSingleRequest()

  googletag.enableServices()
}

function addSafeFrameConfig (pubads) {
  if (!dfpConfig.safeFrameConfig) { return }
  pubads.setSafeFrameConfig(dfpConfig.safeFrameConfig)
}

function addTargeting (pubads) {
  if (!dfpConfig.globalTargeting) { return }

  for (const key in dfpConfig.globalTargeting) {
    if (dfpConfig.globalTargeting.hasOwnProperty(key)) {
      pubads.setTargeting(key, dfpConfig.globalTargeting[key])
    }
  }
}

function addLocation (pubads) {
  if (!dfpConfig.location) { return }

  if (typeof dfpConfig.location === 'string') {
    pubads.setLocation(dfpConfig.location)
    return
  }

  if (!Array.isArray(dfpConfig.location)) {
    throw new Error('Location must be an array or string')
  }

  pubads.setLocation.apply(pubads, dfpConfig.location)
}

function addPPID (pubads) {
  if (!dfpConfig.ppid) { return }

  pubads.setPublisherProvidedId(dfpConfig.ppid)
}

Here is one of the ads components:

<template>
  <div class="feed-spacer">
    <dfp-ad class="feed-ad"
            :id="adId"
            :adName="adName"
            :sizes="sizes"
            :responsiveMapping="responsiveMapping"
            :targetings="targetings"
            :recreateOnRouteChange="false">
    </dfp-ad>
  </div>
</template>

<script>
import DfpAd from '@/dfp/component/dfp-ad.vue'

export default {
  components: {DfpAd},
  name: 'feed-ad',
  props: ['instance'],
  data () {
    return {
      responsiveMapping: [
        {viewportSize: [1280, 0], adSizes: [728, 90]},
        {viewportSize: [640, 0], adSizes: [300, 250]},
        {viewportSize: [320, 0], adSizes: [300, 250]}
      ],
      sizes: [[728, 90], [300, 250]]
    }
  },
  computed: {
    adId () {
      return `div-id-for-mid${this.instance}-leaderboard`
    },
    adName () {
      return this.$route.meta.pageId
    },
    targetings () {
      const targetings = [
        { key: 's1', values: this.$route.meta.pageId },
        { key: 'pid', values: this.$route.meta.pageId },
        { key: 'pagetype', values: this.$route.meta.pageType },
        { key: 'channel', values: this.$route.meta.pageId },
        { key: 'test', values: this.$route.query.test },
        { key: 'pos', values: `mid${this.instance}` }
      ]

      switch (this.$route.name) {
        case 'games':
          targetings.push('some_tag', this.$route.params.slug)
          break
        case 'show':
          targetings.push('some_other_tag', this.$route.params.slug)
          break
      }
      return targetings
    }
  }
}
</script>

<style lang="scss" scoped>

  ...

</style>

Did anybody have a similar problem? Am I doing something wrong? Or maybe you just can't destroy and create slots in SPA without causing memory leak?

EDIT

Here is the screenshot of the detached nodes:

enter image description here

like image 817
jbojcic Avatar asked Nov 25 '17 13:11

jbojcic


1 Answers

We had a huge memory leak in our our vue web application We found out we had event listeners on the window, seems like dfp created them.

    window.addEventHook = window.addEventListener;
    window.addEventListener = function () {
        if (!window.listenerHook)
            window.listenerHook = [];

        window.listenerHook.push({name: arguments[0], callback: arguments[1] });
        window.addEventHook.apply(window,arguments);
    };

We used this to save all the events listener being attached to window when the app first loaded, then when we want to remove dfp ads we iterate the array and execute window.removeEventListener on each of them (This will remove all window event listeners from window, You need to add checks to see you are not removing something important)

This solved our memory leak problem.

like image 69
Doctor Strange Avatar answered Nov 20 '22 09:11

Doctor Strange