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 (
+
+
+
+
+ )
+}
+
+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)
+ })
+})