Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions app/Http/Middleware/CacheResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
16 changes: 15 additions & 1 deletion app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Services\RuneliteApiService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
Expand Down Expand Up @@ -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)),
];
}
}
23 changes: 21 additions & 2 deletions resources/js/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -114,7 +123,10 @@ async function openMenuForSearch(): Promise<void> {
@focus="searchVisible = true"
@blur="onBlur"
/>
<div v-if="searchVisible && searchResults.length > 0" class="app-header__search-results">
<div v-if="searchVisible && searchPending" class="app-header__search-results">
<span class="app-header__search-pending">Loading plugins…</span>
</div>
<div v-else-if="searchVisible && searchResults.length > 0" class="app-header__search-results">
<a
v-for="plugin in searchResults"
:key="plugin.id"
Expand Down Expand Up @@ -199,7 +211,10 @@ async function openMenuForSearch(): Promise<void> {
@blur="onBlur"
/>
</div>
<div v-if="searchResults.length > 0" class="app-header__drawer-results">
<div v-if="searchPending" class="app-header__drawer-results">
<span class="app-header__search-pending">Loading plugins…</span>
</div>
<div v-else-if="searchResults.length > 0" class="app-header__drawer-results">
<a
v-for="plugin in searchResults"
:key="plugin.id"
Expand Down Expand Up @@ -304,6 +319,10 @@ async function openMenuForSearch(): Promise<void> {
@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;
}
Expand Down
119 changes: 119 additions & 0 deletions tests/Feature/DeferredPluginPropTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
* The plugin list is shared with every page but only the header search reads it,
* so it is deferred out of the initial document. The homepage is the exception:
* it renders the list itself and passes its own eager prop.
*/
class DeferredPluginPropTest extends TestCase
{
private ?string $assetVersion = null;

protected function setUp(): void
{
parent::setUp();

$this->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<string, string> */
private function inertiaHeaders(): array
{
return [
'X-Inertia' => 'true',
'X-Inertia-Version' => $this->inertiaVersion(),
];
}

/** @return array<string, string> */
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' => []]),
]);
}
}