Page title stops updating after switching tab

I have a page with a counter in it that I display also in the page title.
All works good except when i switch tab away from my app, the page title stops updating even though it should continue updating.
Don’t know if this is intended or I’m doing it wrong but if I change my code and invoke directly document.title = counter it works as expected.
Having it as <Metadata title={counter} /> doesn’t work if i switch to another tab.

Hi @c-ciobanu

Could you share a repo link, some code z(how the counter is updated … is this client side to via graphql query?) or even a small short video demonstrating the issue?

This will help us diagnose and hopefully suggest a solution.

For example, if the counter value comes from a GraphQL query, just add the Metadata component to your cell (not Layout or Page) and update the value in the Success.

That can set the title from the query result dynamically – for example, this is how you can set the title of the page to your Blog post title etc … or description or any og tags.

It’s all client side, here’s the code I’m using: popcorn-time/web/src/pages/PomodoroPage/PomodoroTimer/PomodoroTimer.tsx at master · c-ciobanu/popcorn-time · GitHub.
It’s a simple interval that changes the state every second and the Metadata picks up the value from the state.

Thanks for sharing code… super helpful!

I’m not the best React developer but I wondered if:

  const formattedTimeLeft = formatSecondsToMinutesAndSeconds(secondsLeft)

  return (
    <>
      <Metadata title={formattedTimeLeft} />

perhaps should use useState() to set and get the formattedTimeLeft. That might trigger the Metadata component to get new info?

I’m going to ask ChatGPT what it thinks… it agrees!

import { useState, useEffect } from 'react'
import { Metadata } from '@redwoodjs/web'

import { Card, CardContent, CardDescription, CardHeader } from 'src/components/ui/card'
import { useInterval } from 'src/hooks/useInterval/useInterval'

import alarm from './alarm.mp3'
import end from './end.mp3'
import ticking from './ticking.mp3'

const alarmSound = new Audio(alarm)
const endSound = new Audio(end)
const tickingSound = new Audio(ticking)

const formatSecondsToMinutesAndSeconds = (seconds: number) => {
  const m = Math.floor(seconds / 60).toString()
  const s = (seconds % 60).toString()

  return `${m.padStart(2, '0')}:${s.padStart(2, '0')}`
}

const sendNotification = (title: string, body: string) => {
  if (!Notification) {
    return
  }

  return new Notification(title, { body })
}

enum Phase {
  Pomodoro = 'Pomodoro',
  ShortBreak = 'Short break',
  LongBreak = 'Long break',
}

export type PomodoroTimerProps = {
  settings: { pomodoro: number; shortBreak: number; longBreak: number }
}

const PomodoroTimer = ({ settings }: PomodoroTimerProps) => {
  const [currentPhase, setCurrentPhase] = useState(Phase.Pomodoro)
  const [secondsLeft, setSecondsLeft] = useState(settings.pomodoro * 60)
  const [runningPhase, setRunningPhase] = useState(1)
  const [formattedTimeLeft, setFormattedTimeLeft] = useState(formatSecondsToMinutesAndSeconds(secondsLeft))

  useEffect(() => {
    // Update the formatted time whenever secondsLeft changes
    setFormattedTimeLeft(formatSecondsToMinutesAndSeconds(secondsLeft))
  }, [secondsLeft])

  useInterval(
    () => {
      const nextPhase = runningPhase === 7 ? Phase.LongBreak : Phase.ShortBreak
      const secondsToNextPhase = secondsLeft - 1

      if (runningPhase === 12 && secondsToNextPhase === 0) {
        setSecondsLeft(0)

        sendNotification('You did it!', 'You made it to the end! Keep up the great work! 💪')
        endSound.play()
      } else if (secondsToNextPhase === 0) {
        if (currentPhase === Phase.Pomodoro) {
          setCurrentPhase(nextPhase)
          setSecondsLeft((nextPhase === Phase.LongBreak ? settings.longBreak : settings.shortBreak) * 60)

          sendNotification('Well done!', `Time to take a ${nextPhase.toLowerCase()} now.`)
        } else {
          setCurrentPhase(Phase.Pomodoro)
          setSecondsLeft(settings.pomodoro * 60)

          sendNotification('Hope you are well rested now!', `It's time to go at it again.`)
        }

        setRunningPhase((state) => state + 1)

        alarmSound.play()
      } else {
        setSecondsLeft(secondsToNextPhase)

        if (currentPhase === Phase.Pomodoro && secondsToNextPhase === 300) {
          sendNotification('Pomodoro ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 5 minutes.`)
        } else if (currentPhase === Phase.Pomodoro && secondsToNextPhase === 60) {
          sendNotification('Pomodoro ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 1 minute.`)
        } else if (secondsToNextPhase === 60) {
          sendNotification('Break ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 1 minute.`)
        } else if (secondsToNextPhase === 5) {
          tickingSound.play()
        }
      }
    },
    secondsLeft === 0 ? null : 1000
  )

  return (
    <>
      <Metadata title={formattedTimeLeft} />

      <Card className="border-2 border-dashed py-10">
        <CardHeader>
          <CardDescription className="text-center">{currentPhase}</CardDescription>
        </CardHeader>

        <CardContent>
          <p className="text-center text-8xl">{formattedTimeLeft}</p>
        </CardContent>
      </Card>
    </>
  )
}

export default PomodoroTimer

Might that work?

No, that is basically the same thing.
The Metadata is refreshed if you are in the page but if you switch tab it stops working.

Ah, I read that but forgot …

Ok, my go to again for React …

The behavior you’re observing — where the Metadata component stops updating when switching to another tab — is likely due to how browsers handle background tabs and inactive windows to improve performance and reduce resource consumption. Specifically, when you switch tabs, the browser may “throttle” or “suspend” JavaScript execution, including setInterval, setTimeout, and possibly custom interval hooks like useInterval.

Here’s why this might happen:

  1. Browser Tab Throttling:
    Many modern browsers pause or heavily throttle the execution of background JavaScript in inactive tabs. This includes suspending intervals or drastically reducing their frequency to conserve resources.

Impact on useInterval: Your useInterval hook relies on regular updates to secondsLeft. If the browser suspends the execution of the useInterval function, the secondsLeft state won’t be updated, and therefore the Metadata title will also not be updated.
Throttling: In most cases, setInterval may not fire as often as expected, meaning your useInterval hook will also not be called at the correct frequency, if at all.
2. Visibility API:
The browser’s document.visibilityState API allows you to detect when the page is in the background (inactive tab). You can use this to handle updates more explicitly.

Solution: Handle Tab Visibility Changes
You can detect when the tab is inactive using the document.visibilityState property and pause or adjust the timer updates accordingly. To ensure the timer stays in sync when switching back, you can record the time when the tab goes inactive and calculate how much time has passed when the user returns.

Here’s how you can implement it:

import { useState, useEffect } from 'react'
import { Metadata } from '@redwoodjs/web'
import { Card, CardContent, CardDescription, CardHeader } from 'src/components/ui/card'
import { useInterval } from 'src/hooks/useInterval/useInterval'

import alarm from './alarm.mp3'
import end from './end.mp3'
import ticking from './ticking.mp3'

const alarmSound = new Audio(alarm)
const endSound = new Audio(end)
const tickingSound = new Audio(ticking)

const formatSecondsToMinutesAndSeconds = (seconds: number) => {
  const m = Math.floor(seconds / 60).toString()
  const s = (seconds % 60).toString()

  return `${m.padStart(2, '0')}:${s.padStart(2, '0')}`
}

const sendNotification = (title: string, body: string) => {
  if (!Notification) {
    return
  }

  return new Notification(title, { body })
}

enum Phase {
  Pomodoro = 'Pomodoro',
  ShortBreak = 'Short break',
  LongBreak = 'Long break',
}

export type PomodoroTimerProps = {
  settings: { pomodoro: number; shortBreak: number; longBreak: number }
}

const PomodoroTimer = ({ settings }: PomodoroTimerProps) => {
  const [currentPhase, setCurrentPhase] = useState(Phase.Pomodoro)
  const [secondsLeft, setSecondsLeft] = useState(settings.pomodoro * 60)
  const [runningPhase, setRunningPhase] = useState(1)
  const [formattedTimeLeft, setFormattedTimeLeft] = useState(formatSecondsToMinutesAndSeconds(secondsLeft))

  useEffect(() => {
    setFormattedTimeLeft(formatSecondsToMinutesAndSeconds(secondsLeft))
  }, [secondsLeft])

  useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.visibilityState === 'visible') {
        // When returning to the page, ensure the timer is synced.
        // You could adjust time here based on how long the tab was hidden.
        // For simplicity, we leave the interval running.
      }
    }
    document.addEventListener('visibilitychange', handleVisibilityChange)

    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange)
    }
  }, [])

  useInterval(
    () => {
      const nextPhase = runningPhase === 7 ? Phase.LongBreak : Phase.ShortBreak
      const secondsToNextPhase = secondsLeft - 1

      if (runningPhase === 12 && secondsToNextPhase === 0) {
        setSecondsLeft(0)
        sendNotification('You did it!', 'You made it to the end! Keep up the great work! 💪')
        endSound.play()
      } else if (secondsToNextPhase === 0) {
        if (currentPhase === Phase.Pomodoro) {
          setCurrentPhase(nextPhase)
          setSecondsLeft((nextPhase === Phase.LongBreak ? settings.longBreak : settings.shortBreak) * 60)
          sendNotification('Well done!', `Time to take a ${nextPhase.toLowerCase()} now.`)
        } else {
          setCurrentPhase(Phase.Pomodoro)
          setSecondsLeft(settings.pomodoro * 60)
          sendNotification('Hope you are well rested now!', `It's time to go at it again.`)
        }
        setRunningPhase((state) => state + 1)
        alarmSound.play()
      } else {
        setSecondsLeft(secondsToNextPhase)
        if (currentPhase === Phase.Pomodoro && secondsToNextPhase === 300) {
          sendNotification('Pomodoro ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 5 minutes.`)
        } else if (currentPhase === Phase.Pomodoro && secondsToNextPhase === 60) {
          sendNotification('Pomodoro ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 1 minute.`)
        } else if (secondsToNextPhase === 60) {
          sendNotification('Break ending soon!', `A ${nextPhase.toLowerCase()} is coming next in 1 minute.`)
        } else if (secondsToNextPhase === 5) {
          tickingSound.play()
        }
      }
    },
    secondsLeft === 0 ? null : 1000
  )

  return (
    <>
      <Metadata title={formattedTimeLeft} />
      <Card className="border-2 border-dashed py-10">
        <CardHeader>
          <CardDescription className="text-center">{currentPhase}</CardDescription>
        </CardHeader>
        <CardContent>
          <p className="text-center text-8xl">{formattedTimeLeft}</p>
        </CardContent>
      </Card>
    </>
  )
}

export default PomodoroTimer

I know I’m just asking ChatGPT but for me it really helps to understand the nuances of React and hooks.

Perhaps other in the community have an idea?

No, none of this is helpful.
I even changed the logic to use a web worker for the interval so there would be no issues with throttling and visibility but the issue is still there.
I think the issue might be with how Metadata sets the document title or maybe something related to the dom in inactive tabs not re-rendering or triggering side effects?
Because if I manually update the document title from my tick function then it works all as expected.
Very strange.

That might be your best option.

The Metadata component code is here: redwood/packages/web/src/components/Metadata.tsx at a4c09015052ade8c8f83713ba9534b350dc79644 · redwoodjs/redwood · GitHub

and based on GitHub - nfl/react-helmet: A document head manager for React

Maybe something in how Helmet works might help explain this behavior.

This is a rather novel use case I haven’t seen before – most of the time titles are static or uploaded from some data fetch.

I’ll ask around for others for input.