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:
test
2026-09-20 14:49:33 +02:00
parent 1d18052fde
commit 37100ced3f
8 changed files with 770 additions and 217 deletions
@@ -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>
)
}