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:
2026-09-20 14:49:33 +02:00
parent a5e51617a6
commit 822b96e92a
8 changed files with 770 additions and 217 deletions
@@ -0,0 +1,195 @@
<?php
use App\Models\Collection;
use App\Models\Artwork;
use App\Models\User;
use App\Services\SmartCollectionService;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
function profileCollectionsLazyFixture(): array
{
$user = User::factory()->create(['username' => 'lazycollections']);
$collections = Collection::factory()->for($user)->count(3)->sequence(
['title' => 'First collection', 'profile_order' => 0],
['title' => 'Featured collection', 'is_featured' => true, 'profile_order' => 1],
['title' => 'Third collection', 'profile_order' => 2],
)->create();
return [$user, $collections];
}
it('omits collection production from the default Posts SSR request', function (): void {
[$user] = profileCollectionsLazyFixture();
$collectionQueries = [];
DB::listen(function ($query) use (&$collectionQueries): void {
if (preg_match('/(?:from|join) [`"]?(collections|collection_artwork)[`"]?/i', $query->sql)) {
$collectionQueries[] = $query->sql;
}
});
$this->get('/@' . $user->username)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'posts')
->where('collections', null));
expect($collectionQueries)->toBe([]);
});
it('keeps collections in direct Collections SSR with the existing order and limit', function (): void {
[$user, $collections] = profileCollectionsLazyFixture();
$this->get('/@' . $user->username . '/collections')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'collections')
->has('collections', 3)
->where('collections.0.id', $collections[1]->id)
->where('collections.1.id', $collections[0]->id)
->where('collections.2.id', $collections[2]->id));
});
it('returns the same profile card payload from the lazy endpoint without tracking a view', function (): void {
[$user] = profileCollectionsLazyFixture();
$before = (int) ($user->profile_views_count ?? 0);
$expected = app(\App\Services\CollectionService::class)
->mapCollectionCardPayloads(
app(\App\Services\CollectionService::class)->getProfileCollections($user, null),
false,
null,
);
$this->getJson('/api/profile/' . $user->username . '/collections')
->assertOk()
->assertJson(['data' => $expected]);
expect((int) $user->fresh()->profile_views_count)->toBe($before);
});
it('keeps private collections owner-only in the lazy endpoint', function (): void {
[$user] = profileCollectionsLazyFixture();
Collection::factory()->for($user)->create([
'title' => 'Private collection',
'visibility' => Collection::VISIBILITY_PRIVATE,
]);
$this->getJson('/api/profile/' . $user->username . '/collections')
->assertOk()
->assertJsonMissing(['title' => 'Private collection']);
$this->actingAs($user)
->getJson('/api/profile/' . $user->username . '/collections')
->assertOk()
->assertJsonFragment(['title' => 'Private collection']);
});
it('preserves the authenticated non-owner payload and viewer state', function (): void {
[$user] = profileCollectionsLazyFixture();
$viewer = User::factory()->create(['username' => 'collectionviewer']);
$service = app(\App\Services\CollectionService::class);
$expected = $service->mapCollectionCardPayloads(
$service->getProfileCollections($user, $viewer),
false,
$viewer,
);
$this->actingAs($viewer)
->getJson('/api/profile/' . $user->username . '/collections')
->assertOk()
->assertJson(['data' => $expected]);
});
it('keeps the existing first twelve collection order', function (): void {
$user = User::factory()->create(['username' => 'collectionlimit']);
Collection::factory()->for($user)->count(13)->sequence(
fn ($sequence) => ['profile_order' => $sequence->index],
)->create();
$service = app(\App\Services\CollectionService::class);
$expectedIds = $service->getProfileCollections($user, null)->pluck('id')->all();
expect($expectedIds)->toHaveCount(12);
$this->getJson('/api/profile/' . $user->username . '/collections')
->assertOk()
->assertJsonPath('data.*.id', $expectedIds);
});
it('bulk-loads manual collection covers without mapper fan-out', function (): void {
$user = User::factory()->create(['username' => 'collectionmapper']);
$collections = Collection::factory()->for($user)->count(3)->create();
foreach ($collections as $index => $collection) {
$artwork = Artwork::factory()->for($user)->create([
'title' => 'Collection artwork ' . $index,
]);
$collection->artworks()->attach($artwork->id, ['order_num' => 1]);
}
$service = app(\App\Services\CollectionService::class);
$loaded = $service->getProfileCollections($user, null);
DB::flushQueryLog();
DB::enableQueryLog();
$payload = $service->mapCollectionCardPayloads($loaded, false, null);
$queries = DB::getQueryLog();
expect($payload)->toHaveCount(3)
->and($queries)->toHaveCount(2)
->and(collect($payload)->pluck('cover_artwork_id')->filter()->count())->toBe(3);
});
it('keeps manual mapper query counts bounded from one through twelve collections', function (): void {
$user = User::factory()->create(['username' => 'collectionmatrix']);
$collections = Collection::factory()->for($user)->count(12)->create();
foreach ($collections as $index => $collection) {
$artwork = Artwork::factory()->for($user)->create(['title' => 'Matrix artwork ' . $index]);
$collection->artworks()->attach($artwork->id, ['order_num' => 1]);
}
$service = app(\App\Services\CollectionService::class);
$matrix = [];
foreach ([1, 3, 6, 12] as $size) {
DB::flushQueryLog();
DB::enableQueryLog();
$loaded = $service->getProfileCollections($user, null, $size);
$producer = count(DB::getQueryLog());
DB::flushQueryLog();
$service->mapCollectionCardPayloads($loaded, false, null);
$mapper = count(DB::getQueryLog());
$matrix[$size] = [$producer, $mapper, $producer + $mapper];
}
expect($matrix[1][1])->toBe($matrix[3][1])
->and($matrix[3][1])->toBe($matrix[6][1])
->and($matrix[6][1])->toBe($matrix[12][1]);
});
it('does not alter the smart collection cover and rule-output path', function (): void {
$user = User::factory()->create(['username' => 'smartguard']);
$artwork = Artwork::factory()->for($user)->create(['title' => 'Smart result']);
$collection = Collection::factory()->for($user)->create([
'mode' => Collection::MODE_SMART,
'smart_rules_json' => ['match' => 'all', 'rules' => [['field' => 'is_featured', 'value' => true]]],
]);
$smart = Mockery::mock(SmartCollectionService::class);
$smart->shouldReceive('firstArtwork')->once()->withArgs(fn (Collection $actual, bool $ownerView): bool => $actual->is($collection) && $ownerView === false)->andReturn($artwork);
$smart->shouldReceive('smartSummary')->once()->andReturn('smart rule summary');
app()->instance(SmartCollectionService::class, $smart);
$payload = app(\App\Services\CollectionService::class)->mapCollectionCardPayloads(
new \Illuminate\Database\Eloquent\Collection([$collection->load('user')]),
false,
null,
);
expect($payload[0]['cover_artwork_id'])->toBe($artwork->id)
->and($payload[0]['smart_summary'])->toBe('smart rule summary');
});