From db1fd340879390df8ea739bdd7ceffb5665d2d07 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:14:57 +0530 Subject: [PATCH 1/4] perf: pause image loading animations when tab is hidden --- resources/js/hooks/use-scrambled-hash.ts | 41 +++++++++++---- tests/Unit/ScrambledHashVisibility.test.tsx | 57 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 tests/Unit/ScrambledHashVisibility.test.tsx diff --git a/resources/js/hooks/use-scrambled-hash.ts b/resources/js/hooks/use-scrambled-hash.ts index 1251847b6..b7986cd63 100644 --- a/resources/js/hooks/use-scrambled-hash.ts +++ b/resources/js/hooks/use-scrambled-hash.ts @@ -14,18 +14,37 @@ const listeners = new Set<() => void>(); let currentFrame: number | null = null; let interval: ReturnType | undefined; +const start = () => { + if ( + interval !== undefined || + document.visibilityState === 'hidden' || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + + currentFrame ??= 0; + interval = setInterval(() => { + currentFrame = (currentFrame ?? 0) + 1; + listeners.forEach((notify) => notify()); + }, FRAME_MS); +}; + +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) { + document.addEventListener('visibilitychange', onVisibilityChange); + start(); } return () => { @@ -35,6 +54,10 @@ const subscribe = (listener: () => void) => { clearInterval(interval); interval = undefined; currentFrame = null; + document.removeEventListener( + 'visibilitychange', + onVisibilityChange, + ); } }; }; diff --git a/tests/Unit/ScrambledHashVisibility.test.tsx b/tests/Unit/ScrambledHashVisibility.test.tsx new file mode 100644 index 000000000..a57a4adab --- /dev/null +++ b/tests/Unit/ScrambledHashVisibility.test.tsx @@ -0,0 +1,57 @@ +/// +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(); + vi.stubGlobal('window', { + matchMedia: () => ({ matches: false }), + }); + 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); + + unsubscribe(); + assert.equal(events.has('visibilitychange'), false); +}); From 0866957748b8de9335bcfa2fe3155c7b182b992e Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:03:11 +0530 Subject: [PATCH 2/4] fix: clear scrambled image frames when reduced motion is enabled --- resources/js/hooks/use-scrambled-hash.ts | 15 ++++++++++----- tests/Unit/ScrambledHashVisibility.test.tsx | 13 ++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/resources/js/hooks/use-scrambled-hash.ts b/resources/js/hooks/use-scrambled-hash.ts index b7986cd63..993b1b090 100644 --- a/resources/js/hooks/use-scrambled-hash.ts +++ b/resources/js/hooks/use-scrambled-hash.ts @@ -15,11 +15,16 @@ let currentFrame: number | null = null; let interval: ReturnType | undefined; const start = () => { - if ( - interval !== undefined || - document.visibilityState === 'hidden' || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { + if (interval !== undefined || document.visibilityState === 'hidden') { + return; + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + if (currentFrame !== null) { + currentFrame = null; + listeners.forEach((notify) => notify()); + } + return; } diff --git a/tests/Unit/ScrambledHashVisibility.test.tsx b/tests/Unit/ScrambledHashVisibility.test.tsx index a57a4adab..c44a33c75 100644 --- a/tests/Unit/ScrambledHashVisibility.test.tsx +++ b/tests/Unit/ScrambledHashVisibility.test.tsx @@ -24,8 +24,9 @@ afterEach(() => { test('pauses the image loading animation offscreen and resumes on return', async () => { vi.useFakeTimers(); + let reducedMotion = false; vi.stubGlobal('window', { - matchMedia: () => ({ matches: false }), + matchMedia: () => ({ matches: reducedMotion }), }); const events = new Map void>(); const document = { @@ -52,6 +53,16 @@ test('pauses the image loading animation offscreen and resumes on return', async vi.advanceTimersByTime(70); assert.equal(notify.mock.calls.length, 3); + document.visibilityState = 'hidden'; + events.get('visibilitychange')?.(); + reducedMotion = true; + document.visibilityState = 'visible'; + events.get('visibilitychange')?.(); + assert.equal(notify.mock.calls.length, 4); + assert.ok(useScrambledHash('abcd').every(({ isLocked }) => isLocked)); + vi.advanceTimersByTime(700); + assert.equal(notify.mock.calls.length, 4); + unsubscribe(); assert.equal(events.has('visibilitychange'), false); }); From 79bcc6a087f9d13e9e3a9377db53d525054c847f Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:17:11 +0530 Subject: [PATCH 3/4] fix: stop scrambling immediately when reduced motion changes --- resources/js/hooks/use-scrambled-hash.ts | 21 ++++++++++++++- tests/Unit/ScrambledHashVisibility.test.tsx | 30 ++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/resources/js/hooks/use-scrambled-hash.ts b/resources/js/hooks/use-scrambled-hash.ts index 993b1b090..442addcb8 100644 --- a/resources/js/hooks/use-scrambled-hash.ts +++ b/resources/js/hooks/use-scrambled-hash.ts @@ -13,13 +13,14 @@ 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 (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + if (reducedMotionQuery?.matches) { if (currentFrame !== null) { currentFrame = null; listeners.forEach((notify) => notify()); @@ -35,6 +36,20 @@ const start = () => { }, 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); @@ -48,6 +63,8 @@ const subscribe = (listener: () => void) => { listeners.add(listener); if (listeners.size === 1) { + reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotionQuery.addEventListener('change', onReducedMotionChange); document.addEventListener('visibilitychange', onVisibilityChange); start(); } @@ -59,6 +76,8 @@ 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 index c44a33c75..de51a9ffa 100644 --- a/tests/Unit/ScrambledHashVisibility.test.tsx +++ b/tests/Unit/ScrambledHashVisibility.test.tsx @@ -25,8 +25,19 @@ afterEach(() => { 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: () => ({ matches: reducedMotion }), + 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 = { @@ -53,16 +64,29 @@ test('pauses the image loading animation offscreen and resumes on return', async 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, 4); + assert.equal(notify.mock.calls.length, 6); assert.ok(useScrambledHash('abcd').every(({ isLocked }) => isLocked)); vi.advanceTimersByTime(700); - assert.equal(notify.mock.calls.length, 4); + assert.equal(notify.mock.calls.length, 6); unsubscribe(); + assert.equal(onMotionChange, undefined); assert.equal(events.has('visibilitychange'), false); }); From b0134a559f0e1b57302a7f4508268c9a1da141de Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:18:24 +0530 Subject: [PATCH 4/4] style: keep updated source within project formatting conventions --- resources/js/hooks/use-scrambled-hash.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/js/hooks/use-scrambled-hash.ts b/resources/js/hooks/use-scrambled-hash.ts index 442addcb8..1fb26c1d9 100644 --- a/resources/js/hooks/use-scrambled-hash.ts +++ b/resources/js/hooks/use-scrambled-hash.ts @@ -63,7 +63,9 @@ const subscribe = (listener: () => void) => { listeners.add(listener); if (listeners.size === 1) { - reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotionQuery = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ); reducedMotionQuery.addEventListener('change', onReducedMotionChange); document.addEventListener('visibilitychange', onVisibilityChange); start(); @@ -76,7 +78,10 @@ const subscribe = (listener: () => void) => { clearInterval(interval); interval = undefined; currentFrame = null; - reducedMotionQuery?.removeEventListener('change', onReducedMotionChange); + reducedMotionQuery?.removeEventListener( + 'change', + onReducedMotionChange, + ); reducedMotionQuery = undefined; document.removeEventListener( 'visibilitychange',