Defer similar-AI recommendations until the rail is near view.
ArtworkPage already imports this hook; ship the implementation so similar-AI work stays off the initial render.
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div ref={anchorRef}>
|
||||||
|
<output data-testid={`state${suffix}`}>{loading ? 'loading' : error ? 'error' : 'ready'}</output>
|
||||||
|
<output data-testid={`items${suffix}`}>{items.map((item) => item.title).join('|')}</output>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<Harness artworkId={10} />)
|
||||||
|
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(<Harness artworkId={11} />)
|
||||||
|
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(<Harness artworkId={12} />)
|
||||||
|
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(<Harness artworkId={12} />)
|
||||||
|
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(<Harness artworkId={13} />)
|
||||||
|
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(<Harness artworkId={16} />)
|
||||||
|
await act(async () => callback([{ isIntersecting: true }]))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Persisted rail'))
|
||||||
|
first.unmount()
|
||||||
|
|
||||||
|
render(<Harness artworkId={16} />)
|
||||||
|
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(<Harness artworkId={17} />)
|
||||||
|
await act(async () => callbacks[0]([{ isIntersecting: true }]))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork A'))
|
||||||
|
view.rerender(<Harness artworkId={18} />)
|
||||||
|
await act(async () => callbacks[1]([{ isIntersecting: true }]))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork B'))
|
||||||
|
view.rerender(<Harness artworkId={17} />)
|
||||||
|
|
||||||
|
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(<><Harness artworkId={19} /><Harness artworkId={19} /></>)
|
||||||
|
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(<Harness artworkId={22} instance="a" />)
|
||||||
|
render(<Harness artworkId={22} instance="b" />)
|
||||||
|
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(<Harness artworkId={23} instance="a" />)
|
||||||
|
const second = render(<Harness artworkId={23} instance="b" />)
|
||||||
|
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(<Harness artworkId={20} />)
|
||||||
|
await act(async () => callback([{ isIntersecting: true }]))
|
||||||
|
first.unmount()
|
||||||
|
await act(async () => {})
|
||||||
|
|
||||||
|
render(<Harness artworkId={20} />)
|
||||||
|
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(<Harness artworkId={21} />)
|
||||||
|
await act(async () => callback([{ isIntersecting: true }]))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error'))
|
||||||
|
first.unmount()
|
||||||
|
|
||||||
|
render(<Harness artworkId={21} />)
|
||||||
|
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(<Harness artworkId={14} />)
|
||||||
|
await act(async () => callback([{ isIntersecting: true }]))
|
||||||
|
view.rerender(<Harness artworkId={15} />)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user