Defer profile favourites until needed

This commit is contained in:
2026-08-30 07:30:15 +02:00
parent 187292a374
commit dba1f73e01
7 changed files with 276 additions and 15 deletions
+11 -4
View File
@@ -16,6 +16,7 @@ import TabWorlds from '../../components/profile/tabs/TabWorlds'
import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
@@ -92,6 +93,11 @@ export default function ProfileShow() {
initialFeaturedArtworks: featuredArtworks,
enabled: activeTab === 'artworks',
})
const favouritesState = useLazyProfileFavourites({
username: user?.username || user?.name || '',
initialFavourites: favourites,
enabled: activeTab === 'favourites',
})
const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return
@@ -133,10 +139,8 @@ export default function ProfileShow() {
? artworks
: (artworks?.data ?? [])
const artworkNextCursor = artworks?.next_cursor ?? null
const favouriteList = Array.isArray(favourites)
? favourites
: (favourites?.data ?? [])
const favouriteNextCursor = favourites?.next_cursor ?? null
const favouriteList = favouritesState.items
const favouriteNextCursor = favouritesState.nextCursor
// Normalise social links (may be object keyed by platform, or array)
const socialLinksObj = Array.isArray(socialLinks)
@@ -281,6 +285,9 @@ export default function ProfileShow() {
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
isOwner={isOwner}
username={user.username || user.name}
loading={favouritesState.loading}
error={favouritesState.error}
onRetry={favouritesState.retry}
/>
)}
{activeTab === 'activity' && (
@@ -13,7 +13,7 @@ function FavSkeleton() {
* TabFavourites
* Shows artworks the user has favourited.
*/
export default function TabFavourites({ favourites, isOwner, username }) {
export default function TabFavourites({ favourites, isOwner, username, loading = false, error = false, onRetry }) {
const initialItems = Array.isArray(favourites)
? favourites
: (favourites?.data ?? [])
@@ -80,7 +80,20 @@ export default function TabFavourites({ favourites, isOwner, username }) {
{isOwner ? 'Your Favourites' : 'Favourites'}
</h2>
{items.length === 0 ? (
{loading && items.length === 0 && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`initial-sk-${i}`} />)}
</div>
)}
{error && items.length === 0 && (
<div className="py-12 text-center text-slate-400">
<p>Favourites could not be loaded.</p>
{onRetry ? <button type="button" onClick={onRetry} className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">Retry</button> : null}
</div>
)}
{!loading && !error && items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-heart text-3xl" />
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export default function useLazyProfileFavourites({ username, initialFavourites, enabled }) {
const hasInitialValue = initialFavourites !== null && initialFavourites !== undefined
const initialItems = Array.isArray(initialFavourites)
? initialFavourites
: (initialFavourites?.data ?? [])
const [items, setItems] = useState(() => initialItems)
const [nextCursor, setNextCursor] = useState(() => initialFavourites?.next_cursor ?? null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const requestKeyRef = useRef(username)
const requestedRef = useRef(hasInitialValue)
const abortRef = useRef(null)
useEffect(() => {
if (requestKeyRef.current === username) return undefined
abortRef.current?.abort()
abortRef.current = null
requestKeyRef.current = username
requestedRef.current = false
setItems([])
setNextCursor(null)
setLoading(false)
setError(false)
return undefined
}, [username])
useEffect(() => {
if (requestKeyRef.current !== username || !hasInitialValue) return undefined
abortRef.current?.abort()
abortRef.current = null
requestedRef.current = true
setItems(initialItems)
setNextCursor(initialFavourites?.next_cursor ?? null)
setLoading(false)
setError(false)
return undefined
}, [hasInitialValue, initialFavourites, initialItems, username])
const load = useCallback(async () => {
if (!enabled || hasInitialValue || requestedRef.current || !username) return
requestedRef.current = true
const controller = new AbortController()
const requestKey = requestKeyRef.current
abortRef.current = controller
setLoading(true)
setError(false)
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/favourites`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
})
if (!response.ok) throw new Error('Favourites could not be loaded.')
const payload = await response.json()
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
setItems(Array.isArray(payload?.data) ? payload.data : [])
setNextCursor(payload?.next_cursor ?? null)
} catch (caught) {
if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
requestedRef.current = false
setError(true)
} finally {
if (requestKey === requestKeyRef.current) {
abortRef.current = null
setLoading(false)
}
}
}, [enabled, hasInitialValue, username])
useEffect(() => {
if (enabled) load()
}, [enabled, load])
useEffect(() => () => abortRef.current?.abort(), [])
return { items, nextCursor, loading, error, retry: load }
}
@@ -0,0 +1,74 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import useLazyProfileFavourites from './useLazyProfileFavourites'
function Harness({ username = 'alice', initial = null, enabled = false }) {
const state = useLazyProfileFavourites({
username,
initialFavourites: initial,
enabled,
})
return <output data-testid="state">{JSON.stringify(state)}</output>
}
describe('useLazyProfileFavourites', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('does not request before activation and requests page one once on activation', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: 1 }], next_cursor: 'c1' }),
})
const view = render(<Harness />)
expect(fetchMock).not.toHaveBeenCalled()
view.rerender(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
expect(fetchMock).toHaveBeenCalledWith('/api/profile/alice/favourites', expect.objectContaining({
credentials: 'same-origin',
}))
})
it('uses SSR data without a page-one request and survives rerenders', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
const view = render(<Harness enabled initial={{ data: [{ id: 9 }], next_cursor: 'c2' }} />)
view.rerender(<Harness enabled initial={{ data: [{ id: 9 }], next_cursor: 'c2' }} />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 9 }]))
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not refetch after switching away and back', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: 3 }], next_cursor: null }),
})
const view = render(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([{ id: 3 }]))
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('resets profile state and ignores a stale response', async () => {
let resolveRequest
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => {
resolveRequest = resolve
}))
const view = render(<Harness username="alice" enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness username="bob" enabled />)
expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([])
resolveRequest({ ok: true, json: async () => ({ data: [{ id: 1 }], next_cursor: null }) })
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([]))
})
})