Skip to content

[6.x] Build the control panel nav only when an Inertia page is rendered - #15477

Open
lazerg wants to merge 3 commits into
statamic:6.xfrom
lazerg:fix/issue-15475-lazy-cp-nav-share
Open

lazerg wants to merge 3 commits into
statamic:6.xfrom
lazerg:fix/issue-15475-lazy-cp-nav-share

Conversation

@lazerg

@lazerg lazerg commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The HandleAuthenticatedInertiaRequests middleware sits at the end of the statamic.cp.authenticated group, so it computes its shared props on every authenticated CP request. Building the nav is the expensive part of that, since it walks every navigation tree once per site and gate-checks every creatable content type. On CP routes that never render an Inertia page (thumbnails, SVG and PDF asset responses, endpoints that return JSON) all of that work is thrown away. The reporter measured 140 queries and 277 gate checks on a request that returns a PNG.

Inertia::share() evaluates nothing, and Inertia resolves closures anywhere in the shared props tree when it builds a page response. Wrapping nav, licensing and sessionExpiry in closures therefore leaves page responses identical and skips the work everywhere else.

Statamic::nonInertiaPageData() is the one place that reads those props outside of an Inertia response, for blade pages rendered through the root view, so it now resolves closures itself.

One side effect worth mentioning: the Statamic::$isRenderingCpException check inside nav() was unreachable before, because the middleware always ran ahead of the exception handler. It applies now, so production CP error pages skip the nav, which they never render anyway since they use the blank layout.

Fixes #15475

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this — the approach is right. I went through Inertia's PropsResolver to check the premise rather than take it on faith, and it does recurse the whole prop tree resolving callables, so wrapping these three in closures really is transparent for page responses. Partial reloads come out identical too. I also confirmed the regression test is load-bearing: reverting just the two src/ files makes it_doesnt_build_the_nav_for_responses_that_arent_inertia_pages fail.

One thing I'd like changed before this goes in — see the inline comment on resolveProps().

Not blocking, but worth being aware of: RendersControlPanelExceptions sets Statamic::$isRenderingCpException = true before calling ->toResponse(), so the guard in nav() now evaluates at render time rather than middleware time, and CP error pages will get an empty nav prop where previously they got the full one. Nothing breaks visually — errors/Error.vue and errors/404.vue both use the Blank layout — but the identical guard in protectedProps() is still evaluated eagerly, so licensing and sessionExpiry do still resolve on those pages. Just flagging the asymmetry.

Comment thread src/Statamic.php
Comment on lines +523 to +531
private static function resolveProps(array $props)
{
return collect($props)->map(function ($value) {
if ($value instanceof Closure) {
$value = $value();
}

return is_array($value) ? static::resolveProps($value) : $value;
})->all();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't quite match how Inertia itself resolves props. ResolvesCallables::resolveCallable() does:

return is_object($value) && is_callable($value) ? App::call($value) : $value;

Two differences:

  1. No container injection. A shared closure with a type-hinted dependency resolves fine on an Inertia page, but fatals on a blade page:
    ArgumentCountError: Too few arguments to function {closure}(), 0 passed in src/Statamic.php on line 527 and exactly 1 expected
    
  2. Invokable objects aren't resolved. Inertia::share(['foo' => new SomeInvokable]) resolves on an Inertia page; here the object passes straight through to json_encode() and comes out as {}.

Our own three closures take no arguments, so core is unaffected either way. But Inertia::share() is reachable by addons, and this PR is what first puts callables into the shared props at all, so this seems like the moment to make the two paths agree:

private static function resolveProps(array $props)
{
    return collect($props)->map(function ($value) {
        if (is_object($value) && is_callable($value)) {
            $value = App::call($value);
        }

        return is_array($value) ? static::resolveProps($value) : $value;
    })->all();
}

Illuminate\Support\Facades\App is already imported in this file, and the Closure import stays since it's used elsewhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea4546a. I reproduced both cases first. The type-hinted closure threw the same ArgumentCountError you quoted, at Statamic.php:527. The invokable came back as [] in the JSON. resolveProps() now does the is_object() && is_callable() check and calls App::call(), so both paths behave the same.

I also added a test that shares a closure with a Request dependency and an invokable object, and asserts both resolve outside Inertia.

@lazerg

lazerg commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

You're right about the error page asymmetry, and I left it that way on purpose. Wrapping protectedProps() in a closure too would drop licensing and sessionExpiry from CP error pages. That is a behaviour change this PR doesn't need, so I'd rather do it separately.

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.

HandleAuthenticatedInertiaRequests eagerly builds the full CP nav (Nav::build()) on every /cp/* request, even ones that never render Inertia

2 participants