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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user