22

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

Max Liashuk
  • 935
  • 6
  • 18
jbojcic
  • 837
  • 1
  • 10
  • 23
  • 1
    Could you repeat it in the sandbox? codesandbox.io – Piterden Nov 28 '17 at 23:37
  • @Piterden I updated answer with proof of concept app in Angular as it happens there too so you can run that if that helps – jbojcic Nov 30 '17 at 21:43
  • the only thing i might say is i guess you are forgetting about unsubscribing to observables as with Observables Once you subscribe to one, it will keep working until you unsubscribe, even if you navigate to another view – Rahul Singh Dec 03 '17 at 07:01
  • @RahulSingh I noticed that ngx-dfp misses two unsubscribes but that's not the main problem. Even if I add that, objects added by google scripts still keep some references. Check the image in updated question – jbojcic Dec 04 '17 at 09:22
  • This sounds like it could very well be a bug. I'm not familiar with this API, does it have a bug tracker where you can check and maybe report this? Does it happen when you don't use a framework and only vanilla js? – Luis Orduz Dec 04 '17 at 13:08
  • @LuisOrduz I haven't tried with Vanilla js but I am pretty sure this is not framework related. I posted a question on DFP help forum so I'll see – jbojcic Dec 04 '17 at 14:54

1 Answers1

2

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.

Doctor Strange
  • 189
  • 3
  • 12
  • I'll try it out. How did you check which event listener is from dfp and needs to be removed? – jbojcic Aug 01 '18 at 14:50
  • Well we started with removing everything to see if it helps, Then you can make your hook with more logic to avoid removing the events you need or something like that, I think dfp are the onload events, not sure. – Doctor Strange Aug 01 '18 at 20:07