Lazy-load profile collections until the tab is opened.
Keep the profile payload smaller on first paint and fetch collections through a dedicated API when the collections tab is needed.
This commit is contained in:
@@ -18,6 +18,7 @@ import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
||||
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
||||
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
|
||||
import useLazyProfileCollections from '../../lib/useLazyProfileCollections'
|
||||
|
||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||
|
||||
@@ -104,6 +105,11 @@ export default function ProfileShow() {
|
||||
initialWorldHistory: worldHistory,
|
||||
enabled: activeTab === 'worlds' || activeTab === 'achievements',
|
||||
})
|
||||
const collectionsState = useLazyProfileCollections({
|
||||
username: user?.username || user?.name || '',
|
||||
initialCollections: collections,
|
||||
enabled: activeTab === 'collections',
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
if (!VALID_TABS.includes(tab)) return
|
||||
@@ -252,12 +258,15 @@ export default function ProfileShow() {
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections
|
||||
collections={collections}
|
||||
collections={collectionsState.items}
|
||||
isOwner={isOwner}
|
||||
createUrl={collectionCreateUrl}
|
||||
reorderUrl={collectionReorderUrl}
|
||||
featuredUrl={collectionsFeaturedUrl}
|
||||
featureLimit={collectionFeatureLimit}
|
||||
loading={collectionsState.loading}
|
||||
error={collectionsState.error}
|
||||
onRetry={collectionsState.retry}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'about' && (
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { act } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TabCollections from './TabCollections'
|
||||
|
||||
const card = (id, overrides = {}) => ({
|
||||
id,
|
||||
title: `Collection ${id}`,
|
||||
slug: `collection-${id}`,
|
||||
type: 'personal',
|
||||
mode: 'manual',
|
||||
visibility: 'public',
|
||||
is_featured: id === 1,
|
||||
artworks_count: id === 1 ? 12 : 0,
|
||||
likes_count: id === 1 ? 4 : 0,
|
||||
followers_count: 0,
|
||||
collaborators_count: 1,
|
||||
updated_at: '2026-08-30T09:00:00.000000Z',
|
||||
featured_at: id === 1 ? '2026-08-29T09:00:00.000000Z' : null,
|
||||
description_excerpt: id === 1 ? 'A populated card.' : '',
|
||||
smart_summary: null,
|
||||
owner: { name: 'Fixture Owner', username: 'fixture', profile_url: '/@fixture/collections' },
|
||||
cover_image: id === 1 ? 'https://cdn.example.test/cover.webp' : null,
|
||||
cover_image_maturity: { should_blur: false, is_mature_effective: false },
|
||||
saved: false,
|
||||
save_url: null,
|
||||
unsave_url: null,
|
||||
login_url: '/login',
|
||||
url: `/@fixture/collections/collection-${id}`,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const props = {
|
||||
collections: [card(1), card(2)],
|
||||
isOwner: false,
|
||||
createUrl: null,
|
||||
reorderUrl: null,
|
||||
featuredUrl: '/collections/featured',
|
||||
featureLimit: 3,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TabCollections SSR hydration', () => {
|
||||
it('hydrates populated collection cards without markup drift or errors', async () => {
|
||||
const container = document.createElement('div')
|
||||
const serverMarkup = renderToString(<TabCollections {...props} />)
|
||||
container.innerHTML = serverMarkup
|
||||
const parsedServerMarkup = container.innerHTML
|
||||
document.body.appendChild(container)
|
||||
const recoverableErrors = []
|
||||
|
||||
const root = hydrateRoot(container, <TabCollections {...props} />, {
|
||||
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(container.innerHTML).toBe(parsedServerMarkup)
|
||||
expect(recoverableErrors).toHaveLength(0)
|
||||
expect(container.textContent).toContain('Collection 1')
|
||||
expect(container.textContent).toContain('12 artworks')
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('hydrates loaded-empty state without a lazy request', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
const emptyProps = { ...props, collections: [] }
|
||||
const container = document.createElement('div')
|
||||
const serverMarkup = renderToString(<TabCollections {...emptyProps} />)
|
||||
container.innerHTML = serverMarkup
|
||||
const parsedServerMarkup = container.innerHTML
|
||||
document.body.appendChild(container)
|
||||
const recoverableErrors = []
|
||||
|
||||
const root = hydrateRoot(container, <TabCollections {...emptyProps} />, {
|
||||
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(container.innerHTML).toBe(parsedServerMarkup)
|
||||
expect(recoverableErrors).toHaveLength(0)
|
||||
expect(container.textContent).toContain('No public collections yet')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
|
||||
const FILTERS = ['all', 'featured', 'smart', 'manual']
|
||||
|
||||
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
|
||||
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3, loading = false, error = false, onRetry }) {
|
||||
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [filter, setFilter] = useState('all')
|
||||
@@ -140,6 +140,15 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
{loading ? <p className="py-10 text-center text-sm text-slate-400">Loading collections…</p> : null}
|
||||
{error ? (
|
||||
<div className="mb-5 rounded-[24px] border border-rose-300/20 bg-rose-400/10 px-5 py-4 text-sm text-rose-100">
|
||||
<span>Collections could not be loaded.</span>
|
||||
{onRetry ? <button type="button" onClick={onRetry} className="ml-3 underline">Retry</button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<>
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
||||
@@ -218,6 +227,8 @@ export default function TabCollections({ collections, isOwner, createUrl, reorde
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function useLazyProfileCollections({ username, initialCollections, enabled }) {
|
||||
const hasInitialValue = Array.isArray(initialCollections)
|
||||
const [items, setItems] = useState(() => hasInitialValue ? initialCollections : [])
|
||||
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
|
||||
setItems([])
|
||||
setLoaded(false)
|
||||
setLoading(false)
|
||||
setError(false)
|
||||
|
||||
return undefined
|
||||
}, [username])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialValue || requestKeyRef.current !== username) return undefined
|
||||
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
requestedRef.current = true
|
||||
setItems(initialCollections)
|
||||
setLoaded(true)
|
||||
setLoading(false)
|
||||
setError(false)
|
||||
|
||||
return undefined
|
||||
}, [hasInitialValue, initialCollections, 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)}/collections`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Collections could not be loaded.')
|
||||
|
||||
const payload = await response.json()
|
||||
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
|
||||
|
||||
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||
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 { items, loaded, loading, error, retry: load }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useLazyProfileCollections from './useLazyProfileCollections'
|
||||
|
||||
function Harness({ username = 'alice', initial = null, enabled = false }) {
|
||||
const state = useLazyProfileCollections({
|
||||
username,
|
||||
initialCollections: initial,
|
||||
enabled,
|
||||
})
|
||||
|
||||
return <output data-testid="state">{JSON.stringify(state)}</output>
|
||||
}
|
||||
|
||||
describe('useLazyProfileCollections', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('does not request while the profile remains on Posts', () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
render(<Harness />)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requests once on first Collections activation and reuses the result', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 1, title: 'One' }] }),
|
||||
})
|
||||
const view = render(<Harness />)
|
||||
|
||||
view.rerender(<Harness enabled />)
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toHaveLength(1))
|
||||
|
||||
view.rerender(<Harness enabled={false} />)
|
||||
view.rerender(<Harness enabled />)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats an empty response as loaded and does not refetch', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: [] }) })
|
||||
const view = render(<Harness enabled />)
|
||||
|
||||
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
|
||||
view.rerender(<Harness enabled={false} />)
|
||||
view.rerender(<Harness enabled />)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses direct Collections SSR data without a lazy request', () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
const initial = [{ id: 9, title: 'SSR collection' }]
|
||||
const view = render(<Harness initial={initial} enabled />)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual(initial)
|
||||
})
|
||||
|
||||
it('ignores a stale response after profile navigation', async () => {
|
||||
let resolveAlice
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveAlice = resolve }))
|
||||
const view = render(<Harness username="alice" enabled />)
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.rerender(<Harness username="bob" enabled />)
|
||||
resolveAlice({ ok: true, json: async () => ({ data: [{ id: 'alice' }] }) })
|
||||
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).items).toEqual([]))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user