diff --git a/resources/js/lib/useDeferredSimilarAi.js b/resources/js/lib/useDeferredSimilarAi.js new file mode 100644 index 00000000..35894ad7 --- /dev/null +++ b/resources/js/lib/useDeferredSimilarAi.js @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react' + +const REGISTRY = new Map() +const ROOT_MARGIN = '800px 0px' +const IDLE_TIMEOUT = 3000 + +export function __resetDeferredSimilarAiCache() { + for (const entry of REGISTRY.values()) { + entry.controller?.abort() + } + REGISTRY.clear() +} + +function scheduleIdle(callback) { + if (typeof window.requestIdleCallback === 'function') { + const handle = window.requestIdleCallback(callback, { timeout: IDLE_TIMEOUT }) + return () => window.cancelIdleCallback?.(handle) + } + + const handle = window.setTimeout(callback, IDLE_TIMEOUT) + return () => window.clearTimeout(handle) +} + +export function useDeferredSimilarAi(artworkId, anchorRef) { + const [state, setState] = useState(() => ({ + id: artworkId, + items: REGISTRY.get(Number(artworkId || 0))?.items || [], + })) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + + useEffect(() => { + let cancelled = false + const id = Number(artworkId || 0) + const controller = new AbortController() + let attachedEntry = null + let triggered = false + + const currentEntry = REGISTRY.get(id) + const cached = currentEntry?.status === 'completed' ? currentEntry.items : null + + setState({ id, items: cached || [] }) + setLoading(false) + setError(false) + + if (!id || cached) { + return () => controller.abort() + } + + let stopIdle = () => {} + let observer = null + let observationEstablished = false + + const detach = () => { + if (!attachedEntry) return + attachedEntry.subscribers -= 1 + const entry = attachedEntry + attachedEntry = null + if (entry.subscribers === 0 && entry.status === 'in-flight') { + entry.controller.abort() + } + } + + const attach = (entry) => { + if (cancelled || attachedEntry === entry) return + attachedEntry = entry + entry.subscribers += 1 + setLoading(true) + + entry.promise + .then((nextItems) => { + if (!cancelled) setState({ id, items: nextItems }) + }) + .catch((requestError) => { + if (!cancelled && requestError?.name !== 'AbortError') { + setState({ id, items: [] }) + setError(true) + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + if (attachedEntry === entry) { + attachedEntry = null + entry.subscribers -= 1 + } + }) + } + + const load = async () => { + if (triggered || cancelled) return + triggered = true + observer?.disconnect() + stopIdle() + + let entry = REGISTRY.get(id) + if (entry?.status === 'completed') { + if (!cancelled) setState({ id, items: entry.items }) + return + } + if (entry?.status !== 'in-flight' || entry.controller.signal.aborted) { + const requestController = new AbortController() + entry = { + status: 'in-flight', + controller: requestController, + promise: null, + subscribers: 0, + } + REGISTRY.set(id, entry) + entry.promise = Promise.resolve() + .then(() => fetch(`/api/art/${id}/similar-ai`, { + credentials: 'same-origin', + signal: requestController.signal, + })) + .then((response) => { + if (!response.ok) throw new Error('similar fetch failed') + return response.json() + }) + .then((payload) => { + const nextItems = Array.isArray(payload?.data) ? payload.data : [] + if (REGISTRY.get(id) === entry) { + REGISTRY.set(id, { status: 'completed', items: nextItems }) + } + return nextItems + }) + .catch((requestError) => { + if (REGISTRY.get(id) === entry) REGISTRY.delete(id) + throw requestError + }) + entry.promise.catch(() => {}) + } + + attach(entry) + } + + if (typeof window.IntersectionObserver === 'function' && anchorRef?.current) { + try { + observer = new window.IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) load() + }, { rootMargin: ROOT_MARGIN }) + observer.observe(anchorRef.current) + observationEstablished = true + } catch { + observer?.disconnect() + observer = null + } + } + + if (!observationEstablished) { + stopIdle = scheduleIdle(load) + } + + return () => { + cancelled = true + observer?.disconnect() + stopIdle() + detach() + controller.abort() + } + }, [artworkId, anchorRef]) + + return { + items: Number(state.id || 0) === Number(artworkId || 0) ? state.items : [], + loading: Number(state.id || 0) === Number(artworkId || 0) ? loading : false, + error: Number(state.id || 0) === Number(artworkId || 0) ? error : false, + } +} diff --git a/resources/js/lib/useDeferredSimilarAi.test.jsx b/resources/js/lib/useDeferredSimilarAi.test.jsx new file mode 100644 index 00000000..bde454b0 --- /dev/null +++ b/resources/js/lib/useDeferredSimilarAi.test.jsx @@ -0,0 +1,288 @@ +import React, { useRef } from 'react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { + __resetDeferredSimilarAiCache, + useDeferredSimilarAi, +} from './useDeferredSimilarAi' + +function Harness({ artworkId, instance = '' }) { + const anchorRef = useRef(null) + const { items, loading, error } = useDeferredSimilarAi(artworkId, anchorRef) + const suffix = instance ? `-${instance}` : '' + + return ( +
+ {loading ? 'loading' : error ? 'error' : 'ready'} + {items.map((item) => item.title).join('|')} +
+ ) +} + +function response(data, ok = true) { + return { ok, json: async () => ({ data }) } +} + +describe('useDeferredSimilarAi', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + delete window.IntersectionObserver + delete window.requestIdleCallback + delete window.cancelIdleCallback + __resetDeferredSimilarAiCache() + }) + + it('does not fetch during initial render', async () => { + global.fetch = vi.fn() + window.IntersectionObserver = vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })) + + render() + await act(async () => {}) + + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('loads once when the section approaches the viewport', async () => { + let callback + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Near rail' }])) + + render() + await act(async () => callback([{ isIntersecting: true }])) + + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Near rail')) + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(window.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { rootMargin: '800px 0px' }) + expect(global.fetch).toHaveBeenCalledWith('/api/art/11/similar-ai', expect.objectContaining({ signal: expect.any(AbortSignal) })) + }) + + it('does not use the idle fallback when observation is established', async () => { + const observe = vi.fn() + window.IntersectionObserver = vi.fn(() => ({ observe, disconnect: vi.fn() })) + window.requestIdleCallback = vi.fn() + window.cancelIdleCallback = vi.fn() + global.fetch = vi.fn() + + vi.useFakeTimers() + try { + render() + await act(async () => vi.advanceTimersByTime(3100)) + + expect(observe).toHaveBeenCalledTimes(1) + expect(window.requestIdleCallback).not.toHaveBeenCalled() + expect(global.fetch).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('uses the idle fallback once when IntersectionObserver is unavailable', async () => { + let idleCallback + window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 }) + window.cancelIdleCallback = vi.fn() + global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Idle rail' }])) + + render() + expect(global.fetch).not.toHaveBeenCalled() + await act(async () => idleCallback()) + await act(async () => idleCallback()) + + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Idle rail')) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('ignores repeated observer callbacks and preserves error behavior', async () => { + let callback + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) + + render() + await act(async () => { + callback([{ isIntersecting: true }]) + callback([{ isIntersecting: true }]) + }) + + await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error')) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('does not request the same artwork again after unmount and remount', async () => { + let callback + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Persisted rail' }])) + + const first = render() + await act(async () => callback([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Persisted rail')) + first.unmount() + + render() + expect(screen.getByTestId('items').textContent).toBe('Persisted rail') + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('reuses artwork A when navigating A to B and back to A', async () => { + const callbacks = [] + window.IntersectionObserver = vi.fn((next) => { + callbacks.push(next) + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn() + .mockResolvedValueOnce(response([{ title: 'Artwork A' }])) + .mockResolvedValueOnce(response([{ title: 'Artwork B' }])) + + const view = render() + await act(async () => callbacks[0]([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork A')) + view.rerender() + await act(async () => callbacks[1]([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork B')) + view.rerender() + + expect(screen.getByTestId('items').textContent).toBe('Artwork A') + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + it('shares one in-flight request between hook instances', async () => { + const callbacks = [] + window.IntersectionObserver = vi.fn((next) => { + callbacks.push(next) + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Shared rail' }])) + + render(<>) + await act(async () => { + callbacks[0]([{ isIntersecting: true }]) + callbacks[1]([{ isIntersecting: true }]) + }) + + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1)) + }) + + it('keeps the shared request alive when one consumer unmounts', async () => { + const callbacks = [] + let resolveRequest + window.IntersectionObserver = vi.fn((next) => { + callbacks.push(next) + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockReturnValue(new Promise((resolve) => { resolveRequest = resolve })) + + const first = render() + render() + await act(async () => { + callbacks[0]([{ isIntersecting: true }]) + callbacks[1]([{ isIntersecting: true }]) + }) + expect(global.fetch).toHaveBeenCalledTimes(1) + + first.unmount() + expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(false) + + await act(async () => resolveRequest(response([{ title: 'Surviving rail' }]))) + await waitFor(() => expect(screen.getByTestId('items-b').textContent).toBe('Surviving rail')) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('aborts the shared request after the final consumer unmounts', async () => { + const callbacks = [] + let requestOptions + window.IntersectionObserver = vi.fn((next) => { + callbacks.push(next) + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn().mockImplementation((url, options) => { + requestOptions = options + return new Promise(() => {}) + }) + + const first = render() + const second = render() + await act(async () => { + callbacks[0]([{ isIntersecting: true }]) + callbacks[1]([{ isIntersecting: true }]) + }) + expect(global.fetch).toHaveBeenCalledTimes(1) + + first.unmount() + expect(requestOptions.signal.aborted).toBe(false) + second.unmount() + expect(requestOptions.signal.aborted).toBe(true) + }) + + it('allows an aborted request to be fetched again later', async () => { + let callback + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn() + .mockImplementationOnce((url, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))) + })) + .mockResolvedValueOnce(response([{ title: 'Retried rail' }])) + + const first = render() + await act(async () => callback([{ isIntersecting: true }])) + first.unmount() + await act(async () => {}) + + render() + await act(async () => callback([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Retried rail')) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + it('allows a failed request to be retried after remount', async () => { + let callback + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: false, json: async () => ({}) }) + .mockResolvedValueOnce(response([{ title: 'Recovered rail' }])) + + const first = render() + await act(async () => callback([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error')) + first.unmount() + + render() + await act(async () => callback([{ isIntersecting: true }])) + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Recovered rail')) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + it('resets for a new artwork ID and ignores the stale response', async () => { + let callback + let resolveOld + window.IntersectionObserver = vi.fn((next) => { + callback = next + return { observe: vi.fn(), disconnect: vi.fn() } + }) + global.fetch = vi.fn() + .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + .mockResolvedValueOnce(response([{ title: 'New rail' }])) + + const view = render() + await act(async () => callback([{ isIntersecting: true }])) + view.rerender() + await act(async () => callback([{ isIntersecting: true }])) + + await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('New rail')) + await act(async () => resolveOld(response([{ title: 'Old rail' }]))) + expect(screen.getByTestId('items').textContent).toBe('New rail') + expect(global.fetch).toHaveBeenCalledTimes(2) + }) +})