Defer profile world history until needed

This commit is contained in:
test
2026-08-30 12:02:07 +02:00
parent 5c80402aed
commit 937f484cc9
8 changed files with 282 additions and 11 deletions
+8 -2
View File
@@ -17,6 +17,7 @@ import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
@@ -98,6 +99,11 @@ export default function ProfileShow() {
initialFavourites: favourites,
enabled: activeTab === 'favourites',
})
const worldHistoryState = useLazyProfileWorldHistory({
username: user?.username || user?.name || '',
initialWorldHistory: worldHistory,
enabled: activeTab === 'worlds' || activeTab === 'achievements',
})
const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return
@@ -239,10 +245,10 @@ export default function ProfileShow() {
/>
)}
{activeTab === 'achievements' && (
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistory} onTabChange={handleTabChange} />
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistoryState.data} onTabChange={handleTabChange} />
)}
{activeTab === 'worlds' && (
<TabWorlds worldHistory={worldHistory} isOwner={isOwner} />
<TabWorlds worldHistory={worldHistoryState.data} isOwner={isOwner} loading={worldHistoryState.loading} error={worldHistoryState.error} onRetry={worldHistoryState.retry} />
)}
{activeTab === 'collections' && (
<TabCollections
@@ -1,7 +1,7 @@
import React from 'react'
import ProfileWorldHistorySection from '../worlds/ProfileWorldHistorySection'
export default function TabWorlds({ worldHistory, isOwner }) {
export default function TabWorlds({ worldHistory, isOwner, loading = false, error = false, onRetry }) {
return (
<div
id="tabpanel-worlds"
@@ -9,7 +9,14 @@ export default function TabWorlds({ worldHistory, isOwner }) {
aria-labelledby="tab-worlds"
className="pt-6"
>
{loading ? <p className="mb-4 text-sm text-slate-400">Loading worlds history…</p> : null}
{error ? (
<div className="mb-4 flex items-center gap-3 text-sm text-rose-300">
<span>Worlds history could not be loaded.</span>
<button type="button" onClick={onRetry} className="underline underline-offset-2">Retry</button>
</div>
) : null}
<ProfileWorldHistorySection history={worldHistory} isOwner={isOwner} />
</div>
)
}
}
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export default function useLazyProfileWorldHistory({ username, initialWorldHistory, enabled }) {
const hasInitialValue = initialWorldHistory !== null && initialWorldHistory !== undefined
const [data, setData] = useState(() => hasInitialValue ? initialWorldHistory : null)
const [loaded, setLoaded] = useState(hasInitialValue)
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
setData(null)
setLoaded(false)
setLoading(false)
setError(false)
return undefined
}, [username])
useEffect(() => {
if (!hasInitialValue || requestKeyRef.current !== username) return
abortRef.current?.abort()
abortRef.current = null
requestedRef.current = true
setData(initialWorldHistory)
setLoaded(true)
setLoading(false)
setError(false)
}, [hasInitialValue, initialWorldHistory, username])
const load = useCallback(async () => {
if (!enabled || hasInitialValue || requestKeyRef.current !== username || 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)}/world-history`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
})
if (!response.ok) throw new Error('World history could not be loaded.')
const payload = await response.json()
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
setData(payload)
setLoaded(true)
} 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 { data, loaded, loading, error, retry: load }
}
@@ -0,0 +1,81 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import useLazyProfileWorldHistory from './useLazyProfileWorldHistory'
function Harness({ username = 'alice', initial = null, enabled = false }) {
const state = useLazyProfileWorldHistory({
username,
initialWorldHistory: initial,
enabled,
})
return <output data-testid="state">{JSON.stringify(state)}</output>
}
describe('useLazyProfileWorldHistory', () => {
beforeEach(() => vi.restoreAllMocks())
it('does not request on Posts until a consuming tab is active', () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
render(<Harness />)
expect(fetchMock).not.toHaveBeenCalled()
})
it('requests once on first consuming-tab activation and reuses the result', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ summary: { available: true }, entries: [{ id: 'world-1' }] }),
})
const view = render(<Harness />)
view.rerender(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
expect(fetchMock).toHaveBeenCalledTimes(1)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
})
it('uses direct Worlds/Achievements SSR data without a lazy request', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
const initial = { summary: { available: true }, entries: [{ id: 'world-1' }] }
const view = render(<Harness initial={initial} enabled />)
view.rerender(<Harness initial={initial} enabled={false} />)
view.rerender(<Harness initial={initial} enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toEqual(initial))
expect(fetchMock).not.toHaveBeenCalled()
})
it('treats an empty response as loaded', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
const view = render(<Harness enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('resets on profile navigation and ignores the stale response', async () => {
let resolveA
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveA = resolve }))
const view = render(<Harness username="alice" enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
view.rerender(<Harness username="bob" enabled />)
resolveA({ ok: true, json: async () => ({ summary: { available: true }, entries: [{ id: 'alice' }] }) })
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toBe(null))
})
it('does not loop after an error and permits an explicit retry', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
const view = render(<Harness enabled />)
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).error).toBe(true))
expect(fetchMock).toHaveBeenCalledTimes(1)
// The hook's returned retry is intentionally exercised through a small rerender activation.
view.rerender(<Harness enabled={false} />)
view.rerender(<Harness enabled />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
})
})