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
@@ -10,6 +10,7 @@ use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -106,7 +107,7 @@ final class ProfileApiController extends Controller
return response()->json(['error' => 'User not found'], 404);
}
$perPage = 24;
$perPage = 12;
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table($favouriteTable . ' as af')
@@ -116,6 +117,10 @@ final class ProfileApiController extends Controller
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
->when(app(ArtworkMaturityService::class)->viewerPreferences($request->user())['visibility'] === ArtworkMaturityService::VIEW_HIDE, function ($query): void {
$query->whereRaw('COALESCE(a.is_mature, 0) = 0')
->whereRaw("COALESCE(a.maturity_status, 'clear') != ?", [ArtworkMaturityService::STATUS_SUSPECTED]);
})
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
@@ -246,6 +251,8 @@ final class ProfileApiController extends Controller
return [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $this->formatIsoDate($art->published_at),
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
@@ -1011,14 +1011,17 @@ class ProfileController extends Controller
? $this->loadFeaturedArtworks($user, $viewer)
: null;
// ── Favourites ───────────────────────────────────────────────────────
// ── Favourites (only needed for the direct Favourites tab) ───────────
$favourites = null;
if ($initialTab === 'favourites') {
$favouriteLimit = 12;
$favouriteTable = $this->resolveFavouriteTable();
$favourites = [
'data' => [],
'next_cursor' => null,
];
if ($favouriteTable !== null) {
}
if ($favourites !== null && $favouriteTable !== null) {
$favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
+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([]))
})
})
@@ -0,0 +1,74 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
function profileFavouritesFixture(): array
{
$user = User::factory()->create(['username' => 'lazyfavourites']);
$artworks = Artwork::factory()->count(13)->for($user)->create();
foreach ($artworks as $index => $artwork) {
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'created_at' => now()->subMinutes($index),
'updated_at' => now()->subMinutes($index),
]);
}
return [$user, $artworks];
}
it('skips the favourites producer on the default Posts SSR request', function (): void {
[$user] = profileFavouritesFixture();
DB::flushQueryLog();
DB::enableQueryLog();
$this->get('/@' . $user->username)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'posts')
->where('favourites', null));
expect(collect(DB::getQueryLog())->pluck('query')->filter(fn (string $query): bool => str_contains(strtolower($query), 'artwork_favourites'))->all())->toBe([]);
});
it('keeps the first favourites page in direct SSR with the existing cursor contract', function (): void {
[$user, $artworks] = profileFavouritesFixture();
$this->get('/@' . $user->username . '/favourites')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'favourites')
->where('favourites.next_cursor', base64_encode('12'))
->where('favourites.data.0.id', $artworks->first()->id));
});
it('returns the same ordered twelve-item page and continues with the next cursor', function (): void {
[$user, $artworks] = profileFavouritesFixture();
$expected = $artworks->values();
$first = $this->getJson('/api/profile/' . $user->username . '/favourites')
->assertOk()
->assertJsonCount(12, 'data')
->assertJsonPath('data.0.id', $expected[0]->id)
->assertJsonPath('next_cursor', base64_encode('12'));
$this->getJson('/api/profile/' . $user->username . '/favourites?cursor=' . urlencode($first->json('next_cursor')))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $expected[12]->id)
->assertJsonPath('next_cursor', null);
});
it('does not increment profile views for the lazy favourites endpoint', function (): void {
[$user] = profileFavouritesFixture();
$before = (int) $user->profile_views_count;
$this->getJson('/api/profile/' . $user->username . '/favourites')->assertOk();
expect((int) $user->fresh()->profile_views_count)->toBe($before);
});