diff --git a/resources/js/hooks/use-scrambled-hash.ts b/resources/js/hooks/use-scrambled-hash.ts index 1251847b6..1fb26c1d9 100644 --- a/resources/js/hooks/use-scrambled-hash.ts +++ b/resources/js/hooks/use-scrambled-hash.ts @@ -13,19 +13,62 @@ export type ScrambledCharacter = { const listeners = new Set<() => void>(); let currentFrame: number | null = null; let interval: ReturnType | undefined; +let reducedMotionQuery: MediaQueryList | undefined; + +const start = () => { + if (interval !== undefined || document.visibilityState === 'hidden') { + return; + } + + if (reducedMotionQuery?.matches) { + if (currentFrame !== null) { + currentFrame = null; + listeners.forEach((notify) => notify()); + } + + return; + } + + currentFrame ??= 0; + interval = setInterval(() => { + currentFrame = (currentFrame ?? 0) + 1; + listeners.forEach((notify) => notify()); + }, FRAME_MS); +}; + +const onReducedMotionChange = () => { + if (reducedMotionQuery?.matches) { + clearInterval(interval); + interval = undefined; + + if (currentFrame !== null) { + currentFrame = null; + listeners.forEach((notify) => notify()); + } + } else if (listeners.size > 0) { + start(); + } +}; + +const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearInterval(interval); + interval = undefined; + } else if (listeners.size > 0) { + start(); + } +}; const subscribe = (listener: () => void) => { listeners.add(listener); - if ( - interval === undefined && - !window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { - currentFrame = 0; - interval = setInterval(() => { - currentFrame = (currentFrame ?? 0) + 1; - listeners.forEach((notify) => notify()); - }, FRAME_MS); + if (listeners.size === 1) { + reducedMotionQuery = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ); + reducedMotionQuery.addEventListener('change', onReducedMotionChange); + document.addEventListener('visibilitychange', onVisibilityChange); + start(); } return () => { @@ -35,6 +78,15 @@ const subscribe = (listener: () => void) => { clearInterval(interval); interval = undefined; currentFrame = null; + reducedMotionQuery?.removeEventListener( + 'change', + onReducedMotionChange, + ); + reducedMotionQuery = undefined; + document.removeEventListener( + 'visibilitychange', + onVisibilityChange, + ); } }; }; diff --git a/tests/Unit/ScrambledHashVisibility.test.tsx b/tests/Unit/ScrambledHashVisibility.test.tsx new file mode 100644 index 000000000..de51a9ffa --- /dev/null +++ b/tests/Unit/ScrambledHashVisibility.test.tsx @@ -0,0 +1,92 @@ +/// +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 | null, + ) => { + unsubscribe = subscribe(notify); + return snapshot(); + }, +})); + +afterEach(() => { + unsubscribe?.(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('pauses the image loading animation offscreen and resumes on return', async () => { + vi.useFakeTimers(); + let reducedMotion = false; + let onMotionChange: (() => void) | undefined; + vi.stubGlobal('window', { + matchMedia: () => ({ + get matches() { + return reducedMotion; + }, + addEventListener: (name: string, callback: () => void) => { + if (name === 'change') onMotionChange = callback; + }, + removeEventListener: () => { + onMotionChange = undefined; + }, + }), + }); + 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 { useScrambledHash } = await import( + '../../resources/js/hooks/use-scrambled-hash' + ); + + assert.equal(useScrambledHash('abcd').length, 4); + vi.advanceTimersByTime(140); + assert.equal(notify.mock.calls.length, 2); + + document.visibilityState = 'hidden'; + events.get('visibilitychange')?.(); + vi.advanceTimersByTime(700); + assert.equal(notify.mock.calls.length, 2); + + document.visibilityState = 'visible'; + events.get('visibilitychange')?.(); + vi.advanceTimersByTime(70); + assert.equal(notify.mock.calls.length, 3); + + reducedMotion = true; + onMotionChange?.(); + assert.equal(notify.mock.calls.length, 4); + assert.ok(useScrambledHash('abcd').every(({ isLocked }) => isLocked)); + vi.advanceTimersByTime(700); + assert.equal(notify.mock.calls.length, 4); + + reducedMotion = false; + onMotionChange?.(); + vi.advanceTimersByTime(70); + assert.equal(notify.mock.calls.length, 5); + + document.visibilityState = 'hidden'; + events.get('visibilitychange')?.(); + reducedMotion = true; + document.visibilityState = 'visible'; + events.get('visibilitychange')?.(); + assert.equal(notify.mock.calls.length, 6); + assert.ok(useScrambledHash('abcd').every(({ isLocked }) => isLocked)); + vi.advanceTimersByTime(700); + assert.equal(notify.mock.calls.length, 6); + + unsubscribe(); + assert.equal(onMotionChange, undefined); + assert.equal(events.has('visibilitychange'), false); +});