From 7ab8a3045eafddea40f9d29461b6f55f991e85ce Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:57:18 +0530 Subject: [PATCH] perf: stop shared clock updates in hidden tabs --- resources/js/hooks/use-now.ts | 45 ++++++++++++++++++--- tests/Unit/UseNowVisibility.test.tsx | 59 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/UseNowVisibility.test.tsx diff --git a/resources/js/hooks/use-now.ts b/resources/js/hooks/use-now.ts index 379f07e494..fd9bc35cf6 100644 --- a/resources/js/hooks/use-now.ts +++ b/resources/js/hooks/use-now.ts @@ -4,15 +4,42 @@ const listeners = new Set<() => void>(); let now = Date.now(); let interval: ReturnType | undefined; -const subscribe = (listener: () => void) => { - listeners.add(listener); +const tick = () => { + now = Date.now(); + listeners.forEach((notify) => notify()); +}; +const start = () => { if (interval === undefined) { now = Date.now(); - interval = setInterval(() => { - now = Date.now(); - listeners.forEach((notify) => notify()); - }, 30_000); + interval = setInterval(tick, 30_000); + } +}; + +const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearInterval(interval); + interval = undefined; + } else if (listeners.size > 0) { + tick(); + start(); + } +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + + if (listeners.size === 1) { + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibilityChange); + } + + if ( + typeof document === 'undefined' || + document.visibilityState !== 'hidden' + ) { + start(); + } } return () => { @@ -21,6 +48,12 @@ const subscribe = (listener: () => void) => { if (listeners.size === 0) { clearInterval(interval); interval = undefined; + if (typeof document !== 'undefined') { + document.removeEventListener( + 'visibilitychange', + onVisibilityChange, + ); + } } }; }; diff --git a/tests/Unit/UseNowVisibility.test.tsx b/tests/Unit/UseNowVisibility.test.tsx new file mode 100644 index 0000000000..01b16751ca --- /dev/null +++ b/tests/Unit/UseNowVisibility.test.tsx @@ -0,0 +1,59 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterEach, test, vi } from 'vite-plus/test'; + +const notify = vi.fn(); +let unsubscribe: () => void; +vi.doMock('react', () => ({ + ...React, + useSyncExternalStore: ( + subscribe: (callback: () => void) => () => void, + snapshot: () => number, + ) => { + unsubscribe = subscribe(notify); + return snapshot(); + }, +})); + +afterEach(() => { + unsubscribe?.(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('stops the shared clock in background tabs and refreshes on return', async () => { + vi.useFakeTimers(); + const start = new Date('2026-10-25T12:00:00Z').getTime(); + vi.setSystemTime(start); + const events = new Map void>(); + const document = { + visibilityState: 'visible', + addEventListener: (event: string, handler: () => void) => events.set(event, handler), + removeEventListener: (event: string) => events.delete(event), + }; + vi.stubGlobal('document', document); + + const { useNow } = await import('../../resources/js/hooks/use-now'); + assert.equal(useNow().getTime(), start); + assert.equal(events.has('visibilitychange'), true); + + vi.advanceTimersByTime(30_000); + assert.equal(notify.mock.calls.length, 1); + + document.visibilityState = 'hidden'; + events.get('visibilitychange')?.(); + vi.advanceTimersByTime(90_000); + assert.equal(notify.mock.calls.length, 1); + + document.visibilityState = 'visible'; + events.get('visibilitychange')?.(); + assert.equal(notify.mock.calls.length, 2); + assert.equal(useNow().getTime(), start + 120_000); + + vi.advanceTimersByTime(30_000); + assert.equal(notify.mock.calls.length, 3); + + unsubscribe(); + assert.equal(events.has('visibilitychange'), false); +});