Prefetch artwork prev/next navigation off the critical path.

Move neighbor lookup into a dedicated service and let the viewer load adjacent artworks after first paint instead of blocking the detail request.
This commit is contained in:
2026-08-29 12:25:49 +02:00
parent bf902f509a
commit 437d943827
7 changed files with 430 additions and 45 deletions
@@ -0,0 +1,98 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('returns only the image contract for navigation prefetch', function (): void {
$artwork = Artwork::factory()->create([
'title' => 'Prefetch Artwork',
'slug' => 'prefetch-artwork',
'hash' => 'aabbccddeeff00112233445566778899',
'file_path' => 'uploads/prefetch.jpg',
]);
$queries = 0;
DB::listen(function () use (&$queries): void {
$queries++;
});
$response = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
->assertOk();
expect($queries)->toBe(1)
->and($response->json())->toHaveKeys(['id', 'title', 'slug', 'thumbs'])
->and($response->json('thumbs'))->toHaveKeys(['md', 'lg'])
->and($response->json('thumbs.xl'))->toBeNull()
->and($response->json('stats'))->toBeNull()
->and($response->json('thumbs.lg.url'))->toBe(
'https://cdn.skinbase.org/artworks/lg/aa/bb/aabbccddeeff00112233445566778899.webp'
);
});
it('does not prefetch missing or unpublished artwork', function (): void {
$unpublished = Artwork::factory()->unpublished()->create();
$this->getJson('/api/artworks/'.$unpublished->id.'/page?prefetch=1')
->assertNotFound();
$this->getJson('/api/artworks/999999/page?prefetch=1')
->assertNotFound();
});
it('keeps full page navigation data available separately from prefetch', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'full-navigation-artwork',
]);
$this->getJson('/api/artworks/'.$artwork->id.'/page')
->assertOk()
->assertJsonStructure([
'id', 'title', 'thumbs', 'user', 'categories', 'tags', 'stats', 'viewer',
]);
});
it('measures the lightweight prefetch contract for guest and authenticated viewers', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'prefetch-measurement-artwork',
'hash' => 'aabbccddeeff00112233445566778899',
]);
DB::enableQueryLog();
DB::flushQueryLog();
$guestPrefetch = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')->assertOk();
$guestPrefetchQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$guestFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
$guestFullQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$authPrefetch = $this->actingAs(User::factory()->create())
->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
->assertOk();
$authPrefetchQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$authFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
$authFullQueries = count(DB::getQueryLog());
DB::disableQueryLog();
fwrite(STDERR, sprintf(
"M18B3 prefetch bytes/queries: guest=%d/%d auth=%d/%d; full guest=%d/%d auth=%d/%d\n",
strlen((string) $guestPrefetch->getContent()),
$guestPrefetchQueries,
strlen((string) $authPrefetch->getContent()),
$authPrefetchQueries,
strlen((string) $guestFull->getContent()),
$guestFullQueries,
strlen((string) $authFull->getContent()),
$authFullQueries,
));
expect($guestPrefetchQueries)->toBe(1)
->and($authPrefetchQueries)->toBeLessThanOrEqual(2)
->and($guestPrefetch->json())->toEqual($authPrefetch->json());
});
@@ -0,0 +1,92 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function m18b6PublishedArtwork(User $user, string $slug): Artwork
{
return Artwork::factory()->for($user)->create([
'slug' => $slug,
'published_at' => now()->subDay(),
'is_public' => true,
'is_approved' => true,
]);
}
it('returns the correct previous and next artwork for a middle item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$middle = m18b6PublishedArtwork($user, 'middle');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$middle->id)
->assertOk()
->assertJson([
'prev_id' => $first->id,
'next_id' => $last->id,
'prev_url' => url('/art/'.$first->id.'/first'),
'next_url' => url('/art/'.$last->id.'/last'),
'prev_slug' => 'first',
'next_slug' => 'last',
])
->assertJsonStructure([
'prev_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
'next_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
]);
});
it('wraps the first item to the last item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$first->id)
->assertOk()
->assertJsonPath('prev_id', $last->id)
->assertJsonPath('next_id', $last->id)
->assertJsonPath('prev_preview.id', $last->id)
->assertJsonPath('next_preview.id', $last->id);
});
it('wraps the last item to the first item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$last->id)
->assertOk()
->assertJsonPath('prev_id', $first->id)
->assertJsonPath('next_id', $first->id)
->assertJsonPath('prev_preview.id', $first->id)
->assertJsonPath('next_preview.id', $first->id);
});
it('returns the empty contract for a missing or hidden artwork', function (): void {
$empty = [
'prev_id' => null,
'next_id' => null,
'prev_url' => null,
'next_url' => null,
'prev_slug' => null,
'next_slug' => null,
'prev_preview' => null,
'next_preview' => null,
];
$this->getJson('/api/artworks/navigation/999999')
->assertOk()
->assertExactJson($empty);
$private = Artwork::factory()->private()->create();
$this->getJson('/api/artworks/navigation/'.$private->id)
->assertOk()
->assertExactJson($empty);
$this->actingAs(User::factory()->create())
->getJson('/api/artworks/navigation/'.$private->id)
->assertOk()
->assertExactJson($empty);
});