Tab Visibility Interval Test V2

More advanced version of Tab-Safe interval testing

by ryunp

HTML

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<div style="max-width: 666px; margin: 0 auto">
  <div id="intro-text" style="text-align: center;">
    <p style="font-weight: bold;margin: 0px; padding: 0px">Observing Timers During Tab Visibility Changes</p>
    <p style="margin: 0px; padding: 0px">[<a href="https://jsfiddle.net/ryunp/0kbm36eq/" target="_blank">JSFiddle</a>]</p>
    <p>The goal is to pause the timer/interval when the tab focus is lost, and resume when activated.</p>
    <p>Timers and interval callbacks continue triggering when a tab is backgrounded. Some behavior, like jQuery's '.fadeTo()' animation, gets buffered until the tab is reactivated which can lead to repeated 'machine gun' executions. When initially adding the animation callback for testing, the issue appeared. However, the issue no longer occurs. Haven't figured out why.</p>
    <p>See what happens when toggling the PauseOnVisibilityChange flag and changing tabs. Check Dev Console for event notifications.</p>
  </div>
  <hr>
  <div id="controls" style="text-align: center; display: flex; flex-wrap: wrap; justify-content: center;">
    <button id="start">Start</button>
    <button id="pause">Pause</button>
    <button id="resume">Resume</button>
    <button id="stop">Stop</button>
    <label><input id="pause-toggle" type="checkbox" checked>PauseOnVisibilityChange</label>
    <label><input id="jquery-toggle" type="checkbox" checked>jQuery Animation</label>
  </div>
  <hr>
  <div style="height: 375px; display: flex; flex-wrap: wrap; flex-direction: row;">
    <div style="flex-grow: 1; border: 1px dashed #aaa; ">
      <div id="time">Interval: </div>
      <div>Active Elapsed: <span id="elapsed">0ms</span></div>
      <br>
      <div id="properties" style="min-width: 320px;"></div>
    </div>
    <div id="event-log" style="flex-grow: 1; max-height: 375px; overflow-y: auto; border: 1px dashed #aaa; "></div>
  </div>
</div>

CSS

th {
  text-align: left;
}

#intro-text {
  opacity: 1;
}

.transition {
  transition: opacity 200ms;
}

#intro-text.faded {
  opacity: 0.5;
}

JavaScript

class TabSafeHeartbeat {
  constructor (intervalMs) {
    this.enabled = false
    this.executeCount = 0
    this.intervalHandle = null
    this.intervalMs = intervalMs
    this.isPaused = false
    this.lastExecutionTime = null
    this.lastPauseTime = null
    this.onTabVisibilityChangeFn = this.onTabVisibilityChange.bind(this)
    this.pauseOnVisibilityChange = true
    this.resumeOnVisibility = false
    this.sleepTimeSinceLastExecute = 0
    this.thresholdReachedHandlers = new Set()
    this.timeoutHandle = null

    if (this.pauseOnVisibilityChange)
      this.enableTabVisibilityAware()
  }

  onTabVisibilityChange () {
    if (document.visibilityState === 'visible') {
      console.log("Tab activated")

      if (this.resumeOnVisibility) {
        this.resume()
      }

    } else {
      console.log("Tab hidden")

      if (this.enabled) {
        if (this.isPaused) {
          this.resumeOnVisibility = false
        } else {
          this.resumeOnVisibility = true
          this.pause()
        }
      }
    }
  }

  enableTabVisibilityAware () {
    console.log("Toggling tab visibility awareness on")
    this.pauseOnVisibilityChange = true
    document.addEventListener("visibilitychange", this.onTabVisibilityChangeFn); 
  }

  disableTabVisibilityAware () {
    console.log("Toggling tab visibility awareness off")
    this.pauseOnVisibilityChange = false
    document.removeEventListener("visibilitychange", this.onTabVisibilityChangeFn);
  }

  start (intervalMs = null) {
    if (intervalMs !== null) {
        this.intervalMs = intervalMs
    }

    console.log(`Starting interval with ${this.intervalMs}ms`)

    // Abort previous interval if already running
    if (this.enabled) {
      this.stop()
    }

    this.enabled = true
    this.lastPauseTime = null
    this.lastExecutionTime = Date.now()
    this.sleepTimeSinceLastExecute = 0
    this.intervalHandle = setInterval(this.onThresholdReached.bind(this), this.intervalMs)
  }

  resume () {
    if...