From dba1f73e01d4d4b0cb9bac4cd1db7dec5fad480a Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 30 Aug 2026 07:30:15 +0200 Subject: [PATCH] Defer profile favourites until needed --- .../Controllers/Api/ProfileApiController.php | 9 +- .../Controllers/User/ProfileController.php | 19 +++-- resources/js/Pages/Profile/ProfileShow.jsx | 15 +++- .../components/profile/tabs/TabFavourites.jsx | 17 +++- resources/js/lib/useLazyProfileFavourites.js | 83 +++++++++++++++++++ .../js/lib/useLazyProfileFavourites.test.jsx | 74 +++++++++++++++++ .../Profile/ProfileFavouritesLazyTest.php | 74 +++++++++++++++++ 7 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 resources/js/lib/useLazyProfileFavourites.js create mode 100644 resources/js/lib/useLazyProfileFavourites.test.jsx create mode 100644 tests/Feature/Profile/ProfileFavouritesLazyTest.php diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php index 183aaadb..de27b38e 100644 --- a/app/Http/Controllers/Api/ProfileApiController.php +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -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, diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index c27a64c7..81657c33 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -1011,14 +1011,17 @@ class ProfileController extends Controller ? $this->loadFeaturedArtworks($user, $viewer) : null; - // ── Favourites ─────────────────────────────────────────────────────── - $favouriteLimit = 12; - $favouriteTable = $this->resolveFavouriteTable(); - $favourites = [ - 'data' => [], - 'next_cursor' => null, - ]; - if ($favouriteTable !== null) { + // ── Favourites (only needed for the direct Favourites tab) ─────────── + $favourites = null; + if ($initialTab === 'favourites') { + $favouriteLimit = 12; + $favouriteTable = $this->resolveFavouriteTable(); + $favourites = [ + 'data' => [], + 'next_cursor' => 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) diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index d23ddc5c..07edfbba 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -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' && ( diff --git a/resources/js/components/profile/tabs/TabFavourites.jsx b/resources/js/components/profile/tabs/TabFavourites.jsx index 9d003b58..a5307781 100644 --- a/resources/js/components/profile/tabs/TabFavourites.jsx +++ b/resources/js/components/profile/tabs/TabFavourites.jsx @@ -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'} - {items.length === 0 ? ( + {loading && items.length === 0 && ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ )} + + {error && items.length === 0 && ( +
+

Favourites could not be loaded.

+ {onRetry ? : null} +
+ )} + + {!loading && !error && items.length === 0 ? (
diff --git a/resources/js/lib/useLazyProfileFavourites.js b/resources/js/lib/useLazyProfileFavourites.js new file mode 100644 index 00000000..3181957f --- /dev/null +++ b/resources/js/lib/useLazyProfileFavourites.js @@ -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 } +} diff --git a/resources/js/lib/useLazyProfileFavourites.test.jsx b/resources/js/lib/useLazyProfileFavourites.test.jsx new file mode 100644 index 00000000..20315653 --- /dev/null +++ b/resources/js/lib/useLazyProfileFavourites.test.jsx @@ -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 {JSON.stringify(state)} +} + +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() + + expect(fetchMock).not.toHaveBeenCalled() + view.rerender() + + 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() + view.rerender() + + 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() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + view.rerender() + view.rerender() + 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() + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + view.rerender() + 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([])) + }) +}) diff --git a/tests/Feature/Profile/ProfileFavouritesLazyTest.php b/tests/Feature/Profile/ProfileFavouritesLazyTest.php new file mode 100644 index 00000000..d913335f --- /dev/null +++ b/tests/Feature/Profile/ProfileFavouritesLazyTest.php @@ -0,0 +1,74 @@ +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); +});