diff --git a/app/Http/Middleware/CacheResponse.php b/app/Http/Middleware/CacheResponse.php index 1920d38..c64376a 100644 --- a/app/Http/Middleware/CacheResponse.php +++ b/app/Http/Middleware/CacheResponse.php @@ -19,8 +19,21 @@ public function handle(Request $request, Closure $next): Response return $response; } - $inertiaHeader = $request->header('X-Inertia', ''); - $cacheKey = 'response:'.md5($request->fullUrl().$inertiaHeader); + /** + * Every header that changes which props end up in the body has to be + * part of the key. Partial and once-prop requests hit the same URL with + * the same `X-Inertia: true` as a full visit, so keying on those two + * alone would let a partial response containing a single prop be served + * as though it were the whole page. + */ + $cacheKey = 'response:'.md5(implode('|', [ + $request->fullUrl(), + $request->header('X-Inertia', ''), + $request->header('X-Inertia-Partial-Component', ''), + $request->header('X-Inertia-Partial-Data', ''), + $request->header('X-Inertia-Partial-Except', ''), + $request->header('X-Inertia-Except-Once-Props', ''), + ])); $cached = Cache::get($cacheKey); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0c77b22..afd544f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ use App\Services\RuneliteApiService; use Illuminate\Http\Request; +use Inertia\Inertia; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -43,7 +44,20 @@ public function share(Request $request): array 'name' => config('app.name'), 'apiUrl' => rtrim(config('services.runelite_api.client_url'), '/'), 'appUrl' => config('app.url'), - 'plugins' => $this->runeliteApi->getPlugins([]), + /** + * Only the header search needs this list, and only once the user + * starts typing, so it is kept out of the initial document and + * fetched after paint. + * + * The homepage passes its own eager `plugins` prop, which replaces + * this one before the response is resolved, so that page still + * server-renders the full table and issues no follow-up request. + * + * `once()` keeps the client from re-fetching the same list on every + * subsequent visit; the TTL bounds how stale a long session can get. + */ + 'plugins' => Inertia::defer(fn (): array => $this->runeliteApi->getPlugins([])) + ->once(until: now()->addMinutes(10)), ]; } } diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 3a59df4..0d622ad 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -29,6 +29,15 @@ function isActive(link: { href: string; exact: boolean }): boolean { return currentPath.value.startsWith(link.href); } +/** + * The plugin list is a deferred prop, so it is absent from the initial document + * and arrives shortly after paint. Until then the box takes input but has + * nothing to match against, which would otherwise look like "no results". + */ +const pluginsLoaded = computed(() => Array.isArray(page.props.plugins)); + +const searchPending = computed(() => searchInput.value.trim().length > 0 && !pluginsLoaded.value); + const searchResults = computed(() => { const q = searchInput.value.trim(); if (!q) return []; @@ -114,7 +123,10 @@ async function openMenuForSearch(): Promise { @focus="searchVisible = true" @blur="onBlur" /> -
+
+ Loading plugins… +
+ -
+
+ Loading plugins… +
+
{ @apply border-t border-neutral-800; } +.app-header__search-pending { + @apply block px-3 py-2.5 text-sm text-gray-400; +} + .app-header__search-result-name { @apply truncate font-medium; } diff --git a/tests/Feature/DeferredPluginPropTest.php b/tests/Feature/DeferredPluginPropTest.php new file mode 100644 index 0000000..efebda0 --- /dev/null +++ b/tests/Feature/DeferredPluginPropTest.php @@ -0,0 +1,119 @@ +fakeRuneliteApi(); + } + + public function test_the_list_is_absent_from_a_non_homepage_document(): void + { + $page = $this->get(route('top'))->assertOk()->viewData('page'); + + $this->assertArrayNotHasKey('plugins', $page['props']); + } + + public function test_the_list_is_advertised_as_deferred_so_the_client_fetches_it(): void + { + $page = $this->get(route('top'))->assertOk()->viewData('page'); + + $this->assertContains('plugins', $page['deferredProps']['default'] ?? []); + } + + public function test_a_partial_request_returns_the_deferred_list(): void + { + $response = $this->withHeaders($this->partialHeaders())->get(route('top'))->assertOk(); + + $this->assertCount(1, $response->json('props.plugins')); + } + + public function test_the_homepage_still_renders_its_list_eagerly(): void + { + $page = $this->get(route('home'))->assertOk()->viewData('page'); + + $this->assertCount(1, $page['props']['plugins']); + $this->assertArrayNotHasKey('deferredProps', $page); + } + + /** + * A partial response holds a single prop but hits the same URL, with the + * same X-Inertia header, as a full visit. Both must not share a cache entry. + */ + public function test_partial_and_full_responses_do_not_share_a_cache_entry(): void + { + $partial = $this->withHeaders($this->partialHeaders())->get(route('top'))->assertOk(); + + $this->assertArrayNotHasKey('metrics', $partial->json('props')); + + // withHeaders() accumulates, so the partial headers have to be cleared + // or the second request is another partial rather than a full visit. + $this->flushHeaders(); + + $full = $this->withHeaders($this->inertiaHeaders())->get(route('top'))->assertOk(); + + $this->assertArrayHasKey('metrics', $full->json('props')); + } + + /** The asset version is set per request by the middleware, so read it back off a render. */ + private function inertiaVersion(): string + { + return $this->assetVersion ??= (string) $this->get(route('top'))->viewData('page')['version']; + } + + /** @return array */ + private function inertiaHeaders(): array + { + return [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => $this->inertiaVersion(), + ]; + } + + /** @return array */ + private function partialHeaders(): array + { + return $this->inertiaHeaders() + [ + 'X-Inertia-Partial-Component' => 'Top100', + 'X-Inertia-Partial-Data' => 'plugins', + ]; + } + + private function fakeRuneliteApi(): void + { + $plugin = [ + 'id' => 1, + 'name' => 'example-plugin', + 'display' => 'Example Plugin', + 'author' => 'Example Author', + 'description' => 'An example plugin.', + 'tags' => 'example,test', + 'warning' => '', + 'created_on' => '2024-01-01T00:00:00.000Z', + 'updated_on' => '2024-06-01T00:00:00.000Z', + 'all_time_high' => 200, + 'current_installs' => 100, + ]; + + Http::fake([ + '*/plugins/top' => Http::response(['success' => true, 'data' => ['rankings' => []]]), + '*/plugins' => Http::response(['success' => true, 'data' => [$plugin]]), + '*/developers' => Http::response(['success' => true, 'data' => ['developers' => []]]), + '*' => Http::response(['success' => true, 'data' => []]), + ]); + } +}