Skip to content

perf: defer the shared plugin list out of the initial document (metric: byte weight / FCP) - #8

Open
phyceClaw wants to merge 1 commit into
phyce:mainfrom
phyceClaw:perf-defer-shared-plugin-prop
Open

perf: defer the shared plugin list out of the initial document (metric: byte weight / FCP)#8
phyceClaw wants to merge 1 commit into
phyce:mainfrom
phyceClaw:perf-defer-shared-plugin-prop

Conversation

@phyceClaw

Copy link
Copy Markdown

Metric targeted: Total byte weight → FCP

plugins is a shared Inertia prop, so all 2048 records are embedded in the HTML of every page even though only the header search box reads them. Measured against production:

Page Inertia JSON of which plugins Share
/naturalspeech 925 KB 907 KB 98%
/developers/growing 938 KB 907 KB 97%
/top 969 KB 907 KB 94%
/developers 1,095 KB 907 KB 83%

Change

Inertia::defer(...)->once(until: 10 minutes):

  • Deferred — the list is excluded from the initial document and fetched by the client after paint, taking ~907 KB uncompressed (~250 KB on the wire) off the critical path of every non-homepage response.
  • Once — the client records it and sends X-Inertia-Except-Once-Props on later visits, so navigating between pages does not re-send it. This matters: deferring alone would only move the same bytes off the critical path, not stop repeating them on every navigation.

The homepage is deliberately unaffected

PluginController@index passes its own eager plugins prop, and PropsResolver::resolve() does array_merge($shared, $props) before resolution — so on / the page prop replaces the DeferProp object outright. The homepage still server-renders the full table (SSR and crawlers see it as today) and advertises no deferred prop, so it issues no follow-up request.

That behaviour is load-bearing and would break silently, so there is a test asserting the homepage still has an eager plugins prop and no deferredProps metadata.

A cache bug this would otherwise have exposed

CacheResponse keyed on md5(fullUrl + X-Inertia). A deferred fetch hits the same URL with the same X-Inertia: true as a full visit — the only difference is X-Inertia-Partial-Data / X-Inertia-Partial-Component. So the two would have shared a cache entry for 60 seconds, and a partial response containing only plugins could be served in place of a whole page (or vice versa).

The key now also covers the partial and once-prop headers. test_partial_and_full_responses_do_not_share_a_cache_entry fails if that hunk is reverted — I verified this rather than assuming it.

Without deferred props this bug was latent, since nothing issued partial requests. It has to be fixed in this PR, not after.

Tradeoffs worth a maintainer's opinion

  • Staleness. With once() the search index can be up to 10 minutes old within a single session, while the homepage table stays fresh. Install counts move slowly so this seemed a fair trade, but the TTL is one argument — or drop ->once(...) entirely and accept the re-fetch per navigation.
  • Search latency. On a non-homepage cold load, typing immediately after paint hits a brief window where the list has not arrived. The dropdown now shows "Loading plugins…" rather than appearing to find nothing.

Verification

vendor/bin/pint --dirty, npm run build, vue-tsc --noEmit all pass; suite green (7 passed). ESLint on AppHeader.vue reports 3 import/order errors — identical to main, since this branch adds no imports there.

⚠️ The FCP effect is projected, not measured — this VPS is build-only, so I could not re-run Lighthouse end to end. The byte figures are measured off the live payload.

Interaction with the other open PRs

🤖 Generated with Claude Code

Metric: total byte weight / FCP (~907 KB off every non-homepage document)

`plugins` is a shared Inertia prop, so all 2048 records are embedded in
every page even though only the header search reads them. Measured on
production it is nearly the whole payload:

  /naturalspeech        925 KB JSON, 907 KB plugins  (98%)
  /developers/growing   938 KB JSON, 907 KB plugins  (97%)
  /top                  969 KB JSON, 907 KB plugins  (94%)

It becomes a deferred prop, so the initial document omits it and the
client fetches it after paint, and a once-prop, so navigating between
pages does not re-send it. Without `once()` deferring would only move the
bytes off the critical path rather than stop repeating them.

The homepage is unaffected. PluginController@index passes its own eager
`plugins`, and PropsResolver merges page props over shared props before
resolution, so the DeferProp is replaced outright: the homepage still
server-renders the table and advertises no deferred prop. There is a test
for this, since it is the part most likely to break silently.

CacheResponse had to be fixed in the same change. It keyed on the URL and
the X-Inertia header only, but a deferred fetch hits the same URL with the
same X-Inertia: true as a full visit, differing only in the partial
headers. The two would have shared a cache entry for 60s, so a partial
response carrying a single prop could be served as a whole page. The key
now includes the partial and once-prop headers. Reverting just that hunk
fails the accompanying test.

The header search shows a pending state while the list is in flight,
instead of looking like it found no matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant