ArtworkPage already imports this hook; ship the implementation so similar-AI work stays off the initial render.
167 lines
4.7 KiB
JavaScript
167 lines
4.7 KiB
JavaScript
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,
|
|
}
|
|
}
|